Coverage for python / lsst / obs / base / _instrument.py: 22%

193 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-14 02:03 -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/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("Instrument", "loadCamera", "makeExposureRecordFromObsInfo") 

25 

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 

34 

35import astropy.time 

36 

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 

51 

52from ._read_curated_calibs import CuratedCalibration, read_all 

53 

54if TYPE_CHECKING: 

55 from astro_metadata_translator import MetadataTranslator, ObservationInfo 

56 

57 from lsst.daf.butler import Registry 

58 

59 from .filters import FilterDefinitionCollection 

60 

61_LOG = logging.getLogger(__name__) 

62 

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} 

88 

89 

90class Instrument(InstrumentBase): 

91 """Rubin-specified base for instrument-specific logic for the Gen3 Butler. 

92 

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. 

100 

101 Notes 

102 ----- 

103 Concrete instrument subclasses must have the same construction signature as 

104 the base class. 

105 """ 

106 

107 policyName: str | None = None 

108 """Instrument specific name to use when locating a policy or configuration 

109 file in the file system.""" 

110 

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`)""" 

115 

116 standardCuratedDatasetTypes: Set[str] = frozenset(StandardCuratedCalibrationDatasetTypes) 

117 """The dataset types expected to be obtained from the obsDataPackage. 

118 

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 """ 

125 

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. 

129 

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`)""" 

134 

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 """ 

141 

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() 

149 

150 def __init__(self, collection_prefix: str | None = None): 

151 super().__init__(collection_prefix=collection_prefix) 

152 

153 @classmethod 

154 @lru_cache 

155 def getCuratedCalibrationNames(cls) -> frozenset[str]: 

156 """Return the names of all the curated calibration dataset types. 

157 

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. 

164 

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"} 

174 

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) 

181 

182 curated.update(cls.additionalCuratedDatasetTypes) 

183 return frozenset(curated) 

184 

185 @abstractmethod 

186 def getCamera(self) -> Camera: 

187 """Retrieve the cameraGeom representation of this instrument. 

188 

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() 

193 

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. 

199 

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) 

209 

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. 

214 

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 

230 

231 registry.syncDimensionData( 

232 "physical_filter", 

233 {"instrument": self.getName(), "name": filter.physical_filter, "band": band}, 

234 update=update, 

235 ) 

236 

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. 

242 

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. 

269 

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) 

285 

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. 

291 

292 Default implementation does nothing. 

293 

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 

322 

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. 

329 

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)) 

369 

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. 

375 

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 ) 

426 

427 @classmethod 

428 def _getSpecificCuratedCalibrationPath(cls, datasetTypeName: str) -> str | None: 

429 """Return the path of the curated calibration directory. 

430 

431 Parameters 

432 ---------- 

433 datasetTypeName : `str` 

434 The name of the standard dataset type to find. 

435 

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 

447 

448 if cls.policyName is None: 

449 raise TypeError(f"Instrument {cls.getName()} has an obs data package but no policy name.") 

450 

451 calibPath = os.path.join(data_package_dir, cls.policyName, datasetTypeName) 

452 

453 if os.path.exists(calibPath): 

454 return calibPath 

455 

456 return None 

457 

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. 

463 

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. 

483 

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 

497 

498 # Register the dataset type 

499 butler.registry.registerDatasetType(datasetType) 

500 _LOG.info("Processing %r curated calibration", datasetType.name) 

501 

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") 

511 

512 calib_class = cast(type[CuratedCalibration], calib_class) 

513 

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) 

530 

531 calibsDict, calib_type = read_all(calibPath, camera, calib_class, calib_dimensions, filters) 

532 

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) 

547 

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"] 

556 

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))) 

563 

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) 

574 

575 @classmethod 

576 def group_name_to_group_id(cls, group_name: str) -> int: 

577 """Translate the exposure group name to an integer. 

578 

579 Parameters 

580 ---------- 

581 group_name : `str` 

582 The name of the exposure group. 

583 

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. 

590 

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) 

598 

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`. 

602 

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 [] 

607 

608 

609def makeExposureRecordFromObsInfo( 

610 obsInfo: ObservationInfo, universe: DimensionUniverse, **kwargs: Any 

611) -> DimensionRecord: 

612 """Construct an exposure DimensionRecord from 

613 `astro_metadata_translator.ObservationInfo`. 

614 

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. 

624 

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"] 

632 

633 # Some registries support additional items. 

634 supported = {meta.name for meta in dimension.metadata} 

635 

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) 

646 

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) 

656 

657 if (k := "azimuth") in supported: 

658 extras[k] = float(azimuth) if azimuth is not None else None 

659 

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}") 

667 

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 ) 

680 

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 ) 

704 

705 

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. 

709 

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. 

722 

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`. 

733 

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