Repository: cta-observatory/ctapipe
Pull request #1889 (2022-04-22 08:20:22)

Improved configuration of reference metadata
## use case:

The user expects that the metadata in the output file's header is useful, for example to build a file catalog necessary to retrieve the data later.

## what this PR adds:

Part of the reference metadata was already configurable (the Contact info), this adds two additional user-configurable  parts: 
- the `Instrument` metadata (not to be confused with the InstrumentDescription, this is just id information about the instrument, in this case the subarray used). This lets the user set the subarray name, version, and other instrument-related attributes.  If they are not specified, they are automatically filled as before (or left unspecified)
- `CONTEXT` metadata, which is fully user-defined and allows an additional set of context-specific headers to be added as key-value pairs.

## changes

- Added user-defined CONTEXT metadata, which can be added to the config
file
- Made `metadata.Instrument` a configurable class similar to
`metadata.Contact` so values can be overridden in config files

The result is one can create a config file for a subarray definition like:

```yaml
EventSource:
  allowed_tels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 19, 35]

DataWriter:
  Instrument:  # this is the new configurable info
    site: CTA-North
    class_: Subarray
    id_: alpha
    version: Prod5b
```
And that information will be written to the file's reference headers:

```sh
ctapipe-fileinfo output.h5
...
        INSTRUMENT:
            CLASS: Subarray
            ID: alpha
            SITE: CTA-North
            SUBTYPE: unspecified
            TYPE: unspecified
            VERSION: Prod5b
...
```

Similarly the arbitrary CONTEXT metadata can be  set as follows:

```yaml
DataWriter:
  context_metadata:
    "SIMULATION PRODUCTION": prod5b
    "SIMULATION DATASET":  Prod5b_Paranal_AdvancedBaseline_NSB1x_South_40deg
    "DESCRIPTION": "This is just a test"
```
Using spaces between keys allows a hierarchy (we could also support dots or similar)

```
ctapipe-fileinfo output.h5
...
    CONTEXT:
        DESCRIPTION: This is just a test
        SIMULATION:
            DATASET: Prod5b_Paranal_AdvancedBaseline_NSB1x_South_40deg
            PRODUCTION: prod5b
...
```
This is useful for example for storing parameters that are not automatically read from the MC files into the `SimulationConfigContainer` , or for storing things like calibration versions, etc.

## Still todo:
- [x] make tests
## issues
-  due to how the config system works, you cannot "chain" context metadata, meaning if you specify it in two included config files, the latter one fully replaces the former's dictionary, rather than adding to it.
- the `id_` and `class_` attributes have underscores to avoid linter/IDE complaints about python name conflicts (even if they do not conflict).  This is a bit ugly, could consider removing them and just deal with complaints 


Patch:
diff --git a/README.rst b/README.rst
index bd89dad8..8516de79 100644
--- a/README.rst
+++ b/README.rst
@@ -23,12 +23,6 @@ ctapipe |ci| |codacy| |coverage| |conda| |doilatest|
    :target: https://doi.org/10.5281/zenodo.4581045
 .. |doiv011| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.4746317.svg
    :target: https://doi.org/10.5281/zenodo.4746317
-.. |doiv012| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.5720333.svg
-   :target: https://doi.org/10.5281/zenodo.5720333
-.. |doiv013| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.6425499.svg
-   :target: https://doi.org/10.5281/zenodo.6425499
-.. |doiv014| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.6460868.svg
-   :target: https://doi.org/10.5281/zenodo.6460868
 
 Low-level data processing pipeline software for
 `CTA <www.cta-observatory.org>`_ (the Cherenkov Telescope Array)
@@ -45,9 +39,6 @@ Citing this software
 --------------------
 If you use this software for a publication, please cite the proper version using the following DOIs:
 
-- v0.14.0 : |doiv014|
-- v0.13.0 : |doiv013|
-- v0.12.0 : |doiv012|
 - v0.11.0 : |doiv011|
 - v0.10.5 : |doiv010|
 - v0.9.1 : |doiv09|
@@ -66,11 +57,17 @@ environment (this is optional).
 The following command will set up a conda virtual environment, add the
 necessary package channels, and install ctapipe specified version and its dependencies::
 
