Repository: cta-observatory/ctapipe
Pull request #2019 (2022-07-15 11:03:22)

Add pixel, channel, sample information to CameraReadout, update OpticsDescription
This adapts our instrument data model a bit to get more in line with what we already know from the DL0 data model and it makes the optics tables in the file more consistent.


The `tel_type` attribute is moved from the `TelescopeDescription` to the `OpticsDescription` as `size_type` to avoid overriding the builin `type`.

A new enum `ReflectorShape` is introduced for OpticsDescription and it is deduced from simtel metadata  files where it is available and was added to the known telescopes in the guessing table.

The optics table now only contains one row per optics description, not one per telescope type:

On this branch:
```
optics_name size_type reflector_shape mirror_area num_mirrors num_mirror_tiles equivalent_focal_length effective_focal_length
ASTRI SST SCHWARZSCHILD_COUDER 14.126235008239746 2 2 2.1500000953674316 2.1519100666046143
MST MST HYBRID 106.2413558959961 1 86 16.0 16.445049285888672
LST LST PARABOLIC 386.7332458496094 1 198 28.0 29.30565071105957
```

On master:

```
description name type mirror_area num_mirrors num_mirror_tiles equivalent_focal_length effective_focal_length
MST_MST_NectarCam MST MST 106.2413558959961 1 86 16.0 16.445049285888672
LST_LST_LSTCam LST LST 386.7332458496094 1 198 28.0 29.30565071105957
MST_MST_FlashCam MST MST 106.2413558959961 1 86 16.0 16.445049285888672
SST_ASTRI_CHEC ASTRI SST 14.126235008239746 2 2 2.1500000953674316 2.1519100666046143
```


We also drop support for older data model version.


Patch:
diff --git a/ctapipe/instrument/__init__.py b/ctapipe/instrument/__init__.py
index 2c20f4a4..db941ce9 100644
--- a/ctapipe/instrument/__init__.py
+++ b/ctapipe/instrument/__init__.py
@@ -1,7 +1,7 @@
 from .atmosphere import get_atmosphere_profile_functions
 from .camera import CameraDescription, CameraGeometry, CameraReadout, PixelShape
 from .guess import guess_telescope
-from .optics import FocalLengthKind, OpticsDescription
+from .optics import FocalLengthKind, OpticsDescription, ReflectorShape, SizeType
 from .subarray import SubarrayDescription, UnknownTelescopeID
 from .telescope import TelescopeDescription
 
@@ -17,4 +17,6 @@ __all__ = [
     "TelescopeDescription",
     "UnknownTelescopeID",
     "FocalLengthKind",
+    "ReflectorShape",
+    "SizeType",
 ]
diff --git a/ctapipe/instrument/camera/description.py b/ctapipe/instrument/camera/description.py
index fe52b4a7..1d4efc27 100644
--- a/ctapipe/instrument/camera/description.py
+++ b/ctapipe/instrument/camera/description.py
@@ -2,9 +2,10 @@
 Classes pertaining to the description of a Cherenkov camera
 """
 
+from ctapipe.utils import find_all_matching_datasets
+
 from .geometry import CameraGeometry
 from .readout import CameraReadout
-from ctapipe.utils import find_all_matching_datasets
 
 __all__ = ["CameraDescription"]
 
@@ -16,7 +17,7 @@ class CameraDescription:
 
     Parameters
     ----------
-    camera_name: str
+    name: str
         Camera name (e.g. NectarCam, LSTCam, ...)
     geometry: CameraGeometry
        The pixel geometry of this camera
@@ -24,9 +25,15 @@ class CameraDescription:
        The readout properties for this camera
     """
 
-    def __init__(self, camera_name, geometry: CameraGeometry, readout: CameraReadout):
+    __slots__ = (
+        "name",
+        "geometry",
+        "readout",
+    )
+
+    def __init__(self, name, geometry: CameraGeometry, readout: CameraReadout):
 
-        self.camera_name = camera_name
+        self.name = name
         self.geometry = geometry
         self.readout = readout
 
@@ -53,13 +60,13 @@ class CameraDescription:
         return find_all_matching_datasets(pattern, regexp_group=1)
 
     @classmethod
-    def from_name(cls, camera_name):
+    def from_name(cls, name):
         """
         Construct a CameraDescription from a camera name
 
         Parameters
         ----------
-        camera_name: str
+        name: str
             Camera name (e.g. NectarCam, LSTCam, ...)
 
         Returns
@@ -68,20 +75,20 @@ class CameraDescription:
 
         """
 
-        geometry = CameraGeometry.from_name(camera_name)
+        geometry = CameraGeometry.from_name(name)
         try:
-            readout = CameraReadout.from_name(camera_name)
+            readout = CameraReadout.from_name(name)
         except FileNotFoundError:
             readout = None
-        return cls(camera_name=camera_name, geometry=geometry, readout=readout)
+        return cls(name=name, geometry=geometry, readout=readout)
 
     def __str__(self):
-        return f"{self.camera_name}"
+        return f"{self.name}"
 
     def __repr__(self):
