Repository: cta-observatory/ctapipe
Pull request #1923 (2022-06-02 09:45:49)

Store both equivalent and effective focal length in OpticsDescription, add __slots__
This is adding a new field `effective_focal_length` to the optics description, so we don't need to choose there but when we actually do the transforms.

Like this, it's clear inside ctapipe what focal length is used when and it doesn't depend on options what is inside the OpticsDescription.

The choice options now say which of the focal lengths is attached to the `CameraGeometry.frame` attribute when creating the subarray, so all things loading a subarray (SimTelEventSource, HDF5EventSource, Subarray.from_hdf, TableLoader) now gain this option.

codecov[bot]: # [Codecov](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory) Report
> Merging [#1923](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory) (b31fdec) into [master](https://codecov.io/gh/cta-observatory/ctapipe/commit/a3f616370fc0741205623071fb58e5e8b68c66cc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory) (a3f6163) will **decrease** coverage by `0.00%`.
> The diff coverage is `96.29%`.

```diff
@@            Coverage Diff             @@
##           master    #1923      +/-   ##
==========================================
- Coverage   92.07%   92.07%   -0.01%     
==========================================
  Files         189      189              
  Lines       14922    14938      +16     
==========================================
+ Hits        13740    13754      +14     
- Misses       1182     1184       +2     
```


| [Impacted Files](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory) | Coverage Δ | |
|---|---|---|
| [ctapipe/instrument/subarray.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pbnN0cnVtZW50L3N1YmFycmF5LnB5) | `93.16% <90.00%> (-0.32%)` | :arrow_down: |
| [ctapipe/instrument/optics.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pbnN0cnVtZW50L29wdGljcy5weQ==) | `89.47% <90.90%> (-0.92%)` | :arrow_down: |
| [ctapipe/instrument/tests/test\_optics.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pbnN0cnVtZW50L3Rlc3RzL3Rlc3Rfb3B0aWNzLnB5) | `97.77% <100.00%> (ø)` | |
| [ctapipe/instrument/tests/test\_subarray.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pbnN0cnVtZW50L3Rlc3RzL3Rlc3Rfc3ViYXJyYXkucHk=) | `100.00% <100.00%> (ø)` | |
| [ctapipe/io/hdf5eventsource.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pby9oZGY1ZXZlbnRzb3VyY2UucHk=) | `91.54% <100.00%> (+0.08%)` | :arrow_up: |
| [ctapipe/io/simteleventsource.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pby9zaW10ZWxldmVudHNvdXJjZS5weQ==) | `94.93% <100.00%> (ø)` | |
| [ctapipe/io/tableloader.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pby90YWJsZWxvYWRlci5weQ==) | `95.27% <100.00%> (+0.03%)` | :arrow_up: |
| [ctapipe/io/tests/test\_datawriter.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pby90ZXN0cy90ZXN0X2RhdGF3cml0ZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [ctapipe/io/tests/test\_simteleventsource.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS9pby90ZXN0cy90ZXN0X3NpbXRlbGV2ZW50c291cmNlLnB5) | `100.00% <100.00%> (ø)` | |
| [ctapipe/tools/tests/test\_tools.py](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory#diff-Y3RhcGlwZS90b29scy90ZXN0cy90ZXN0X3Rvb2xzLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory). Last update [a3f6163...b31fdec](https://codecov.io/gh/cta-observatory/ctapipe/pull/1923?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cta-observatory).

maxnoe: Let's merge this PR and the other one affecting the data model: #1949 and then I can recreate the higher level test data files with the new data model. 

Patch:
diff --git a/ctapipe/core/traits.py b/ctapipe/core/traits.py
index 58ff335d..c336e44c 100644
--- a/ctapipe/core/traits.py
+++ b/ctapipe/core/traits.py
@@ -1,17 +1,17 @@
 """
 Traitlet implementations for ctapipe
 """
+import copy
+import os
+import pathlib
 from collections import UserList
 from fnmatch import fnmatch
 from typing import Optional
-import copy
-from astropy.time import Time
-import pathlib
 from urllib.parse import urlparse
-import os
 
 import traitlets
 import traitlets.config
+from astropy.time import Time
 from traitlets import Undefined
 
 from .component import non_abstract_children
@@ -65,6 +65,7 @@ List = traitlets.List
 Set = traitlets.Set
 CRegExp = traitlets.CRegExp
 CaselessStrEnum = traitlets.CaselessStrEnum
+UseEnum = traitlets.UseEnum
 TraitError = traitlets.TraitError
 TraitType = traitlets.TraitType
 observe = traitlets.observe
@@ -72,10 +73,10 @@ flag = traitlets.config.boolean_flag
 
 
 class AstroTime(TraitType):
-    """ A trait representing a point in Time, as understood by `astropy.time`"""
+    """A trait representing a point in Time, as understood by `astropy.time`"""
 
     def validate(self, obj, value):
-        """ try to parse and return an ISO time string """
+        """try to parse and return an ISO time string"""
         try:
             the_time = Time(value)
             the_time.format = "iso"
@@ -273,7 +274,7 @@ class TelescopePatternList(UserList):
 
     @property
     def tel(self):
-        """ access the value per telescope_id, e.g. `param.tel[2]`"""
+        """access the value per telescope_id, e.g. `param.tel[2]`"""
         if self._lookup:
             return self._lookup
         else:
@@ -511,7 +512,7 @@ class TelescopeParameter(List):
 
 
 class FloatTelescopeParameter(TelescopeParameter):
-    """ a `~ctapipe.core.traits.TelescopeParameter` with Float trait type"""
+    """a `~ctapipe.core.traits.TelescopeParameter` with Float trait type"""
 
     def __init__(self, **kwargs):
         """Create a new IntTelescopeParameter"""
@@ -519,7 +520,7 @@ class FloatTelescopeParameter(TelescopeParameter):
 
 
 class IntTelescopeParameter(TelescopeParameter):
-    """ a `~ctapipe.core.traits.TelescopeParameter` with Int trait type"""
+    """a `~ctapipe.core.traits.TelescopeParameter` with Int trait type"""
 
     def __init__(self, **kwargs):
         """Create a new IntTelescopeParameter"""
@@ -527,7 +528,7 @@ class IntTelescopeParameter(TelescopeParameter):
 
 
 class BoolTelescopeParameter(TelescopeParameter):
-    """ a `~ctapipe.core.traits.TelescopeParameter` with Bool trait type"""
+    """a `~ctapipe.core.traits.TelescopeParameter` with Bool trait type"""
 
     def __init__(self, **kwargs):
         """Create a new BoolTelescopeParameter"""
diff --git a/ctapipe/instrument/__init__.py b/ctapipe/instrument/__init__.py
index 561e992e..2c20f4a4 100644
--- a/ctapipe/instrument/__init__.py
+++ b/ctapipe/instrument/__init__.py
@@ -1,10 +1,9 @@
-from .camera import CameraDescription, CameraGeometry, CameraReadout, PixelShape
 from .atmosphere import get_atmosphere_profile_functions
-from .telescope import TelescopeDescription
-from .optics import OpticsDescription
-from .subarray import SubarrayDescription, UnknownTelescopeID
+from .camera import CameraDescription, CameraGeometry, CameraReadout, PixelShape
 from .guess import guess_telescope
-
+from .optics import FocalLengthKind, OpticsDescription
+from .subarray import SubarrayDescription, UnknownTelescopeID
+from .telescope import TelescopeDescription
 
 __all__ = [
     "CameraDescription",
@@ -17,4 +16,5 @@ __all__ = [
     "SubarrayDescription",
     "TelescopeDescription",
     "UnknownTelescopeID",
+    "FocalLengthKind",
 ]
diff --git a/ctapipe/instrument/optics.py b/ctapipe/instrument/optics.py
index 8103f067..a733bac0 100644
--- a/ctapipe/instrument/optics.py
+++ b/ctapipe/instrument/optics.py
@@ -3,6 +3,7 @@ Classes and functions related to telescope Optics
 """
 
 import logging
+from enum import Enum, auto, unique
 
 import astropy.units as u
 import numpy as np
@@ -11,6 +12,28 @@ from ..utils import get_table_dataset
 
 logger = logging.getLogger(__name__)
 
+__all__ = [
+    "OpticsDescription",
+    "FocalLengthKind",
+]
+
+
+@unique
+class FocalLengthKind(Enum):
+    """
+    Enumeration for the different kinds of focal lengths.
+    """
+
+    #: Effective focal length computed from ray tracing a point source
+    #: and calculating the off-axis center of mass of the light distribution.
+    #: This focal length should be used in coordinate transforms between camera
+    #: frame and telescope frame to correct for the mean effect of coma abberation.
+    EFFECTIVE = auto()
+    #: Equivalent focal length is the nominal focal length of the main reflector
+    #: for single mirror telescopes and the thin-lens equivalent for dual mirror
+    #: telescopes.
+    EQUIVALENT = auto()
+
 
 class OpticsDescription:
     """
@@ -24,10 +47,19 @@ class OpticsDescription:
     ----------
     num_mirrors: int
         Number of mirrors, i. e. 2 for Schwarzschild-Couder else 1
-    equivalent_focal_length: Quantity(float)
-        effective focal-length of telescope, independent of which type of
-        optics (as in the Monte-Carlo)
-    mirror_area: float
+    equivalent_focal_length : astropy.units.Quantity[length]
+        Equivalent focal-length of telescope, independent of which type of
+        optics (as in the Monte-Carlo). This is the nominal focal length
+        for single mirror telescopes and the equivalent focal length for dual
+        mirror telescopes.
+    effective_focal_length : astropy.units.Quantity[length]
+        The effective_focal_length is the focal length estimated from
+        ray tracing to correct for coma aberration. It is thus not automatically
+        available for all simulations, but only if it was set beforehand
+        in the simtel configuration. This is the focal length that should be
+        used for transforming from camera frame to telescope frame for all
+        reconstruction tasks to correct for the mean aberration.
+    mirror_area: astropy.units.Quantity[area]
         total reflective surface area of the optical system (in m^2)
     num_mirror_tiles: int
         number of mirror facets
@@ -41,35 +73,50 @@ class OpticsDescription:
     """
 
     __slots__ = (
+        "name",
+        "effective_focal_length",
         "equivalent_focal_length",
         "mirror_area",
-        "name",
-        "num_mirror_tiles",
         "num_mirrors",
+        "num_mirror_tiles",
     )
 
-    @u.quantity_input(mirror_area=u.m**2, equivalent_focal_length=u.m)
+    @u.quantity_input(
+        mirror_area=u.m**2,
+        equivalent_focal_length=u.m,
+        effective_focal_length=u.m,
+    )
     def __init__(
         self,
         name,
         num_mirrors,
         equivalent_focal_length,
-        mirror_area=None,
-        num_mirror_tiles=None,
+        effective_focal_length,
+        mirror_area,
+        num_mirror_tiles,
     ):
 
         self.name = name
         self.equivalent_focal_length = equivalent_focal_length.to(u.m)
+        self.effective_focal_length = effective_focal_length.to(u.m)
         self.mirror_area = mirror_area
         self.num_mirrors = num_mirrors
         self.num_mirror_tiles = num_mirror_tiles
 
     def __hash__(self):
         """Make this hashable, so it can be used as dict keys or in sets"""
+        # From python >= 3.10, hash of nan is random, we want a fixed hash also for
+        # unknown effective focal length:
+        if np.isnan(self.effective_focal_length.value):
+            effective_focal_length = -1
+        else:
+            effective_focal_length = self.effective_focal_length.to_value(u.m)
+
         return hash(
             (
-                round(self.equivalent_focal_length.to_value(u.m), 3),
-                round(self.mirror_area.to_value(u.m**2), 3),
+                round(self.equivalent_focal_length.to_value(u.m), 4),
+                round(effective_focal_length, 4),
+                round(self.mirror_area.to_value(u.m**2)),
                 self.num_mirrors,
                 self.num_mirror_tiles,
             )
@@ -121,7 +168,7 @@ class OpticsDescription:
 
         if version == "1.0":
             mask = table["tel_description"] == name
-        elif version == "2.0":
+        else:
             mask = table["description"] == name
 
         if np.count_nonzero(mask) == 0:
@@ -129,19 +176,24 @@ class OpticsDescription:
 
         if version == "1.0":
             num_mirrors = 1 if table["mirror_type"][mask][0] == "DC" else 2
-        elif version == "2.0":
+        else:
             num_mirrors = table["num_mirrors"][mask][0]
 
-        flen = table["equivalent_focal_length"][mask].quantity[0]
+        if version in {"1.0", "2.0"}:
+            eff_focal_length = np.nan * u.m
+        else:
+            eff_focal_length = table["effective_focal_length"][mask].quantity[0]
+
+        focal_length = table["equivalent_focal_length"][mask].quantity[0]
 
-        optics = cls(
+        return cls(
             name=name,
             num_mirrors=num_mirrors,
-            equivalent_focal_length=flen,
+            equivalent_focal_length=focal_length,
+            effective_focal_length=eff_focal_length,
             mirror_area=table["mirror_area"][mask].quantity[0],
             num_mirror_tiles=table["num_mirror_tiles"][mask][0],
         )
-        return optics
 
     @classmethod
     def get_known_optics_names(cls, optics_table="optics"):
@@ -167,6 +219,7 @@ class OpticsDescription:
             f"{self.__class__.__name__}"
             f"(name={self.name}"
             f", equivalent_focal_length={self.equivalent_focal_length:.2f}"
+            f", effective_focal_length={self.effective_focal_length:.2f}"
             f", num_mirrors={self.num_mirrors}"
             f", mirror_area={self.mirror_area:.2f}"
             ")"
diff --git a/ctapipe/instrument/subarray.py b/ctapipe/instrument/subarray.py
index 5e4f8720..bdf6fbfa 100644
--- a/ctapipe/instrument/subarray.py
+++ b/ctapipe/instrument/subarray.py
@@ -6,19 +6,19 @@ from contextlib import ExitStack
 from copy import copy
 from itertools import groupby
 from typing import Dict, List, Union
-from astropy.coordinates.earth import EarthLocation
 
 import numpy as np
 import tables
 from astropy import units as u
 from astropy.coordinates import SkyCoord
+from astropy.coordinates.earth import EarthLocation
 from astropy.table import QTable, Table, join
 from astropy.utils import lazyproperty
 
 from .. import __version__ as CTAPIPE_VERSION
 from ..coordinates import CameraFrame, GroundFrame
 from .camera import CameraDescription, CameraGeometry, CameraReadout
-from .optics import OpticsDescription
+from .optics import FocalLengthKind, OpticsDescription
 from .telescope import TelescopeDescription
 
 __all__ = ["SubarrayDescription"]
@@ -320,17 +320,25 @@ class SubarrayDescription:
                 [t.optics.equivalent_focal_length.to_value(u.m) for t in unique_types],
                 u.m,
             )
-            cols = {
-                "description": [str(t) for t in unique_types],
-                "name": [t.name for t in unique_types],
-                "type": [t.type for t in unique_types],
-                "mirror_area": mirror_area,
-                "num_mirrors": [t.optics.num_mirrors for t in unique_types],
-                "num_mirror_tiles": [t.optics.num_mirror_tiles for t in unique_types],
-                "equivalent_focal_length": focal_length,
-            }
-            tab = Table(cols)
-            tab.meta["TAB_VER"] = "2.0"
+            effective_focal_length = u.Quantity(
+                [t.optics.effective_focal_length.to_value(u.m) for t in unique_types],
+                u.m,
+            )
+            tab = Table(
+                {
+                    "description": [str(t) for t in unique_types],
+                    "name": [t.name for t in unique_types],
+                    "type": [t.type for t in unique_types],
+                    "mirror_area": mirror_area,
+                    "num_mirrors": [t.optics.num_mirrors for t in unique_types],
+                    "num_mirror_tiles": [
+                        t.optics.num_mirror_tiles for t in unique_types
+                    ],
+                    "equivalent_focal_length": focal_length,
+                    "effective_focal_length": effective_focal_length,
+                }
+            )
+            tab.meta["TAB_VER"] = "3.0"
         else:
             raise ValueError(f"Table type '{kind}' not known")
 
@@ -372,9 +380,10 @@ class SubarrayDescription:
         """
         Draw a quick matplotlib plot of the array
         """
+        from matplotlib import pyplot as plt
+
         from ctapipe.coordinates.ground_frames import EastingNorthingFrame
         from ctapipe.visualization import ArrayDisplay
-        from matplotlib import pyplot as plt
 
         plt.figure(figsize=(8, 8))
         ad = ArrayDisplay(subarray=self, frame=EastingNorthingFrame(), tel_scale=0.75)
@@ -538,10 +547,13 @@ class SubarrayDescription:
                 meta["reference_itrs_z"] = itrs.z.to_value(u.m)
 
     @classmethod
-    def from_hdf(cls, path):
+    def from_hdf(cls, path, focal_length_choice=FocalLengthKind.EFFECTIVE):
         # here to prevent circular import
         from ..io import read_table
 
+        if isinstance(focal_length_choice, str):
+            focal_length_choice = FocalLengthKind[focal_length_choice.upper()]
+
         layout = read_table(
             path, "/configuration/instrument/subarray/layout", table_cls=QTable
         )
@@ -586,11 +598,15 @@ class SubarrayDescription:
                     " Reprocessing the data with ctapipe >= 0.12 will fix this problem."
                 )
 
+        has_eff = "effective_focal_length" in optics_table.colnames
         optic_descriptions = [
             OpticsDescription(
                 row["name"],
                 num_mirrors=row["num_mirrors"],
                 equivalent_focal_length=row["equivalent_focal_length"],
+                effective_focal_length=(
+                    row["effective_focal_length"] if has_eff else np.nan * u.m
+                ),
                 mirror_area=row["mirror_area"],
                 num_mirror_tiles=row["num_mirror_tiles"],
             )
@@ -603,9 +619,22 @@ class SubarrayDescription:
             # copy to support different telescopes with same camera geom
             camera = copy(cameras[row["camera_index"]])
             optics = optic_descriptions[row["optics_index"]]
-            focal_length = optics.equivalent_focal_length
-            camera.geometry.frame = CameraFrame(focal_length=focal_length)
 
+            if focal_length_choice is FocalLengthKind.EFFECTIVE:
+                focal_length = optics.effective_focal_length
+                if np.isnan(focal_length.value):
+                    raise RuntimeError(
+                        "`focal_length_choice` was set to 'effective', but the"
+                        " effective focal length was not present in the file. "
+                        " Use nominal focal length or adapt configuration"
+                        " to include the effective focal length"
+                    )
+            elif focal_length_choice is FocalLengthKind.EQUIVALENT:
+                focal_length = optics.equivalent_focal_length
+            else:
+                raise ValueError(f"Invalid focal length choice: {focal_length_choice}")
+
+            camera.geometry.frame = CameraFrame(focal_length=focal_length)
             telescope_descriptions[row["tel_id"]] = TelescopeDescription(
                 name=row["name"], tel_type=row["type"], optics=optics, camera=camera
             )
diff --git a/ctapipe/io/datawriter.py b/ctapipe/io/datawriter.py
index 5b773c50..6fea8fb1 100644
--- a/ctapipe/io/datawriter.py
+++ b/ctapipe/io/datawriter.py
@@ -48,9 +48,11 @@ def _get_tel_index(event, tel_id):
 # - increase the patch number if there is a small bugfix to the model.
 DATA_MODEL_VERSION = "v4.0.0"
 DATA_MODEL_CHANGE_HISTORY = """
-- v4.0.0: Container prefixes are now included for reconstruction algorithms
-          and true parameters.
-          Telescope Impact Parameters were added.
+- v4.0.0: - Container prefixes are now included for reconstruction algorithms
+            and true parameters.
+          - Telescope Impact Parameters were added.
+          - Effective focal length and nominal focal length are both included
+            in the optics description now.
 - v3.0.0: reconstructed core uncertainties splitted in their X-Y components
 - v2.2.0: added R0 and R1 outputs
 - v2.1.0: hillas and timing parameters are per default saved in telescope frame (degree) as opposed to camera frame (m)
@@ -102,7 +104,7 @@ def write_reference_metadata_headers(
         product=meta.Product(
             description="ctapipe Data Product",
             data_category=category,
-            data_level=[l.name for l in data_levels],
+            data_level=[level.name for level in data_levels],
             data_association="Subarray",
             data_model_name="ASWG",
             data_model_version=DATA_MODEL_VERSION,
diff --git a/ctapipe/io/eventseeker.py b/ctapipe/io/eventseeker.py
index bb77b069..421caae9 100644
--- a/ctapipe/io/eventseeker.py
+++ b/ctapipe/io/eventseeker.py
@@ -2,6 +2,7 @@
 Handles seeking to a particular event in a `ctapipe.io.EventSource`
 """
 from copy import deepcopy
+
 from ctapipe.core import Component
 
 __all__ = ["EventSeeker"]
@@ -28,7 +29,7 @@ class EventSeeker(Component):
     To obtain a particular event in a simtel file:
 
     >>> from ctapipe.io import SimTelEventSource
-    >>> event_source = SimTelEventSource(input_url="dataset://gamma_test_large.simtel.gz", focal_length_choice="nominal")
+    >>> event_source = SimTelEventSource(input_url="dataset://gamma_test_large.simtel.gz", focal_length_choice="EQUIVALENT")
     >>> seeker = EventSeeker(event_source=event_source)
     >>> event = seeker.get_event_index(2)
     >>> print(event.count)
@@ -37,7 +38,7 @@ class EventSeeker(Component):
     To obtain a particular event in a simtel file from its event_id:
 
     >>> from ctapipe.io import SimTelEventSource
-    >>> event_source = SimTelEventSource(input_url="dataset://gamma_test_large.simtel.gz", back_seekable=True, focal_length_choice="nominal")
+    >>> event_source = SimTelEventSource(input_url="dataset://gamma_test_large.simtel.gz", back_seekable=True, focal_length_choice="EQUIVALENT")
     >>> seeker = EventSeeker(event_source=event_source)
     >>> event = seeker.get_event_id(31007)
     >>> print(event.count)
diff --git a/ctapipe/io/hdf5eventsource.py b/ctapipe/io/hdf5eventsource.py
index c60b9980..b68fc5bb 100644
--- a/ctapipe/io/hdf5eventsource.py
+++ b/ctapipe/io/hdf5eventsource.py
@@ -1,43 +1,46 @@
-import astropy.units as u
-from astropy.utils.decorators import lazyproperty
 import logging
+from ast import literal_eval
+
+import astropy.units as u
 import numpy as np
 import tables
-from ast import literal_eval
+from astropy.utils.decorators import lazyproperty
+
+from ctapipe.instrument.optics import FocalLengthKind
 
-from ..core import Container, Field
-from ..instrument import SubarrayDescription
 from ..containers import (
-    ConcentrationContainer,
     ArrayEventContainer,
+    CameraHillasParametersContainer,
+    CameraTimingParametersContainer,
+    ConcentrationContainer,
     DL1CameraContainer,
     EventIndexContainer,
-    CameraHillasParametersContainer,
     HillasParametersContainer,
+    ImageParametersContainer,
     IntensityStatisticsContainer,
     LeakageContainer,
     MorphologyContainer,
     ParticleClassificationContainer,
+    PeakTimeStatisticsContainer,
+    R1CameraContainer,
     ReconstructedEnergyContainer,
-    SimulationConfigContainer,
-    SimulatedShowerContainer,
+    ReconstructedGeometryContainer,
     SimulatedEventContainer,
-    PeakTimeStatisticsContainer,
-    CameraTimingParametersContainer,
+    SimulatedShowerContainer,
+    SimulationConfigContainer,
+    TelescopeImpactParameterContainer,
+    TelescopeTriggerContainer,
+    TelEventIndexContainer,
     TimingParametersContainer,
     TriggerContainer,
-    ImageParametersContainer,
-    TelEventIndexContainer,
-    TelescopeTriggerContainer,
-    R1CameraContainer,
-    TelescopeImpactParameterContainer,
-    ReconstructedGeometryContainer,
 )
+from ..core import Container, Field
+from ..core.traits import UseEnum
+from ..instrument import SubarrayDescription
+from ..utils import IndexFinder
+from .datalevels import DataLevel
 from .eventsource import EventSource
 from .hdf5tableio import HDF5TableReader
-from .datalevels import DataLevel
-from ..utils import IndexFinder
-
 from .tableloader import DL2_SUBARRAY_GROUP, DL2_TELESCOPE_GROUP
 
 __all__ = ["HDF5EventSource"]
@@ -123,6 +126,20 @@ class HDF5EventSource(EventSource):
         image parameters evaluated on these.
     """
 
+    focal_length_choice = UseEnum(
+        FocalLengthKind,
+        default_value=FocalLengthKind.EFFECTIVE,
+        help=(
+            "If both nominal and effective focal lengths are available, "
+            " which one to use for the `CameraFrame` attached"
+            " to the `CameraGeometry` instances in the `SubarrayDescription`"
+            ", which will be used in CameraFrame to TelescopeFrame coordinate"
+            " transforms. The 'nominal' focal length is the one used during "
+            " the simulation, the 'effective' focal length is computed using specialized "
+            " ray-tracing from a point light source"
+        ),
+    ).tag(config=True)
+
     def __init__(self, input_url=None, config=None, parent=None, **kwargs):
         """
         EventSource for dl1 files in the standard DL1 data format
@@ -142,7 +159,10 @@ class HDF5EventSource(EventSource):
         super().__init__(input_url=input_url, config=config, parent=parent, **kwargs)
 
         self.file_ = tables.open_file(self.input_url)
-        self._full_subarray = SubarrayDescription.from_hdf(self.input_url)
+        self._full_subarray = SubarrayDescription.from_hdf(
+            self.input_url,
+            focal_length_choice=self.focal_length_choice,
+        )
 
         if self.allowed_tels:
             self._subarray = self._full_subarray.select_subarray(self.allowed_tels)
diff --git a/ctapipe/io/simteleventsource.py b/ctapipe/io/simteleventsource.py
index ec04d91e..a4cd6917 100644
--- a/ctapipe/io/simteleventsource.py
+++ b/ctapipe/io/simteleventsource.py
@@ -29,17 +29,12 @@ from ..containers import (
 )
 from ..coordinates import CameraFrame
 from ..core import Map
-from ..core.traits import (
-    Bool,
-    CaselessStrEnum,
-    Float,
-    Undefined,
-    create_class_enum_trait,
-)
+from ..core.traits import Bool, Float, Undefined, UseEnum, create_class_enum_trait
 from ..instrument import (
     CameraDescription,
     CameraGeometry,
     CameraReadout,
+    FocalLengthKind,
     OpticsDescription,
     SubarrayDescription,
     TelescopeDescription,
@@ -186,16 +181,17 @@ class SimTelEventSource(EventSource):
         ),
     ).tag(config=True)
 
-    focal_length_choice = CaselessStrEnum(
-        ["nominal", "effective"],
-        default_value="effective",
+    focal_length_choice = UseEnum(
+        FocalLengthKind,
+        default_value=FocalLengthKind.EFFECTIVE,
         help=(
-            "if both nominal and effective focal lengths are available in the "
-            "SimTelArray file, which one to use when constructing the "
-            "SubarrayDescription (which will be used in CameraFrame to TelescopeFrame "
-            "coordinate transforms. The 'nominal' focal length is the one used during "
-            "the simulation, the 'effective' focal length is computed using specialized "
-            "ray-tracing from a point light source"
+            "If both nominal and effective focal lengths are available in the"
+            " SimTelArray file, which one to use for the `CameraFrame` attached"
+            " to the `CameraGeometry` instances in the `SubarrayDescription`"
+            ", which will be used in CameraFrame to TelescopeFrame coordinate"
+            " transforms. The 'nominal' focal length is the one used during "
+            " the simulation, the 'effective' focal length is computed using specialized "
+            " ray-tracing from a point light source"
         ),
     ).tag(config=True)
 
@@ -316,17 +312,26 @@ class SimTelEventSource(EventSource):
             n_pixels = cam_settings["n_pixels"]
             mirror_area = u.Quantity(cam_settings["mirror_area"], u.m**2)
 
-            nominal_focal_length = u.Quantity(cam_settings["focal_length"], u.m)
+            equivalent_focal_length = u.Quantity(cam_settings["focal_length"], u.m)
             effective_focal_length = u.Quantity(
                 cam_settings.get("effective_focal_length", np.nan), u.m
             )
 
             try:
-                telescope = guess_telescope(n_pixels, nominal_focal_length)
+                telescope = guess_telescope(n_pixels, equivalent_focal_length)
             except ValueError:
                 telescope = unknown_telescope(mirror_area, n_pixels)
 
-            if self.focal_length_choice == "effective":
+            optics = OpticsDescription(
+                name=telescope.name,
+                num_mirrors=telescope.n_mirrors,
+                equivalent_focal_length=equivalent_focal_length,
+                effective_focal_length=effective_focal_length,
+                mirror_area=mirror_area,
+                num_mirror_tiles=cam_settings["n_mirrors"],
+            )
+
+            if self.focal_length_choice is FocalLengthKind.EFFECTIVE:
                 if np.isnan(effective_focal_length):
                     raise RuntimeError(
                         f"`SimTelEventSource.focal_length_choice` was set to"
@@ -336,22 +341,18 @@ class SimTelEventSource(EventSource):
                         " to include the effective focal length"
                     )
                 focal_length = effective_focal_length
+            elif self.focal_length_choice is FocalLengthKind.EQUIVALENT:
+                focal_length = equivalent_focal_length
             else:
-                focal_length = nominal_focal_length
-
-            optics = OpticsDescription(
-                name=telescope.name,
-                num_mirrors=telescope.n_mirrors,
-                equivalent_focal_length=focal_length,
-                mirror_area=mirror_area,
-                num_mirror_tiles=cam_settings["n_mirrors"],
-            )
+                raise ValueError(
+                    f"Invalid focal length choice: {self.focal_length_choice}"
+                )
 
             camera = build_camera(
                 cam_settings,
                 pixel_settings,
                 telescope,
-                frame=CameraFrame(focal_length=optics.equivalent_focal_length),
+                frame=CameraFrame(focal_length=focal_length),
             )
 
             tel_descriptions[tel_id] = TelescopeDescription(
diff --git a/ctapipe/io/tableloader.py b/ctapipe/io/tableloader.py
index e48eec5a..5d724cf1 100644
--- a/ctapipe/io/tableloader.py
+++ b/ctapipe/io/tableloader.py
@@ -3,19 +3,21 @@ Class and related functions to read DL1 (a,b) and/or DL2 (a) data
 from an HDF5 file produced with ctapipe-process.
 """
 
-from pathlib import Path
 import re
 from collections import defaultdict
+from pathlib import Path
 from typing import Dict
-from astropy.utils.decorators import lazyproperty
 
 import numpy as np
-from astropy.table import vstack, Table
 import tables
+from astropy.table import Table, vstack
+from astropy.utils.decorators import lazyproperty
+
+from ctapipe.instrument.optics import FocalLengthKind
 
-from ..core import Component, traits, Provenance
+from ..core import Component, Provenance, traits
 from ..instrument import SubarrayDescription
-from .astropy_helpers import read_table, join_allow_empty
+from .astropy_helpers import join_allow_empty, read_table
 
 __all__ = ["TableLoader"]
 
@@ -183,9 +185,22 @@ class TableLoader(Component):
         False, help="join subarray instrument information to each event"
     ).tag(config=True)
 
+    focal_length_choice = traits.UseEnum(
+        FocalLengthKind,
+        default_value=FocalLengthKind.EFFECTIVE,
+        help=(
+            "If both nominal and effective focal lengths are available, "
+            " which one to use for the `CameraFrame` attached"
+            " to the `CameraGeometry` instances in the `SubarrayDescription`"
+            ", which will be used in CameraFrame to TelescopeFrame coordinate"
+            " transforms. The 'nominal' focal length is the one used during "
+            " the simulation, the 'effective' focal length is computed using specialized "
+            " ray-tracing from a point light source"
+        ),
+    ).tag(config=True)
+
     def __init__(self, input_url=None, h5file=None, **kwargs):
         self._should_close = False
-
         # enable using input_url as posarg
         if input_url not in {None, traits.Undefined}:
             kwargs["input_url"] = input_url
@@ -203,7 +218,10 @@ class TableLoader(Component):
             self.input_url = Path(h5file.filename)
             self.h5file = h5file
 
-        self.subarray = SubarrayDescription.from_hdf(self.h5file)
+        self.subarray = SubarrayDescription.from_hdf(
+            self.h5file,
+            focal_length_choice=self.focal_length_choice,
+        )
 
         Provenance().add_input_file(self.input_url, role="Event data")
 
diff --git a/docs/conf.py b/docs/conf.py
index 2b23e1f0..904334eb 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -84,6 +84,7 @@ def setup(app):
         "Set",
         "TraitError",
         "Unicode",
+        "UseEnum",
     ]
     for alias in aliases:
         getattr(traits, alias).__name__ = alias
diff --git a/examples/load_one_event.py b/examples/load_one_event.py
index cf17b384..bb857362 100644
--- a/examples/load_one_event.py
+++ b/examples/load_one_event.py
@@ -5,10 +5,10 @@ purposes
 import sys
 
 from ctapipe.calib import CameraCalibrator
-from ctapipe.io import EventSource
-from ctapipe.utils import get_dataset_path
 from ctapipe.image import ImageProcessor
+from ctapipe.io import EventSource
 from ctapipe.reco import ShowerProcessor
+from ctapipe.utils import get_dataset_path
 
 if __name__ == "__main__":
 
@@ -19,7 +19,9 @@ if __name__ == "__main__":
             "gamma_LaPalma_baseline_20Zd_180Az_prod3b_test.simtel.gz"
         )
 
-    with EventSource(filename, max_events=1, focal_length_choice="nominal") as source:
+    with EventSource(
+        filename, max_events=1, focal_length_choice="EQUIVALENT"
+    ) as source:
         calib = CameraCalibrator(subarray=source.subarray)
         process_images = ImageProcessor(subarray=source.subarray)
         process_shower = ShowerProcessor(subarray=source.subarray)


Test patch:
diff --git a/ctapipe/conftest.py b/ctapipe/conftest.py
index 8742faed..a40d0c0c 100644
--- a/ctapipe/conftest.py
+++ b/ctapipe/conftest.py
@@ -5,14 +5,13 @@ common pytest fixtures for tests in ctapipe
 from copy import deepcopy
 
 import pytest
+from pytest_astropy_header.display import PYTEST_HEADER_MODULES
 
 from ctapipe.instrument import CameraGeometry
 from ctapipe.io import SimTelEventSource
 from ctapipe.utils import get_dataset_path
 from ctapipe.utils.filelock import FileLock
 
-from pytest_astropy_header.display import PYTEST_HEADER_MODULES
-
 PYTEST_HEADER_MODULES.clear()
 PYTEST_HEADER_MODULES["eventio"] = "eventio"
 PYTEST_HEADER_MODULES["numpy"] = "numpy"
@@ -54,7 +53,9 @@ def _global_example_event():
     print("******************** LOAD TEST EVENT ***********************")
 
     # FIXME: switch to prod5b+ file that contains effective focal length
-    with SimTelEventSource(input_url=filename, focal_length_choice="nominal") as reader:
+    with SimTelEventSource(
+        input_url=filename, focal_length_choice="EQUIVALENT"
+    ) as reader:
         event = next(iter(reader))
 
     return event
@@ -69,7 +70,9 @@ def example_subarray():
 
     print("******************** LOAD TEST EVENT ***********************")
 
-    with SimTelEventSource(input_url=filename, focal_length_choice="nominal") as reader:
+    with SimTelEventSource(
+        input_url=filename, focal_length_choice="EQUIVALENT"
+    ) as reader:
         return reader.subarray
 
 
@@ -94,7 +97,7 @@ def _subarray_and_event_gamma_off_axis_500_gev():
 
     path = get_dataset_path("lst_prod3_calibration_and_mcphotons.simtel.zst")
 
-    with SimTelEventSource(path, focal_length_choice="nominal") as source:
+    with SimTelEventSource(path, focal_length_choice="EQUIVALENT") as source:
         it = iter(source)
         # we want the second event, first event is a corner clipper
         next(it)
@@ -288,8 +291,8 @@ def dl1_by_type_file(dl1_tmp_path, prod5_gamma_simtel_path):
     """
     DL1 file containing both images and parameters from a gamma simulation set.
     """
-    from ctapipe.tools.process import ProcessorTool
     from ctapipe.core import run_tool
+    from ctapipe.tools.process import ProcessorTool
 
     output = dl1_tmp_path / "gamma_by_type.dl1.h5"
 
@@ -384,7 +387,7 @@ def dl1_muon_file(dl1_tmp_path):
             "--write-images",
             "--DataWriter.write_parameters=False",
             "--DataWriter.Contact.name=αℓℓ the äüöß",
-            "--SimTelEventSource.focal_length_choice=nominal",
+            "--SimTelEventSource.focal_length_choice=EQUIVALENT",
         ]
         assert run_tool(ProcessorTool(), argv=argv, cwd=dl1_tmp_path) == 0
         return output
@@ -395,8 +398,8 @@ def dl1_proton_file(dl1_tmp_path, prod5_proton_simtel_path):
     """
     DL1 file containing images and parameters for a prod5 proton run
     """
-    from ctapipe.tools.process import ProcessorTool
     from ctapipe.core import run_tool
+    from ctapipe.tools.process import ProcessorTool
 
     output = dl1_tmp_path / "proton.dl1.h5"
 
diff --git a/ctapipe/instrument/tests/test_optics.py b/ctapipe/instrument/tests/test_optics.py
index 966bb31e..fb815d5d 100644
--- a/ctapipe/instrument/tests/test_optics.py
+++ b/ctapipe/instrument/tests/test_optics.py
@@ -13,7 +13,7 @@ from ctapipe.utils.datasets import get_dataset_path
 
 
 def test_guess_optics():
-    """ make sure we can guess an optics type from metadata"""
+    """make sure we can guess an optics type from metadata"""
     from ctapipe.instrument import guess_telescope
 
     answer = guess_telescope(1855, 28.0 * u.m)
@@ -31,8 +31,9 @@ def test_construct_optics():
         name="test",
         num_mirrors=1,
         num_mirror_tiles=100,
-        mirror_area=u.Quantity(550, u.m ** 2),
+        mirror_area=u.Quantity(550, u.m**2),
         equivalent_focal_length=u.Quantity(10, u.m),
+        effective_focal_length=u.Quantity(11, u.m),
     )
 
     with pytest.raises(TypeError):
@@ -42,12 +43,13 @@ def test_construct_optics():
             num_mirror_tiles=100,
             mirror_area=550,
             equivalent_focal_length=10,
+            effective_focal_length=11,
         )
 
 
 @pytest.mark.parametrize("optics_name", OpticsDescription.get_known_optics_names())
 def test_optics_from_name(optics_name):