-  CTAPIPE_VER=0.14.0
-  wget https://raw.githubusercontent.com/cta-observatory/ctapipe/v$CTAPIPE_VER/environment.yml
-  conda env create -n cta -f environment.yml
-  conda activate cta
-  conda install -c conda-forge ctapipe=$CTAPIPE_VER
+    CTAPIPE_VER=0.11.0
+    wget https://raw.githubusercontent.com/cta-observatory/ctapipe/v$CTAPIPE_VER/environment.yml
+    conda env create -n cta -f environment.yml
+    conda activate cta
+    conda install -c conda-forge ctapipe=$CTAPIPE_VER
+
+
+**Note**: To prevent packages you installed with `pip install --user` from taking precedence over the conda environment, run::
+
+    conda env config vars set PYTHONNOUSERSITE=1 -n cta
+
   
 **Note**: this environment contains many useful packages that are not strictly requirements of ctapipe.
 To get only ctapipe and its direct dependencies, just do ``conda install -c conda-forge ctapipe[=<version>]`` in an environment
diff --git a/ctapipe/core/component.py b/ctapipe/core/component.py
index c82cfbb1..b4fa32fc 100644
--- a/ctapipe/core/component.py
+++ b/ctapipe/core/component.py
@@ -58,7 +58,7 @@ def non_abstract_children(base):
     -------
     non_abstract : dict
         dict of all non-abstract subclasses
-    """
+     """
     subclasses = base.__subclasses__() + [
         g for s in base.__subclasses__() for g in non_abstract_children(s)
     ]
@@ -143,8 +143,8 @@ class Component(Configurable, metaclass=AbstractConfigurableMeta):
 
         # set up logging (for some reason the logger registered by LoggingConfig
         # doesn't use a child logger of the parent by default)