-        return "{}(camera_name={}, geometry={}, readout={})".format(
+        return "{}(name={}, geometry={}, readout={})".format(
             self.__class__.__name__,
-            self.camera_name,
+            self.name,
             str(self.geometry),
             str(self.readout),
         )
diff --git a/ctapipe/instrument/camera/geometry.py b/ctapipe/instrument/camera/geometry.py
index afe69f0f..67a52b58 100644
--- a/ctapipe/instrument/camera/geometry.py
+++ b/ctapipe/instrument/camera/geometry.py
@@ -88,30 +88,33 @@ class CameraGeometry:
 
     Parameters
     ----------
-    self: type
+    self : type
         description
-    camera_name: str
+    name : str
          Camera name (e.g. NectarCam, LSTCam, ...)
-    pix_id: array(int)
+    pix_id : array(int)
         pixels id numbers
-    pix_x: array with units
+    pix_x : array with units
         position of each pixel (x-coordinate)
-    pix_y: array with units
+    pix_y : array with units
         position of each pixel (y-coordinate)
-    pix_area: array(float)
+    pix_area : array(float)
         surface area of each pixel, if None will be calculated
-    neighbors: list(arrays)
+    neighbors : list(arrays)
         adjacency list for each pixel
-    pix_type: string
+    pix_type : string
         either 'rectangular' or 'hexagonal'
-    pix_rotation: value convertable to an `astropy.coordinates.Angle`
+    pix_rotation : value convertable to an `astropy.coordinates.Angle`
         rotation angle with unit (e.g. 12 * u.deg), or "12d"
-    cam_rotation: overall camera rotation with units
+    cam_rotation : overall camera rotation with units
     """
 
+    CURRENT_TAB_VERSION = "2.0"
+    SUPPORTED_TAB_VERSIONS = {"1.0", "1", "1.1", "2.0"}
+
     def __init__(
         self,
-        camera_name,
+        name,
         pix_id,
         pix_x,
         pix_y,
@@ -138,7 +141,7 @@ class CameraGeometry:
             )
 
         self.n_pixels = len(pix_x)
-        self.camera_name = camera_name
+        self.name = name
         self.pix_id = pix_id
         self.pix_x = pix_x
         self.pix_y = pix_y
@@ -166,9 +169,6 @@ class CameraGeometry:
             else:
                 self._neighbors = csr_matrix(neighbors)
 
-        if self.pix_area is None:
-            self.pix_area = self.guess_pixel_area(pix_x, pix_y, pix_type)
-
         if apply_derotation:
             self.rotate(self.cam_rotation)
 
@@ -176,7 +176,10 @@ class CameraGeometry:
         self._border_cache = {}
 
     def __eq__(self, other):
-        if self.camera_name != other.camera_name:
+        if not isinstance(other, CameraGeometry):
+            return NotImplemented
+
+        if self.name != other.name:
             return False
 
         if self.n_pixels != other.n_pixels:
@@ -287,7 +290,7 @@ class CameraGeometry:
         pix_area = (cam.pix_area * scale**2).to(trans_x.unit**2)
 
         return CameraGeometry(
-            camera_name=cam.camera_name,
+            name=cam.name,
             pix_id=cam.pix_id,
             pix_x=trans_x,
             pix_y=trans_y,
@@ -303,7 +306,7 @@ class CameraGeometry:
     def __hash__(self):
         return hash(
             (
-                self.camera_name,
+                self.name,
                 round(self.pix_x[0].value, 3),
                 round(self.pix_y[0].value, 3),
                 self.pix_type,
@@ -316,7 +319,7 @@ class CameraGeometry:
 
     def __getitem__(self, slice_):
         return CameraGeometry(
-            camera_name=" ".join([self.camera_name, " sliced"]),
+            name=" ".join([self.name, " sliced"]),
             pix_id=self.pix_id[slice_],
             pix_x=self.pix_x[slice_],
             pix_y=self.pix_y[slice_],
@@ -328,27 +331,6 @@ class CameraGeometry:
             apply_derotation=False,
         )
 
-    @classmethod
-    def guess_pixel_area(cls, pix_x, pix_y, pix_type):
-        """
-        Guess pixel area based on the pixel type and layout.
-        This first uses `guess_pixel_width` and then calculates
-        area from the given pixel type.
-
-        Note this will not work on cameras with varying pixel sizes.
-        """
-
-        dist = cls.guess_pixel_width(pix_x, pix_y)
-
-        if pix_type == PixelShape.HEXAGON:
-            area = 2 * np.sqrt(3) * (dist / 2) ** 2
-        elif pix_type == PixelShape.SQUARE:
-            area = dist**2
-        else:
-            raise KeyError("unsupported pixel type")
-
-        return np.ones(pix_x.shape) * area
-
     @lazyproperty
     def pixel_width(self):
         """
@@ -572,7 +554,7 @@ class CameraGeometry:
         return np.squeeze(image_1d)
 
     @classmethod
-    def from_name(cls, camera_name="NectarCam", version=None):
+    def from_name(cls, name="NectarCam", version=None):
         """
         Construct a CameraGeometry using the name of the camera and array.
 
@@ -582,10 +564,10 @@ class CameraGeometry:
 
         Parameters
         ----------
-        camera_name: str
+        name : str
             Camera name (e.g. NectarCam, LSTCam, ...)
-        version:
-            camera version id (currently unused)
+        version :
+            camera version id
 
         Returns
         -------
@@ -597,9 +579,7 @@ class CameraGeometry:
         else:
             verstr = f"-{version:03d}"
 
-        tabname = "{camera_name}{verstr}.camgeom".format(
-            camera_name=camera_name, verstr=verstr
-        )
+        tabname = "{name}{verstr}.camgeom".format(name=name, verstr=verstr)
         table = get_table_dataset(tabname, role="dl0.tel.svc.camera")
         return CameraGeometry.from_table(table)
 
@@ -613,8 +593,8 @@ class CameraGeometry:
             meta=dict(
                 PIX_TYPE=self.pix_type.value,
                 TAB_TYPE="ctapipe.instrument.CameraGeometry",
-                TAB_VER="1.1",
-                CAM_ID=self.camera_name,
+                TAB_VER=self.CURRENT_TAB_VERSION,
+                CAM_ID=self.name,
                 PIX_ROT=self.pix_rotation.deg,
                 CAM_ROT=self.cam_rotation.deg,
             ),
@@ -643,31 +623,33 @@ class CameraGeometry:
         kwargs: extra keyword arguments
             extra arguments passed to `astropy.table.Table.read`, depending on
             file type (e.g. format, hdu, path)
-
-
         """
 
         tab = url_or_table
         if not isinstance(url_or_table, Table):
             tab = Table.read(url_or_table, **kwargs)
 
+        version = tab.meta.get("TAB_VER")
+        if version not in cls.SUPPORTED_TAB_VERSIONS:
+            raise IOError(f"Unsupported camera geometry table version: {version}")
+
         return cls(
-            camera_name=tab.meta.get("CAM_ID", "Unknown"),
+            name=tab.meta.get("CAM_ID", "Unknown"),
             pix_id=tab["pix_id"],
             pix_x=tab["pix_x"].quantity,
             pix_y=tab["pix_y"].quantity,
             pix_area=tab["pix_area"].quantity,
             pix_type=tab.meta["PIX_TYPE"],
-            pix_rotation=Angle(tab.meta["PIX_ROT"] * u.deg),
-            cam_rotation=Angle(tab.meta["CAM_ROT"] * u.deg),
+            pix_rotation=Angle(tab.meta["PIX_ROT"], u.deg),
+            cam_rotation=Angle(tab.meta["CAM_ROT"], u.deg),
         )
 
     def __repr__(self):
         return (
-            "CameraGeometry(camera_name='{camera_name}', pix_type={pix_type}, "
+            "CameraGeometry(name='{name}', pix_type={pix_type}, "
             "npix={npix}, cam_rot={camrot:.3f}, pix_rot={pixrot:.3f}, frame={frame})"
         ).format(
-            camera_name=self.camera_name,
+            name=self.name,
             pix_type=self.pix_type,
             npix=len(self.pix_id),
             pixrot=self.pix_rotation,
@@ -676,7 +658,7 @@ class CameraGeometry:
         )
 
     def __str__(self):
-        return self.camera_name
+        return self.name
 
     @lazyproperty
     def neighbors(self):
@@ -883,13 +865,13 @@ class CameraGeometry:
         rr = np.ones_like(xx).value * (xx[1] - xx[0]) / 2.0
 
         return cls(
-            camera_name=-1,
+            name="RectangularCamera",
             pix_id=ids,
             pix_x=xx,
             pix_y=yy,
             pix_area=(2 * rr) ** 2,
             neighbors=None,
-            pix_type="rectangular",
+            pix_type=PixelShape.SQUARE,
         )
 
     def get_border_pixel_mask(self, width=1):
diff --git a/ctapipe/instrument/camera/readout.py b/ctapipe/instrument/camera/readout.py
index 39a92aef..73e8c1d1 100644
--- a/ctapipe/instrument/camera/readout.py
+++ b/ctapipe/instrument/camera/readout.py
@@ -7,8 +7,8 @@ import logging
 import numpy as np
 from astropy import units as u
 from astropy.table import Table
-from ctapipe.utils import get_table_dataset
 
+from ctapipe.utils import get_table_dataset
 
 __all__ = ["CameraReadout"]
 
@@ -20,25 +20,38 @@ def parse_dotted_version(version):
 
 
 class CameraReadout:
+    """Stores properties related to the readout of a Cherenkov Camera."""
+
+    CURRENT_TAB_VERSION = "3.0"
+    SUPPORTED_TAB_VERSIONS = {"3.0"}
+
     __slots__ = (
-        "camera_name",
+        "name",
         "sampling_rate",
         "reference_pulse_shape",
         "reference_pulse_sample_width",
+        "n_channels",
+        "n_pixels",
+        "n_samples",
+        "n_samples_long",
     )
 
     def __init__(
         self,
-        camera_name,
+        name,
         sampling_rate,
         reference_pulse_shape,
         reference_pulse_sample_width,
+        n_channels,
+        n_pixels,
+        n_samples,
+        n_samples_long=None,
     ):
         """Stores properties related to the readout of a Cherenkov Camera.
 
         Parameters
         ----------
-        camera_name: str
+        name: str
              Camera name (e.g. NectarCam, LSTCam, ...)
         sampling_rate : u.Quantity[frequency]
             Sampling rate of the waveform
@@ -48,34 +61,52 @@ class CameraReadout:
         reference_pulse_sample_width : u.Quantity[time]
             The amount of time corresponding to each sample in the 2nd
             dimension of reference_pulse_shape
-
+        n_channels : int
+            Number of gain channels
+        n_pixels : int
+            Number of pixels
+        n_samples : int
+            Number of waveform samples for normal events
+        n_samples_long : int or None
+            Number of waveform samples for long events. Not all cameras
+            have long event types. Leave None if camera does not support long
+            events.
         """
-        self.camera_name = camera_name
+        self.name = name
         self.sampling_rate = sampling_rate
         self.reference_pulse_shape = reference_pulse_shape
         self.reference_pulse_sample_width = reference_pulse_sample_width
+        self.n_channels = n_channels
+        self.n_pixels = n_pixels
+        self.n_samples = n_samples
+        self.n_samples_long = n_samples_long
 
     def __eq__(self, other):
-        if self.camera_name != other.camera_name:
-            return False
-
-        if self.sampling_rate != other.sampling_rate:
-            return False
-
-        if (self.reference_pulse_shape != other.reference_pulse_shape).all():
-            return False
-
-        if self.reference_pulse_sample_width != other.reference_pulse_sample_width:
+        if not isinstance(other, self.__class__):
             return False
 
-        return True
+        return (
+            self.n_pixels == other.n_pixels
+            and self.n_samples == other.n_samples
+            and self.n_samples_long == other.n_samples_long
+            and self.name == other.name
+            and u.isclose(self.sampling_rate, other.sampling_rate)
+            and u.isclose(
+                self.reference_pulse_sample_width, other.reference_pulse_sample_width
+            )
+            and np.allclose(self.reference_pulse_shape, other.reference_pulse_shape)
+        )
 
     def __hash__(self):
         return hash(
             (
-                self.camera_name,
+                self.name,
                 round(self.sampling_rate.to_value(u.GHz), 3),
                 self.reference_pulse_shape.size,
+                self.n_channels,
+                self.n_pixels,
+                self.n_samples,
+                self.n_samples_long,
                 round(self.reference_pulse_sample_width.to_value(u.ns), 2),
             )
         )
@@ -95,7 +126,7 @@ class CameraReadout:
         return u.Quantity(sample_time, u.ns)
 
     @classmethod
-    def from_name(cls, camera_name="NectarCam", version=None):
+    def from_name(cls, name="NectarCam", version=None):
         """Construct a CameraReadout using the name of the camera and array.
 
         This expects that there is a resource accessible ``ctapipe_resources``
@@ -105,7 +136,7 @@ class CameraReadout:
 
         Parameters
         ----------
-        camera_name: str
+        name: str
              Camera name (e.g. NectarCam, LSTCam, ...)
         version:
            camera version id (currently unused)
@@ -121,16 +152,14 @@ class CameraReadout:
         else:
             verstr = f"-{version:03d}"
 
-        tabname = "{camera_name}{verstr}.camreadout".format(
-            camera_name=camera_name, verstr=verstr
-        )
+        tabname = "{name}{verstr}.camreadout".format(name=name, verstr=verstr)
         table = get_table_dataset(tabname, role="dl0.tel.svc.camera")
         return CameraReadout.from_table(table)
 
     def to_table(self):
         """Convert this to an `astropy.table.Table`."""
-        n_channels = len(self.reference_pulse_shape)
-        tables = [
+        n_channels = self.n_channels
+        columns = [
             *[self.reference_pulse_shape[i] for i in range(n_channels)],
             self.reference_pulse_sample_time,
         ]
@@ -138,18 +167,24 @@ class CameraReadout:
             *[f"reference_pulse_shape_channel{i}" for i in range(n_channels)],
             "reference_pulse_sample_time",
         ]
+        meta = dict(
+            TAB_TYPE="ctapipe.instrument.CameraReadout",
+            TAB_VER=self.CURRENT_TAB_VERSION,
+            CAM_ID=self.name,
+            NCHAN=n_channels,
+            NPIXELS=self.n_pixels,
+            NSAMPLES=self.n_samples,
+            SAMPFREQ=self.sampling_rate.to_value(u.GHz),
+            REFWIDTH=self.reference_pulse_sample_width.to_value(u.ns),
+        )
+
+        if self.n_samples_long is not None:
+            meta["NSAMPLNG"] = self.n_samples_long
 
         return Table(
-            tables,
+            columns,
             names=names,
-            meta=dict(
-                TAB_TYPE="ctapipe.instrument.CameraReadout",
-                TAB_VER="2.0",
-                CAM_ID=self.camera_name,
-                NCHAN=n_channels,
-                SAMPFREQ=self.sampling_rate.to_value(u.GHz),
-                REFWIDTH=self.reference_pulse_sample_width.to_value(u.ns),
-            ),
+            meta=meta,
         )
 
     @classmethod
@@ -170,36 +205,43 @@ class CameraReadout:
         if not isinstance(url_or_table, Table):
             tab = Table.read(url_or_table, **kwargs)
 
-        camera_name = tab.meta.get("CAM_ID", "Unknown")
+        version = tab.meta.get("TAB_VER", "")
+        if version not in cls.SUPPORTED_TAB_VERSIONS:
+            raise IOError(
+                f"CameraReadout table has unsupported version: {version},"
+                f" supported are: {cls.SUPPORTED_TAB_VERSIONS}."
+            )
+
+        name = tab.meta.get("CAM_ID", "Unknown")
         n_channels = tab.meta["NCHAN"]
         sampling_rate = u.Quantity(tab.meta["SAMPFREQ"], u.GHz)
 
-        if parse_dotted_version(tab.meta["TAB_VER"]) >= (2, 0):
-            ref_width_key = "REFWIDTH"
-        else:
-            ref_width_key = "REF_WIDTH"
-
-        reference_pulse_sample_width = u.Quantity(tab.meta[ref_width_key], u.ns)
+        reference_pulse_sample_width = u.Quantity(tab.meta["REFWIDTH"], u.ns)
         reference_pulse_shape = np.array(
             [tab[f"reference_pulse_shape_channel{i}"] for i in range(n_channels)]
         )
 
         return cls(
-            camera_name=camera_name,
+            name=name,
             sampling_rate=sampling_rate,
             reference_pulse_shape=reference_pulse_shape,
             reference_pulse_sample_width=reference_pulse_sample_width,
+            n_channels=tab.meta["NCHAN"],
+            n_pixels=tab.meta["NPIXELS"],
+            n_samples=tab.meta["NSAMPLES"],
+            n_samples_long=tab.meta.get("NSAMPLNG"),
         )
 
     def __repr__(self):
         return (
-            "CameraReadout(camera_name='{camera_name}', sampling_rate='{sampling_rate}', "
-            "reference_pulse_sample_width={reference_pulse_sample_width})"
-        ).format(
-            camera_name=self.camera_name,
-            sampling_rate=self.sampling_rate,
-            reference_pulse_sample_width=self.reference_pulse_sample_width,
+            f"CameraReadout(name={self.name!r}"
+            f", sampling_rate={self.sampling_rate}"
+            f", n_channels={self.n_channels}"
+            f", n_pixels={self.n_pixels}"
+            f", n_samples={self.n_samples}"
+            f", n_samples_long={self.n_samples_long}"
+            ")"
         )
 
     def __str__(self):
-        return self.camera_name
+        return self.name
diff --git a/ctapipe/instrument/guess.py b/ctapipe/instrument/guess.py
index 856be851..8c40efc6 100644
--- a/ctapipe/instrument/guess.py
+++ b/ctapipe/instrument/guess.py
@@ -9,6 +9,8 @@ from collections import namedtuple
 import astropy.units as u
 import numpy as np
 
+from .optics import ReflectorShape
+
 __all__ = [
     "GuessingKey",
     "GuessingResult",
@@ -17,6 +19,7 @@ __all__ = [
     "type_from_mirror_area",
 ]
 
+
 GuessingKey = namedtuple(
     "GuessingKey",
     ["n_pixels", "focal_length", "num_mirror_tiles"],
@@ -24,7 +27,7 @@ GuessingKey = namedtuple(
 )
 
 GuessingResult = namedtuple(
-    "GuessingResult", ["type", "name", "camera_name", "n_mirrors"]
+    "GuessingResult", ["type", "name", "camera_name", "n_mirrors", "reflector_shape"]
 )
 
 
@@ -51,23 +54,35 @@ def _build_lookup_tree(telescopes):
 
 # focal length must have at most two digits after period
 # as we round the lookup to two digits
+_sc = ReflectorShape.SCHWARZSCHILD_COUDER
+_dc = ReflectorShape.DAVIES_COTTON
+_h = ReflectorShape.HYBRID
+_p = ReflectorShape.PARABOLIC
+
+# This is a list of tuples instead of a dict to be able to check for duplicates
 TELESCOPE_NAMES = [
-    (GuessingKey(2048, 2.28), GuessingResult("SST", "GCT", "CHEC", 2)),
-    (GuessingKey(2368, 2.15), GuessingResult("SST", "ASTRI", "ASTRICam", 2)),
-    (GuessingKey(2048, 2.15), GuessingResult("SST", "ASTRI", "CHEC", 2)),
-    (GuessingKey(1296, 5.60), GuessingResult("SST", "1M", "DigiCam", 1)),
-    (GuessingKey(1764, 16.0), GuessingResult("MST", "MST", "FlashCam", 1)),
-    (GuessingKey(1855, 16.0), GuessingResult("MST", "MST", "NectarCam", 1)),
-    (GuessingKey(1855, 28.0), GuessingResult("LST", "LST", "LSTCam", 1)),
-    (GuessingKey(11328, 5.59), GuessingResult("MST", "SCT", "SCTCam", 1)),
+    (GuessingKey(2048, 2.28), GuessingResult("SST", "GCT", "CHEC", 2, _sc)),
+    (GuessingKey(2368, 2.15), GuessingResult("SST", "ASTRI", "ASTRICam", 2, _sc)),
+    (GuessingKey(2048, 2.15), GuessingResult("SST", "ASTRI", "CHEC", 2, _sc)),
+    (GuessingKey(1296, 5.60), GuessingResult("SST", "1M", "DigiCam", 1, _dc)),
+    (GuessingKey(1764, 16.0), GuessingResult("MST", "MST", "FlashCam", 1, _h)),
+    (GuessingKey(1855, 16.0), GuessingResult("MST", "MST", "NectarCam", 1, _h)),
+    (GuessingKey(1855, 28.0), GuessingResult("LST", "LST", "LSTCam", 1, _p)),
+    (GuessingKey(11328, 5.59), GuessingResult("MST", "SCT", "SCTCam", 1, _sc)),
     # Non-CTA Telescopes
-    (GuessingKey(1039, 16.97, 964), GuessingResult("LST", "MAGIC-1", "MAGICCam", 1)),
-    (GuessingKey(1039, 16.97, 247), GuessingResult("LST", "MAGIC-2", "MAGICCam", 1)),
-    (GuessingKey(1039, 17.0, 964), GuessingResult("LST", "MAGIC-1", "MAGICCam", 1)),
-    (GuessingKey(1039, 17.0, 247), GuessingResult("LST", "MAGIC-2", "MAGICCam", 1)),
-    (GuessingKey(960, 15.0), GuessingResult("MST", "HESS-I", "HESS-I", 1)),
-    (GuessingKey(2048, 36.0), GuessingResult("LST", "HESS-II", "HESS-II", 1)),
-    (GuessingKey(1440, 4.89), GuessingResult("SST", "FACT", "FACT", 1)),
+    (
+        GuessingKey(1039, 16.97, 964),
+        GuessingResult("LST", "MAGIC-1", "MAGICCam", 1, _p),
+    ),
+    (
+        GuessingKey(1039, 16.97, 247),
+        GuessingResult("LST", "MAGIC-2", "MAGICCam", 1, _p),
+    ),
+    (GuessingKey(1039, 17.0, 964), GuessingResult("LST", "MAGIC-1", "MAGICCam", 1, _p)),
+    (GuessingKey(1039, 17.0, 247), GuessingResult("LST", "MAGIC-2", "MAGICCam", 1, _p)),
+    (GuessingKey(960, 15.0), GuessingResult("MST", "HESS-I", "HESS-I", 1, _dc)),
+    (GuessingKey(2048, 36.0), GuessingResult("LST", "HESS-II", "HESS-II", 1, _p)),
+    (GuessingKey(1440, 4.89), GuessingResult("SST", "FACT", "FACT", 1, _h)),
 ]
 LOOKUP_TREE = _build_lookup_tree(TELESCOPE_NAMES)
 
@@ -129,4 +144,5 @@ def unknown_telescope(mirror_area, n_pixels, n_mirrors=-1):
         name=f"UNKNOWN-{mirror_area.to_value(u.m**2):.0f}M2",
         camera_name=f"UNKNOWN-{n_pixels}PX",
         n_mirrors=n_mirrors,
+        reflector_shape="UNKNOWN",
     )
diff --git a/ctapipe/instrument/optics.py b/ctapipe/instrument/optics.py
index a733bac0..cba3a80c 100644
--- a/ctapipe/instrument/optics.py
+++ b/ctapipe/instrument/optics.py
@@ -7,6 +7,7 @@ from enum import Enum, auto, unique
 
 import astropy.units as u
 import numpy as np
+from astropy.table import QTable
 
 from ..utils import get_table_dataset
 
@@ -35,6 +36,40 @@ class FocalLengthKind(Enum):
     EQUIVALENT = auto()
 
 
+@unique
+class SizeType(str, Enum):
+    """
+    Enumeration of different telescope sizes (LST, MST, SST)
+    """
+
+    #: Unkown
+    UNKNOWN = "UNKNOWN"
+    #: A telescope with a mirror diameter larger than 16m
+    LST = "LST"
+    #: A telescope with a mirror diameter larger than 8m
+    MST = "MST"
+    #: Telescopes with a mirror diameter smaller than 8m
+    SST = "SST"
+
+
+@unique
+class ReflectorShape(Enum):
+    """
+    Enumeration of the different reflector shapes
+    """
+
+    #: Unkown
+    UNKNOWN = "UNKNOWN"
+    #: A telescope with a parabolic dish
+    PARABOLIC = "PARABOLIC"
+    #: A telescope with a Davies--Cotton dish
+    DAVIES_COTTON = "DAVIES_COTTON"
+    #: A telescope with a hybrid between parabolic and Davies--Cotton dish
+    HYBRID = "HYBRID"
+    #: A dual mirror Schwarzschild-Couder reflector
+    SCHWARZSCHILD_COUDER = "SCHWARZSCHILD_COUDER"
+
+
 class OpticsDescription:
     """
     Describes the optics of a Cherenkov Telescope mirror
@@ -45,7 +80,9 @@ class OpticsDescription:
 
     Parameters
     ----------
-    num_mirrors: int
+    name : str
+        Name of this optical system
+    num_mirrors : int
         Number of mirrors, i. e. 2 for Schwarzschild-Couder else 1
     equivalent_focal_length : astropy.units.Quantity[length]
         Equivalent focal-length of telescope, independent of which type of
@@ -59,9 +96,9 @@ class OpticsDescription:
         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]
