From b5d7751f4ec4161eb6e3bdad0fb070eb1fb661b8 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Mon, 24 Jan 2022 15:12:29 -0500 Subject: [PATCH 1/2] Prototype factories for loading cameraGeom. --- python/lsst/obs/base/__init__.py | 1 + python/lsst/obs/base/_camera_loaders.py | 402 ++++++++++++++++++++++++ python/lsst/obs/base/_instrument.py | 185 ++++++++++- 3 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 python/lsst/obs/base/_camera_loaders.py diff --git a/python/lsst/obs/base/__init__.py b/python/lsst/obs/base/__init__.py index 9b58147d..489eb39d 100644 --- a/python/lsst/obs/base/__init__.py +++ b/python/lsst/obs/base/__init__.py @@ -19,6 +19,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from ._camera_loaders import * from ._fitsRawFormatterBase import * from ._instrument import * from .cameraMapper import * diff --git a/python/lsst/obs/base/_camera_loaders.py b/python/lsst/obs/base/_camera_loaders.py new file mode 100644 index 00000000..4e1feed6 --- /dev/null +++ b/python/lsst/obs/base/_camera_loaders.py @@ -0,0 +1,402 @@ +# This file is part of obs_base. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +__all__ = ( + "CameraBuilderUpdater", + "CameraLoader", + "DetectorBuilderUpdater", + "DetectorLoader", +) + +from abc import ABC, abstractmethod +from typing import Any, Iterable, Optional, Union + +from lsst.afw.cameraGeom import Camera, CameraBuilder, Detector, DetectorBuilder +from lsst.daf.butler import Butler, DataId, DatasetRef, DatasetType + + +class CameraBuilderUpdater(ABC): + """An interface for classes that know how to modify a `CameraBuilder`. + + Notes + ----- + It is expected that these objects will be saved to data repositories with + data IDs that identify only the ``instrument`` dimension. They are + typically versioned in `~lsst.daf.butler.CollectionType.CALIBRATION` + collections. + + This interface should only be implemented by classes that modify state + associated with the full camera, such as the distortion model; similar + classes whose instances correspond to a single detector should implement + *only* `DetectorBuilderUpdater` instead. + """ + + @abstractmethod + def update_camera_builder(self, builder: CameraBuilder) -> None: + """Update the given camera builder with this object's state. + + Parameters + ---------- + builder : `CameraBuilder` + Camera builder to update. + """ + raise NotImplementedError() + + +class DetectorBuilderUpdater(ABC): + """An interface for classes that know how to modify a `DetectorBuilder`. + + Notes + ----- + It is expected that these objects will be saved to data repositories with + data IDs that identify only the ``instrument`` and ``detector`` dimensions. + They are typically versioned in + `~lsst.daf.butler.CollectionType.CALIBRATION` collections. + + This interface should only be implemented by classes that modify state + associated with the full camera, such as the distortion model; similar + classes whose instances correspond to a single detector should implement + *only* `DetectorBuilderUpdater` instead. + """ + + @property + @abstractmethod + def detector_id(self) -> int: + """The integer ID of the detector this object corresponds to (`int`). + + This is assumed to be both a valid index for a `Camera` object and a + valid ``detector`` data ID value. + """ + raise NotImplementedError() + + @abstractmethod + def update_detector_builder(self, builder: DetectorBuilder) -> None: + """Update the given detector builder with this object's state. + + Parameters + ---------- + builder : `DetectorBuilder` + Detector builder to update. + """ + raise NotImplementedError() + + +class DetectorLoader: + """Helper class for loading detectors from data repositories, including + overrides of versioned content. + + Parameters + ---------- + arg : `str`, `int`, `DatasetRef`, `Detector`, \ + `DetectorBuilder` + If a `DatasetRef`, `Detector`, or `DetectorBuilder`, + an object that can be used or loaded to provide the detector directly + (``camera`` will be ignored). + If an `int` or `str`, a detector identifier to use with ``camera``. + butler : `Butler` + Butler client to read from. If not initialized with the desired + collection search path, the ``collections`` argument to `update` must + be used. + camera : `str`, `DatasetType`, `DatasetRef`, `Camera`, \ + `CameraBuilder`, optional + Used to obtain a `Camera` or `CameraBuilder` as a way to get a + `DetectorBuilder`. If a `Camera` is given, the detector is extracted + and then a `Detector.rebuild` is called, instead of rebuilding the + full camera. Defaults to "camera" (as the nominal dataset type name). + data_id : `dict` or `lsst.daf.butler.DataCoordinate`, optional + Data ID values that identify the ``exposure`` and/or ``visit`` + dimension(s). May include any extended keys supported by `Butler.get`, + such as ``day_obs``. Should not identify the ``detector`` dimension. + **kwargs + Additional keyword arguments are interpreted as data ID key-value pairs + and forwarded directly to `Butler.get`. + + Notes + ----- + Most code should use the higher-level `Instrument.load_detector` interface + to this functionality instead of using this class directly. + """ + + def __init__( + self, + arg: Union[str, int, DatasetRef, Detector, DetectorBuilder], + /, + butler: Butler, + camera: Union[str, DatasetType, DatasetRef, Camera, CameraBuilder] = "camera", + data_id: Optional[DataId] = None, + **kwargs: Any, + ): + if isinstance(arg, (int, str)): + if isinstance(camera, CameraBuilder): + self.builder = camera[arg] + elif isinstance(camera, Camera): + self.builder = camera[arg].rebuild() + elif isinstance(camera, DatasetRef): + self.builder = butler.getDirect(camera)[arg].rebuild() + elif isinstance(arg, (str, DatasetType)): + self.builder = butler.get(camera, data_id)[arg].rebuild() + else: + raise TypeError(f"Unrecognized argument for camera: {camera!r} ({type(arg)}).") + elif isinstance(arg, DetectorBuilder): + self.builder = arg + elif isinstance(arg, Detector): + self.builder = arg.rebuild() + elif isinstance(arg, DatasetRef): + self.builder = butler.getDirect(arg).rebuild() + else: + raise TypeError(f"Unrecognized first argument: {arg!r} ({type(arg)}).") + self.butler = butler + # We keep data ID and kwargs for data ID separate so we can pass them + # as-is to Butler.get, which has special handling of non-dimension keys + # that we currently can't call directly (DM-30439). + self._data_id = data_id + self._data_id_kwargs = kwargs + + def update( + self, + *, + collections: Optional[Iterable[str]] = None, + **kwargs: Union[bool, str, DatasetType, DatasetRef, DetectorBuilderUpdater], + ) -> None: + """Apply updates to the detector. + + Parameters + ---------- + collections : `Iterable` [ `str` ], optional + Collections to search, in order. If not provided, the butler used + to initialize this loader must already have the right collections. + **kwargs + Named updates to apply to the detector. Keys are typically those + in `Instrument.detector_calibrations`, but are only used here in + error messages. Values are one of the following: + + - `False`: do not update this dataset (same as not passing a key). + - `str`, `DatasetType`: load from the butler with this custom + dataset type. + - `DatasetRef`: load with `Butler.getDirect` (must be a resolved + reference). + - `DetectorBuilderUpdater`: just apply this calibration directly. + + Notes + ----- + This method only applies the updates given as keyword arguments, while + the `Instrument.load_detector` method attempts to apply all updates + potentially used by that instrument. + """ + for component, arg in kwargs.items(): + if arg is False: + continue + elif isinstance(arg, DetectorBuilderUpdater): + visitor = arg + elif isinstance(arg, DatasetRef): + visitor = self.butler.getDirect(arg) + elif isinstance(arg, (str, DatasetType)): + visitor = self.butler.get( + arg, + self._data_id, + collections=collections, + detector=self.builder.getId(), + **self._data_id_kwargs, + ) + else: + raise TypeError(f"Unrecognized value for {component}: {arg!r} ({type(arg)}).") + visitor.update_detector_builder(self.builder) + + def finish(self) -> Detector: + """Finish updating and return the updated `Detector`.""" + return self.builder.finish() + + +class CameraLoader: + """Helper class for loading detectors from data repositories, including + overrides of versioned content. + + Parameters + ---------- + butler : `Butler` + Butler client to read from. If not initialized with the desired + collection search path, the ``collections`` argument to `update` must + be used. + camera : `str`, `DatasetType`, `DatasetRef`, `Camera`, `CameraBuilder`, \ + optional + A `CameraBuilder`, a `Camera` to rebuild into one, or a butler dataset + type or reference that can be used to load a `Camera`. + Defaults to "camera" (as the nominal dataset type name). + data_id : `dict` or `lsst.daf.butler.DataCoordinate`, optional + Data ID values that identify the ``exposure`` and/or ``visit`` + dimension(s). May include any extended keys supported by `Butler.get`, + such as ``day_obs``. + **kwargs + Additional keyword arguments are interpreted as data ID key-value pairs + and forwarded directly to `Butler.get`. + + Notes + ----- + Most code should use the higher-level `Instrument.load_camera` interface + to this functionality instead of using this class directly. + """ + + def __init__( + self, + butler: Butler, + camera: Union[str, DatasetType, DatasetRef, Camera, CameraBuilder] = "camera", + data_id: Optional[DataId] = None, + **kwargs: Any, + ): + if isinstance(camera, CameraBuilder): + self.builder = camera + elif isinstance(camera, Camera): + self.builder = camera.rebuild() + elif isinstance(camera, DatasetRef): + self.builder = butler.getDirect(camera).rebuild() + elif isinstance(camera, (str, DatasetType)): + self.builder = butler.get(camera, data_id).rebuild() + else: + raise TypeError(f"Unrecognized camera argument: {camera!r} ({type(camera)}).") + self.butler = butler + # We keep data ID and kwargs for data ID separate so we can pass them + # as-is to Butler.get, which has special handling of non-dimension keys + # that we currently can't call directly (DM-30439). + self._data_id = data_id + self._data_id_kwargs = kwargs + + def update_camera( + self, + *, + collections: Optional[Iterable[str]] = None, + **kwargs: Union[ + bool, + str, + DatasetType, + DatasetRef, + CameraBuilderUpdater, + ], + ) -> None: + """Apply updates to the camera as a whole. + + Parameters + ---------- + collections : `Iterable` [ `str` ], optional + Collections to search, in order. If not provided, the butler used + to initialize this loader must already have the right collections. + **kwargs + Keys are typically those in `Instrument.camera_calibrations`, but + are only used here in error messages. Values are one of the + following: + + - `False`: do not update this dataset (same as not passing a key). + - `str`, `DatasetType`: load from the butler with this custom + dataset type. + - `DatasetRef`: load with `Butler.getDirect` (must be a resolved + reference). + - `CameraBuilderUpdater`: just apply this calibration directly. + + Notes + ----- + This method only applies the updates given as keyword arguments, while + the `Instrument.load_camera` method attempts to apply all updates + potentially used by that instrument. + """ + for component, arg in kwargs.items(): + if arg is False: + continue + elif isinstance(arg, CameraBuilderUpdater): + visitor = arg + elif isinstance(arg, DatasetRef): + visitor = self.butler.getDirect(arg) + elif isinstance(arg, (str, DatasetType)): + visitor = self.butler.get(arg, self._data_id, collections=collections, **self._data_id_kwargs) + else: + raise TypeError(f"Unrecognized value for {component}: {arg!r} ({type(arg)}).") + visitor.update_camera_builder(self.builder) + + def update_detectors( + self, + *, + collections: Optional[Iterable[str]] = None, + **kwargs: Union[ + bool, + str, + DatasetType, + Iterable[DatasetRef], + Iterable[DetectorBuilderUpdater], + ], + ) -> None: + """Apply updates to the camera's detectors. + + Parameters + ---------- + collections : `Iterable` [ `str` ], optional + Collections to search, in order. If not provided, the butler used + to initialize this loader must already have the right collections. + **kwargs + Keys are typically those in `Instrument.detector_calibrations`, but + are only used here in error messages. Values are one of the + following: + + - `False`: do not update these datasets (same as not passing a + key). + - `str`, `DatasetType`: load from the butler with this custom + dataset type. + - `Iterable` of `DatasetRef`: load with `Butler.getDirect` (must be + resolved references). + - `Iterable` of `DetectorBuilderUpdater`: apply these calibrations + directly. + + Notes + ----- + This method only applies the updates given as keyword arguments, while + the `Instrument.load_camera` method attempts to apply all updates + potentially used by that instrument. + """ + for component, arg in kwargs.items(): + if arg is False: + continue + if isinstance(arg, (str, DatasetType)): + # It might be better to do queryDatasets here and then call + # getDirect, but we need DM-30439 to allow non-dimension keys + # in the data ID first. + for detector_builder in self.builder: + visitor = self.butler.get( + arg, + self._data_id, + collections=collections, + detector=detector_builder.getId(), + **self._data_id_kwargs, + ) + visitor.update_detector_builder(detector_builder) + elif arg is True: + raise TypeError("'True' is not a valid value for keyword argument {component!r}.") + else: + for item in arg: + if isinstance(item, DetectorBuilderUpdater): + visitor = item + elif isinstance(item, DatasetRef): + visitor = self.butler.getDirect(item) + else: + raise TypeError(f"Unrecognized item in {component}: {item!r} ({type(item)}).") + visitor.update_detector_builder(self.builder[visitor.detector_id]) + + def finish(self) -> Camera: + """Finish updating and return the updated `Camera`.""" + return self.builder.finish() diff --git a/python/lsst/obs/base/_instrument.py b/python/lsst/obs/base/_instrument.py index 00698ebf..fd2481d0 100644 --- a/python/lsst/obs/base/_instrument.py +++ b/python/lsst/obs/base/_instrument.py @@ -27,15 +27,28 @@ from abc import abstractmethod from collections import defaultdict from functools import lru_cache -from typing import TYPE_CHECKING, AbstractSet, Any, FrozenSet, Optional, Sequence, Set, Tuple +from typing import ( + TYPE_CHECKING, + AbstractSet, + Any, + FrozenSet, + Iterable, + Mapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) import astropy.time -from lsst.afw.cameraGeom import Camera +from lsst.afw.cameraGeom import Camera, CameraBuilder, Detector, DetectorBuilder from lsst.daf.butler import ( Butler, CollectionType, DataCoordinate, DataId, + DatasetRef, DatasetType, DimensionRecord, DimensionUniverse, @@ -45,6 +58,8 @@ from lsst.pipe.base import Instrument as InstrumentBase from lsst.utils import getPackageDir +from ._camera_loaders import CameraLoader, DetectorLoader + if TYPE_CHECKING: from astro_metadata_translator import ObservationInfo from lsst.daf.butler import Registry @@ -519,6 +534,172 @@ def makeDataIdTranslatorFactory(self) -> TranslatorFactory: """ raise NotImplementedError("Must be implemented by derived classes.") + def load_detector( + self, + arg: Union[str, int, DatasetRef, Detector, DetectorBuilder], + /, + butler: Butler, + camera: Union[str, DatasetType, DatasetRef, Camera, CameraBuilder] = "camera", + data_id: Optional[DataId] = None, + collections: Optional[Iterable[str]] = None, + **kwargs: Any, + ) -> Detector: + """Load a detector from a butler repository and update it with all + appropriate versioned calibrations for this instrument. + + Parameters + ---------- + arg : `str`, `int`, `DatasetRef`, `Detector`, `DetectorBuilder` + If a `DatasetRef`, `Detector`, or `DetectorBuilder`, an object that + can be used or loaded to provide the detector directly (``camera`` + will be ignored). + If an `int` or `str`, a detector identifier to use with ``camera``. + butler : `Butler` + Butler client to read from. If not initialized with the desired + collection search path, the ``collections`` argument must be + provided. + camera : `str`, `DatasetType`, `DatasetRef`, `Camera`, \ + `CameraBuilder`, optional + Used to obtain a `Camera` or `CameraBuilder` as a way to get a + `DetectorBuilder`. If a `Camera` is given, the detector is + extracted and then a `Detector.rebuild` is called, instead of + rebuilding the full camera. Defaults to "camera" (as the nominal + dataset type name). + data_id : `dict` or `lsst.daf.butler.DataCoordinate`, optional + Data ID values that identify the ``exposure`` and/or ``visit`` + dimension(s). May include any extended keys supported by + `Butler.get`, such as ``day_obs``. Should not identify the + ``detector`` dimension. + collections : `Iterable` [ `str` ], optional + Collections to search, in order. If not provided, the butler used + to initialize this loader must already have the right collections. + **kwargs + Additional keyword arguments that are in the keys of + `detector_calibrations` will be interpreted as overrides of the + calibrations to apply; see `DetectorLoader.update` for details. + Other keyword arguments are interpreted as data ID key-value + pairs and forwarded directly to `Butler.get`. + + Returns + ------- + detector : `Detector` + The updated detector. + + Examples + -------- + Read detector 11 from the nominal camera and apply all default + calibrations valid during the observation of exposure 500:: + + detector = my_instrument.load_detector(11, butler, exposure=500) + + The same, but use a string detector name and day_obs/seq_num for + exposure, and don't attempt to apply gains from PTC: + + detector = my_instrument.load_detector( + "R12S21", + butler, + day_obs=20250129, + seq_num=1261, + ptc=False, + ) + """ + update_kwargs = {} + for k, v in self.detector_calibrations.items(): + update_kwargs[k] = kwargs.pop(k, v) + loader = DetectorLoader( + arg, + butler, + camera=camera, + data_id=data_id, + collections=collections, + **kwargs, + instrument=self.getName(), + ) + loader.update(collections=collections, **update_kwargs) + return loader.finish() + + def load_camera( + self, + butler: Butler, + camera: Union[str, DatasetType, DatasetRef, Camera, CameraBuilder] = "camera", + data_id: Optional[DataId] = None, + **kwargs: Any, + ) -> Camera: + """ + Parameters + ---------- + butler : `Butler` + Butler client to read from. If not initialized with the desired + collection search path, the ``collections`` argument must be + provided. + camera : `str`, `DatasetType`, `DatasetRef`, `Camera`, \ + `CameraBuilder`, optional + A `CameraBuilder`, a `Camera` to rebuild into one, or a butler + dataset type or reference that can be used to load a `Camera`. + Defaults to "camera" (as the nominal dataset type name). + data_id : `dict` or `lsst.daf.butler.DataCoordinate`, optional + Data ID values that identify the ``exposure`` and/or ``visit`` + dimension(s). May include any extended keys supported by + `Butler.get`, such as ``day_obs``. + **kwargs + Additional keyword arguments that are in the keys of + `detector_calibrations` or `camera_calibations` will be interpreted + as overrides of the calibrations to apply; see + `CameraLoader.update` for details. Other keyword arguments are + interpreted as data ID key-value pairs and forwarded directly to + `Butler.get`. + + Returns + ------- + camera : `Camera` + The updated camera. + + Examples + -------- + Read the nominal camera and apply all default calibrations valid during + the observation of exposure 500:: + + camera = my_instrument.load_camera(butler, exposure=500) + + The same, but use day_obs/seq_num for exposure, and don't attempt to + apply gains from PTC: + + camera = my_instrument.load_camera( + butler, + day_obs=20250129, + seq_num=1261, + ptc=False, + ) + """ + update_camera_kwargs = {} + for k, v in self.camera_calibrations.items(): + update_camera_kwargs[k] = kwargs.pop(k, v) + update_detectors_kwargs = {} + for k, v in self.detector_calibrations.items(): + update_detectors_kwargs = kwargs.pop(k, v) + loader = CameraLoader(butler, camera, data_id=data_id, **kwargs) + loader.update_camera(**update_camera_kwargs) + loader.update_detectors(**update_detectors_kwargs) + return loader.finish() + + @property + def detector_calibrations(self) -> Mapping[str, str]: + """A mapping of the calibration dataset types that should be used to + update `Detector` objects for this instrument. + """ + return {} + + @property + def camera_calibrations(self) -> Mapping[str, str]: + """A mapping of the calibration dataset types that should be used to + update `Camera` objects for this instrument. + + This should be disjoint with `detector_calibrations`; those detector + calibrations are also used to update camera objects, but are not part + of this container. + """ + return {} + def makeExposureRecordFromObsInfo(obsInfo: ObservationInfo, universe: DimensionUniverse) -> DimensionRecord: """Construct an exposure DimensionRecord from From f23023df447403ebc6956bdc93d55788ff601589 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Mon, 31 Jan 2022 16:59:11 -0500 Subject: [PATCH 2/2] Add stubs for loading cameraGeom inside PipelineTasks. --- python/lsst/obs/base/_camera_loaders.py | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/python/lsst/obs/base/_camera_loaders.py b/python/lsst/obs/base/_camera_loaders.py index 4e1feed6..f7681b82 100644 --- a/python/lsst/obs/base/_camera_loaders.py +++ b/python/lsst/obs/base/_camera_loaders.py @@ -33,6 +33,7 @@ from lsst.afw.cameraGeom import Camera, CameraBuilder, Detector, DetectorBuilder from lsst.daf.butler import Butler, DataId, DatasetRef, DatasetType +from lsst.pipe.base import ButlerQuantumContext class CameraBuilderUpdater(ABC): @@ -400,3 +401,97 @@ def update_detectors( def finish(self) -> Camera: """Finish updating and return the updated `Camera`.""" return self.builder.finish() + + +def load_detector_for_quantum( + arg: Union[str, int, DatasetRef, Detector, DetectorBuilder], + /, + butlerQC: ButlerQuantumContext, + camera: Union[DatasetRef, Camera, CameraBuilder, None] = None, + **kwargs: Union[bool, DatasetRef, DetectorBuilderUpdater], +) -> Detector: + """A variant of `Instrument.load_detector` for use inside + `PipelineTask.runQuantum`. + + Parameters + ---------- + arg : `str`, `int`, `DatasetRef`, `Detector`, `DetectorBuilder` + If a `DatasetRef`, `Detector`, or `DetectorBuilder`, an object that + can be used or loaded to provide the detector directly (``camera`` + will be ignored). + If an `int` or `str`, a detector identifier to use with ``camera``. + butlerQC : `ButlerQuantumContext` + Butler client proxy to read from. + camera : `DatasetRef`, `Camera`, `CameraBuilder`, optional + Used to obtain a `Camera` or `CameraBuilder` as a way to get a + `DetectorBuilder`. If a `Camera` is given, the detector is + extracted and then a `Detector.rebuild` is called, instead of + rebuilding the full camera. Required (unlike + `Instrument.load_detector`) if the first argument is a `str` or `int`. + **kwargs + Named updates to apply to the detector. Keys are typically those in + `Instrument.detector_calibrations`, but are only used here in error + messages. Values are one of the following: + + - `False`: do not update this dataset (same as not passing a key). + - `DatasetRef`: load with `ButlerQuantumContext.get` (must be a + resolved reference). + - `DetectorBuilderUpdater`: just apply this calibration directly. + + Returns + ------- + detector : `Detector` + The loaded detector object. + + Notes + ----- + This function only applies the updates given as keyword arguments, while + the `Instrument.load_detector` method attempts to apply all updates + potentially used by that instrument. + """ + # Implementing this by delegating to DetectorLoader is tricky because + # ButlerQuantumContext doesn't expose its underlying butler. Hopefully we + # can resolve this while the rest of the RFC is worked out. + raise NotImplementedError("TODO") + + +def load_camera_for_quantum( + butlerQC: ButlerQuantumContext, + camera: Union[DatasetRef, Camera, CameraBuilder], + **kwargs: Union[ + bool, DatasetRef, CameraBuilderUpdater, Iterable[DatasetRef], Iterable[DetectorBuilderUpdater] + ], +) -> Camera: + """ + Parameters + ---------- + butler : `Butler` + Butler client to read from. If not initialized with the desired + collection search path, the ``collections`` argument must be + provided. + camera : `DatasetRef`, `Camera`, `CameraBuilder`, optional + A `CameraBuilder`, a `Camera` to rebuild into one, or a butler + reference that can be used to load a `Camera`. + **kwargs + Named updates to apply to the camera or its detectors, depending on + whether the key is in `Instrument.detector_calibrations` or + `Instrument.camera_calibrations` (for the instrument identified by + ``butlerQC.quantum.dataId``): + + - `False`: do not use this calibration (same as no key). + - `DatasetRef`: a `CameraBuilderUpdater` dataset to load and apply. + - `CameraBuilderUpdater`: a full-camera update to apply directly. + - `Iterable` [ `DatasetRef` ]: per-detector `DetectorBuilderUpdater` + datasets to load and apply. + - `Iterable` [ `DetectorBuilderUpdater` ]: per-detector updates to + apply directly. + + Returns + ------- + camera : `Camera` + The updated camera. + """ + # Implementing this by delegating to DetectorLoader is tricky because + # ButlerQuantumContext doesn't expose its underlying butler. Hopefully we + # can resolve this while the rest of the RFC is worked out. + raise NotImplementedError("TODO")