-        if parent is not None:
-            self.log = parent.log.getChild(self.__class__.__name__)
+        if self.parent:
+            self.log = self.parent.log.getChild(self.__class__.__name__)
         else:
             self.log = getLogger(
                 self.__class__.__module__ + "." + self.__class__.__name__
@@ -203,7 +203,7 @@ class Component(Configurable, metaclass=AbstractConfigurableMeta):
         return subclasses
 
     def get_current_config(self):
-        """return the current configuration as a dict (e.g. the values
+        """ return the current configuration as a dict (e.g. the values
         of all traits, even if they were not set during configuration)
         """
         name = self.__class__.__name__
@@ -216,7 +216,7 @@ class Component(Configurable, metaclass=AbstractConfigurableMeta):
         return config
 
     def _repr_html_(self):
-        """nice HTML rep, with blue for non-default values"""
+        """ nice HTML rep, with blue for non-default values"""
         traits = self.traits()
         name = self.__class__.__name__
         lines = [
diff --git a/ctapipe/core/tool.py b/ctapipe/core/tool.py
index d934da4c..71c8bbd0 100644
--- a/ctapipe/core/tool.py
+++ b/ctapipe/core/tool.py
@@ -192,10 +192,7 @@ class Tool(Application):
         self.version = version
         self.raise_config_file_errors = True  # override traitlets.Application default
 
-        # tools defined in other modules should have those modules as base
-        # logging name
-        module_name = self.__class__.__module__.split(".")[0]
-        self.log = logging.getLogger(f"{module_name}.{self.name}")
+        self.log = logging.getLogger("ctapipe." + self.name)
         self.trait_warning_handler = CollectTraitWarningsHandler()
         self.update_logging_config()
 
diff --git a/ctapipe/io/datawriter.py b/ctapipe/io/datawriter.py
index ed8d811e..0a89125e 100644
--- a/ctapipe/io/datawriter.py
+++ b/ctapipe/io/datawriter.py
@@ -6,11 +6,12 @@ Class to write DL1 (a,b) and DL2 (a) data from an event stream
 
 import pathlib
 from collections import defaultdict
-from traitlets import Instance
+from typing import List
 
 import numpy as np
 import tables
 from astropy import units as u
+from traitlets import Dict, Instance
 
 from ..containers import (
     ArrayEventContainer,
@@ -60,8 +61,14 @@ PROV = Provenance()
 
 
 def write_reference_metadata_headers(
-    obs_ids, subarray, writer, is_simulation, data_levels, contact_info
-):
+    obs_ids: List[int],
+    subarray: SubarrayDescription,
+    writer: "DataWriter",
+    is_simulation: bool,
+    data_levels,
+    contact_info: meta.Contact,
+    instrument_info: meta.Instrument,
+) -> None:
     """
     Attaches Core Provenence headers to an output HDF5 file.
     Right now this is hard-coded for use with the ctapipe-process tool
@@ -79,6 +86,10 @@ def write_reference_metadata_headers(
     data_levels: List[DataLevel]
         list of data levels that were requested/generated
         (e.g. from `DataWriter.datalevels`)
+    contact_info: meta.Contact
+        contact metadata
+    instrument_info: meta.Instrument
+        instrument metadata
     """
     activity = PROV.current_activity.provenance
     category = "Sim" if is_simulation else "Other"
@@ -101,15 +112,12 @@ def write_reference_metadata_headers(
             id_=",".join(str(x) for x in obs_ids),
         ),
         activity=meta.Activity.from_provenance(activity),
-        instrument=meta.Instrument(
-            site="Other",  # need a way to detect site...
-            class_="Subarray",
-            type_="unknown",
-            version="unknown",
-            id_=subarray.name,
-        ),
+        instrument=instrument_info,
     )
 
+    if reference.instrument.id_ == "unspecified":
+        reference.instrument.id_ = subarray.name
+
     headers = reference.to_dict()
     meta.write_to_hdf5(headers, writer.h5file)
 
@@ -133,6 +141,17 @@ class DataWriter(Component):
 
     # pylint: disable=too-many-instance-attributes
     contact_info = Instance(meta.Contact, kw={}).tag(config=True)
+    instrument_info = Instance(meta.Instrument, kw={}).tag(config=True)
+
+    context_metadata = Dict(
+        help=(
+            "Additional metadata keywords and values that describe this data. "
+            "This should be a dictionary where the keys will be appended to the "
+            "CONTEXT section of the output file's attributes. Keys can be hierarchical "
+            "by using a space between each level, e.g. `SIMULATION PRODUCTION` "
+            "would make a key PRODUCTION grouped under the key SIMULATION"
+        )
+    ).tag(config=True)
 
     output_path = Path(
         help="output filename", default_value=pathlib.Path("events.dl1.h5")
@@ -226,6 +245,7 @@ class DataWriter(Component):
 
         self.event_source = event_source
         self.contact_info = meta.Contact(parent=self)
+        self.instrument_info = meta.Instrument(parent=self)
 
         self._at_least_one_event = False
         self._is_simulation = event_source.is_simulation
@@ -306,7 +326,11 @@ class DataWriter(Component):
                 is_simulation=self._is_simulation,
                 data_levels=self.datalevels,
                 contact_info=self.contact_info,
+                instrument_info=self.instrument_info,
             )
+
+            self._write_context_metadata_headers()
+
             self._writer.close()
             self._writer = None
 
@@ -750,3 +774,23 @@ class DataWriter(Component):
                 )
 
         self._generate_table_indices(self._writer.h5file, "/dl1/event/subarray")
+
+    def _write_context_metadata_headers(self):
+        """write out any user-defined metadata in the context_metadata field to the
+        headers.
+
+        This will create a set of headers that start with CONTEXT <KEY> = value
+
+        Keys can be hierarchical separated by spaces
+
+        """
+
+        # first append CONTEXT to each key in the dict
+
+        context_dict = {}
+
+        for key, value in self.context_metadata.items():
+            key = " ".join(["CONTEXT", key])
+            context_dict[key] = value
+
+        meta.write_to_hdf5(context_dict, self._writer.h5file)
diff --git a/ctapipe/io/metadata.py b/ctapipe/io/metadata.py
index e8b08520..da77c6cd 100644
--- a/ctapipe/io/metadata.py
+++ b/ctapipe/io/metadata.py
@@ -55,7 +55,7 @@ CONVERSIONS = {Time: lambda t: t.utc.iso, list: str}
 
 
 class Contact(Configurable):
-    """ Contact information """
+    """Contact information"""
 
     name = Unicode("unknown").tag(config=True)
     email = Unicode("unknown").tag(config=True)
@@ -63,7 +63,7 @@ class Contact(Configurable):
 
     @default("name")
     def default_name(self):
-        """ if no name specified, use the system's user name"""
+        """if no name specified, use the system's user name"""
         try:
             return pwd.getpwuid(os.getuid()).pw_gecos
         except RuntimeError:
@@ -90,17 +90,17 @@ class Product(HasTraits):
     # pylint: disable=no-self-use
     @default("creation_time")
     def default_time(self):
-        """ return current time by default """
+        """return current time by default"""
         return Time.now().iso
 
     @default("id_")
     def default_product_id(self):
-        """ default id is a UUID """
+        """default id is a UUID"""
         return str(uuid.uuid4())
 
 
 class Process(HasTraits):
-    """ Process (top-level workflow) information """
+    """Process (top-level workflow) information"""
 
     type_ = Enum(["Observation", "Simulation", "Other"], "Other")
     subtype = Unicode("")
@@ -108,11 +108,11 @@ class Process(HasTraits):
 
 
 class Activity(HasTraits):
-    """ Activity (tool) information """
+    """Activity (tool) information"""
 
     @classmethod
     def from_provenance(cls, activity):
-        """ construct Activity metadata from existing ActivityProvenance object"""
+        """construct Activity metadata from existing ActivityProvenance object"""
         return Activity(
             name=activity["activity_name"],
             type_="software",
@@ -132,12 +132,12 @@ class Activity(HasTraits):
     # pylint: disable=no-self-use
     @default("start_time")
     def default_time(self):
-        """ default time is now """
+        """default time is now"""
         return Time.now().iso
 
 
-class Instrument(HasTraits):
-    """ Instrumental Context """
+class Instrument(Configurable):
+    """Instrumental Context"""
 
     site = Enum(
         [
@@ -155,7 +155,7 @@ class Instrument(HasTraits):
         "Other",
         help="Which site of CTA (or external telescope) "
         "this instrument is associated with",
-    )
+    ).tag(config=True)
     class_ = Enum(
         [
             "Array",
@@ -170,15 +170,21 @@ class Instrument(HasTraits):
             "Other",
         ],
         "Other",
-    )
-    type_ = Unicode("unspecified")
-    subtype = Unicode("unspecified")
-    version = Unicode("unspecified")
-    id_ = Unicode("unspecified")
+    ).tag(config=True)
+    type_ = Unicode("unspecified").tag(config=True)
+    subtype = Unicode("unspecified").tag(config=True)
+    version = Unicode("unspecified").tag(config=True)
+    id_ = Unicode("unspecified").tag(config=True)
+
+    def __repr__(self):
+        return (
+            f"Contact({self.site=}, {self.class_=}, {self.type_=}, "
+            f"{self.subtype=}, {self.version=}, {self.id_=})"
+        )
 
 
 def _to_dict(hastraits_instance, prefix=""):
-    """ helper to convert a HasTraits to a dict with keys
+    """helper to convert a HasTraits to a dict with keys
     in the required CTA format (upper-case, space separated)
     """
     res = {}
@@ -199,8 +205,8 @@ def _to_dict(hastraits_instance, prefix=""):
 
 
 class Reference(HasTraits):
-    """ All the reference Metadata required for a CTA output file, plus a way to turn
-    it into a dict() for easy addition to the header of a file """
+    """All the reference Metadata required for a CTA output file, plus a way to turn
+    it into a dict() for easy addition to the header of a file"""
 
     contact = Instance(Contact)
     product = Instance(Product)
@@ -225,7 +231,7 @@ class Reference(HasTraits):
         return meta
 
 
-def write_to_hdf5(metadata, h5file, path='/'):
+def write_to_hdf5(metadata, h5file, path="/"):
     """
     Write metadata fields to a PyTables HDF5 file handle.
 
@@ -246,7 +252,7 @@ def write_to_hdf5(metadata, h5file, path='/'):
             node._v_attrs[key] = value  # pylint: disable=protected-access
 
 
-def read_metadata(h5file, path='/'):
+def read_metadata(h5file, path="/"):
     """
     Read metadata from an hdf5 file
 
diff --git a/ctapipe/tools/process.py b/ctapipe/tools/process.py
index b988c196..f9b0bef1 100644
--- a/ctapipe/tools/process.py
+++ b/ctapipe/tools/process.py
@@ -14,6 +14,7 @@ from ..io import DataLevel, DataWriter, EventSource, SimTelEventSource, write_ta
 from ..io.datawriter import DATA_MODEL_VERSION
 from ..reco import ShowerProcessor
 from ..utils import EventTypeFilter
+from ..io import metadata
 
 
 COMPATIBLE_DATALEVELS = [
@@ -134,7 +135,14 @@ class ProcessorTool(Tool):
     }
 
     classes = (
-        [CameraCalibrator, DataWriter, ImageProcessor, ShowerProcessor]
+        [
+            CameraCalibrator,
+            DataWriter,
+            ImageProcessor,
+            ShowerProcessor,
+            metadata.Instrument,
+            metadata.Contact,
+        ]
         + classes_with_traits(EventSource)
         + classes_with_traits(ImageCleaner)
         + classes_with_traits(ImageExtractor)
@@ -184,7 +192,7 @@ class ProcessorTool(Tool):
 
     @property
     def should_compute_dl2(self):
-        """ returns true if we should compute DL2 info """
+        """returns true if we should compute DL2 info"""
         if self.force_recompute_dl2:
             return True
         return self.write.write_stereo_shower or self.write.write_mono_shower
@@ -281,7 +289,7 @@ class ProcessorTool(Tool):
 
 
 def main():
-    """ run the tool"""
+    """run the tool"""
     tool = ProcessorTool()
     tool.run()
 


Test patch:
diff --git a/ctapipe/core/tests/test_component.py b/ctapipe/core/tests/test_component.py
index 2df00d5a..f871d893 100644
--- a/ctapipe/core/tests/test_component.py
+++ b/ctapipe/core/tests/test_component.py
@@ -6,11 +6,11 @@ from traitlets.config.loader import Config
 import astropy.units as u
 import warnings
 
-from ctapipe.core import Component, Tool
+from ctapipe.core import Component
 
 
 def test_non_abstract_children():
-    """check that we can find all constructable children"""
+    """ check that we can find all constructable children """
     from ctapipe.core import non_abstract_children
 
     class AbstractBase(ABC):
@@ -72,13 +72,13 @@ def test_get_config_from_hierarchy():
 
 
 class SubComponent(Component):
-    """An Example Component, this is the help text"""
+    """ An Example Component, this is the help text"""
 
     value = Float(default_value=-1.0, help="float parameter").tag(config=True)
 
 
 class ExampleComponent(Component):
-    """An Example Component, this is the help text"""
+    """ An Example Component, this is the help text"""
 
     param = Float(default_value=1.0, help="float parameter").tag(config=True)
 
@@ -88,13 +88,13 @@ class ExampleComponent(Component):
 
 
 class ExampleSubclass1(ExampleComponent):
-    """a subclass of ExampleComponent"""
+    """ a subclass of ExampleComponent"""
 
     pass
 
 
 class ExampleSubclass2(ExampleComponent):
-    """Another ExampleComponent"""
+    """ Another ExampleComponent """
 
     description = "A shorter description"
     param = Float(default_value=3.0, help="float parameter").tag(config=True)
@@ -102,7 +102,7 @@ class ExampleSubclass2(ExampleComponent):
 
 
 def test_component_is_abstract():
-    """check that we can make an abstract component"""
+    """ check that we can make an abstract component """
 
     class AbstractComponent(Component):
         @abstractmethod
@@ -128,7 +128,7 @@ def test_component_simple():
 
 
 def test_component_kwarg_setting():
-    """check that we can construct a component by setting traits via kwargs"""
+    """ check that we can construct a component by setting traits via kwargs """
     comp = ExampleComponent(param=3)
     assert comp.param == 3
 
@@ -147,13 +147,13 @@ def test_component_kwarg_setting():
 
 
 def test_help():
-    """check that component help strings are generated correctly"""
+    """ check that component help strings are generated correctly """
     help_msg = ExampleComponent.class_get_help()
     assert "Default: 1.0" in help_msg
 
 
 def test_config():
-    """check that components can be constructed by config dict"""
+    """ check that components can be constructed by config dict """
     config = Config()
     config["ExampleComponent"] = Config()
     config["ExampleComponent"]["param"] = 199.0
@@ -162,7 +162,7 @@ def test_config():
 
 
 def test_config_baseclass():
-    """check that parent and subclass configuration works"""
+    """ check that parent and subclass configuration works """
     config = Config()
     config["ExampleComponent"] = Config()
     config["ExampleComponent"]["param"] = 199.0
@@ -191,7 +191,7 @@ def test_config_subclass2():
 
 
 def test_config_sibling1():
-    """check sibling config"""
+    """ check sibling config """
     config = Config()
     config["ExampleSubclass1"] = Config()
     config["ExampleSubclass1"]["param"] = 199.0
@@ -202,7 +202,7 @@ def test_config_sibling1():
 
 
 def test_config_sibling2():
-    """check sibling config"""
+    """ check sibling config """
     config = Config()
     config["ExampleSubclass2"] = Config()
     config["ExampleSubclass2"]["param"] = 199.0
@@ -213,7 +213,7 @@ def test_config_sibling2():
 
 
 def test_config_baseclass_then_subclass():
-    """check base and subclass config"""
+    """ check base and subclass config """
     config = Config()
     config["ExampleComponent"] = Config()
     config["ExampleComponent"]["param"] = 199.0
@@ -224,7 +224,7 @@ def test_config_baseclass_then_subclass():
 
 
 def test_config_subclass_then_baseclass():
-    """check subclass and base config"""
+    """ check subclass and base config """
     config = Config()
     config["ExampleSubclass1"] = Config()
     config["ExampleSubclass1"]["param"] = 229.0
@@ -235,7 +235,7 @@ def test_config_subclass_then_baseclass():
 
 
 def test_config_override():
-    """check that we can override a trait set in the config"""
+    """ check that we can override a trait set in the config """
     config = Config()
     config["ExampleComponent"] = Config()
     config["ExampleComponent"]["param"] = 199.0
@@ -244,7 +244,7 @@ def test_config_override():
 
 
 def test_config_override_subclass():
-    """check that we can override a trait set in the config"""
+    """ check that we can override a trait set in the config """
     config = Config()
     config["ExampleComponent"] = Config()
     config["ExampleComponent"]["param"] = 199.0
@@ -253,14 +253,14 @@ def test_config_override_subclass():
 
 
 def test_extra():
-    """check that traits are settable"""
+    """ check that traits are settable """
     comp = ExampleSubclass2(extra=229.0)
     assert comp.has_trait("extra") is True
     assert comp.extra == 229.0
 
 
 def test_extra_config():
-    """check setting trait via config"""
+    """ check setting trait via config """
     config = Config()
     config["ExampleSubclass2"] = Config()
     config["ExampleSubclass2"]["extra"] = 229.0
@@ -269,7 +269,7 @@ def test_extra_config():
 
 
 def test_unknown_traitlet_raises():
-    """check that setting an incorrect trait raises an exception"""
+    """ check that setting an incorrect trait raises an exception """
     with pytest.raises(TraitError):
         # the current traitlets version already warns about this
         # will be raising an error in the future, but we want the error
@@ -294,25 +294,25 @@ def test_extra_config_missing():
 
 
 def test_default():
-    """check default values work"""
+    """ check default values work"""
     comp = ExampleComponent()
     assert comp.param == 1.0
 
 
 def test_default_subclass():
-    """check default values work in subclasses"""
+    """ check default values work in subclasses"""
     comp = ExampleSubclass1()
     assert comp.param == 1.0
 
 
 def test_default_subclass_override():
-    """check overrides work in subclasses"""
+    """ check overrides work in subclasses"""
     comp = ExampleSubclass2()
     assert comp.param == 3.0
 
 
 def test_change_default():
-    """check we can change a default value"""
+    """ check we can change a default value"""
     old_default = ExampleComponent.param.default_value
     ExampleComponent.param.default_value = 199.0
     comp = ExampleComponent()
@@ -321,7 +321,7 @@ def test_change_default():
 
 
 def test_change_default_subclass():
-    """check we can change a default value in subclass"""
+    """ check we can change a default value in subclass """
     old_default = ExampleComponent.param.default_value
     ExampleComponent.param.default_value = 199.0
     comp = ExampleSubclass1()
@@ -330,7 +330,7 @@ def test_change_default_subclass():
 
 
 def test_change_default_subclass_override():
-    """check override default value"""
+    """ check override default value  """
     old_default = ExampleComponent.param.default_value
     ExampleComponent.param.default_value = 199.0
     comp = ExampleSubclass2()
@@ -339,7 +339,7 @@ def test_change_default_subclass_override():
 
 
 def test_help_changed_default():
-    """check that the help text is updated if the default is changed"""
+    """ check that the help text is updated if the default is changed """
     old_default = ExampleComponent.param.default_value
     ExampleComponent.param.default_value = 199.0
     help_msg = ExampleComponent.class_get_help()
@@ -359,7 +359,7 @@ def test_non_abstract_subclasses():
 
 
 def test_from_name():
-    """Make sure one can construct a Component subclass by name"""
+    """ Make sure one can construct a Component subclass by name"""
     subclass = ExampleComponent.from_name("ExampleSubclass1")
     assert isinstance(subclass, ExampleSubclass1)
     subclass = ExampleComponent.from_name("ExampleSubclass2")
@@ -367,14 +367,14 @@ def test_from_name():
 
 
 def test_from_name_config():
-    """make sure one can construct a Component subclass by name + config"""
+    """ make sure one can construct a Component subclass by name + config"""
     config = Config({"ExampleComponent": {"param": 229.0}})
     subclass = ExampleComponent.from_name("ExampleSubclass1", config=config)
     assert subclass.param == 229.0
 
 
 def test_component_current_config():
-    """make sure one can get the full current configuration"""
+    """ make sure one can get the full current configuration"""
     comp = ExampleComponent()
     full_config = comp.get_current_config()
     assert "ExampleComponent" in full_config
@@ -383,7 +383,7 @@ def test_component_current_config():
 
 
 def test_component_html_repr():
-    """check the HTML repr for Jupyter notebooks"""
+    """ check the HTML repr for Jupyter notebooks """
     comp = ExampleComponent()
     html = comp._repr_html_()
     assert len(html) > 10
@@ -434,30 +434,3 @@ def test_full_config():
     comp_from_config = MyComponent(config=config)
 
     assert dict_config == comp_from_config.get_current_config()
-
-
-def test_logging_hierarchy():
-    class Foo(Component):
-        def __init__(self, **kwargs):
-            super().__init__(**kwargs)
-
-    class Bar(Component):
-        def __init__(self, **kwargs):
-            super().__init__(**kwargs)
-            self.foo = Foo(parent=self)
-
-    class Baz(Tool):
-        name = "baz"
-
-        def setup(self):
-            self.bar = Bar(parent=self)
-
-        def start(self):
-            pass
-
-    baz = Baz()
-    baz.setup()
-
-    assert baz.log.name == "ctapipe.baz"
-    assert baz.bar.log.name == "ctapipe.baz.Bar"
-    assert baz.bar.foo.log.name == "ctapipe.baz.Bar.Foo"
diff --git a/ctapipe/io/tests/test_datawriter.py b/ctapipe/io/tests/test_datawriter.py
index 97feb96d..e3182ec7 100644
--- a/ctapipe/io/tests/test_datawriter.py
+++ b/ctapipe/io/tests/test_datawriter.py
@@ -16,7 +16,7 @@ from ctapipe.utils import get_dataset_path
 
 
 def generate_dummy_dl2(event):
-    """ generate some dummy DL2 info and see if we can write it """
+    """generate some dummy DL2 info and see if we can write it"""
 
     algos = ["HillasReconstructor", "ImPACTReconstructor"]
 
@@ -242,7 +242,9 @@ def test_metadata(tmpdir: Path):
                     "name": "Maximilian Nöthe",
                     "email": "maximilian.noethe@tu-dortmund.de",
                     "organization": "TU Dortmund",
-                }
+                },
+                "Instrument": {"site": "CTA-North", "id_": "alpha"},
+                "context_metadata": {"EXAMPLE": "test_value"},
             }
         }
     )
@@ -264,6 +266,9 @@ def test_metadata(tmpdir: Path):
             assert meta["CTA CONTACT NAME"] == "Maximilian Nöthe"
             assert meta["CTA CONTACT EMAIL"] == "maximilian.noethe@tu-dortmund.de"
             assert meta["CTA CONTACT ORGANIZATION"] == "TU Dortmund"
+            assert meta["CTA INSTRUMENT SITE"] == "CTA-North"
+            assert meta["CTA INSTRUMENT ID"] == "alpha"
+            assert meta["CONTEXT EXAMPLE"] == "test_value"
 
 
 def test_write_only_r1(r1_hdf5_file):