+    mirror_area : astropy.units.Quantity[area]
         total reflective surface area of the optical system (in m^2)
-    num_mirror_tiles: int
+    num_mirror_tiles : int
         number of mirror facets
 
     Raises
@@ -72,13 +109,18 @@ class OpticsDescription:
         if the units of one of the inputs are missing or incompatible
     """
 
+    CURRENT_TAB_VERSION = "4.0"
+    COMPATIBLE_VERSIONS = {"4.0"}
+
     __slots__ = (
         "name",
+        "size_type",
         "effective_focal_length",
         "equivalent_focal_length",
         "mirror_area",
         "num_mirrors",
         "num_mirror_tiles",
+        "reflector_shape",
     )
 
     @u.quantity_input(
@@ -89,14 +131,18 @@ class OpticsDescription:
     def __init__(
         self,
         name,
+        size_type,
         num_mirrors,
         equivalent_focal_length,
         effective_focal_length,
         mirror_area,
         num_mirror_tiles,
+        reflector_shape,
     ):
 
         self.name = name
+        self.size_type = SizeType(size_type)
+        self.reflector_shape = ReflectorShape(reflector_shape)
         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
@@ -117,13 +163,15 @@ class OpticsDescription:
                 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.size_type.value,
+                self.reflector_shape.value,
                 self.num_mirrors,
                 self.num_mirror_tiles,
             )
         )
 
     def __eq__(self, other):
-        """Make this hashable, so it can be used as dict keys or in sets"""
+        """For eq, we just compare equal hash"""
         return hash(self) == hash(other)
 
     @classmethod
@@ -154,45 +202,28 @@ class OpticsDescription:
 
         version = table.meta.get("TAB_VER")
 
-        # we introduced the TAB_VER after switching to the second version
-        # of this table, so when the version is missing, it can be either 1 or 2
-        # we guess the version by looking for the mirror_type attribute.
-        if version is None:
-            if "mirror_type" in table.colnames:
-                version = "1.0"
-            else:
-                version = "2.0"
-
-        if version not in {"1.0", "2.0"}:
+        if version not in cls.COMPATIBLE_VERSIONS:
             raise ValueError(f"Unsupported version of optics table: {version}")
 
-        if version == "1.0":
-            mask = table["tel_description"] == name
-        else:
-            mask = table["description"] == name
-
-        if np.count_nonzero(mask) == 0:
-            raise ValueError(f"Unknown telescope name {name}")
+        mask = table["optics_name"] == name
 
-        if version == "1.0":
-            num_mirrors = 1 if table["mirror_type"][mask][0] == "DC" else 2
-        else:
-            num_mirrors = table["num_mirrors"][mask][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]
+        (idx,) = np.nonzero(mask)
+        if len(idx) == 0:
+            raise ValueError(f"Unknown optics name {name}")
 
-        focal_length = table["equivalent_focal_length"][mask].quantity[0]
+        # QTable so that accessing row[col] is a quantity
+        table = QTable(table)
+        row = table[idx[0]]
 
         return cls(
             name=name,
-            num_mirrors=num_mirrors,
-            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],
+            size_type=row["size_type"],
+            reflector_shape=row["reflector_shape"],
+            num_mirrors=row["num_mirrors"],
+            equivalent_focal_length=row["equivalent_focal_length"],
+            effective_focal_length=row["effective_focal_length"],
+            mirror_area=row["mirror_area"],
+            num_mirror_tiles=row["num_mirror_tiles"],
         )
 
     @classmethod
@@ -212,12 +243,14 @@ class OpticsDescription:
             table = get_table_dataset(optics_table, role="get_known_optics_names")
         else:
             table = optics_table
-        return np.array(table["tel_description"])
+        return np.array(table["name"])
 
     def __repr__(self):
         return (
-            f"{self.__class__.__name__}"
-            f"(name={self.name}"
+            f"{self.__class__.__name__}("
+            f"name={self.name}"
+            f", size_type={self.size_type.value}"
+            f", reflector_shape={self.reflector_shape.value}"
             f", equivalent_focal_length={self.equivalent_focal_length:.2f}"
             f", effective_focal_length={self.effective_focal_length:.2f}"
             f", num_mirrors={self.num_mirrors}"
diff --git a/ctapipe/instrument/subarray.py b/ctapipe/instrument/subarray.py
index 449c6e65..73440067 100644
--- a/ctapipe/instrument/subarray.py
+++ b/ctapipe/instrument/subarray.py
@@ -62,6 +62,9 @@ class SubarrayDescription:
        dict of TelescopeDescription for each telescope in the subarray
     """
 