-    """ try constructing all by name """
+    """try constructing all by name"""
     optics = OpticsDescription.from_name(optics_name)
     assert optics.equivalent_focal_length > 0
     # make sure the string rep gives back the name:
@@ -58,7 +60,7 @@ def test_optics_from_name_user_supplied_table():
     table = get_table_dataset("optics", role="")
     optics = OpticsDescription.from_name("SST-GCT", optics_table=table)
     assert optics.name == "SST-GCT"
-    assert optics.mirror_area > 1.0 * u.m ** 2
+    assert optics.mirror_area > 1.0 * u.m**2
 
 
 def test_optics_from_dump_instrument():
diff --git a/ctapipe/instrument/tests/test_subarray.py b/ctapipe/instrument/tests/test_subarray.py
index 1124b5f7..03bba2ff 100644
--- a/ctapipe/instrument/tests/test_subarray.py
+++ b/ctapipe/instrument/tests/test_subarray.py
@@ -1,11 +1,11 @@
 """ Tests for SubarrayDescriptions """
 from copy import deepcopy
-from astropy.coordinates.earth import EarthLocation
 
 import numpy as np
+import pytest
 from astropy import units as u
 from astropy.coordinates import SkyCoord
-import pytest
+from astropy.coordinates.earth import EarthLocation
 
 from ctapipe.coordinates import TelescopeFrame
 from ctapipe.instrument import (
@@ -128,7 +128,7 @@ def test_hdf(example_subarray, tmp_path):
     path = tmp_path / "subarray.h5"
 
     example_subarray.to_hdf(path)
-    read = SubarrayDescription.from_hdf(path)
+    read = SubarrayDescription.from_hdf(path, focal_length_choice="EQUIVALENT")
 
     assert example_subarray == read
 
@@ -147,7 +147,7 @@ def test_hdf(example_subarray, tmp_path):
     with tables.open_file(path, "r+") as hdf:
         del hdf.root.configuration.instrument.subarray._v_attrs.name
 
-    no_name = SubarrayDescription.from_hdf(path)
+    no_name = SubarrayDescription.from_hdf(path, focal_length_choice="EQUIVALENT")
     assert no_name.name == "Unknown"
 
     # Test we can also write and read to an already opened h5file
@@ -155,7 +155,8 @@ def test_hdf(example_subarray, tmp_path):
         example_subarray.to_hdf(h5file)
 
     with tables.open_file(path, "r") as h5file:
-        assert SubarrayDescription.from_hdf(h5file) == example_subarray
+        read = SubarrayDescription.from_hdf(h5file, focal_length_choice="EQUIVALENT")
+        assert read == example_subarray
 
 
 def test_hdf_same_camera(tmp_path):
@@ -172,7 +173,7 @@ def test_hdf_same_camera(tmp_path):
 
     path = tmp_path / "subarray.h5"
     array.to_hdf(path)
-    read = SubarrayDescription.from_hdf(path)
+    read = SubarrayDescription.from_hdf(path, focal_length_choice="EQUIVALENT")
     assert array == read
 
 
@@ -202,7 +203,7 @@ def test_hdf_duplicate_string_repr(tmp_path):
 
     path = tmp_path / "subarray.h5"
     array.to_hdf(path)
-    read = SubarrayDescription.from_hdf(path)
+    read = SubarrayDescription.from_hdf(path, focal_length_choice="EQUIVALENT")
     assert array == read
     assert (
         read.tel[1].optics.num_mirror_tiles == read.tel[2].optics.num_mirror_tiles + 1
diff --git a/ctapipe/io/tests/test_datawriter.py b/ctapipe/io/tests/test_datawriter.py
index 71af6357..d1b32f5e 100644
--- a/ctapipe/io/tests/test_datawriter.py
+++ b/ctapipe/io/tests/test_datawriter.py
@@ -6,13 +6,14 @@ from pathlib import Path
 
 import numpy as np
 import tables
-from traitlets.config import Config
 from astropy import units as u
+from traitlets.config import Config
+
 from ctapipe.calib import CameraCalibrator
 from ctapipe.containers import (
     ParticleClassificationContainer,
-    ReconstructedGeometryContainer,
     ReconstructedEnergyContainer,
+    ReconstructedGeometryContainer,
 )
 from ctapipe.instrument import SubarrayDescription
 from ctapipe.io import DataLevel, EventSource
@@ -66,19 +67,12 @@ def generate_dummy_dl2(event):
 def test_write(tmpdir: Path):
     """
     Check that we can write and read data from R0-DL2 to files
-
-    Parameters
-    ----------
-    tmpdir :
-        temp directory fixture
     """
 
     output_path = Path(tmpdir / "events.dl1.h5")
     source = EventSource(
-        get_dataset_path("gamma_LaPalma_baseline_20Zd_180Az_prod3b_test.simtel.gz"),
-        max_events=20,
-        allowed_tels=[1, 2, 3, 4],
-        focal_length_choice="nominal",
+        get_dataset_path("gamma_prod5.simtel.zst"),
+        focal_length_choice="EQUIVALENT",
     )
     calibrate = CameraCalibrator(subarray=source.subarray)
 
@@ -108,15 +102,15 @@ def test_write(tmpdir: Path):
     # full test of the data model, a verify tool should be created.
     with tables.open_file(output_path) as h5file:
         # check R0:
-        r0tel = h5file.get_node("/r0/event/telescope/tel_001")
+        r0tel = h5file.get_node("/r0/event/telescope/tel_004")
         assert r0tel.col("waveform").max() > 0
 
         # check R1:
-        r1tel = h5file.get_node("/r1/event/telescope/tel_001")
+        r1tel = h5file.get_node("/r1/event/telescope/tel_004")
         assert r1tel.col("waveform").max() > 0
 
         # check DL1:
-        images = h5file.get_node("/dl1/event/telescope/images/tel_001")
+        images = h5file.get_node("/dl1/event/telescope/images/tel_004")
         assert images.col("image").max() > 0.0
         assert (
             h5file.root._v_attrs[
@@ -138,7 +132,7 @@ def test_write(tmpdir: Path):
             assert np.count_nonzero(dl2_energy.col(f"{prefix}_tel_ids")[0]) == 3
 
             dl2_tel_energy = h5file.get_node(
-                f"/dl2/event/telescope/energy/{prefix}/tel_002"
+                f"/dl2/event/telescope/energy/{prefix}/tel_004"
             )
             assert np.allclose(dl2_tel_energy.col(f"{prefix}_tel_energy"), 10)
             assert "tel_ids" not in dl2_tel_energy
@@ -156,10 +150,8 @@ def test_roundtrip(tmpdir: Path):
 
     output_path = Path(tmpdir / "events.DL1DL2.h5")
     source = EventSource(
-        get_dataset_path("gamma_LaPalma_baseline_20Zd_180Az_prod3b_test.simtel.gz"),
-        max_events=20,
-        allowed_tels=[1, 2, 3, 4],
-        focal_length_choice="nominal",
+        get_dataset_path("gamma_prod5.simtel.zst"),
+        focal_length_choice="EQUIVALENT",
     )
     calibrate = CameraCalibrator(subarray=source.subarray)
 
@@ -198,7 +190,7 @@ def test_roundtrip(tmpdir: Path):
     # check a few things in the output just to make sure there is output. For a
     # full test of the data model, a verify tool should be created.
     with tables.open_file(output_path) as h5file:
-        images = h5file.get_node("/dl1/event/telescope/images/tel_001")
+        images = h5file.get_node("/dl1/event/telescope/images/tel_004")
 
         assert len(images) > 0
         assert images.col("image").dtype == np.int32
@@ -230,10 +222,7 @@ def test_dl1writer_no_events(tmpdir: Path):
     """
 
     output_path = Path(tmpdir / "no_events.dl1.h5")
-    dataset = "lst_prod3_calibration_and_mcphotons.simtel.zst"
-    with EventSource(
-        get_dataset_path(dataset), focal_length_choice="nominal"
-    ) as source:
+    with EventSource("dataset://gamma_prod5.simtel.zst") as source:
         # exhaust source
         for _ in source:
             pass
diff --git a/ctapipe/io/tests/test_eventseeker.py b/ctapipe/io/tests/test_eventseeker.py
index 58ec2547..4adc648e 100644
--- a/ctapipe/io/tests/test_eventseeker.py
+++ b/ctapipe/io/tests/test_eventseeker.py
@@ -1,7 +1,8 @@
-from ctapipe.utils import get_dataset_path
+import pytest
+
 from ctapipe.io import SimTelEventSource
 from ctapipe.io.eventseeker import EventSeeker
-import pytest
+from ctapipe.utils import get_dataset_path
 
 dataset = get_dataset_path("gamma_test_large.simtel.gz")
 
@@ -11,7 +12,7 @@ def test_eventseeker():
     with SimTelEventSource(
         input_url=dataset,
         back_seekable=True,
-        focal_length_choice='nominal',
+        focal_length_choice="EQUIVALENT",
     ) as reader:
 
         seeker = EventSeeker(event_source=reader)
@@ -37,8 +38,10 @@ def test_eventseeker():
             seeker.get_event_index(dict())
 
     with SimTelEventSource(
-        input_url=dataset, max_events=5, back_seekable=True,
-        focal_length_choice='nominal',
+        input_url=dataset,
+        max_events=5,
+        back_seekable=True,
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         seeker = EventSeeker(event_source=reader)
         with pytest.raises(IndexError):
@@ -48,8 +51,9 @@ def test_eventseeker():
 
 def test_eventseeker_edit():
     with SimTelEventSource(
-        input_url=dataset, back_seekable=True,
-        focal_length_choice='nominal',
+        input_url=dataset,
+        back_seekable=True,
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         seeker = EventSeeker(event_source=reader)
         event = seeker.get_event_index(1)
@@ -63,8 +67,9 @@ def test_eventseeker_edit():
 def test_eventseeker_simtel():
     # Ensure the EventSeeker can forward seek even if back-seeking is not possible
     with SimTelEventSource(
-        input_url=dataset, back_seekable=False,
-        focal_length_choice='nominal',
+        input_url=dataset,
+        back_seekable=False,
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         seeker = EventSeeker(event_source=reader)
         event = seeker.get_event_index(1)
diff --git a/ctapipe/io/tests/test_prod2.py b/ctapipe/io/tests/test_prod2.py
index 98be8be0..03cea37f 100644
--- a/ctapipe/io/tests/test_prod2.py
+++ b/ctapipe/io/tests/test_prod2.py
@@ -11,7 +11,7 @@ def test_eventio_prod2():
     with pytest.warns(UnknownPixelShapeWarning):
         with SimTelEventSource(
             input_url=dataset,
-            focal_length_choice='nominal',
+            focal_length_choice="EQUIVALENT",
         ) as reader:
             for event in reader:
                 if event.count == 2:
diff --git a/ctapipe/io/tests/test_simteleventsource.py b/ctapipe/io/tests/test_simteleventsource.py
index 49bc2d38..6694d926 100644
--- a/ctapipe/io/tests/test_simteleventsource.py
+++ b/ctapipe/io/tests/test_simteleventsource.py
@@ -1,21 +1,19 @@
 import copy
-from ctapipe.instrument.camera.geometry import UnknownPixelShapeWarning
+from itertools import zip_longest
+from pathlib import Path
 
-import numpy as np
 import astropy.units as u
-from astropy.coordinates import Angle
-from itertools import zip_longest
+import numpy as np
 import pytest
+from astropy.coordinates import Angle
 from astropy.time import Time
-from pathlib import Path
 from traitlets.config import Config
 
-
 from ctapipe.calib.camera.gainselection import ThresholdGainSelector
+from ctapipe.instrument.camera.geometry import UnknownPixelShapeWarning
+from ctapipe.io import DataLevel
 from ctapipe.io.simteleventsource import SimTelEventSource, apply_simtel_r1_calibration
 from ctapipe.utils import get_dataset_path
-from ctapipe.io import DataLevel
-
 
 gamma_test_large_path = get_dataset_path("gamma_test_large.simtel.gz")
 gamma_test_path = get_dataset_path("gamma_test.simtel.gz")
@@ -36,7 +34,7 @@ def test_simtel_event_source_on_gamma_test_one_event():
     with SimTelEventSource(
         input_url=gamma_test_large_path,
         back_seekable=True,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         assert not reader.is_stream
 
@@ -69,7 +67,7 @@ def test_that_event_is_not_modified_after_loop():
 def test_additional_meta_data_from_simulation_config():
     with SimTelEventSource(
         input_url=gamma_test_large_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         pass
 
@@ -111,7 +109,7 @@ def test_additional_meta_data_from_simulation_config():
 def test_properties():
     source = SimTelEventSource(
         input_url=gamma_test_large_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     )
 
     assert source.is_simulation
@@ -126,7 +124,7 @@ def test_gamma_file_prod2():
     with pytest.warns(UnknownPixelShapeWarning):
         with SimTelEventSource(
             input_url=dataset,
-            focal_length_choice="nominal",
+            focal_length_choice="EQUIVALENT",
         ) as reader:
             assert reader.is_compatible(dataset)
             assert reader.is_stream  # using gzip subprocess makes it a stream
@@ -147,7 +145,7 @@ def test_max_events():
     with SimTelEventSource(
         input_url=gamma_test_large_path,
         max_events=max_events,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         count = 0
         for _ in reader:
@@ -159,7 +157,7 @@ def test_pointing():
     with SimTelEventSource(
         input_url=gamma_test_large_path,
         max_events=3,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         for e in reader:
             assert np.isclose(e.pointing.array_altitude.to_value(u.deg), 70)
@@ -180,7 +178,7 @@ def test_allowed_telescopes():
         input_url=gamma_test_large_path,
         allowed_tels=allowed_tels,
         max_events=5,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         assert not allowed_tels.symmetric_difference(reader.subarray.tel_ids)
         for event in reader:
@@ -212,7 +210,7 @@ def test_calibration_events():
     with SimTelEventSource(
         input_url=calib_events_path,
         skip_calibration_events=False,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
         for event, expected_type, expected_id in zip_longest(
             reader, expected_types, expected_ids
@@ -225,7 +223,7 @@ def test_trigger_times():
 
     source = SimTelEventSource(
         input_url=calib_events_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     )
     t0 = Time("2020-05-06T15:30:00")
     t1 = Time("2020-05-06T15:40:00")
@@ -240,7 +238,7 @@ def test_trigger_times():
 def test_true_image():
     with SimTelEventSource(
         input_url=calib_events_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as reader:
 
         for event in reader:
@@ -252,7 +250,7 @@ def test_instrument():
     """Test if same telescope types share a single instance of CameraGeometry"""
     source = SimTelEventSource(
         input_url=gamma_test_large_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     )
     subarray = source.subarray
     assert subarray.tel[1].optics.num_mirrors == 1
@@ -319,23 +317,47 @@ def test_focal_length_choice():
         SimTelEventSource(gamma_test_large_path)
 
     with pytest.raises(RuntimeError):
-        SimTelEventSource(gamma_test_large_path, focal_length_choice="effective")
+        SimTelEventSource(gamma_test_large_path, focal_length_choice="EFFECTIVE")
 
-    s = SimTelEventSource(gamma_test_large_path, focal_length_choice="nominal")
-    assert s.subarray.tel[1].optics.equivalent_focal_length == 28 * u.m
+    s = SimTelEventSource(gamma_test_large_path, focal_length_choice="EQUIVALENT")
+    tel = s.subarray.tel[1]
+    assert tel.camera.geometry.frame.focal_length == 28 * u.m
+    assert u.isclose(tel.optics.equivalent_focal_length, 28.0 * u.m, atol=0.05 * u.m)
+    assert np.isnan(tel.optics.effective_focal_length)
 
     # this file does
-    s = SimTelEventSource(prod5b_path, focal_length_choice="effective")
+    s = SimTelEventSource(prod5b_path, focal_length_choice="EFFECTIVE")
     assert u.isclose(
-        s.subarray.tel[1].optics.equivalent_focal_length, 29.3 * u.m, atol=0.05 * u.m
+        s.subarray.tel[1].optics.equivalent_focal_length, 28.0 * u.m, atol=0.05 * u.m
+    )
+    assert u.isclose(
+        s.subarray.tel[1].optics.effective_focal_length, 29.3 * u.m, atol=0.05 * u.m
+    )
+    assert u.isclose(
+        s.subarray.tel[1].camera.geometry.frame.focal_length,
+        29.3 * u.m,
+        atol=0.05 * u.m,
+    )
+
+    s = SimTelEventSource(prod5b_path, focal_length_choice="EQUIVALENT")
+    assert u.isclose(
+        s.subarray.tel[1].optics.equivalent_focal_length, 28.0 * u.m, atol=0.05 * u.m
     )
     # check guessing of the name is not affected by focal length choice
     assert str(s.subarray.tel[1]) == "LST_LST_LSTCam"
 
-    s = SimTelEventSource(prod5b_path, focal_length_choice="nominal")
+    s = SimTelEventSource(prod5b_path, focal_length_choice="EQUIVALENT")
     assert u.isclose(
         s.subarray.tel[1].optics.equivalent_focal_length, 28.0 * u.m, atol=0.05 * u.m
     )
+    assert u.isclose(
+        s.subarray.tel[1].optics.effective_focal_length, 29.3 * u.m, atol=0.05 * u.m
+    )
+    assert u.isclose(
+        s.subarray.tel[1].camera.geometry.frame.focal_length,
+        28.0 * u.m,
+        atol=0.05 * u.m,
+    )
     # check guessing of the name is not affected by focal length choice
     assert str(s.subarray.tel[1]) == "LST_LST_LSTCam"
 
@@ -389,14 +411,14 @@ def test_true_image_sum():
     # this file does not contain true pe info
     with SimTelEventSource(
         gamma_test_large_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as s:
         e = next(iter(s))
         assert np.all(np.isnan(sim.true_image_sum) for sim in e.simulation.tel.values())
 
     with SimTelEventSource(
         calib_events_path,
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as s:
         e = next(iter(s))
 
@@ -412,7 +434,7 @@ def test_true_image_sum():
     with SimTelEventSource(
         calib_events_path,
         allowed_tels={2, 3},
-        focal_length_choice="nominal",
+        focal_length_choice="EQUIVALENT",
     ) as s:
         e = next(iter(s))
         assert e.simulation.tel[2].true_image_sum == true_image_sums[2]
diff --git a/ctapipe/reco/tests/test_HillasReconstructor.py b/ctapipe/reco/tests/test_HillasReconstructor.py
index b926184f..d50c882c 100644
--- a/ctapipe/reco/tests/test_HillasReconstructor.py
+++ b/ctapipe/reco/tests/test_HillasReconstructor.py
@@ -1,24 +1,25 @@
 from copy import deepcopy
+
 import numpy as np
-from astropy import units as u
 import pytest
-
+from astropy import units as u
+from astropy.coordinates import AltAz, SkyCoord
 from traitlets.config import Config
+
+from ctapipe.calib import CameraCalibrator
 from ctapipe.containers import HillasParametersContainer
 from ctapipe.image.image_processor import ImageProcessor
 from ctapipe.instrument import SubarrayDescription, TelescopeDescription
 from ctapipe.io import SimTelEventSource
-from ctapipe.reco.hillas_reconstructor import HillasReconstructor, HillasPlane
+from ctapipe.reco.hillas_reconstructor import HillasPlane, HillasReconstructor
 from ctapipe.utils import get_dataset_path
-from astropy.coordinates import SkyCoord, AltAz
-from ctapipe.calib import CameraCalibrator
 
 
 def test_estimator_results():
     """
     creating some planes pointing in different directions (two
     north-south, two east-west) and that have a slight position errors (+-
-    0.1 m in one of the four cardinal directions """
+    0.1 m in one of the four cardinal directions"""
     horizon_frame = AltAz()
 
     p1 = SkyCoord(alt=43 * u.deg, az=45 * u.deg, frame=horizon_frame)
@@ -63,7 +64,7 @@ def test_h_max_results(example_subarray):
     """
     creating some planes pointing in different directions (two
     north-south, two east-west) and that have a slight position errors (+-
-    0.1 m in one of the four cardinal directions """
+    0.1 m in one of the four cardinal directions"""
     horizon_frame = AltAz()
 
     p1 = SkyCoord(alt=0 * u.deg, az=45 * u.deg, frame=horizon_frame)
@@ -121,11 +122,13 @@ def test_invalid_events(subarray_and_event_gamma_off_axis_500_gev):
 
     # perform no quality cuts, so we can see if our additional checks on valid
     # input work
-    config = Config({
-        "StereoQualityQuery": {
-            "quality_criteria": [],
+    config = Config(
+        {
+            "StereoQualityQuery": {
+                "quality_criteria": [],
+            }
         }
-    })
+    )
     hillas_reconstructor = HillasReconstructor(subarray, config=config)
 
     calib(event)
@@ -192,26 +195,30 @@ def test_reconstruction_against_simulation(subarray_and_event_gamma_off_axis_500
     "filename",
     [
         "gamma_divergent_LaPalma_baseline_20Zd_180Az_prod3_test.simtel.gz",
-        "gamma_LaPalma_baseline_20Zd_180Az_prod3b_test.simtel.gz"
-    ]
+        "gamma_LaPalma_baseline_20Zd_180Az_prod3b_test.simtel.gz",
+    ],
 )
 def test_CameraFrame_against_TelescopeFrame(filename):
 
     input_file = get_dataset_path(filename)
-        # "gamma_divergent_LaPalma_baseline_20Zd_180Az_prod3_test.simtel.gz"
+    # "gamma_divergent_LaPalma_baseline_20Zd_180Az_prod3_test.simtel.gz"
     # )
 
-    source = SimTelEventSource(input_file, max_events=10, focal_length_choice="nominal")
+    source = SimTelEventSource(
+        input_file, max_events=10, focal_length_choice="EQUIVALENT"
+    )
 
     # too few events survive for this test with the defautl quality criteria,
     # use less restrictive ones
-    config = Config({
-        "StereoQualityQuery": {
-            "quality_criteria": [
-                ("valid_width", "parameters.hillas.width.value > 0"),
-            ]
+    config = Config(
+        {
+            "StereoQualityQuery": {
+                "quality_criteria": [
+                    ("valid_width", "parameters.hillas.width.value > 0"),
+                ]
+            }
         }
-    })
+    )
 
     calib = CameraCalibrator(subarray=source.subarray)
     reconstructor = HillasReconstructor(source.subarray, config=config)
@@ -247,10 +254,12 @@ def test_CameraFrame_against_TelescopeFrame(filename):
                 tel = getattr(result_telescope_frame, field)
 
                 if hasattr(cam, "unit"):
-                    assert u.isclose(cam, tel, rtol=1e-3, atol=1e-3 * tel.unit, equal_nan=True)
+                    assert u.isclose(
+                        cam, tel, rtol=1e-3, atol=1e-3 * tel.unit, equal_nan=True
+                    )
                 elif isinstance(cam, list):
                     assert cam == tel
                 else:
                     assert np.isclose(cam, tel, rtol=1e-3, atol=1e-3, equal_nan=True)
 
-    assert reconstructed_events > 0 # check that we reconstruct at least 1 event
+    assert reconstructed_events > 0  # check that we reconstruct at least 1 event
diff --git a/ctapipe/reco/tests/test_reconstruction_methods.py b/ctapipe/reco/tests/test_reconstruction_methods.py
index ec99c748..6acea98a 100644
--- a/ctapipe/reco/tests/test_reconstruction_methods.py
+++ b/ctapipe/reco/tests/test_reconstruction_methods.py
@@ -1,13 +1,11 @@
+import pytest
 from astropy import units as u
 
+from ctapipe.calib import CameraCalibrator
 from ctapipe.image import ImageProcessor
 from ctapipe.io import EventSource
-from ctapipe.reco import HillasReconstructor, HillasIntersection
-
+from ctapipe.reco import HillasIntersection, HillasReconstructor
 from ctapipe.utils import get_dataset_path
-from ctapipe.calib import CameraCalibrator
-
-import pytest
 
 
 @pytest.fixture
@@ -30,7 +28,7 @@ def test_reconstructors(reconstructors):
         "gamma_LaPalma_baseline_20Zd_180Az_prod3b_test.simtel.gz"
     )
 
-    source = EventSource(filename, max_events=10, focal_length_choice="nominal")
+    source = EventSource(filename, max_events=10, focal_length_choice="EQUIVALENT")
     subarray = source.subarray
     calib = CameraCalibrator(source.subarray)
     image_processor = ImageProcessor(source.subarray)
diff --git a/ctapipe/tools/tests/test_process.py b/ctapipe/tools/tests/test_process.py
index f463ab53..32cbf62b 100644
--- a/ctapipe/tools/tests/test_process.py
+++ b/ctapipe/tools/tests/test_process.py
@@ -8,6 +8,7 @@ import numpy as np
 import pandas as pd
 import pytest
 import tables
+
 from ctapipe.core import run_tool
 from ctapipe.instrument.subarray import SubarrayDescription
 from ctapipe.io import DataLevel, EventSource, read_table
@@ -215,11 +216,9 @@ def test_stage_2_from_simtel(tmp_path):
             ProcessorTool(),
             argv=[
                 f"--config={config}",
-                f"--input={GAMMA_TEST_LARGE}",
+                "--input=dataset://gamma_prod5.simtel.zst",
                 f"--output={output}",
-                "--max-events=5",
                 "--overwrite",
-                "--SimTelEventSource.focal_length_choice=nominal",
             ],
             cwd=tmp_path,
         )
@@ -304,7 +303,7 @@ def test_training_from_simtel(tmp_path):
                 f"--output={output}",
                 "--max-events=5",
                 "--overwrite",
-                "--SimTelEventSource.focal_length_choice=nominal",
+                "--SimTelEventSource.focal_length_choice=EQUIVALENT",
             ],
             cwd=tmp_path,
         )
diff --git a/ctapipe/tools/tests/test_tools.py b/ctapipe/tools/tests/test_tools.py
index ebf74a75..704f7029 100644
--- a/ctapipe/tools/tests/test_tools.py
+++ b/ctapipe/tools/tests/test_tools.py
@@ -8,12 +8,15 @@ import sys
 import matplotlib as mpl
 import numpy as np
 import tables
+
 from ctapipe.core import run_tool
 from ctapipe.utils import get_dataset_path
 
 GAMMA_TEST_LARGE = get_dataset_path("gamma_test_large.simtel.gz")
 LST_MUONS = get_dataset_path("lst_muons.simtel.zst")
-PROD5B_PATH = get_dataset_path("gamma_20deg_0deg_run2___cta-prod5-paranal_desert-2147m-Paranal-dark_cone10-100evts.simtel.zst")
+PROD5B_PATH = get_dataset_path(
+    "gamma_20deg_0deg_run2___cta-prod5-paranal_desert-2147m-Paranal-dark_cone10-100evts.simtel.zst"
+)
 
 
 def test_muon_reconstruction_simtel(tmp_path):
@@ -26,7 +29,7 @@ def test_muon_reconstruction_simtel(tmp_path):
             argv=[
                 f"--input={LST_MUONS}",
                 f"--output={muon_simtel_output_file}",
-                "--SimTelEventSource.focal_length_choice=nominal",
+                "--SimTelEventSource.focal_length_choice=EQUIVALENT",
                 "--overwrite",
             ],
             cwd=tmp_path,
@@ -50,6 +53,7 @@ def test_muon_reconstruction_dl1(tmp_path, dl1_muon_file):
             argv=[
                 f"--input={dl1_muon_file}",
                 f"--output={muon_dl1_output_file}",
+                "--HDF5EventSource.focal_length_choice=EQUIVALENT",
                 "--overwrite",
             ],
             cwd=tmp_path,
@@ -75,10 +79,11 @@ def test_display_dl1(tmp_path, dl1_image_file, dl1_parameters_file):
         run_tool(
             DisplayDL1Calib(),
             argv=[
-                "--max-events=1", "--telescope=11",
-                "--SimTelEventSource.focal_length_choice=nominal",
+                "--max-events=1",
+                "--telescope=11",
+                "--SimTelEventSource.focal_length_choice=EQUIVALENT",
             ],
-            cwd=tmp_path
+            cwd=tmp_path,
         )
         == 0
     )
@@ -106,7 +111,7 @@ def test_info():
 
 
 def test_fileinfo(tmp_path, dl1_image_file):
-    """ check we can run ctapipe-fileinfo and get results """
+    """check we can run ctapipe-fileinfo and get results"""
     import yaml
     from astropy.table import Table
 
@@ -150,15 +155,13 @@ def test_dump_instrument(tmp_path):
     assert (tmp_path / "FlashCam.camgeom.fits.gz").exists()
 
     assert (
-        run_tool(tool, [f"--input={PROD5B_PATH}", "--format=ecsv"], cwd=tmp_path)
-        == 0
+        run_tool(tool, [f"--input={PROD5B_PATH}", "--format=ecsv"], cwd=tmp_path) == 0
     )
 
     assert (tmp_path / "MonteCarloArray.optics.ecsv").exists()
 
     assert (
-        run_tool(tool, [f"--input={PROD5B_PATH}", "--format=hdf5"], cwd=tmp_path)
-        == 0
+        run_tool(tool, [f"--input={PROD5B_PATH}", "--format=hdf5"], cwd=tmp_path) == 0
     )
     assert (tmp_path / "subarray.h5").exists()