Coverage for python / lsst / pipe / base / _instrument.py: 41%
127 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-20 01:37 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-20 01:37 -0800
1# This file is part of pipe_base.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
28from __future__ import annotations
30__all__ = ("Instrument",)
32import contextlib
33import datetime
34from abc import ABCMeta, abstractmethod
35from collections.abc import Sequence
36from typing import TYPE_CHECKING, Any, Self, cast, final
38from lsst.daf.butler import DataCoordinate, DataId, DimensionPacker, DimensionRecord, Formatter
39from lsst.daf.butler.registry import DataIdError
40from lsst.pex.config import Config, RegistryField
41from lsst.resources import ResourcePath, ResourcePathExpression
42from lsst.utils import doImportType
43from lsst.utils.introspection import get_full_type_name
45from ._observation_dimension_packer import observation_packer_registry
47if TYPE_CHECKING:
48 from lsst.daf.butler import Registry
51class Instrument(metaclass=ABCMeta):
52 """Base class for instrument-specific logic for the Gen3 Butler.
54 Parameters
55 ----------
56 collection_prefix : `str`, optional
57 Prefix for collection names to use instead of the instrument's own
58 name. This is primarily for use in simulated-data repositories, where
59 the instrument name may not be necessary and/or sufficient to
60 distinguish between collections.
62 Notes
63 -----
64 Concrete instrument subclasses must have the same construction signature as
65 the base class.
66 """
68 configPaths: Sequence[ResourcePathExpression] = ()
69 """Paths to config files to read for specific Tasks.
71 The paths in this list should contain files of the form `task.py`, for
72 each of the Tasks that requires special configuration.
73 """
75 policyName: str | None = None
76 """Instrument specific name to use when locating a policy or configuration
77 file in the file system."""
79 raw_definition: tuple[str, tuple[str, ...], str] | None = None
80 """Dataset type definition to use for "raw" datasets. This is a tuple
81 of the dataset type name, a tuple of dimension names, and the storage class
82 name. If `None` the ingest system will use its default definition."""
84 def __init__(self, collection_prefix: str | None = None):
85 if collection_prefix is None:
86 collection_prefix = self.getName()
87 self.collection_prefix = collection_prefix
89 @classmethod
90 @abstractmethod
91 def getName(cls) -> str:
92 """Return the short (dimension) name for this instrument.
94 This is not (in general) the same as the class name - it's what is used
95 as the value of the "instrument" field in data IDs, and is usually an
96 abbreviation of the full name.
97 """
98 raise NotImplementedError()
100 @abstractmethod
101 def register(self, registry: Registry, *, update: bool = False) -> None:
102 """Insert instrument, and other relevant records into `Registry`.
104 Parameters
105 ----------
106 registry : `lsst.daf.butler.Registry`
107 Registry client for the data repository to modify.
108 update : `bool`, optional
109 If `True` (`False` is default), update existing records if they
110 differ from the new ones.
112 Raises
113 ------
114 lsst.daf.butler.registry.ConflictingDefinitionError
115 Raised if any existing record has the same key but a different
116 definition as one being registered.
118 Notes
119 -----
120 New records can always be added by calling this method multiple times,
121 as long as no existing records have changed (if existing records have
122 changed, ``update=True`` must be used). Old records can never be
123 removed by this method.
125 Implementations should guarantee that registration is atomic (the
126 registry should not be modified if any error occurs) and idempotent at
127 the level of individual dimension entries; new detectors and filters
128 should be added, but changes to any existing record should not be.
129 This can generally be achieved via a block like
131 .. code-block:: python
133 with registry.transaction():
134 registry.syncDimensionData("instrument", ...)
135 registry.syncDimensionData("detector", ...)
136 self.registerFilters(registry)
137 """
138 raise NotImplementedError()
140 @classmethod
141 def fromName(cls, name: str, registry: Registry, collection_prefix: str | None = None) -> Self:
142 """Given an instrument name and a butler registry, retrieve a
143 corresponding instantiated instrument object.
145 Parameters
146 ----------
147 name : `str`
148 Name of the instrument (must match the return value of `getName`).
149 registry : `lsst.daf.butler.Registry`
150 Butler registry to query to find the information.
151 collection_prefix : `str`, optional
152 Prefix for collection names to use instead of the instrument's own
153 name. This is primarily for use in simulated-data repositories,
154 where the instrument name may not be necessary and/or sufficient to
155 distinguish between collections.
157 Returns
158 -------
159 instrument : `Instrument`
160 An instance of the relevant `Instrument`.
162 Notes
163 -----
164 The instrument must be registered in the corresponding butler.
166 Raises
167 ------
168 LookupError
169 Raised if the instrument is not known to the supplied registry.
170 ModuleNotFoundError
171 Raised if the class could not be imported. This could mean
172 that the relevant obs package has not been setup.
173 TypeError
174 Raised if the class name retrieved is not a string or the imported
175 symbol is not an `Instrument` subclass.
176 """
177 try:
178 records = list(registry.queryDimensionRecords("instrument", instrument=name))
179 except DataIdError:
180 records = None
181 if not records:
182 raise LookupError(f"No registered instrument with name '{name}'.")
183 cls_name = records[0].class_name
184 if not isinstance(cls_name, str):
185 raise TypeError(
186 f"Unexpected class name retrieved from {name} instrument dimension (got {cls_name})"
187 )
188 return cls._from_cls_name(cls_name, collection_prefix)
190 @classmethod
191 def from_string(
192 cls, name: str, registry: Registry | None = None, collection_prefix: str | None = None
193 ) -> Self:
194 """Return an instance from the short name or class name.
196 If the instrument name is not qualified (does not contain a '.') and a
197 butler registry is provided, this will attempt to load the instrument
198 using `Instrument.fromName()`. Otherwise the instrument will be
199 imported and instantiated.
201 Parameters
202 ----------
203 name : `str`
204 The name or fully-qualified class name of an instrument.
205 registry : `lsst.daf.butler.Registry`, optional
206 Butler registry to query to find information about the instrument,
207 by default `None`.
208 collection_prefix : `str`, optional
209 Prefix for collection names to use instead of the instrument's own
210 name. This is primarily for use in simulated-data repositories,
211 where the instrument name may not be necessary and/or sufficient
212 to distinguish between collections.
214 Returns
215 -------
216 instrument : `Instrument`
217 The instantiated instrument.
219 Raises
220 ------
221 RuntimeError
222 Raised if the instrument can not be imported, instantiated, or
223 obtained from the registry.
224 TypeError
225 Raised if the instrument is not a subclass of
226 `~lsst.pipe.base.Instrument`.
228 See Also
229 --------
230 Instrument.fromName : Constructing Instrument from a name.
231 """
232 if "." not in name and registry is not None:
233 try:
234 instr = cls.fromName(name, registry, collection_prefix=collection_prefix)
235 except Exception as err:
236 raise RuntimeError(
237 f"Could not get instrument from name: {name}. Failed with exception: {err}"
238 ) from err
239 else:
240 try:
241 instr_class = doImportType(name)
242 except Exception as err:
243 raise RuntimeError(
244 f"Could not import instrument: {name}. Failed with exception: {err}"
245 ) from err
246 instr = instr_class(collection_prefix=collection_prefix)
247 if not isinstance(instr, cls):
248 raise TypeError(f"{name} is not a {get_full_type_name(cls)} subclass.")
249 return instr
251 @classmethod
252 def from_data_id(cls, data_id: DataCoordinate, collection_prefix: str | None = None) -> Self:
253 """Instantiate an `Instrument` object from a fully-expanded data ID.
255 Parameters
256 ----------
257 data_id : `~lsst.daf.butler.DataCoordinate`
258 Expanded data ID that includes the instrument dimension.
259 collection_prefix : `str`, optional
260 Prefix for collection names to use instead of the instrument's own
261 name. This is primarily for use in simulated-data repositories,
262 where the instrument name may not be necessary and/or sufficient to
263 distinguish between collections.
265 Returns
266 -------
267 instrument : `Instrument`
268 An instance of the relevant `Instrument`.
270 Raises
271 ------
272 TypeError
273 Raised if the class name retrieved is not a string or the imported
274 symbol is not an `Instrument` subclass.
275 """
276 return cls._from_cls_name(
277 cast(DimensionRecord, data_id.records["instrument"]).class_name, collection_prefix
278 )
280 @classmethod
281 def _from_cls_name(cls, cls_name: str, collection_prefix: str | None = None) -> Self:
282 """Instantiate an `Instrument` object type name.
284 This just provides common error-handling for `fromName` and
285 `from_data_id`
287 Parameters
288 ----------
289 cls_name : `str`
290 Fully-qualified name of the type.
291 collection_prefix : `str`, optional
292 Prefix for collection names to use instead of the instrument's own
293 name. This is primarily for use in simulated-data repositories,
294 where the instrument name may not be necessary and/or sufficient to
295 distinguish between collections.
297 Returns
298 -------
299 instrument : `Instrument`
300 An instance of the relevant `Instrument`.
302 Raises
303 ------
304 TypeError
305 Raised if the class name retrieved is not a string or the imported
306 symbol is not an `Instrument` subclass.
307 """
308 instrument_cls: type = doImportType(cls_name)
309 if not issubclass(instrument_cls, cls):
310 raise TypeError(
311 f"{instrument_cls!r}, obtained from importing {cls_name}, is not a subclass "
312 f"of {get_full_type_name(cls)}."
313 )
314 return instrument_cls(collection_prefix=collection_prefix)
316 @staticmethod
317 def importAll(registry: Registry) -> None:
318 """Import all the instruments known to this registry.
320 This will ensure that all metadata translators have been registered.
322 Parameters
323 ----------
324 registry : `lsst.daf.butler.Registry`
325 Butler registry to query to find the information.
327 Notes
328 -----
329 It is allowed for a particular instrument class to fail on import.
330 This might simply indicate that a particular obs package has
331 not been setup.
332 """
333 records = list(registry.queryDimensionRecords("instrument"))
334 for record in records:
335 cls = record.class_name
336 with contextlib.suppress(Exception):
337 doImportType(cls)
339 @abstractmethod
340 def getRawFormatter(self, dataId: DataId) -> type[Formatter]:
341 """Return the Formatter class that should be used to read a particular
342 raw file.
344 Parameters
345 ----------
346 dataId : `DataId`
347 Dimension-based ID for the raw file or files being ingested.
349 Returns
350 -------
351 formatter : `lsst.daf.butler.Formatter` class
352 Class to be used that reads the file into the correct
353 Python object for the raw data.
354 """
355 raise NotImplementedError()
357 def applyConfigOverrides(self, name: str, config: Config) -> None:
358 """Apply instrument-specific overrides for a task config.
360 Parameters
361 ----------
362 name : `str`
363 Name of the object being configured; typically the _DefaultName
364 of a Task.
365 config : `lsst.pex.config.Config`
366 Config instance to which overrides should be applied.
367 """
368 for root in self.configPaths:
369 resource = ResourcePath(root, forceDirectory=True, forceAbsolute=True)
370 uri = resource.join(f"{name}.py", forceDirectory=False)
371 if uri.exists():
372 config.load(uri)
374 @staticmethod
375 def formatCollectionTimestamp(timestamp: str | datetime.datetime) -> str:
376 """Format a timestamp for use in a collection name.
378 Parameters
379 ----------
380 timestamp : `str` or `datetime.datetime`
381 Timestamp to format. May be a date or datetime string in extended
382 ISO format (assumed UTC), with or without a timezone specifier, a
383 datetime string in basic ISO format with a timezone specifier, a
384 naive `datetime.datetime` instance (assumed UTC) or a
385 timezone-aware `datetime.datetime` instance (converted to UTC).
386 This is intended to cover all forms that string ``CALIBDATE``
387 metadata values have taken in the past, as well as the format this
388 method itself writes out (to enable round-tripping).
390 Returns
391 -------
392 formatted : `str`
393 Standardized string form for the timestamp.
394 """
395 if isinstance(timestamp, str):
396 if "-" in timestamp:
397 # extended ISO format, with - and : delimiters
398 timestamp = datetime.datetime.fromisoformat(timestamp)
399 else:
400 # basic ISO format, with no delimiters (what this method
401 # returns)
402 timestamp = datetime.datetime.strptime(timestamp, "%Y%m%dT%H%M%S%z")
403 if not isinstance(timestamp, datetime.datetime):
404 raise TypeError(f"Unexpected date/time object: {timestamp!r}.")
405 if timestamp.tzinfo is not None:
406 timestamp = timestamp.astimezone(datetime.UTC)
407 return f"{timestamp:%Y%m%dT%H%M%S}Z"
409 @staticmethod
410 def makeCollectionTimestamp() -> str:
411 """Create a timestamp string for use in a collection name from the
412 current time.
414 Returns
415 -------
416 formatted : `str`
417 Standardized string form of the current time.
418 """
419 return Instrument.formatCollectionTimestamp(datetime.datetime.now(tz=datetime.UTC))
421 def makeDefaultRawIngestRunName(self) -> str:
422 """Make the default instrument-specific run collection string for raw
423 data ingest.
425 Returns
426 -------
427 coll : `str`
428 Run collection name to be used as the default for ingestion of
429 raws.
430 """
431 return self.makeCollectionName("raw", "all")
433 def makeUnboundedCalibrationRunName(self, *labels: str) -> str:
434 """Make a RUN collection name appropriate for inserting calibration
435 datasets whose validity ranges are unbounded.
437 Parameters
438 ----------
439 *labels : `str`
440 Extra strings to be included in the base name, using the default
441 delimiter for collection names. Usually this is the name of the
442 ticket on which the calibration collection is being created.
444 Returns
445 -------
446 name : `str`
447 Run collection name.
448 """
449 return self.makeCollectionName("calib", *labels, "unbounded")
451 def makeCuratedCalibrationRunName(self, calibDate: str, *labels: str) -> str:
452 """Make a RUN collection name appropriate for inserting curated
453 calibration datasets with the given ``CALIBDATE`` metadata value.
455 Parameters
456 ----------
457 calibDate : `str`
458 The ``CALIBDATE`` metadata value.
459 *labels : `str`
460 Strings to be included in the collection name (before
461 ``calibDate``, but after all other terms), using the default
462 delimiter for collection names. Usually this is the name of the
463 ticket on which the calibration collection is being created.
465 Returns
466 -------
467 name : `str`
468 Run collection name.
469 """
470 return self.makeCollectionName("calib", *labels, "curated", self.formatCollectionTimestamp(calibDate))
472 def makeCalibrationCollectionName(self, *labels: str) -> str:
473 """Make a CALIBRATION collection name appropriate for associating
474 calibration datasets with validity ranges.
476 Parameters
477 ----------
478 *labels : `str`
479 Strings to be appended to the base name, using the default
480 delimiter for collection names. Usually this is the name of the
481 ticket on which the calibration collection is being created.
483 Returns
484 -------
485 name : `str`
486 Calibration collection name.
487 """
488 return self.makeCollectionName("calib", *labels)
490 @staticmethod
491 def makeRefCatCollectionName(*labels: str) -> str:
492 """Return a global (not instrument-specific) name for a collection that
493 holds reference catalogs.
495 With no arguments, this returns the name of the collection that holds
496 all reference catalogs (usually a ``CHAINED`` collection, at least in
497 long-lived repos that may contain more than one reference catalog).
499 Parameters
500 ----------
501 *labels : `str`
502 Strings to be added to the global collection name, in order to
503 define a collection name for one or more reference catalogs being
504 ingested at the same time.
506 Returns
507 -------
508 name : `str`
509 Collection name.
511 Notes
512 -----
513 This is a ``staticmethod``, not a ``classmethod``, because it should
514 be the same for all instruments.
515 """
516 return "/".join(("refcats",) + labels)
518 def makeUmbrellaCollectionName(self) -> str:
519 """Return the name of the umbrella ``CHAINED`` collection for this
520 instrument that combines all standard recommended input collections.
522 This method should almost never be overridden by derived classes.
524 Returns
525 -------
526 name : `str`
527 Name for the umbrella collection.
528 """
529 return self.makeCollectionName("defaults")
531 def makeCollectionName(self, *labels: str) -> str:
532 """Get the instrument-specific collection string to use as derived
533 from the supplied labels.
535 Parameters
536 ----------
537 *labels : `str`
538 Strings to be combined with the instrument name to form a
539 collection name.
541 Returns
542 -------
543 name : `str`
544 Collection name to use that includes the instrument's recommended
545 prefix.
546 """
547 return "/".join((self.collection_prefix,) + labels)
549 @staticmethod
550 def make_dimension_packer_config_field(
551 doc: str = (
552 "How to pack visit+detector or exposure+detector data IDs into integers. "
553 "The default (None) is to delegate to the Instrument class for which "
554 "registered implementation to use (but still use the nested configuration "
555 "for that implementation)."
556 ),
557 ) -> RegistryField:
558 """Make an `lsst.pex.config.Field` that can be used to configure how
559 data IDs for this instrument are packed.
561 Parameters
562 ----------
563 doc : `str`, optional
564 Documentation for the config field.
566 Returns
567 -------
568 field : `lsst.pex.config.RegistryField`
569 A config field for which calling ``apply`` on the instance
570 attribute constructs an `lsst.daf.butler.DimensionPacker` that
571 defaults to the appropriate one for this instrument.
573 Notes
574 -----
575 This method is expected to be used whenever code requires a single
576 integer that represents the combination of a detector and either a
577 visit or exposure, but in most cases the `lsst.meas.base.IdGenerator`
578 class and its helper configs provide a simpler high-level interface
579 that should be used instead of calling this method directly.
581 This system is designed to work best when the configuration for the ID
582 packer is not overridden at all, allowing the appropriate instrument
583 class to determine the behavior for each data ID encountered. When the
584 configuration does need to be modified (most often when the scheme for
585 packing an instrument's data IDs is undergoing an upgrade), it is
586 important to ensure the overrides are only applied to data IDs with the
587 desired instrument value.
589 Unit tests of code that use a field produced by this method will often
590 want to explicitly set the packer to "observation" and manually set
591 its ``n_detectors`` and ``n_observations`` fields; this will make it
592 unnecessary for tests to provide expanded data IDs.
593 """
594 # The control flow here bounces around a bit when this RegistryField's
595 # apply() method is called, so it merits a thorough walkthrough
596 # somewhere, and that might as well be here:
597 #
598 # - If the config field's name is not `None`, that kind of packer is
599 # constructed and returned with the arguments to `apply`, in just the
600 # way it works with most RegistryFields or ConfigurableFields. But
601 # this is expected to be rare.
602 #
603 # - If the config fields' name is `None`, the `apply` method (which
604 # actually lives on the `pex.config.RegistryInstanceDict` class,
605 # since `RegistryField` is a descriptor), calls
606 # `_make_default_dimension_packer_dispatch` (which is final, and
607 # hence the base class implementation just below is the only one).
608 #
609 # - `_make_default_dimension_packer_dispatch` instantiates an
610 # `Instrument` instance of the type pointed at by the data ID (i.e.
611 # calling `Instrument.from_data_id`), then calls
612 # `_make_default_dimension_packer` on that.
613 #
614 # - The default implementation of `_make_default_dimension_packer` here
615 # in the base class picks the "observation" dimension packer, so if
616 # it's not overridden by a derived class everything proceeds as if
617 # the config field's name was set to that. Note that this sets which
618 # item in the registry is used, but it still pays attention to the
619 # configuration for that entry in the registry field.
620 #
621 # - A subclass implementation of `_make_default_dimension_packer` will
622 # take precedence over the base class, but it's expected that these
623 # will usually just delegate back to ``super()`` while changing the
624 # ``default`` argument to something other than "observation". Once
625 # again, this will control which packer entry in the registry is used
626 # but the result will still reflect the configuration for that packer
627 # in the registry field.
628 #
629 return observation_packer_registry.makeField(
630 doc, default=None, optional=True, on_none=Instrument._make_default_dimension_packer_dispatch
631 )
633 @staticmethod
634 @final
635 def make_default_dimension_packer(
636 data_id: DataCoordinate, is_exposure: bool | None = None
637 ) -> DimensionPacker:
638 """Return the default dimension packer for the given data ID.
640 Parameters
641 ----------
642 data_id : `lsst.daf.butler.DataCoordinate`
643 Data ID that identifies at least the ``instrument`` dimension. Must
644 have dimension records attached.
645 is_exposure : `bool`, optional
646 If `False`, construct a packer for visit+detector data IDs. If
647 `True`, construct a packer for exposure+detector data IDs. If
648 `None`, this is determined based on whether ``visit`` or
649 ``exposure`` is present in ``data_id``, with ``visit`` checked
650 first and hence used if both are present.
652 Returns
653 -------
654 packer : `lsst.daf.butler.DimensionPacker`
655 Object that packs {visit, detector} or {exposure, detector} data
656 IDs into integers.
658 Notes
659 -----
660 When using a dimension packer in task code, using
661 `make_dimension_packer_config_field` to make the packing algorithm
662 configurable is preferred over this method.
664 When obtaining a dimension packer to unpack IDs that were packed by
665 task code, it is similarly preferable to load the configuration for
666 that task and the existing packer configuration field there, to ensure
667 any config overrides are respected. That is sometimes quite difficult,
668 however, and since config overrides for dimension packers are expected
669 to be exceedingly rare, using this simpler method will almost always
670 work.
671 """
673 class _DummyConfig(Config):
674 packer = Instrument.make_dimension_packer_config_field()
676 config = _DummyConfig()
678 return config.packer.apply(data_id, is_exposure=is_exposure) # type: ignore
680 @staticmethod
681 @final
682 def _make_default_dimension_packer_dispatch(
683 config_dict: Any, data_id: DataCoordinate, is_exposure: bool | None = None
684 ) -> DimensionPacker:
685 """Dispatch method used to invoke `_make_dimension_packer`.
687 This method constructs the appropriate `Instrument` subclass from
688 config and then calls its `_make_default_dimension_packer`.
689 It is called when (as usual) the field returned by
690 `make_dimension_packer_config_field` is left to its default selection
691 of `None`.
693 All arguments and return values are the same as
694 `_make_default_dimension_packer.`
695 """
696 instrument = Instrument.from_data_id(data_id)
697 return instrument._make_default_dimension_packer(config_dict, data_id, is_exposure=is_exposure)
699 def _make_default_dimension_packer(
700 self,
701 config_dict: Any,
702 data_id: DataCoordinate,
703 is_exposure: bool | None = None,
704 default: str = "observation",
705 ) -> DimensionPacker:
706 """Construct return the default dimension packer for this instrument.
708 This method is a protected hook for subclasses to override the behavior
709 of `make_dimension_packer_config_field` when the packer is not selected
710 explicitly via configuration.
712 Parameters
713 ----------
714 config_dict
715 Mapping attribute of a `lsst.pex.config.Config` instance that
716 corresponds to a field created by `make_dimension_packer_config`
717 (the actual type of this object is a `lsst.pex.config`
718 implementation detail).
719 data_id : `lsst.daf.butler.DataCoordinate`
720 Data ID that identifies at least the ``instrument`` dimension. For
721 most configurations this must have dimension records attached.
722 is_exposure : `bool`, optional
723 If `False`, construct a packer for visit+detector data IDs. If
724 `True`, construct a packer for exposure+detector data IDs. If
725 `None`, this is determined based on whether ``visit`` or
726 ``exposure`` is present in ``data_id``, with ``visit`` checked
727 first and hence used if both are present.
728 default : `str`, optional
729 Registered name of the dimension packer to select when the
730 configured packer is `None` (as is usually the case). This is
731 intended primarily for derived classes delegating to `super` in
732 reimplementations of this method.
734 Returns
735 -------
736 packer : `lsst.daf.butler.DimensionPacker`
737 Object that packs {visit, detector} or {exposure, detector} data
738 IDs into integers.
739 """
740 return config_dict.apply_with(default, data_id, is_exposure=is_exposure)