+    CURRENT_TAB_VERSION = "2.0"
+    COMPATIBLE_VERSIONS = {"2.0"}
+
     def __init__(
         self,
         name,
@@ -252,7 +255,6 @@ class SubarrayDescription:
             table = self.to_table()
             optics = self.to_table(kind="optics")
             optics["optics_index"] = np.arange(len(optics))
-            optics.remove_columns(["name", "description", "type"])
             table = join(
                 table,
                 optics,
@@ -272,22 +274,22 @@ class SubarrayDescription:
             "TAB_TYPE": kind,
         }
 
-        if self.reference_location is not None:
-            itrs = self.reference_location.itrs
-            meta["OBSGEO-X"] = itrs.x.to_value(u.m)
-            meta["OBSGEO-Y"] = itrs.y.to_value(u.m)
-            meta["OBSGEO-Z"] = itrs.z.to_value(u.m)
-
         if kind == "subarray":
+            if self.reference_location is not None:
+                itrs = self.reference_location.itrs
+                meta["OBSGEO-X"] = itrs.x.to_value(u.m)
+                meta["OBSGEO-Y"] = itrs.y.to_value(u.m)
+                meta["OBSGEO-Z"] = itrs.z.to_value(u.m)
 
-            unique_types = self.telescope_types
+            unique_optics = self.optics_types
 
             ids = list(self.tels.keys())
             descs = [str(t) for t in self.tels.values()]
             tel_names = [t.name for t in self.tels.values()]
-            tel_types = [t.type for t in self.tels.values()]
-            cam_types = [t.camera.camera_name for t in self.tels.values()]
-            optics_index = [unique_types.index(t) for t in self.tels.values()]
+            tel_types = [t.optics.size_type.value for t in self.tels.values()]
+            cam_names = [t.camera.name for t in self.tels.values()]
+            optics_names = [t.optics.name for t in self.tels.values()]
+            optics_index = [unique_optics.index(t.optics) for t in self.tels.values()]
             camera_index = [
                 self.camera_types.index(t.camera) for t in self.tels.values()
             ]
@@ -296,49 +298,48 @@ class SubarrayDescription:
             tab = Table(
                 dict(
                     tel_id=np.array(ids, dtype=np.short),
+                    name=tel_names,
+                    type=tel_types,
                     pos_x=tel_coords.x,
                     pos_y=tel_coords.y,
                     pos_z=tel_coords.z,
-                    name=tel_names,
-                    type=tel_types,
-                    camera_type=cam_types,
+                    camera_name=cam_names,
+                    optics_name=optics_names,
                     camera_index=camera_index,
                     optics_index=optics_index,
                     tel_description=descs,
                 )
             )
-            tab.meta["TAB_VER"] = "1.0"
+            tab.meta["TAB_VER"] = self.CURRENT_TAB_VERSION
 
         elif kind == "optics":
-            unique_types = self.telescope_types
+            unique_optics = self.optics_types
 
             mirror_area = u.Quantity(
-                [t.optics.mirror_area.to_value(u.m**2) for t in unique_types],
+                [o.mirror_area.to_value(u.m**2) for o in unique_optics],
                 u.m**2,
             )
             focal_length = u.Quantity(
-                [t.optics.equivalent_focal_length.to_value(u.m) for t in unique_types],
+                [o.equivalent_focal_length.to_value(u.m) for o in unique_optics],
                 u.m,
             )
             effective_focal_length = u.Quantity(
-                [t.optics.effective_focal_length.to_value(u.m) for t in unique_types],
+                [o.effective_focal_length.to_value(u.m) for o in unique_optics],
                 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],
+                    "optics_name": [o.name for o in unique_optics],
+                    "size_type": [o.size_type.value for o in unique_optics],
+                    "reflector_shape": [o.reflector_shape.value for o in unique_optics],
                     "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
-                    ],
+                    "num_mirrors": [o.num_mirrors for o in unique_optics],
+                    "num_mirror_tiles": [o.num_mirror_tiles for o in unique_optics],
                     "equivalent_focal_length": focal_length,
                     "effective_focal_length": effective_focal_length,
                 }
             )
-            tab.meta["TAB_VER"] = "3.0"
+            tab.meta["TAB_VER"] = OpticsDescription.CURRENT_TAB_VERSION
         else:
             raise ValueError(f"Table type '{kind}' not known")
 
@@ -521,8 +522,18 @@ class SubarrayDescription:
                     "File already contains a SubarrayDescription and overwrite=False"
                 )
 
+            subarray_table = self.to_table(kind="subarray")
+            subarray_table.meta["name"] = self.name
+
+            if self.reference_location is not None:
+                # change FITS convention to something better fitting HDF
+                for direction in ("X", "Y", "Z"):
+                    fits_key = f"OBSGEO-{direction}"
+                    hdf_key = "reference_itrs_" + direction.lower()
+                    subarray_table.meta[hdf_key] = subarray_table.meta.pop(fits_key)
+
             write_table(
-                self.to_table(),
+                subarray_table,
                 h5file,
                 path="/configuration/instrument/subarray/layout",
                 overwrite=overwrite,
@@ -547,14 +558,6 @@ class SubarrayDescription:
                     overwrite=overwrite,
                 )
 
-            meta = h5file.root.configuration.instrument.subarray._v_attrs
-            meta["name"] = self.name
-            if self.reference_location is not None:
-                itrs = self.reference_location.itrs
-                meta["reference_itrs_x"] = itrs.x.to_value(u.m)
-                meta["reference_itrs_y"] = itrs.y.to_value(u.m)
-                meta["reference_itrs_z"] = itrs.z.to_value(u.m)
-
     @classmethod
     def from_hdf(cls, path, focal_length_choice=FocalLengthKind.EFFECTIVE):
         # here to prevent circular import
@@ -567,6 +570,10 @@ class SubarrayDescription:
             path, "/configuration/instrument/subarray/layout", table_cls=QTable
         )
 
+        version = layout.meta.get("TAB_VER")
+        if version not in cls.COMPATIBLE_VERSIONS:
+            raise IOError("Unsupported version of subarray table: {version}")
+
         cameras = {}
 
         # backwards compatibility for older tables, index is the name
@@ -585,12 +592,17 @@ class SubarrayDescription:
                 )
             )
             cameras[idx] = CameraDescription(
-                camera_name=geometry.camera_name, readout=readout, geometry=geometry
+                name=geometry.name, readout=readout, geometry=geometry
             )
 
         optics_table = read_table(
             path, "/configuration/instrument/telescope/optics", table_cls=QTable
         )
+
+        optics_version = optics_table.meta.get("TAB_VER")
+        if optics_version not in OpticsDescription.COMPATIBLE_VERSIONS:
+            raise IOError(f"Unsupported version of optics table: {optics_version}")
+
         # for backwards compatibility
         # if optics_index not in table, guess via telescope_description string
         # might not result in correct array when there are duplicated telescope_description
@@ -607,15 +619,14 @@ 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"],
+                name=row["optics_name"],
+                size_type=row["size_type"],
+                reflector_shape=row["reflector_shape"],
                 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
-                ),
+                effective_focal_length=row["effective_focal_length"],
                 mirror_area=row["mirror_area"],
                 num_mirror_tiles=row["num_mirror_tiles"],
             )
@@ -645,27 +656,20 @@ class SubarrayDescription:
 
             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
+                name=row["name"], optics=optics, camera=camera
             )
 
         positions = np.column_stack([layout[f"pos_{c}"] for c in "xyz"])
 
-        name = "Unknown"
         reference_location = None
-        with ExitStack() as stack:
-            if not isinstance(path, tables.File):
-                path = stack.enter_context(tables.open_file(path, mode="r"))
-
-            attrs = path.root.configuration.instrument.subarray._v_attrs
-            if "name" in attrs:
-                name = str(attrs.name)
-
-            if "reference_itrs_x" in attrs:
-                reference_location = EarthLocation(
-                    x=attrs["reference_itrs_x"] * u.m,
-                    y=attrs["reference_itrs_y"] * u.m,
-                    z=attrs["reference_itrs_z"] * u.m,
-                )
+        name = layout.meta.get("SUBARRAY", "Unknown")
+
+        if "reference_itrs_x" in layout.meta:
+            reference_location = EarthLocation(
+                x=layout.meta["reference_itrs_x"] * u.m,
+                y=layout.meta["reference_itrs_y"] * u.m,
+                z=layout.meta["reference_itrs_z"] * u.m,
+            )
 
         return cls(
             name=name,
diff --git a/ctapipe/instrument/telescope.py b/ctapipe/instrument/telescope.py
index e351b67d..87538f60 100644
--- a/ctapipe/instrument/telescope.py
+++ b/ctapipe/instrument/telescope.py
@@ -13,11 +13,10 @@ Todo:
   telescope :-))
 
 """
+from ..coordinates import CameraFrame
 from .camera import CameraDescription
-from .guess import unknown_telescope, guess_telescope
+from .guess import guess_telescope, unknown_telescope
 from .optics import OpticsDescription
-from ..coordinates import CameraFrame
-
 
 __all__ = ["TelescopeDescription"]
 
@@ -41,7 +40,6 @@ class TelescopeDescription:
 
     __slots__ = (
         "name",
-        "type",
         "optics",
         "camera",
     )
@@ -49,7 +47,6 @@ class TelescopeDescription:
     def __init__(
         self,
         name: str,
-        tel_type: str,
         optics: OpticsDescription,
         camera: CameraDescription,
     ):
@@ -57,9 +54,6 @@ class TelescopeDescription:
         if not isinstance(name, str):
             raise TypeError("`name` must be a str")
 
-        if not isinstance(tel_type, str):
-            raise TypeError("`tel_type` must be a str")
-
         if not isinstance(optics, OpticsDescription):
             raise TypeError("`optics` must be an instance of `OpticsDescription`")
 
@@ -67,7 +61,6 @@ class TelescopeDescription:
             raise TypeError("`camera` must be an instance of `CameraDescription`")
 
         self.name = name
-        self.type = tel_type
         self.optics = optics
         self.camera = camera
 
@@ -86,9 +79,9 @@ class TelescopeDescription:
 
         Parameters
         ----------
-        camera_name: str
+        camera_name : str
            camera name
-        optics_name: str
+        optics_name : str
            optics name (e.g. LST, or SST-ASTRI), also called
            telescope_description
 
@@ -109,16 +102,31 @@ class TelescopeDescription:
         except ValueError:
             result = unknown_telescope(optics.mirror_area, camera.geometry.n_pixels)
 
-        return cls(name=result.name, tel_type=result.type, optics=optics, camera=camera)
+        return cls(name=result.name, optics=optics, camera=camera)
+
+    @property
+    def camera_name(self):
+        """Name of the camera"""
+        return self.camera.name
+
+    @property
+    def optics_name(self):
+        """Name of the optics"""
+        return self.optics.name
+
+    @property
+    def type(self):
+        """Size classification"""
+        return self.optics.size_type
 
     def __str__(self):
-        return f"{self.type}_{self.optics}_{self.camera}"
+        return f"{self.type}_{self.optics_name}_{self.camera_name}"
 
     def __repr__(self):
-        return "{}(type={}, name={}, optics={}, camera={})".format(
-            self.__class__.__name__,
-            self.type,
-            self.name,
-            str(self.optics),
-            str(self.camera),
+        return (
+            f"{self.__class__.__name__}("
+            f"type={self.type.value!r}"
+            f", optics_name={self.optics_name!r}"
+            f", camera_name={self.camera_name!r}"
+            ")"
         )
diff --git a/ctapipe/io/datawriter.py b/ctapipe/io/datawriter.py
index 8be7b6cd..3a95070e 100644
--- a/ctapipe/io/datawriter.py
+++ b/ctapipe/io/datawriter.py
@@ -54,7 +54,13 @@ DATA_MODEL_CHANGE_HISTORY = """
             and true parameters.
           - Telescope Impact Parameters were added.
           - Effective focal length and nominal focal length are both included
-            in the optics description now.
+            in the optics description now. Moved `TelescopeDescription.type`
+            to `OpticsDescription.size_type`. Added `OpticsDescription.reflector_shape`.
+          - n_samples, n_samples_long, n_channels and n_pixels are now part
+            of CameraReadout.
+          - The reference_location (EarthLocation origin of the telescope coordinates)
+            is now included in SubarrayDescription
+          - Only unique optics are stored in the optics table
 - 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)
diff --git a/ctapipe/io/simteleventsource.py b/ctapipe/io/simteleventsource.py
index 6a95d8bf..79779679 100644
--- a/ctapipe/io/simteleventsource.py
+++ b/ctapipe/io/simteleventsource.py
@@ -37,6 +37,7 @@ from ..instrument import (
     CameraReadout,
     FocalLengthKind,
     OpticsDescription,
+    ReflectorShape,
     SubarrayDescription,
     TelescopeDescription,
 )
@@ -100,8 +101,13 @@ def _location_from_meta(global_meta):
     )
 
 
-def build_camera(cam_settings, pixel_settings, telescope, frame):
-    pixel_shape = cam_settings["pixel_shape"][0]
+def build_camera(simtel_telescope, telescope, frame):
+    """Create CameraDescription from eventio data structures"""
+    camera_settings = simtel_telescope["camera_settings"]
+    pixel_settings = simtel_telescope["pixel_settings"]
+    camera_organization = simtel_telescope["camera_organization"]
+
+    pixel_shape = camera_settings["pixel_shape"][0]
     try:
         pix_type, pix_rotation = CameraGeometry.simtel_shape_to_type(pixel_shape)
     except ValueError:
@@ -114,13 +120,13 @@ def build_camera(cam_settings, pixel_settings, telescope, frame):
 
     geometry = CameraGeometry(
         telescope.camera_name,
-        pix_id=np.arange(cam_settings["n_pixels"]),
-        pix_x=u.Quantity(cam_settings["pixel_x"], u.m),
-        pix_y=u.Quantity(cam_settings["pixel_y"], u.m),
-        pix_area=u.Quantity(cam_settings["pixel_area"], u.m**2),
+        pix_id=np.arange(camera_settings["n_pixels"]),
+        pix_x=u.Quantity(camera_settings["pixel_x"], u.m),
+        pix_y=u.Quantity(camera_settings["pixel_y"], u.m),
+        pix_area=u.Quantity(camera_settings["pixel_area"], u.m**2),
         pix_type=pix_type,
         pix_rotation=pix_rotation,
-        cam_rotation=-Angle(cam_settings["cam_rot"], u.rad),
+        cam_rotation=-Angle(camera_settings["cam_rot"], u.rad),
         apply_derotation=True,
         frame=frame,
     )
