Coverage for python / lsst / obs / base / _instrument.py: 22%
193 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-19 01:06 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-19 01:06 -0800
1# This file is part of obs_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 program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22from __future__ import annotations
24__all__ = ("Instrument", "loadCamera", "makeExposureRecordFromObsInfo")
26import logging
27import os.path
28import re
29from abc import abstractmethod
30from collections import defaultdict
31from collections.abc import Sequence, Set
32from functools import lru_cache
33from typing import TYPE_CHECKING, Any, cast
35import astropy.time
37from lsst.afw.cameraGeom import Camera
38from lsst.daf.butler import (
39 Butler,
40 CollectionType,
41 DataCoordinate,
42 DataId,
43 DatasetType,
44 DimensionRecord,
45 DimensionUniverse,
46 Timespan,
47)
48from lsst.daf.butler.registry import DataIdError
49from lsst.pipe.base import Instrument as InstrumentBase
50from lsst.utils import doImport, getPackageDir
52from ._read_curated_calibs import CuratedCalibration, read_all
54if TYPE_CHECKING:
55 from astro_metadata_translator import MetadataTranslator, ObservationInfo
57 from lsst.daf.butler import Registry
59 from .filters import FilterDefinitionCollection
61_LOG = logging.getLogger(__name__)
63# To be a standard text curated calibration means that we use a
64# standard definition for the corresponding DatasetType.
65StandardCuratedCalibrationDatasetTypes = {
66 "defects": {"dimensions": ("instrument", "detector"), "storageClass": "Defects"},
67 "manual_defects": {"dimensions": ("instrument", "detector"), "storageClass": "Defects"},
68 "qe_curve": {"dimensions": ("instrument", "detector"), "storageClass": "QECurve"},
69 "crosstalk": {"dimensions": ("instrument", "detector"), "storageClass": "CrosstalkCalib"},
70 "linearizer": {"dimensions": ("instrument", "detector"), "storageClass": "Linearizer"},
71 "bfk": {"dimensions": ("instrument", "detector"), "storageClass": "BrighterFatterKernel"},
72 "transmission_optics": {"dimensions": ("instrument",), "storageClass": "TransmissionCurve"},
73 "transmission_filter": {
74 "dimensions": ("instrument", "physical_filter"),
75 "storageClass": "TransmissionCurve",
76 },
77 "transmission_filter_detector": {
78 "dimensions": ("instrument", "detector", "physical_filter"),
79 "storageClass": "TransmissionCurve",
80 },
81 "transmission_sensor": {"dimensions": ("instrument", "detector"), "storageClass": "TransmissionCurve"},
82 "transmission_atmosphere": {"dimensions": ("instrument",), "storageClass": "TransmissionCurve"},
83 "transmission_system": {
84 "dimensions": ("instrument", "detector", "physical_filter"),
85 "storageClass": "TransmissionCurve",
86 },
87}
90class Instrument(InstrumentBase):
91 """Rubin-specified base for instrument-specific logic for the Gen3 Butler.
93 Parameters
94 ----------
95 collection_prefix : `str`, optional
96 Prefix for collection names to use instead of the instrument's own
97 name. This is primarily for use in simulated-data repositories, where
98 the instrument name may not be necessary and/or sufficient to
99 distinguish between collections.
101 Notes
102 -----
103 Concrete instrument subclasses must have the same construction signature as
104 the base class.
105 """
107 policyName: str | None = None
108 """Instrument specific name to use when locating a policy or configuration
109 file in the file system."""
111 obsDataPackage: str | None = None
112 """Name of the package containing the text curated calibration files.
113 Usually a obs _data package. If `None` no curated calibration files
114 will be read. (`str`)"""
116 standardCuratedDatasetTypes: Set[str] = frozenset(StandardCuratedCalibrationDatasetTypes)
117 """The dataset types expected to be obtained from the obsDataPackage.
119 These dataset types are all required to have standard definitions and
120 must be known to the base class. Clearing this list will prevent
121 any of these calibrations from being stored. If a dataset type is not
122 known to a specific instrument it can still be included in this list
123 since the data package is the source of truth. (`set` of `str`)
124 """
126 additionalCuratedDatasetTypes: Set[str] = frozenset()
127 """Curated dataset types specific to this particular instrument that do
128 not follow the standard organization found in obs data packages.
130 These are the instrument-specific dataset types written by
131 `writeAdditionalCuratedCalibrations` in addition to the calibrations
132 found in obs data packages that follow the standard scheme.
133 (`set` of `str`)"""
135 translatorClass: MetadataTranslator | None = None
136 """Class to use when extracting information from metadata. If `None`
137 the metadata extraction system will determine the translator class itself.
138 This class can also be used to calculate the observing day offset in some
139 scenarios.
140 """
142 @property
143 @abstractmethod
144 def filterDefinitions(self) -> FilterDefinitionCollection:
145 """`~lsst.obs.base.FilterDefinitionCollection`, defining the filters
146 for this instrument.
147 """
148 raise NotImplementedError()
150 def __init__(self, collection_prefix: str | None = None):
151 super().__init__(collection_prefix=collection_prefix)
153 @classmethod
154 @lru_cache
155 def getCuratedCalibrationNames(cls) -> frozenset[str]:
156 """Return the names of all the curated calibration dataset types.
158 Returns
159 -------
160 names : `frozenset` of `str`
161 The dataset type names of all curated calibrations. This will
162 include the standard curated calibrations even if the particular
163 instrument does not support them.
165 Notes
166 -----
167 The returned list does not indicate whether a particular dataset
168 is present in the Butler repository, simply that these are the
169 dataset types that are handled by `writeCuratedCalibrations`.
170 """
171 # Camera is a special dataset type that is also handled as a
172 # curated calibration.
173 curated = {"camera"}
175 # Make a cursory attempt to filter out curated dataset types
176 # that are not present for this instrument
177 for datasetTypeName in cls.standardCuratedDatasetTypes:
178 calibPath = cls._getSpecificCuratedCalibrationPath(datasetTypeName)
179 if calibPath is not None:
180 curated.add(datasetTypeName)
182 curated.update(cls.additionalCuratedDatasetTypes)
183 return frozenset(curated)
185 @abstractmethod
186 def getCamera(self) -> Camera:
187 """Retrieve the cameraGeom representation of this instrument.
189 This is a temporary API that should go away once ``obs`` packages have
190 a standardized approach to writing versioned cameras to a Gen3 repo.
191 """
192 raise NotImplementedError()
194 @classmethod
195 @lru_cache
196 def getObsDataPackageDir(cls) -> str | None:
197 """Return the root of the obs data package that provides
198 specializations for this instrument.
200 Returns
201 -------
202 dir : `str` or `None`
203 The root of the relevant obs data package, or `None` if this
204 instrument does not have one.
205 """
206 if cls.obsDataPackage is None:
207 return None
208 return getPackageDir(cls.obsDataPackage)
210 def _registerFilters(self, registry: Registry, update: bool = False) -> None:
211 """Register the physical and abstract filter Dimension relationships.
212 This should be called in the `register` implementation, within
213 a transaction context manager block.
215 Parameters
216 ----------
217 registry : `lsst.daf.butler.Registry`
218 The registry to add dimensions to.
219 update : `bool`, optional
220 If `True` (`False` is default), update existing records if they
221 differ from the new ones.
222 """
223 for filter in self.filterDefinitions:
224 # fix for undefined abstract filters causing trouble in the
225 # registry:
226 if filter.band is None:
227 band = filter.physical_filter
228 else:
229 band = filter.band
231 registry.syncDimensionData(
232 "physical_filter",
233 {"instrument": self.getName(), "name": filter.physical_filter, "band": band},
234 update=update,
235 )
237 def writeCuratedCalibrations(
238 self, butler: Butler, collection: str | None = None, labels: Sequence[str] = ()
239 ) -> None:
240 """Write human-curated calibration Datasets to the given Butler with
241 the appropriate validity ranges.
243 Parameters
244 ----------
245 butler : `lsst.daf.butler.Butler`
246 Butler to use to store these calibrations.
247 collection : `str`, optional
248 Name to use for the calibration collection that associates all
249 datasets with a validity range. If this collection already exists,
250 it must be a `~lsst.daf.butler.CollectionType.CALIBRATION`
251 collection, and it must not have any datasets that would conflict
252 with those inserted by this method. If `None`, a collection name
253 is worked out automatically from the instrument name and other
254 metadata by calling
255 `~lsst.obs.base.Instrument.makeCalibrationCollectionName`, but this
256 default name may not work well for long-lived repositories unless
257 ``labels`` is also provided (and changed every time curated
258 calibrations are ingested).
259 labels : `collections.abc.Sequence` [ `str` ], optional
260 Extra strings to include in collection names, after concatenating
261 them with the standard collection name delimiter. If provided,
262 these are inserted into the names of the
263 `~lsst.daf.butler.CollectionType.RUN` collections that datasets are
264 inserted directly into, as well the
265 `~lsst.daf.butler.CollectionType.CALIBRATION` collection if it is
266 generated automatically (i.e., if ``collection is None``).
267 Usually this is just the name of the ticket on which the
268 calibration collection is being created.
270 Notes
271 -----
272 Expected to be called from subclasses. The base method calls
273 ``writeCameraGeom``, ``writeStandardTextCuratedCalibrations``,
274 and ``writeAdditionalCuratedCalibrations``.
275 """
276 # Delegate registration of collections (and creating names for them)
277 # to other methods so they can be called independently with the same
278 # preconditions. Collection registration is idempotent, so this is
279 # safe, and while it adds a bit of overhead, as long as it's one
280 # registration attempt per method (not per dataset or dataset type),
281 # that's negligible.
282 self.writeCameraGeom(butler, collection, labels=labels)
283 self.writeStandardTextCuratedCalibrations(butler, collection, labels=labels)
284 self.writeAdditionalCuratedCalibrations(butler, collection, labels=labels)
286 def writeAdditionalCuratedCalibrations(
287 self, butler: Butler, collection: str | None = None, labels: Sequence[str] = ()
288 ) -> None:
289 """Write additional curated calibrations that might be instrument
290 specific and are not part of the standard set.
292 Default implementation does nothing.
294 Parameters
295 ----------
296 butler : `lsst.daf.butler.Butler`
297 Butler to use to store these calibrations.
298 collection : `str`, optional
299 Name to use for the calibration collection that associates all
300 datasets with a validity range. If this collection already exists,
301 it must be a `~lsst.daf.butler.CollectionType.CALIBRATION`
302 collection, and it must not have any datasets that would conflict
303 with those inserted by this method. If `None`, a collection name
304 is worked out automatically from the instrument name and other
305 metadata by calling
306 `~lsst.obs.base.Instrument.makeCalibrationCollectionName`, but this
307 default name may not work well for long-lived repositories unless
308 ``labels`` is also provided (and changed every time curated
309 calibrations are ingested).
310 labels : `collections.abc.Sequence` [ `str` ], optional
311 Extra strings to include in collection names, after concatenating
312 them with the standard collection name delimiter. If provided,
313 these are inserted into the names of the
314 `~lsst.daf.butler.CollectionType.RUN` collections that datasets are
315 inserted directly into, as well the
316 `~lsst.daf.butler.CollectionType.CALIBRATION` collection if it is
317 generated automatically (i.e. if ``collection is None``). Usually
318 this is just the name of the ticket on which the calibration
319 collection is being created.
320 """
321 return
323 def writeCameraGeom(
324 self, butler: Butler, collection: str | None = None, labels: Sequence[str] = ()
325 ) -> None:
326 """Write the default camera geometry to the butler repository and
327 associate it with the appropriate validity range in a calibration
328 collection.
330 Parameters
331 ----------
332 butler : `lsst.daf.butler.Butler`
333 Butler to use to store these calibrations.
334 collection : `str`, optional
335 Name to use for the calibration collection that associates all
336 datasets with a validity range. If this collection already exists,
337 it must be a `~lsst.daf.butler.CollectionType.CALIBRATION`
338 collection, and it must not have any datasets that would conflict
339 with those inserted by this method. If `None`, a collection name
340 is worked out automatically from the instrument name and other
341 metadata by calling
342 `~lsst.obs.base.Instrument.makeCalibrationCollectionName`, but this
343 default name may not work well for long-lived repositories unless
344 ``labels`` is also provided (and changed every time curated
345 calibrations are ingested).
346 labels : `collections.abc.Sequence` [ `str` ], optional
347 Extra strings to include in collection names, after concatenating
348 them with the standard collection name delimiter. If provided,
349 these are inserted into the names of the
350 `~lsst.daf.butler.CollectionType.RUN` collections that datasets are
351 inserted directly into, as well the
352 `~lsst.daf.butler.CollectionType.CALIBRATION` collection if it is
353 generated automatically (i.e. if ``collection is None``). Usually
354 this is just the name of the ticket on which the calibration
355 collection is being created.
356 """
357 if collection is None:
358 collection = self.makeCalibrationCollectionName(*labels)
359 butler.registry.registerCollection(collection, type=CollectionType.CALIBRATION)
360 run = self.makeUnboundedCalibrationRunName(*labels)
361 butler.registry.registerRun(run)
362 datasetType = DatasetType(
363 "camera", ("instrument",), "Camera", isCalibration=True, universe=butler.dimensions
364 )
365 butler.registry.registerDatasetType(datasetType)
366 camera = self.getCamera()
367 ref = butler.put(camera, datasetType, {"instrument": self.getName()}, run=run)
368 butler.registry.certify(collection, [ref], Timespan(begin=None, end=None))
370 def writeStandardTextCuratedCalibrations(
371 self, butler: Butler, collection: str | None = None, labels: Sequence[str] = ()
372 ) -> None:
373 """Write the set of standardized curated text calibrations to
374 the repository.
376 Parameters
377 ----------
378 butler : `lsst.daf.butler.Butler`
379 Butler to receive these calibration datasets.
380 collection : `str`, optional
381 Name to use for the calibration collection that associates all
382 datasets with a validity range. If this collection already exists,
383 it must be a `~lsst.daf.butler.CollectionType.CALIBRATION`
384 collection, and it must not have any datasets that would conflict
385 with those inserted by this method. If `None`, a collection name
386 is worked out automatically from the instrument name and other
387 metadata by calling
388 `~lsst.obs.base.Instrument.makeCalibrationCollectionName`, but this
389 default name may not work well for long-lived repositories unless
390 ``labels`` is also provided (and changed every time curated
391 calibrations are ingested).
392 labels : `collections.abc.Sequence` [ `str` ], optional
393 Extra strings to include in collection names, after concatenating
394 them with the standard collection name delimiter. If provided,
395 these are inserted into the names of the
396 `~lsst.daf.butler.CollectionType.RUN` collections that datasets are
397 inserted directly into, as well the
398 `~lsst.daf.butler.CollectionType.CALIBRATION` collection if it is
399 generated automatically (i.e., if ``collection is None``). Usually
400 this is just the name of the ticket on which the calibration
401 collection is being created.
402 """
403 if collection is None:
404 collection = self.makeCalibrationCollectionName(*labels)
405 butler.registry.registerCollection(collection, type=CollectionType.CALIBRATION)
406 runs: set[str] = set()
407 for datasetTypeName in self.standardCuratedDatasetTypes:
408 # We need to define the dataset types.
409 if datasetTypeName not in StandardCuratedCalibrationDatasetTypes:
410 raise ValueError(
411 f"DatasetType {datasetTypeName} not in understood list"
412 f" [{'.'.join(StandardCuratedCalibrationDatasetTypes)}]"
413 )
414 definition = StandardCuratedCalibrationDatasetTypes[datasetTypeName]
415 datasetType = DatasetType(
416 datasetTypeName,
417 universe=butler.dimensions,
418 isCalibration=True,
419 # MyPy should be able to figure out that the kwargs here have
420 # the right types, but it can't.
421 **definition, # type: ignore
422 )
423 self._writeSpecificCuratedCalibrationDatasets(
424 butler, datasetType, collection, runs=runs, labels=labels
425 )
427 @classmethod
428 def _getSpecificCuratedCalibrationPath(cls, datasetTypeName: str) -> str | None:
429 """Return the path of the curated calibration directory.
431 Parameters
432 ----------
433 datasetTypeName : `str`
434 The name of the standard dataset type to find.
436 Returns
437 -------
438 path : `str` or `None`
439 The path to the standard curated data directory. `None` if the
440 dataset type is not found or the obs data package is not
441 available.
442 """
443 data_package_dir = cls.getObsDataPackageDir()
444 if data_package_dir is None:
445 # if there is no data package then there can't be datasets
446 return None
448 if cls.policyName is None:
449 raise TypeError(f"Instrument {cls.getName()} has an obs data package but no policy name.")
451 calibPath = os.path.join(data_package_dir, cls.policyName, datasetTypeName)
453 if os.path.exists(calibPath):
454 return calibPath
456 return None
458 def _writeSpecificCuratedCalibrationDatasets(
459 self, butler: Butler, datasetType: DatasetType, collection: str, runs: set[str], labels: Sequence[str]
460 ) -> None:
461 """Write standardized curated calibration datasets for this specific
462 dataset type from an obs data package.
464 Parameters
465 ----------
466 butler : `lsst.daf.butler.Butler`
467 Gen3 butler in which to put the calibrations.
468 datasetType : `lsst.daf.butler.DatasetType`
469 Dataset type to be put.
470 collection : `str`
471 Name of the `~lsst.daf.butler.CollectionType.CALIBRATION`
472 collection that associates all datasets with validity ranges.
473 Must have been registered prior to this call.
474 runs : `set` [ `str` ]
475 Names of runs that have already been registered by previous calls
476 and need not be registered again. Should be updated by this
477 method as new runs are registered.
478 labels : `collections.abc.Sequence` [ `str` ]
479 Extra strings to include in run names when creating them from
480 ``CALIBDATE`` metadata, via calls to `makeCuratedCalibrationName`.
481 Usually this is the name of the ticket on which the calibration
482 collection is being created.
484 Notes
485 -----
486 This method scans the location defined in the ``obsDataPackageDir``
487 class attribute for curated calibrations corresponding to the
488 supplied dataset type. The directory name in the data package must
489 match the name of the dataset type. They are assumed to use the
490 standard layout and can be read by
491 `~lsst.obs.base._read_curated_calibs.read_all` and provide standard
492 metadata.
493 """
494 calibPath = self._getSpecificCuratedCalibrationPath(datasetType.name)
495 if calibPath is None:
496 return
498 # Register the dataset type
499 butler.registry.registerDatasetType(datasetType)
500 _LOG.info("Processing %r curated calibration", datasetType.name)
502 # The class to use to read these calibrations comes from the storage
503 # class.
504 calib_class: Any
505 calib_class = datasetType.storageClass.pytype
506 if not hasattr(calib_class, "readText"):
507 # Let's try the default calib class. All curated
508 # calibrations should be subclasses of that, and the
509 # parent can identify the correct one to use.
510 calib_class = doImport("lsst.ip.isr.IsrCalib")
512 calib_class = cast(type[CuratedCalibration], calib_class)
514 # Read calibs, registering a new run for each CALIBDATE as needed.
515 # We try to avoid registering runs multiple times as an optimization
516 # by putting them in the ``runs`` set that was passed in.
517 camera = self.getCamera()
518 filters = set(self.filterDefinitions.physical_to_band.keys())
519 calib_dimensions: list[str]
520 if datasetType.name in StandardCuratedCalibrationDatasetTypes:
521 calib_dimensions = list(StandardCuratedCalibrationDatasetTypes[datasetType.name]["dimensions"])
522 else:
523 # This should never trigger with real data, but will
524 # trigger on the unit tests.
525 _LOG.warning(
526 "Unknown curated calibration type %s. Attempting to use supplied definition.",
527 datasetType.name,
528 )
529 calib_dimensions = list(datasetType.dimensions.names)
531 calibsDict, calib_type = read_all(calibPath, camera, calib_class, calib_dimensions, filters)
533 datasetRecords = []
534 for path in calibsDict:
535 times = sorted(calibsDict[path])
536 calibs = [calibsDict[path][time] for time in times]
537 atimes: list[astropy.time.Time | None] = [
538 astropy.time.Time(t, format="datetime", scale="utc") for t in times
539 ]
540 atimes += [None]
541 for calib, beginTime, endTime in zip(calibs, atimes[:-1], atimes[1:], strict=True):
542 md = calib.getMetadata()
543 run = self.makeCuratedCalibrationRunName(md["CALIBDATE"], *labels)
544 if run not in runs:
545 butler.registry.registerRun(run)
546 runs.add(run)
548 # DETECTOR and FILTER keywords in the calibration
549 # metadata must exist if the calibration depends on
550 # those dimensions.
551 dimension_arguments = {}
552 if "DETECTOR" in md:
553 dimension_arguments["detector"] = md["DETECTOR"]
554 if "FILTER" in md:
555 dimension_arguments["physical_filter"] = md["FILTER"]
557 dataId = DataCoordinate.standardize(
558 universe=butler.dimensions,
559 instrument=self.getName(),
560 **dimension_arguments,
561 )
562 datasetRecords.append((calib, dataId, run, Timespan(beginTime, endTime)))
564 # Second loop actually does the inserts and filesystem writes. We
565 # first do a butler.put on each dataset, inserting it into the run for
566 # its calibDate. We remember those refs and group them by timespan, so
567 # we can vectorize the certify calls as much as possible.
568 refsByTimespan = defaultdict(list)
569 with butler.transaction():
570 for calib, dataId, run, timespan in datasetRecords:
571 refsByTimespan[timespan].append(butler.put(calib, datasetType, dataId, run=run))
572 for timespan, refs in refsByTimespan.items():
573 butler.registry.certify(collection, refs, timespan)
575 @classmethod
576 def group_name_to_group_id(cls, group_name: str) -> int:
577 """Translate the exposure group name to an integer.
579 Parameters
580 ----------
581 group_name : `str`
582 The name of the exposure group.
584 Returns
585 -------
586 id : `int`
587 The exposure group name in integer form. This integer might be
588 used as an ID to uniquely identify the group in contexts where
589 a string can not be used.
591 Notes
592 -----
593 The default implementation removes all non numeric characters and casts
594 to an integer.
595 """
596 cleaned = re.sub(r"\D", "", group_name)
597 return int(cleaned)
599 def get_curated_calibration_labels(self) -> list[str]:
600 """Return appropriate labels (pieces of a collection name) for a
601 collection populated by `writeCuratedCalibrations`.
603 If this returns an empty list (as the default implementation does),
604 the user will be required to provide a label.
605 """
606 return []
609def makeExposureRecordFromObsInfo(
610 obsInfo: ObservationInfo, universe: DimensionUniverse, **kwargs: Any
611) -> DimensionRecord:
612 """Construct an exposure DimensionRecord from
613 `astro_metadata_translator.ObservationInfo`.
615 Parameters
616 ----------
617 obsInfo : `astro_metadata_translator.ObservationInfo`
618 A `~astro_metadata_translator.ObservationInfo` object corresponding to
619 the exposure.
620 universe : `~lsst.daf.butler.DimensionUniverse`
621 Set of all known dimensions.
622 **kwargs
623 Additional field values for this record.
625 Returns
626 -------
627 record : `~lsst.daf.butler.DimensionRecord`
628 A record containing exposure metadata, suitable for insertion into
629 a `~lsst.daf.butler.Registry`.
630 """
631 dimension = universe["exposure"]
633 # Some registries support additional items.
634 supported = {meta.name for meta in dimension.metadata}
636 ra, dec, sky_angle, azimuth, zenith_angle = (None, None, None, None, None)
637 if obsInfo.tracking_radec is not None:
638 icrs = obsInfo.tracking_radec.icrs
639 ra = float(icrs.ra.degree)
640 dec = float(icrs.dec.degree)
641 if obsInfo.boresight_rotation_coord == "sky":
642 sky_angle = float(obsInfo.boresight_rotation_angle.degree)
643 if obsInfo.altaz_begin is not None:
644 zenith_angle = float(obsInfo.altaz_begin.zen.degree)
645 azimuth = float(obsInfo.altaz_begin.az.degree)
647 extras: dict[str, Any] = {}
648 for meta_key, info_key in (
649 ("has_simulated", "has_simulated_content"),
650 ("seq_start", "group_counter_start"),
651 ("seq_end", "group_counter_end"),
652 ("can_see_sky", "can_see_sky"),
653 ):
654 if meta_key in supported:
655 extras[meta_key] = getattr(obsInfo, info_key)
657 if (k := "azimuth") in supported:
658 extras[k] = float(azimuth) if azimuth is not None else None
660 if "group" in dimension.implied:
661 extras["group"] = obsInfo.exposure_group
662 elif "group_name" in supported:
663 extras["group_name"] = obsInfo.exposure_group
664 extras["group_id"] = obsInfo.visit_id
665 else:
666 raise RuntimeError(f"Unable to determine where to put group metadata in exposure record: {supported}")
668 # In some bad observations, the end time is before the begin time. We
669 # can not let that be ingested as-is because it becomes an unbounded
670 # timespan that will not work correctly with calibration lookups. Instead
671 # force the end time to be the begin time.
672 datetime_end = obsInfo.datetime_end
673 if datetime_end < obsInfo.datetime_begin:
674 datetime_end = obsInfo.datetime_begin
675 _LOG.warning(
676 "Exposure %s:%s has end time before begin time. Forcing it to use the begin time.",
677 obsInfo.instrument,
678 obsInfo.observation_id,
679 )
681 return dimension.RecordClass(
682 instrument=obsInfo.instrument,
683 id=obsInfo.exposure_id,
684 obs_id=obsInfo.observation_id,
685 datetime_begin=obsInfo.datetime_begin,
686 datetime_end=datetime_end,
687 exposure_time=float(obsInfo.exposure_time_requested.to_value("s")),
688 # we are not mandating that dark_time be calculable
689 dark_time=float(obsInfo.dark_time.to_value("s")) if obsInfo.dark_time is not None else None,
690 observation_type=obsInfo.observation_type,
691 observation_reason=obsInfo.observation_reason,
692 day_obs=obsInfo.observing_day,
693 seq_num=obsInfo.observation_counter,
694 physical_filter=obsInfo.physical_filter,
695 science_program=obsInfo.science_program,
696 target_name=obsInfo.object,
697 tracking_ra=ra,
698 tracking_dec=dec,
699 sky_angle=sky_angle,
700 zenith_angle=zenith_angle,
701 **extras,
702 **kwargs,
703 )
706def loadCamera(butler: Butler, dataId: DataId, *, collections: Any = None) -> tuple[Camera, bool]:
707 """Attempt to load versioned camera geometry from a butler, but fall back
708 to obtaining a nominal camera from the `Instrument` class if that fails.
710 Parameters
711 ----------
712 butler : `lsst.daf.butler.Butler`
713 Butler instance to attempt to query for and load a ``camera`` dataset
714 from.
715 dataId : `dict` or `~lsst.daf.butler.DataCoordinate`
716 Data ID that identifies at least the ``instrument`` and ``exposure``
717 dimensions.
718 collections : `typing.Any`, optional
719 Collections to be searched, overriding
720 ``self.butler.collections.defaults``. Can be any of the types
721 supported by the ``collections`` argument to butler construction.
723 Returns
724 -------
725 camera : `lsst.afw.cameraGeom.Camera`
726 Camera object.
727 versioned : `bool`
728 If `True`, the camera was obtained from the butler and should represent
729 a versioned camera from a calibration repository. If `False`, no
730 camera datasets were found, and the returned camera was produced by
731 instantiating the appropriate `Instrument` class and calling
732 `Instrument.getCamera`.
734 Raises
735 ------
736 LookupError
737 Raised when ``dataId`` does not specify a valid data ID.
738 """
739 if collections is None:
740 collections = list(butler.collections.defaults)
741 # Registry would do data ID expansion internally if we didn't do it first,
742 # but we might want an expanded data ID ourselves later, so we do it here
743 # to ensure it only happens once.
744 # This will also catch problems with the data ID not having keys we need.
745 try:
746 dataId = butler.registry.expandDataId(dataId, dimensions=butler.dimensions["exposure"].minimal_group)
747 except DataIdError as exc:
748 raise LookupError(str(exc)) from exc
749 try:
750 cameraRef = butler.get("camera", dataId=dataId, collections=collections)
751 return cameraRef, True
752 except LookupError:
753 pass
754 # We know an instrument data ID is a value, but MyPy doesn't.
755 instrument = Instrument.fromName(dataId["instrument"], butler.registry) # type: ignore
756 assert isinstance(instrument, Instrument) # for mypy
757 return instrument.getCamera(), False