@@ -131,10 +137,15 @@ def build_camera(cam_settings, pixel_settings, telescope, frame):
         reference_pulse_sample_width=u.Quantity(
             pixel_settings["ref_step"], u.ns, dtype="float64"
         ),
+        n_channels=camera_organization["n_gains"],
+        n_pixels=camera_organization["n_pixels"],
+        n_samples=pixel_settings["sum_bins"],
     )
 
     return CameraDescription(
-        camera_name=telescope.camera_name, geometry=geometry, readout=readout
+        name=telescope.camera_name,
+        geometry=geometry,
+        readout=readout,
     )
 
 
@@ -149,10 +160,27 @@ def _telescope_from_meta(telescope_meta, mirror_area):
     telescope_type = type_from_mirror_area(mirror_area)
 
     mirror_class = MirrorClass(int(mirror_class))
+
+    reflector_shape = ReflectorShape.UNKNOWN
+    if mirror_class is MirrorClass.DUAL_MIRROR:
+        reflector_shape = ReflectorShape.SCHWARZSCHILD_COUDER
+    elif mirror_class is MirrorClass.SINGLE_SEGMENTED_MIRROR:
+        if int(telescope_meta.get(b"PARABOLIC_DISH", 0)) == 1:
+            reflector_shape = ReflectorShape.PARABOLIC
+        else:
+            reflector_shape = ReflectorShape.DAVIES_COTTON
+
+            shape_length = float(telescope_meta.get(b"DISH_SHAPE_Length", 0))
+            focal_length = float(telescope_meta.get(b"FOCAL_Length", 0))
+
+            if shape_length != focal_length:
+                reflector_shape = ReflectorShape.HYBRID
+
     n_mirrors = 2 if mirror_class is MirrorClass.DUAL_MIRROR else 1
 
     return GuessingResult(
         type=telescope_type,
+        reflector_shape=reflector_shape,
         name=optics_name.decode("utf-8"),
         camera_name=camera_name.decode("utf-8"),
         n_mirrors=n_mirrors,
@@ -382,7 +410,6 @@ class SimTelEventSource(EventSource):
 
         for tel_id, telescope_description in telescope_descriptions.items():
             cam_settings = telescope_description["camera_settings"]
-            pixel_settings = telescope_description["pixel_settings"]
 
             n_pixels = cam_settings["n_pixels"]
             mirror_area = u.Quantity(cam_settings["mirror_area"], u.m**2)
@@ -418,6 +445,8 @@ class SimTelEventSource(EventSource):
 
             optics = OpticsDescription(
                 name=telescope.name,
+                size_type=telescope.type,
+                reflector_shape=telescope.reflector_shape,
                 num_mirrors=telescope.n_mirrors,
                 equivalent_focal_length=equivalent_focal_length,
                 effective_focal_length=effective_focal_length,
@@ -442,15 +471,13 @@ class SimTelEventSource(EventSource):
                 )
 
             camera = build_camera(
-                cam_settings,
-                pixel_settings,
+                telescope_description,
                 telescope,
                 frame=CameraFrame(focal_length=focal_length),
             )
 
             tel_descriptions[tel_id] = TelescopeDescription(
                 name=telescope.name,
-                tel_type=telescope.type,
                 optics=optics,
                 camera=camera,
             )
diff --git a/ctapipe/tools/dump_instrument.py b/ctapipe/tools/dump_instrument.py
index b6971ad7..1e51ac51 100644
--- a/ctapipe/tools/dump_instrument.py
+++ b/ctapipe/tools/dump_instrument.py
@@ -4,38 +4,26 @@ FITS files that can be loaded independently (e.g. with
 CameraGeometry.from_table()).  The name of the output files are
 automatically generated.
 """
+import os
+import pathlib
 
-from collections import defaultdict
-
-from ctapipe.core import Tool, Provenance
-from ctapipe.core.traits import Unicode, Dict, Enum, Path
+from ctapipe.core import Provenance, Tool
+from ctapipe.core.traits import Enum, Path, Unicode
 from ctapipe.io import EventSource
 
 
-def get_camera_types(subarray):
-    """ return dict of camera names mapped to a list of tel_ids
-     that use that camera
-
-     Parameters
-     ----------
-     subarray: ctapipe.instrument.SubarrayDescription
-
-     """
-
-    cam_types = defaultdict(list)
-
-    for telid in subarray.tel:
-        geom = subarray.tel[telid].camera.geometry
-        cam_types[geom.camera_name].append(telid)
-
-    return cam_types
-
-
 class DumpInstrumentTool(Tool):
     description = Unicode(__doc__)
     name = "ctapipe-dump-instrument"
 
     infile = Path(exists=True, help="input simtelarray file").tag(config=True)
+    outdir = Path(
+        file_ok=False,
+        directory_ok=True,
+        allow_none=True,
+        default_value=None,
+        help="Output directory. If not given, the current working directory will be used.",
+    ).tag(config=True)
     format = Enum(
         ["fits", "ecsv", "hdf5"],
         default_value="fits",
@@ -43,15 +31,21 @@ class DumpInstrumentTool(Tool):
         config=True,
     )
 
-    aliases = Dict(
-        dict(input="DumpInstrumentTool.infile", format="DumpInstrumentTool.format")
-    )
+    aliases = {
+        ("i", "input"): "DumpInstrumentTool.infile",
+        ("f", "format"): "DumpInstrumentTool.format",
+        ("o", "outdir"): "DumpInstrumentTool.outdir",
+    }
 
     def setup(self):
         with EventSource(self.infile) as source:
             self.subarray = source.subarray
 
     def start(self):
+        if self.outdir is None:
+            self.outdir = pathlib.Path(os.getcwd())
+        self.outdir.mkdir(exist_ok=True, parents=True)
+
         if self.format == "hdf5":
             self.subarray.to_hdf("subarray.h5")
         else:
@@ -64,7 +58,7 @@ class DumpInstrumentTool(Tool):
 
     @staticmethod
     def _get_file_format_info(format_name):
-        """ returns file extension + dict of required parameters for
+        """returns file extension + dict of required parameters for
         Table.write"""
         if format_name == "fits":
             return "fits.gz", dict()
@@ -74,24 +68,22 @@ class DumpInstrumentTool(Tool):
             raise NameError(f"format {format_name} not supported")
 
     def write_camera_definitions(self):
-        """ writes out camgeom and camreadout files for each camera"""
-        cam_types = get_camera_types(self.subarray)
+        """writes out camgeom and camreadout files for each camera"""
         self.subarray.info(printer=self.log.info)
-        for cam_name in cam_types:
+        for camera in self.subarray.camera_types:
             ext, args = self._get_file_format_info(self.format)
 
-            self.log.debug(f"writing {cam_name}")
-            tel_id = cam_types[cam_name].pop()
-            geom = self.subarray.tel[tel_id].camera.geometry
-            readout = self.subarray.tel[tel_id].camera.readout
+            self.log.debug("Writing camera %s", camera)
+            geom = camera.geometry
+            readout = camera.readout
 
             geom_table = geom.to_table()
             geom_table.meta["SOURCE"] = str(self.infile)
-            geom_filename = f"{cam_name}.camgeom.{ext}"
+            geom_filename = self.outdir / f"{camera.name}.camgeom.{ext}"
 
             readout_table = readout.to_table()
             readout_table.meta["SOURCE"] = str(self.infile)
-            readout_filename = f"{cam_name}.camreadout.{ext}"
+            readout_filename = self.outdir / f"{camera.name}.camreadout.{ext}"
 
             try:
                 geom_table.write(geom_filename, **args)
@@ -102,13 +94,13 @@ class DumpInstrumentTool(Tool):
                 self.log.warning("couldn't write camera definition because: %s", err)
 
     def write_optics_descriptions(self):
-        """ writes out optics files for each telescope type"""
+        """writes out optics files for each telescope type"""
         sub = self.subarray
         ext, args = self._get_file_format_info(self.format)
 
         tab = sub.to_table(kind="optics")
         tab.meta["SOURCE"] = str(self.infile)
-        filename = f"{sub.name}.optics.{ext}"
+        filename = self.outdir / f"{sub.name}.optics.{ext}"
         try:
             tab.write(filename, **args)
             Provenance().add_output_file(filename, "OpticsDescription")
@@ -122,7 +114,7 @@ class DumpInstrumentTool(Tool):
         ext, args = self._get_file_format_info(self.format)
         tab = sub.to_table(kind="subarray")
         tab.meta["SOURCE"] = str(self.infile)
-        filename = f"{sub.name}.subarray.{ext}"
+        filename = self.outdir / f"{sub.name}.subarray.{ext}"
         try:
             tab.write(filename, **args)
             Provenance().add_output_file(filename, "SubarrayDescription")
diff --git a/ctapipe/utils/datasets.py b/ctapipe/utils/datasets.py
index 71ba6148..91927b22 100644
--- a/ctapipe/utils/datasets.py
+++ b/ctapipe/utils/datasets.py
@@ -31,8 +31,10 @@ __all__ = ["get_dataset_path", "find_in_path", "find_all_matching_datasets"]
 DEFAULT_URL = "http://cccta-dataserver.in2p3.fr/data/ctapipe-extra/v0.3.4/"
 
 
-def get_searchpath_dirs(searchpath=os.getenv("CTAPIPE_SVC_PATH"), url=DEFAULT_URL):
-    """ returns a list of dirs in specified searchpath"""
+def get_searchpath_dirs(searchpath=None, url=DEFAULT_URL):
+    """returns a list of dirs in specified searchpath"""
+    if searchpath is None:
+        searchpath = os.getenv("CTAPIPE_SVC_PATH")
 
     if searchpath == "" or searchpath is None:
         searchpaths = []
@@ -200,12 +202,19 @@ def try_filetypes(basename, role, file_types, url=DEFAULT_URL, **kwargs):
 
     path = None
 
-    # look first in cache so we don't have to try non-existing downloads
-    for ext, reader in file_types.items():
-        filename = basename + ext
-        cache_path = get_cache_path(os.path.join(DEFAULT_URL, filename))
-        if cache_path.exists():
-            path = cache_path
+    # look first in search pathes (includes cache)
+    # so we respect user provided paths and don't have to try non-existing downloads
+    search_paths = get_searchpath_dirs(url=url)
+    for search_path in search_paths:
+        for ext, reader in file_types.items():
+            filename = basename + ext
+            print(search_path, filename, (search_path / filename).exists())
+
+            if (search_path / filename).exists():
+                path = search_path / filename
+                break
+
+        if path is not None:
             break
 
     # no cache hit
diff --git a/ctapipe/visualization/mpl_camera.py b/ctapipe/visualization/mpl_camera.py
index ebd125f2..7149330b 100644
--- a/ctapipe/visualization/mpl_camera.py
+++ b/ctapipe/visualization/mpl_camera.py
@@ -9,8 +9,8 @@ import numpy as np
 from astropy import units as u
 from matplotlib import pyplot as plt
 from matplotlib.collections import PatchCollection
-from matplotlib.colors import Normalize, LogNorm, SymLogNorm
-from matplotlib.patches import Ellipse, RegularPolygon, Circle
+from matplotlib.colors import LogNorm, Normalize, SymLogNorm
+from matplotlib.patches import Circle, Ellipse, RegularPolygon
 
 from ctapipe.instrument import PixelShape
 
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
 
 
 def polar_to_cart(rho, phi):
-    """"returns r, theta(degrees)"""
+    """ "returns r, theta(degrees)"""
     x = rho * np.cos(phi)
     y = rho * np.sin(phi)
     return x, y
@@ -112,7 +112,7 @@ class CameraDisplay:
         self.geom.rotate(self.geom.cam_rotation)
 
         if title is None:
-            title = f"{geometry.camera_name}"
+            title = geometry.name
 
         # initialize the plot and generate the pixels as a
         # RegularPolyCollection
@@ -252,26 +252,26 @@ class CameraDisplay:
             The transparency
         """
 
-        l = np.zeros_like(self.image)
-        l[pixels] = linewidth
-        self.pixel_highlighting.set_linewidth(l)
+        linewidths = np.zeros_like(self.image)
+        linewidths[pixels] = linewidth
+        self.pixel_highlighting.set_linewidth(linewidths)
         self.pixel_highlighting.set_alpha(alpha)
         self.pixel_highlighting.set_edgecolor(color)
         self._update()
 
     def enable_pixel_picker(self):
-        """ enable ability to click on pixels """
+        """enable ability to click on pixels"""
         self.pixels.set_picker(True)
         self.pixels.set_pickradius(self.geom.pixel_width.value[0] / 2)
         self.axes.figure.canvas.mpl_connect("pick_event", self._on_pick)
 
     def set_limits_minmax(self, zmin, zmax):
-        """ set the color scale limits from min to max """
+        """set the color scale limits from min to max"""
         self.pixels.set_clim(zmin, zmax)
         self._update()
 
     def set_limits_percent(self, percent=95):
-        """ auto-scale the color range to percent of maximum """
+        """auto-scale the color range to percent of maximum"""
         zmin = np.nanmin(self.pixels.get_array())
         zmax = np.nanmax(self.pixels.get_array())
         if isinstance(self.pixels.norm, LogNorm):
@@ -363,12 +363,12 @@ class CameraDisplay:
         self._update()
 
     def _update(self):
-        """ signal a redraw if autoupdate is turned on """
+        """signal a redraw if autoupdate is turned on"""
         if self.autoupdate:
             self.update()
 
     def update(self):
-        """ redraw the display now """
+        """redraw the display now"""
         self.axes.figure.canvas.draw()
         if self.colorbar is not None:
             self.colorbar.update_normal(self.pixels)
@@ -475,13 +475,13 @@ class CameraDisplay:
             self._axes_overlays.append(text)
 
     def clear_overlays(self):
-        """ Remove added overlays from the axes """
+        """Remove added overlays from the axes"""
         while self._axes_overlays:
             overlay = self._axes_overlays.pop()
             overlay.remove()
 
     def _on_pick(self, event):
-        """ handler for when a pixel is clicked """
+        """handler for when a pixel is clicked"""
         pix_id = event.ind[-1]
         x = self.geom.pix_x[pix_id].value
         y = self.geom.pix_y[pix_id].value
@@ -505,7 +505,7 @@ class CameraDisplay:
         self.axes.figure.show()
 
     def auto_set_axes_labels(self):
-        """ set the axes labels based on the Frame attribute"""
+        """set the axes labels based on the Frame attribute"""
         axes_labels = ("X", "Y")
         if self.geom.frame is not None:
             axes_labels = list(
@@ -516,7 +516,7 @@ class CameraDisplay:
         self.axes.set_ylabel(f"{axes_labels[1]}  ({self.geom.pix_y.unit})")
 
     def add_frame_name(self, color="grey"):
-        """ label the frame type of the display (e.g. CameraFrame) """
+        """label the frame type of the display (e.g. CameraFrame)"""
 
         frame_name = (
             self.geom.frame.__class__.__name__
diff --git a/docs/tutorials/coordinates_example.ipynb b/docs/tutorials/coordinates_example.ipynb
index 1d165212..678d3d63 100644
--- a/docs/tutorials/coordinates_example.ipynb
+++ b/docs/tutorials/coordinates_example.ipynb
@@ -257,7 +257,7 @@
    "outputs": [],
    "source": [
     "plt.scatter(cam_coords.x, cam_coords.y)\n",
-    "plt.title(f\"Camera type: {geometry.camera_name}\")\n",
+    "plt.title(f\"Camera type: {geometry.name}\")\n",
     "plt.xlabel(f\"x / {cam_coords.x.unit}\")\n",
     "plt.ylabel(f\"y / {cam_coords.y.unit}\")\n",
     "plt.axis(\"square\");"
@@ -302,7 +302,7 @@
     "display = CameraDisplay(cam, ax=ax)\n",
     "\n",
     "ax.set_title(\n",
-    "    f\"La Palma, {obstime}, az={pointing.az.deg:.1f}°, zenith={pointing.zen.deg:.1f}°, camera={geometry.camera_name}\"\n",
+    "    f\"La Palma, {obstime}, az={pointing.az.deg:.1f}°, zenith={pointing.zen.deg:.1f}°, camera={geometry.name}\"\n",
     ")\n",
     "\n",
     "for i, name in enumerate([\"crab nebula\", \"o tau\", \"zet tau\"]):\n",
diff --git a/docs/tutorials/ctapipe_overview.ipynb b/docs/tutorials/ctapipe_overview.ipynb
index e13269bb..ed882987 100644
--- a/docs/tutorials/ctapipe_overview.ipynb
+++ b/docs/tutorials/ctapipe_overview.ipynb
@@ -446,7 +446,7 @@
    },
    "outputs": [],
    "source": [
-    "boundary, picture, min_neighbors = cleaning_level[geometry.camera_name]\n",
+    "boundary, picture, min_neighbors = cleaning_level[geometry.name]\n",
     "\n",
     "clean = tailcuts_clean(\n",
     "    geometry,\n",
diff --git a/examples/plot_showers_in_nominal.py b/examples/plot_showers_in_nominal.py
index fdea14b8..2db90796 100644
--- a/examples/plot_showers_in_nominal.py
+++ b/examples/plot_showers_in_nominal.py
@@ -1,15 +1,15 @@
-from astropy.coordinates import SkyCoord, AltAz
+import warnings
+
 import astropy.units as u
-from ctapipe.io import EventSource
-from ctapipe.image.cleaning import tailcuts_clean
-from ctapipe.calib import CameraCalibrator
-from ctapipe.utils.datasets import get_dataset_path
 import matplotlib.pyplot as plt
 import numpy as np
-import warnings
-
-from ctapipe.coordinates import CameraFrame, NominalFrame, MissingFrameAttributeWarning
+from astropy.coordinates import AltAz, SkyCoord
 
+from ctapipe.calib import CameraCalibrator
+from ctapipe.coordinates import CameraFrame, MissingFrameAttributeWarning, NominalFrame
+from ctapipe.image.cleaning import tailcuts_clean
+from ctapipe.io import EventSource
+from ctapipe.utils.datasets import get_dataset_path
 
 warnings.filterwarnings("ignore", category=MissingFrameAttributeWarning)
 
@@ -55,7 +55,7 @@ with EventSource(input_url=input_url) as source:
                 telescope_pointing=telescope_pointing, focal_length=focal_length
             )
 
-            boundary, picture, min_neighbors = cleaning_level[geom.camera_name]
+            boundary, picture, min_neighbors = cleaning_level[geom.name]
             clean = tailcuts_clean(
                 geom,
                 image,
diff --git a/examples/simple_event_writer.py b/examples/simple_event_writer.py
index 08f4ddb8..c37cf127 100755
--- a/examples/simple_event_writer.py
+++ b/examples/simple_event_writer.py
@@ -8,13 +8,12 @@ An HDF5 file is written with image MC and moment parameters
 """
 from tqdm.auto import tqdm
 
+from ctapipe.calib import CameraCalibrator
 from ctapipe.core import Tool
-from ctapipe.core.traits import Path, Unicode, Dict, Bool
+from ctapipe.core.traits import Bool, Dict, Path, Unicode
+from ctapipe.image import hillas_parameters, tailcuts_clean
 from ctapipe.io import EventSource, HDF5TableWriter
-
-from ctapipe.calib import CameraCalibrator
 from ctapipe.utils import get_dataset_path
-from ctapipe.image import hillas_parameters, tailcuts_clean
 
 
 class SimpleEventWriter(Tool):
@@ -80,7 +79,7 @@ class SimpleEventWriter(Tool):
 
                 # Save Ids, MC infos and Hillas informations
                 self.writer.write(
-                    geom.camera_name, [event.r0, event.simulation.shower, params]
+                    geom.name, [event.r0, event.simulation.shower, params]
                 )
 
     def finish(self):
diff --git a/examples/stereo_reconstruction.py b/examples/stereo_reconstruction.py
index c16f77d1..755e005b 100644
--- a/examples/stereo_reconstruction.py
+++ b/examples/stereo_reconstruction.py
@@ -1,19 +1,16 @@
-import astropy.units as u
-from astropy.coordinates import SkyCoord, AltAz
+import matplotlib.pyplot as plt
+from astropy.coordinates import AltAz, SkyCoord
 
-from ctapipe.io import EventSource
 from ctapipe.calib import CameraCalibrator
+from ctapipe.image import hillas_parameters, leakage
 from ctapipe.image.cleaning import tailcuts_clean
 from ctapipe.image.morphology import number_of_islands
-from ctapipe.image import leakage, hillas_parameters
 from ctapipe.image.timing import timing_parameters
+from ctapipe.io import EventSource
 from ctapipe.reco import HillasReconstructor
 from ctapipe.utils.datasets import get_dataset_path
-
-import matplotlib.pyplot as plt
 from ctapipe.visualization import ArrayDisplay
 
-
 # unoptimized cleaning levels, copied from
 # https://github.com/tudo-astroparticlephysics/cta_preprocessing
 cleaning_level = {
@@ -47,7 +44,7 @@ for event in event_source:
         peakpos = dl1.peak_time
 
         # cleaning
-        boundary, picture, min_neighbors = cleaning_level[geom.camera_name]
+        boundary, picture, min_neighbors = cleaning_level[geom.name]
         clean = tailcuts_clean(
             geom,
             image,
@@ -74,11 +71,11 @@ for event in event_source:
 
         # store timegradients for plotting
         # ASTRI has no timing in PROD3b, so we use skewness instead
-        if geom.camera_name != "ASTRICam":
+        if geom.name != "ASTRICam":
             time_gradients[telescope_id] = timing_c.slope.value
         else:
             time_gradients[telescope_id] = hillas_c.skewness
-        print(geom.camera_name, time_gradients[telescope_id])
+        print(geom.name, time_gradients[telescope_id])
 
         # make sure each telescope get's an arrow
         if abs(time_gradients[telescope_id]) < 0.2:


Test patch:
diff --git a/ctapipe/conftest.py b/ctapipe/conftest.py
index a4d1f68e..bb96ea27 100644
--- a/ctapipe/conftest.py
+++ b/ctapipe/conftest.py
@@ -39,8 +39,7 @@ camera_names = [
 
 @pytest.fixture(scope="function", params=camera_names)
 def camera_geometry(request):
-    path = get_dataset_path(f"{request.param}.camgeom.fits.gz")
-    return CameraGeometry.from_table(path)
+    return CameraGeometry.from_name(request.param)
 
 
 @pytest.fixture(scope="session")
diff --git a/ctapipe/image/tests/test_hillas.py b/ctapipe/image/tests/test_hillas.py
index 0c8c8e8a..9afde338 100644
--- a/ctapipe/image/tests/test_hillas.py
+++ b/ctapipe/image/tests/test_hillas.py
@@ -218,7 +218,7 @@ def test_straight_line_width_0():
                 y = dy - np.sin(psi) * long + np.cos(psi) * trans
 
                 geom = CameraGeometry(
-                    camera_name="testcam",
+                    name="testcam",
                     pix_id=pix_id,
                     pix_x=x * u.m,
                     pix_y=y * u.m,
@@ -238,7 +238,7 @@ def test_single_pixel():
     x, y = np.meshgrid(x, y)
 
     geom = CameraGeometry(
-        camera_name="testcam",
+        name="testcam",
         pix_id=np.arange(9),
         pix_x=x.ravel() * u.cm,
         pix_y=y.ravel() * u.cm,
diff --git a/ctapipe/instrument/camera/tests/test_description.py b/ctapipe/instrument/camera/tests/test_description.py
index 0761b037..4cf1671d 100644
--- a/ctapipe/instrument/camera/tests/test_description.py
+++ b/ctapipe/instrument/camera/tests/test_description.py
@@ -2,5 +2,5 @@ from ctapipe.instrument import CameraDescription
 
 
 def test_known_camera_names(camera_geometry):
-    """ Check that we can get a list of known camera names """
-    assert camera_geometry.camera_name in CameraDescription.get_known_camera_names()
+    """Check that we can get a list of known camera names"""
+    assert camera_geometry.name in CameraDescription.get_known_camera_names()
diff --git a/ctapipe/instrument/camera/tests/test_geometry.py b/ctapipe/instrument/camera/tests/test_geometry.py
index b767d15e..536da776 100644
--- a/ctapipe/instrument/camera/tests/test_geometry.py
+++ b/ctapipe/instrument/camera/tests/test_geometry.py
@@ -14,7 +14,7 @@ def test_construct():
     x = np.linspace(-10, 10, 100)
     y = np.linspace(-10, 10, 100)
     geom = CameraGeometry(
-        camera_name="Unknown",
+        name="Unknown",
         pix_id=np.arange(100),
         pix_x=x * u.m,
         pix_y=y * u.m,
@@ -24,14 +24,14 @@ def test_construct():
         cam_rotation="12d",
     )
 
-    assert geom.camera_name == "Unknown"
+    assert geom.name == "Unknown"
     assert geom.pix_area is not None
     assert (geom.pix_rotation.deg - 10) < 1e-5
     assert (geom.cam_rotation.deg - 10) < 1e-5
 
     with pytest.raises(TypeError):
         geom = CameraGeometry(
-            camera_name="Unknown",
+            name="Unknown",
             pix_id=np.arange(100),
             pix_x=x * u.m,
             pix_y=y * u.m,
@@ -43,7 +43,7 @@ def test_construct():
 
     # test from string:
     geom = CameraGeometry(
-        camera_name="Unknown",
+        name="Unknown",
         pix_id=np.arange(100),
         pix_x=x * u.m,
         pix_y=y * u.m,
@@ -114,7 +114,7 @@ def test_neighbor_pixels(camera_geometry):
 
     # whipple has inhomogenious pixels that mess with pixel neighborhood
     # calculation
-    if not geom.camera_name.startswith("Whipple"):
+    if not geom.name.startswith("Whipple"):
         assert np.all(geom.neighbor_matrix == geom.neighbor_matrix.T)
         assert n_neighbors.count(1) == 0  # no pixel should have a single neighbor
 
@@ -124,7 +124,7 @@ def test_calc_pixel_neighbors_square():
     x, y = np.meshgrid(np.arange(20), np.arange(20))
 
     cam = CameraGeometry(
-        camera_name="test",
+        name="test",
         pix_id=np.arange(400),
         pix_type="rectangular",
         pix_x=u.Quantity(x.ravel(), u.cm),
@@ -144,7 +144,7 @@ def test_calc_pixel_neighbors_square_diagonal():
     x, y = np.meshgrid(np.arange(20), np.arange(20))
 
     cam = CameraGeometry(
-        camera_name="test",
+        name="test",
         pix_id=np.arange(400),
         pix_type="rectangular",
         pix_x=u.Quantity(x.ravel(), u.cm),
@@ -162,7 +162,7 @@ def test_to_and_from_table(prod5_lst):
     tab = prod5_lst_cam.to_table()
     prod5_lst_cam2 = prod5_lst_cam.from_table(tab)
 
-    assert prod5_lst_cam.camera_name == prod5_lst_cam2.camera_name
+    assert prod5_lst_cam.name == prod5_lst_cam2.name
     assert (prod5_lst_cam.pix_x == prod5_lst_cam2.pix_x).all()
     assert (prod5_lst_cam.pix_y == prod5_lst_cam2.pix_y).all()
     assert (prod5_lst_cam.pix_area == prod5_lst_cam2.pix_area).all()
@@ -177,7 +177,7 @@ def test_write_read(tmpdir, prod5_lst):
     prod5_lst_cam.to_table().write(filename, overwrite=True)
     prod5_lst_cam2 = prod5_lst_cam.from_table(filename)
 
-    assert prod5_lst_cam.camera_name == prod5_lst_cam2.camera_name
+    assert prod5_lst_cam.name == prod5_lst_cam2.name
     assert (prod5_lst_cam.pix_x == prod5_lst_cam2.pix_x).all()
     assert (prod5_lst_cam.pix_y == prod5_lst_cam2.pix_y).all()
     assert (prod5_lst_cam.pix_area == prod5_lst_cam2.pix_area).all()
@@ -190,7 +190,7 @@ def test_precal_neighbors():
     overwritten by automatic ones
     """
     geom = CameraGeometry(
-        camera_name="TestCam",
+        name="TestCam",
         pix_id=np.arange(3),
         pix_x=np.arange(3) * u.deg,
         pix_y=np.arange(3) * u.deg,
@@ -239,7 +239,7 @@ def test_rectangle_patch_neighbors():
     pix_x = np.array([-1.1, 0.1, 0.9, -1, 0, 1, -0.9, -0.1, 1.1]) * u.m
     pix_y = np.array([1.1, 1, 0.9, -0.1, 0, 0.1, -0.9, -1, -1.1]) * u.m
     cam = CameraGeometry(
-        camera_name="testcam",
+        name="testcam",
         pix_id=np.arange(pix_x.size),
         pix_x=pix_x,
         pix_y=pix_y,
@@ -290,8 +290,8 @@ def test_hashing(prod5_lst, prod5_mst_nectarcam):
 
 def test_camera_from_name(camera_geometry):
     """check we can construct all cameras from name"""
-    camera = CameraGeometry.from_name(camera_geometry.camera_name)
-    assert str(camera) == camera_geometry.camera_name
+    camera = CameraGeometry.from_name(camera_geometry.name)
+    assert str(camera) == camera_geometry.name
 
 
 def test_camera_coordinate_transform(camera_geometry):
@@ -335,33 +335,6 @@ def test_camera_coordinate_transform(camera_geometry):
     assert np.allclose(geom_cam.pix_x.to_value(unit), geom.pix_x.to_value(unit))
 
 
-def test_guess_area():
-    x = u.Quantity([0, 1, 2], u.cm)
-    y = u.Quantity([0, 0, 0], u.cm)
-    n_pixels = len(x)
-
-    geom = CameraGeometry(
-        "test",
-        pix_id=np.arange(n_pixels),
-        pix_area=None,
-        pix_x=x,
-        pix_y=y,
-        pix_type="rect",
-    )
-
-    assert np.all(geom.pix_area == 1 * u.cm**2)
-
-    geom = CameraGeometry(
-        "test",
-        pix_id=np.arange(n_pixels),
-        pix_area=None,
-        pix_x=x,
-        pix_y=y,
-        pix_type="hexagonal",
-    )
-    assert u.allclose(geom.pix_area, 2 * np.sqrt(3) * (0.5 * u.cm) ** 2)
-
-
 def test_guess_width():
     x = u.Quantity([0, 1, 2], u.cm)
     y = u.Quantity([0, 0, 0], u.cm)
diff --git a/ctapipe/instrument/camera/tests/test_readout.py b/ctapipe/instrument/camera/tests/test_readout.py
index 1878ff7e..509451fb 100644
--- a/ctapipe/instrument/camera/tests/test_readout.py
+++ b/ctapipe/instrument/camera/tests/test_readout.py
@@ -1,4 +1,6 @@
 """ Tests for CameraGeometry """
+from itertools import combinations
+
 import numpy as np
 import pytest
 from astropy import units as u
@@ -8,34 +10,46 @@ from ctapipe.instrument import CameraReadout
 
 def test_construct():
     """Check we can make a CameraReadout from scratch"""
-    camera_name = "Unknown"
+    name = "Unknown"
     sampling_rate = u.Quantity(2, u.GHz)
     reference_pulse_shape = np.ones((2, 20)).astype(np.float64)
     reference_pulse_sample_width = u.Quantity(0.5, u.ns)
     readout = CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
-    assert readout.camera_name == camera_name
+    assert readout.name == name
     assert readout.sampling_rate == sampling_rate
     assert (readout.reference_pulse_shape == reference_pulse_shape).all()
     assert readout.reference_pulse_sample_width == reference_pulse_sample_width
+    assert readout.n_pixels == 1000
+    assert readout.n_channels == 2
+    assert readout.n_samples == 40
+    assert readout.n_samples_long == 80
 
 
 @pytest.fixture(scope="module")
 def readout():
-    camera_name = "Unknown"
+    name = "Unknown"
     sampling_rate = u.Quantity(2, u.GHz)
     reference_pulse_shape = np.ones((2, 20)).astype(np.float64)
     reference_pulse_sample_width = u.Quantity(0.5, u.ns)
     return CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
 
@@ -49,7 +63,7 @@ def test_to_and_from_table(readout):
     tab = readout.to_table()
     readout2 = readout.from_table(tab)
 
-    assert readout.camera_name == readout2.camera_name
+    assert readout.name == readout2.name
     assert readout.sampling_rate == readout2.sampling_rate
     assert np.array_equal(readout.reference_pulse_shape, readout2.reference_pulse_shape)
     assert readout.reference_pulse_sample_width == readout2.reference_pulse_sample_width
@@ -62,7 +76,7 @@ def test_write_read(tmpdir, readout):
     readout.to_table().write(filename, overwrite=True)
     readout2 = readout.from_table(filename)
 
-    assert readout.camera_name == readout2.camera_name
+    assert readout.name == readout2.name
     assert readout.sampling_rate == readout2.sampling_rate
     assert np.array_equal(readout.reference_pulse_shape, readout2.reference_pulse_shape)
     assert readout.reference_pulse_sample_width == readout2.reference_pulse_sample_width
@@ -70,36 +84,52 @@ def test_write_read(tmpdir, readout):
 
 def test_equals():
     """check we can use the == operator"""
-    camera_name = "Unknown"
+    name = "Unknown"
     sampling_rate = u.Quantity(2, u.GHz)
     reference_pulse_shape = np.ones((2, 20)).astype(np.float64)
     reference_pulse_sample_width = u.Quantity(0.5, u.ns)
     readout1 = CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     readout2 = CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     readout3 = CameraReadout(
-        camera_name=4,
+        name=4,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     readout4 = CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=u.Quantity(1, u.ns),
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     assert readout1 is not readout2
@@ -113,41 +143,81 @@ def test_equals():
 
 def test_hashing():
     """ " check that hashes are correctly computed"""
-    camera_name = "Unknown"
+    name = "Unknown"
     sampling_rate = u.Quantity(2, u.GHz)
     reference_pulse_shape = np.ones((2, 20)).astype(np.float64)
     reference_pulse_sample_width = u.Quantity(0.5, u.ns)
     readout1 = CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     readout2 = CameraReadout(
-        camera_name=camera_name,
+        name=name,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     readout3 = CameraReadout(
-        camera_name=4,
+        name=4,
         sampling_rate=sampling_rate,
         reference_pulse_shape=reference_pulse_shape,
         reference_pulse_sample_width=reference_pulse_sample_width,
+        n_pixels=1000,
+        n_channels=2,
+        n_samples=40,
+        n_samples_long=80,
     )
 
     assert len({readout1, readout2, readout3}) == 2
 
 
-def test_camera_from_name(camera_geometry):
+@pytest.mark.parametrize("name", ["LSTCam", "FlashCam", "NectarCam", "CHEC"])
+def test_camera_from_name(name, svc_path):
     """check we can construct all cameras from name"""
+    camera = CameraReadout.from_name(name)
+    assert str(camera) == name
+
+
+def test_eq(example_subarray):
+    """Test telescopes compare equal / non-equal as expected in example subarray"""
+    for tel1, tel2 in combinations(example_subarray.tel.values(), 2):
+        if tel1.camera_name == tel2.camera_name:
+            assert tel1.camera.readout == tel2.camera.readout
+        else:
+            assert tel1.camera.readout != tel2.camera.readout
+
+
+def test_hash(example_subarray):
+    """Test telescope hashes are equal / non-equal as expected in example subarray"""
+    for tel1, tel2 in combinations(example_subarray.tel.values(), 2):
+        if tel1.camera_name == tel2.camera_name:
+            assert hash(tel1.camera.readout) == hash(tel2.camera.readout)
+        else:
+            assert hash(tel1.camera.readout) != hash(tel2.camera.readout)
+
+
+def test_table_roundtrip(prod5_lst):
+    """Test we can roundtrip using from_table / to_table"""
+    from ctapipe.instrument import CameraReadout
+
+    camera_in = prod5_lst.camera.readout
+
+    table = camera_in.to_table()
+    assert table.meta["TAB_VER"] == CameraReadout.CURRENT_TAB_VERSION
+    assert table.meta["TAB_TYPE"] == "ctapipe.instrument.CameraReadout"
+    camera_out = CameraReadout.from_table(table)
 
-    try:
-        camera = CameraReadout.from_name(camera_geometry.camera_name)
-        assert str(camera) == camera_geometry.camera_name
-    except FileNotFoundError:
-        # Most non-cta cameras don't have readout provided on the data server
-        if camera_geometry.camera_name in ["LSTCam", "NectarCam", "FlashCam", "CHEC"]:
-            raise
+    assert camera_in == camera_out
+    assert hash(camera_in) == hash(camera_out)
diff --git a/ctapipe/instrument/conftest.py b/ctapipe/instrument/conftest.py
new file mode 100644
index 00000000..c962abbe
--- /dev/null
+++ b/ctapipe/instrument/conftest.py
@@ -0,0 +1,43 @@
+"""
+Common test fixtures for the instrument module
+"""
+import os
+import shutil
+
+import pytest
+
+from ctapipe.utils.filelock import FileLock
+
+
+@pytest.fixture(scope="session")
+def instrument_dir(tmp_path_factory):
+    """Dump instrument of prod5 subarray into a directory as fits"""
+    from ctapipe.core import run_tool
+    from ctapipe.tools.dump_instrument import DumpInstrumentTool
+
+    path = tmp_path_factory.mktemp("instrument")
+
+    with FileLock(path / ".lock"):
+        if (path / "optics.fits.gz").is_file():
+            return path
+
+        argv = [
+            "--input=dataset://gamma_prod5.simtel.zst",
+            f"--outdir={path}",
+            "--format=fits",
+        ]
+        assert run_tool(DumpInstrumentTool(), argv=argv, cwd=path) == 0
+        optics_path = path / "MonteCarloArray.optics.fits.gz"
+        shutil.move(optics_path, path / "optics.fits.gz")
+        return path
+
+
+@pytest.fixture(scope="function")
+def svc_path(instrument_dir):
+    before = os.getenv("CTAPIPE_SVC_PATH")
+    os.environ["CTAPIPE_SVC_PATH"] = str(instrument_dir.absolute())
+    yield
+    if before is None:
+        del os.environ["CTAPIPE_SVC_PATH"]
+    else:
+        os.environ["CTAPIPE_SVC_PATH"] = before
diff --git a/ctapipe/instrument/tests/test_optics.py b/ctapipe/instrument/tests/test_optics.py
index 938540db..ed095dff 100644
--- a/ctapipe/instrument/tests/test_optics.py
+++ b/ctapipe/instrument/tests/test_optics.py
@@ -1,15 +1,9 @@
 """ Tests for OpticsDescriptions"""
-import os
-import tempfile
 
 import pytest
 from astropy import units as u
 
-from ctapipe.core.tool import run_tool
-from ctapipe.instrument.optics import OpticsDescription
-from ctapipe.tools.dump_instrument import DumpInstrumentTool
-from ctapipe.utils import get_table_dataset
-from ctapipe.utils.datasets import get_dataset_path
+from ctapipe.instrument.optics import OpticsDescription, ReflectorShape, SizeType
 
 
 def test_guess_optics():
@@ -27,6 +21,8 @@ def test_construct_optics():
     fails if units are missing"""
     OpticsDescription(
         name="test",
+        size_type=SizeType.LST,
+        reflector_shape=ReflectorShape.PARABOLIC,
         num_mirrors=1,
         num_mirror_tiles=100,
         mirror_area=u.Quantity(550, u.m**2),
@@ -35,8 +31,11 @@ def test_construct_optics():
     )
 
     with pytest.raises(TypeError):
+        # missing units
         OpticsDescription(
             name="test",
+            size_type=SizeType.LST,
+            reflector_shape=ReflectorShape.PARABOLIC,
             num_mirrors=1,
             num_mirror_tiles=100,
             mirror_area=550,
@@ -45,42 +44,12 @@ def test_construct_optics():
         )
 
 
-@pytest.mark.parametrize("optics_name", OpticsDescription.get_known_optics_names())
-def test_optics_from_name(optics_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:
-    assert str(optics) == optics_name
-
-
-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
-
-
-def test_optics_from_dump_instrument():
+@pytest.mark.parametrize(
+    "optics_name,focal_length",
+    zip(["LST", "MST", "ASTRI"], [28, 16, 2.15] * u.m),
+)
+def test_optics_from_name(optics_name, focal_length, svc_path):
     # test with file written by dump-instrument
-
-    svc_path_before = os.getenv("CTAPIPE_SVC_PATH")
-    cwd = os.getcwd()
-
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        os.chdir(tmp_dir)
-        os.environ["CTAPIPE_SVC_PATH"] = tmp_dir
-
-        infile = get_dataset_path("gamma_test_large.simtel.gz")
-        run_tool(DumpInstrumentTool(), [f"--input={infile}", "--format=ecsv"])
-
-        lst = OpticsDescription.from_name("LST_LST_LSTCam", "MonteCarloArray.optics")
-        assert lst.num_mirrors == 1
-        assert lst.equivalent_focal_length.to_value(u.m) == 28
-        assert lst.num_mirror_tiles == 198
-
-    os.chdir(cwd)
-    if svc_path_before is None:
-        del os.environ["CTAPIPE_SVC_PATH"]
-    else:
-        os.environ["CTAPIPE_SVC_PATH"] = svc_path_before
+    optics = OpticsDescription.from_name(optics_name)
+    assert optics.name == optics_name
+    assert u.isclose(optics.equivalent_focal_length, focal_length)
diff --git a/ctapipe/instrument/tests/test_subarray.py b/ctapipe/instrument/tests/test_subarray.py
index e84584ba..e644eee3 100644
--- a/ctapipe/instrument/tests/test_subarray.py
+++ b/ctapipe/instrument/tests/test_subarray.py
@@ -144,13 +144,6 @@ def test_hdf(example_subarray, tmp_path):
         # test if transforming works
         tel.camera.geometry.transform_to(TelescopeFrame())
 
-    # test that subarrays without name (v0.8.0) work:
-    with tables.open_file(path, "r+") as hdf:
-        del hdf.root.configuration.instrument.subarray._v_attrs.name
-
-    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
     with tables.open_file(path, "w") as h5file:
         example_subarray.to_hdf(h5file)
@@ -166,7 +159,6 @@ def test_hdf_same_camera(tmp_path, prod5_lst, prod5_mst_flashcam):
     """
     frankenstein_lst = TelescopeDescription(
         name="LST",
-        tel_type="LST",
         optics=prod5_lst.optics,
         camera=prod5_mst_flashcam.camera,
     )
diff --git a/ctapipe/instrument/tests/test_telescope.py b/ctapipe/instrument/tests/test_telescope.py
index 2726d58e..4adfa73e 100644
--- a/ctapipe/instrument/tests/test_telescope.py
+++ b/ctapipe/instrument/tests/test_telescope.py
@@ -4,18 +4,41 @@ import pytest
 from ctapipe.instrument.telescope import TelescopeDescription
 
 
+def test_repr(subarray_prod5_paranal):
+    expected = (
+        "TelescopeDescription(type='LST', optics_name='LST', camera_name='LSTCam')"
+    )
+    assert repr(subarray_prod5_paranal.tel[1]) == expected
+
+    expected = (
+        "TelescopeDescription(type='MST', optics_name='MST', camera_name='FlashCam')"
+    )
+    assert repr(subarray_prod5_paranal.tel[5]) == expected
+
+    expected = (
+        "TelescopeDescription(type='SST', optics_name='ASTRI', camera_name='CHEC')"
+    )
+    assert repr(subarray_prod5_paranal.tel[50]) == expected
+
+
+def test_str(subarray_prod5_paranal):
+    assert str(subarray_prod5_paranal.tel[1]) == "LST_LST_LSTCam"
+    assert str(subarray_prod5_paranal.tel[5]) == "MST_MST_FlashCam"
+    assert str(subarray_prod5_paranal.tel[50]) == "SST_ASTRI_CHEC"
+
+
 def test_hash(subarray_prod5_paranal):
     assert len(subarray_prod5_paranal) == 180
     assert len(set(subarray_prod5_paranal.tel.values())) == 4
 
 
-@pytest.mark.parametrize("optics_name", ["LST", "MST"])
-def test_telescope_from_name(optics_name, camera_geometry):
+@pytest.mark.parametrize("camera_name", ["LSTCam", "FlashCam", "NectarCam", "CHEC"])
+@pytest.mark.parametrize("optics_name", ["LST", "MST", "ASTRI"])
+def test_telescope_from_name(optics_name, camera_name, svc_path):
     """Check we can construct all telescopes from their names"""
-    camera_name = camera_geometry.camera_name
     tel = TelescopeDescription.from_name(optics_name, camera_name)
+
     assert optics_name in str(tel)
     assert camera_name in str(tel)
     assert tel.camera.geometry.pix_x.shape[0] > 0
     assert tel.optics.equivalent_focal_length.to("m") > 0
-    assert tel.type in {"MST", "SST", "LST", "UNKNOWN"}
diff --git a/ctapipe/io/tests/test_simteleventsource.py b/ctapipe/io/tests/test_simteleventsource.py
index 1da8b056..336fb82b 100644
--- a/ctapipe/io/tests/test_simteleventsource.py
+++ b/ctapipe/io/tests/test_simteleventsource.py
@@ -11,6 +11,7 @@ from traitlets.config import Config
 
 from ctapipe.calib.camera.gainselection import ThresholdGainSelector
 from ctapipe.instrument.camera.geometry import UnknownPixelShapeWarning
+from ctapipe.instrument.optics import ReflectorShape
 from ctapipe.io import DataLevel
 from ctapipe.io.simteleventsource import SimTelEventSource, apply_simtel_r1_calibration
 from ctapipe.utils import get_dataset_path
@@ -466,14 +467,18 @@ def test_simtel_metadata(monkeypatch):
             subarray = source.subarray
 
     assert subarray.name == "Paranal-prod6"
-    assert subarray.tel[1].camera.camera_name == "LSTcam"
+    assert subarray.tel[1].camera.name == "LSTcam"
     assert subarray.tel[1].optics.name == "LST"
+    assert subarray.tel[1].optics.reflector_shape is ReflectorShape.PARABOLIC
 
-    assert subarray.tel[5].camera.camera_name == "FlashCam"
+    assert subarray.tel[5].camera.name == "FlashCam"
     assert subarray.tel[5].optics.name == "MST"
+    assert subarray.tel[5].optics.reflector_shape is ReflectorShape.HYBRID
 
-    assert subarray.tel[50].camera.camera_name == "SST-Camera"
-    assert subarray.tel[50].optics.name == "SST"
+    tel = subarray.tel[50]
+    assert tel.camera.name == "SST-Camera"
+    assert tel.optics.name == "SST"
+    assert tel.optics.reflector_shape is ReflectorShape.SCHWARZSCHILD_COUDER
 
 
 def test_simtel_no_metadata(monkeypatch):
@@ -487,14 +492,18 @@ def test_simtel_no_metadata(monkeypatch):
         subarray = source.subarray
 
     assert subarray.name == "MonteCarloArray"
-    assert subarray.tel[1].camera.camera_name == "LSTCam"
+    assert subarray.tel[1].camera.name == "LSTCam"
     assert subarray.tel[1].optics.name == "LST"
+    assert subarray.tel[1].optics.reflector_shape is ReflectorShape.PARABOLIC
 
-    assert subarray.tel[5].camera.camera_name == "FlashCam"
+    assert subarray.tel[5].camera.name == "FlashCam"
     assert subarray.tel[5].optics.name == "MST"
+    assert subarray.tel[5].optics.reflector_shape is ReflectorShape.HYBRID
 
-    assert subarray.tel[50].camera.camera_name == "CHEC"
-    assert subarray.tel[50].optics.name == "ASTRI"
+    tel = subarray.tel[50]
+    assert tel.camera.name == "CHEC"
+    assert tel.optics.name == "ASTRI"
+    assert tel.optics.reflector_shape is ReflectorShape.SCHWARZSCHILD_COUDER
 
     # check we get all unknown telescopes if we remove the guessing keys
     with monkeypatch.context() as m:
@@ -504,7 +513,5 @@ def test_simtel_no_metadata(monkeypatch):
         with SimTelEventSource(path) as source:
             subarray = source.subarray
 
-        assert all(
-            [t.camera.camera_name.startswith("UNKNOWN") for t in subarray.tel.values()]
-        )
+        assert all([t.camera.name.startswith("UNKNOWN") for t in subarray.tel.values()])
         assert all([t.optics.name.startswith("UNKNOWN") for t in subarray.tel.values()])