Coverage for python / lsst / pipe / base / _instrument.py: 39%

132 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-31 01:07 -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/>. 

27 

28from __future__ import annotations 

29 

30__all__ = ("Instrument",) 

31 

32import contextlib 

33import datetime 

34from abc import ABCMeta, abstractmethod 

35from collections.abc import Sequence 

36from typing import TYPE_CHECKING, Any, Self, cast, final 

37 

38from lsst.daf.butler import DataCoordinate, DataId, DimensionPacker, DimensionRecord, Formatter, FormatterV2 

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 

44 

45from ._observation_dimension_packer import observation_packer_registry 

46 

47if TYPE_CHECKING: 

48 from lsst.daf.butler import Registry 

49 

50 

51class Instrument(metaclass=ABCMeta): 

52 """Base class for instrument-specific logic for the Gen3 Butler. 

53 

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. 

61 

62 Notes 

63 ----- 

64 Concrete instrument subclasses must have the same construction signature as 

65 the base class. 

66 """ 

67 

68 configPaths: Sequence[ResourcePathExpression] = () 

69 """Paths to config files to read for specific Tasks. 

70 

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

74 

75 policyName: str | None = None 

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

77 file in the file system.""" 

78 

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

83 

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 

88 

89 @classmethod 

90 @abstractmethod 

91 def getName(cls) -> str: 

92 """Return the short (dimension) name for this instrument. 

93 

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

99 

100 @abstractmethod 

101 def register(self, registry: Registry, *, update: bool = False) -> None: 

102 """Insert instrument, and other relevant records into `Registry`. 

103 

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. 

111 

112 Returns 

113 ------- 

114 None 

115 

116 Raises 

117 ------ 

118 lsst.daf.butler.registry.ConflictingDefinitionError 

119 Raised if any existing record has the same key but a different 

120 definition as one being registered. 

121 

122 Notes 

123 ----- 

124 New records can always be added by calling this method multiple times, 

125 as long as no existing records have changed (if existing records have 

126 changed, ``update=True`` must be used). Old records can never be 

127 removed by this method. 

128 

129 Implementations should guarantee that registration is atomic (the 

130 registry should not be modified if any error occurs) and idempotent at 

131 the level of individual dimension entries; new detectors and filters 

132 should be added, but changes to any existing record should not be. 

133 This can generally be achieved via a block like 

134 """ 

135 raise NotImplementedError() 

136 

137 @classmethod 

138 def fromName(cls, name: str, registry: Registry, collection_prefix: str | None = None) -> Self: 

139 """Given an instrument name and a butler registry, retrieve a 

140 corresponding instantiated instrument object. 

141 

142 Parameters 

143 ---------- 

144 name : `str` 

145 Name of the instrument (must match the return value of `getName`). 

146 registry : `lsst.daf.butler.Registry` 

147 Butler registry to query to find the information. 

148 collection_prefix : `str`, optional 

149 Prefix for collection names to use instead of the instrument's own 

150 name. This is primarily for use in simulated-data repositories, 

151 where the instrument name may not be necessary and/or sufficient to 

152 distinguish between collections. 

153 

154 Returns 

155 ------- 

156 instrument : `Instrument` 

157 An instance of the relevant `Instrument`. 

158 

159 Notes 

160 ----- 

161 The instrument must be registered in the corresponding butler. 

162 

163 Raises 

164 ------ 

165 LookupError 

166 Raised if the instrument is not known to the supplied registry. 

167 ModuleNotFoundError 

168 Raised if the class could not be imported. This could mean 

169 that the relevant obs package has not been setup. 

170 TypeError 

171 Raised if the class name retrieved is not a string or the imported 

172 symbol is not an `Instrument` subclass. 

173 """ 

174 try: 

175 records = list(registry.queryDimensionRecords("instrument", instrument=name)) 

176 except DataIdError: 

177 records = None 

178 if not records: 

179 raise LookupError(f"No registered instrument with name '{name}'.") 

180 cls_name = records[0].class_name 

181 if not isinstance(cls_name, str): 

182 raise TypeError( 

183 f"Unexpected class name retrieved from {name} instrument dimension (got {cls_name})" 

184 ) 

185 return cls._from_cls_name(cls_name, collection_prefix) 

186 

187 @classmethod 

188 def from_string( 

189 cls, name: str, registry: Registry | None = None, collection_prefix: str | None = None 

190 ) -> Self: 

191 """Return an instance from the short name or class name. 

192 

193 If the instrument name is not qualified (does not contain a '.') and a 

194 butler registry is provided, this will attempt to load the instrument 

195 using `Instrument.fromName()`. Otherwise the instrument will be 

196 imported and instantiated. 

197 

198 Parameters 

199 ---------- 

200 name : `str` 

201 The name or fully-qualified class name of an instrument. 

202 registry : `lsst.daf.butler.Registry`, optional 

203 Butler registry to query to find information about the instrument, 

204 by default `None`. 

205 collection_prefix : `str`, optional 

206 Prefix for collection names to use instead of the instrument's own 

207 name. This is primarily for use in simulated-data repositories, 

208 where the instrument name may not be necessary and/or sufficient 

209 to distinguish between collections. 

210 

211 Returns 

212 ------- 

213 instrument : `Instrument` 

214 The instantiated instrument. 

215 

216 Raises 

217 ------ 

218 RuntimeError 

219 Raised if the instrument can not be imported, instantiated, or 

220 obtained from the registry. 

221 TypeError 

222 Raised if the instrument is not a subclass of 

223 `~lsst.pipe.base.Instrument`. 

224 

225 See Also 

226 -------- 

227 Instrument.fromName : Constructing Instrument from a name. 

228 """ 

229 if "." not in name and registry is not None: 

230 try: 

231 instr = cls.fromName(name, registry, collection_prefix=collection_prefix) 

232 except Exception as err: 

233 raise RuntimeError( 

234 f"Could not get instrument from name: {name}. Failed with exception: {err}" 

235 ) from err 

236 else: 

237 try: 

238 instr_class = doImportType(name) 

239 except Exception as err: 

240 raise RuntimeError( 

241 f"Could not import instrument: {name}. Failed with exception: {err}" 

242 ) from err 

243 instr = instr_class(collection_prefix=collection_prefix) 

244 if not isinstance(instr, cls): 

245 raise TypeError(f"{name} is not a {get_full_type_name(cls)} subclass.") 

246 return instr 

247 

248 @classmethod 

249 def from_data_id(cls, data_id: DataCoordinate, collection_prefix: str | None = None) -> Self: 

250 """Instantiate an `Instrument` object from a fully-expanded data ID. 

251 

252 Parameters 

253 ---------- 

254 data_id : `~lsst.daf.butler.DataCoordinate` 

255 Expanded data ID that includes the instrument dimension. 

256 collection_prefix : `str`, optional 

257 Prefix for collection names to use instead of the instrument's own 

258 name. This is primarily for use in simulated-data repositories, 

259 where the instrument name may not be necessary and/or sufficient to 

260 distinguish between collections. 

261 

262 Returns 

263 ------- 

264 instrument : `Instrument` 

265 An instance of the relevant `Instrument`. 

266 

267 Raises 

268 ------ 

269 TypeError 

270 Raised if the class name retrieved is not a string or the imported 

271 symbol is not an `Instrument` subclass. 

272 """ 

273 return cls._from_cls_name( 

274 cast(DimensionRecord, data_id.records["instrument"]).class_name, collection_prefix 

275 ) 

276 

277 @classmethod 

278 def _from_cls_name(cls, cls_name: str, collection_prefix: str | None = None) -> Self: 

279 """Instantiate an `Instrument` object type name. 

280 

281 This just provides common error-handling for `fromName` and 

282 `from_data_id` 

283 

284 Parameters 

285 ---------- 

286 cls_name : `str` 

287 Fully-qualified name of the type. 

288 collection_prefix : `str`, optional 

289 Prefix for collection names to use instead of the instrument's own 

290 name. This is primarily for use in simulated-data repositories, 

291 where the instrument name may not be necessary and/or sufficient to 

292 distinguish between collections. 

293 

294 Returns 

295 ------- 

296 instrument : `Instrument` 

297 An instance of the relevant `Instrument`. 

298 

299 Raises 

300 ------ 

301 TypeError 

302 Raised if the class name retrieved is not a string or the imported 

303 symbol is not an `Instrument` subclass. 

304 """ 

305 instrument_cls: type = doImportType(cls_name) 

306 if not issubclass(instrument_cls, cls): 

307 raise TypeError( 

308 f"{instrument_cls!r}, obtained from importing {cls_name}, is not a subclass " 

309 f"of {get_full_type_name(cls)}." 

310 ) 

311 return instrument_cls(collection_prefix=collection_prefix) 

312 

313 @staticmethod 

314 def importAll(registry: Registry) -> dict[str, type[Instrument]]: 

315 """Import all the instruments known to this registry. 

316 

317 This will ensure that all metadata translators have been registered. 

318 

319 Parameters 

320 ---------- 

321 registry : `lsst.daf.butler.Registry` 

322 Butler registry to query to find the information. 

323 

324 Returns 

325 ------- 

326 imported : `dict` [`str`, `type` [`Instrument`]] 

327 A mapping containing all the instrument classes that were loaded 

328 successfully, keyed by their butler names. 

329 

330 Notes 

331 ----- 

332 It is allowed for a particular instrument class to fail on import. 

333 This might simply indicate that a particular obs package has 

334 not been setup. 

335 """ 

336 imported: dict[str, type[Instrument]] = {} 

337 records = list(registry.queryDimensionRecords("instrument")) 

338 for record in records: 

339 cls = record.class_name 

340 instrument_name: str = cast(str, record.name) 

341 with contextlib.suppress(Exception): 

342 instr = doImportType(cls) 

343 assert issubclass(instr, Instrument) 

344 imported[instrument_name] = instr 

345 return imported 

346 

347 @abstractmethod 

348 def getRawFormatter(self, dataId: DataId) -> type[Formatter | FormatterV2]: 

349 """Return the Formatter class that should be used to read a particular 

350 raw file. 

351 

352 Parameters 

353 ---------- 

354 dataId : `DataId` 

355 Dimension-based ID for the raw file or files being ingested. 

356 

357 Returns 

358 ------- 

359 formatter : `type` \ 

360 [`lsst.daf.butler.Formatter` | `lsst.daf.butler.FormatterV2` ] 

361 Class to be used that reads the file into the correct 

362 Python object for the raw data. 

363 """ 

364 raise NotImplementedError() 

365 

366 def applyConfigOverrides(self, name: str, config: Config) -> None: 

367 """Apply instrument-specific overrides for a task config. 

368 

369 Parameters 

370 ---------- 

371 name : `str` 

372 Name of the object being configured; typically the _DefaultName 

373 of a Task. 

374 config : `lsst.pex.config.Config` 

375 Config instance to which overrides should be applied. 

376 """ 

377 for root in self.configPaths: 

378 resource = ResourcePath(root, forceDirectory=True, forceAbsolute=True) 

379 uri = resource.join(f"{name}.py", forceDirectory=False) 

380 if uri.exists(): 

381 config.load(uri) 

382 

383 @staticmethod 

384 def formatCollectionTimestamp(timestamp: str | datetime.datetime) -> str: 

385 """Format a timestamp for use in a collection name. 

386 

387 Parameters 

388 ---------- 

389 timestamp : `str` or `datetime.datetime` 

390 Timestamp to format. May be a date or datetime string in extended 

391 ISO format (assumed UTC), with or without a timezone specifier, a 

392 datetime string in basic ISO format with a timezone specifier, a 

393 naive `datetime.datetime` instance (assumed UTC) or a 

394 timezone-aware `datetime.datetime` instance (converted to UTC). 

395 This is intended to cover all forms that string ``CALIBDATE`` 

396 metadata values have taken in the past, as well as the format this 

397 method itself writes out (to enable round-tripping). 

398 

399 Returns 

400 ------- 

401 formatted : `str` 

402 Standardized string form for the timestamp. 

403 """ 

404 if isinstance(timestamp, str): 

405 if "-" in timestamp: 

406 # extended ISO format, with - and : delimiters 

407 timestamp = datetime.datetime.fromisoformat(timestamp) 

408 else: 

409 # basic ISO format, with no delimiters (what this method 

410 # returns) 

411 timestamp = datetime.datetime.strptime(timestamp, "%Y%m%dT%H%M%S%z") 

412 if not isinstance(timestamp, datetime.datetime): 

413 raise TypeError(f"Unexpected date/time object: {timestamp!r}.") 

414 if timestamp.tzinfo is not None: 

415 timestamp = timestamp.astimezone(datetime.UTC) 

416 return f"{timestamp:%Y%m%dT%H%M%S}Z" 

417 

418 @staticmethod 

419 def makeCollectionTimestamp() -> str: 

420 """Create a timestamp string for use in a collection name from the 

421 current time. 

422 

423 Returns 

424 ------- 

425 formatted : `str` 

426 Standardized string form of the current time. 

427 """ 

428 return Instrument.formatCollectionTimestamp(datetime.datetime.now(tz=datetime.UTC)) 

429 

430 def makeDefaultRawIngestRunName(self) -> str: 

431 """Make the default instrument-specific run collection string for raw 

432 data ingest. 

433 

434 Returns 

435 ------- 

436 coll : `str` 

437 Run collection name to be used as the default for ingestion of 

438 raws. 

439 """ 

440 return self.makeCollectionName("raw", "all") 

441 

442 def makeUnboundedCalibrationRunName(self, *labels: str) -> str: 

443 """Make a RUN collection name appropriate for inserting calibration 

444 datasets whose validity ranges are unbounded. 

445 

446 Parameters 

447 ---------- 

448 *labels : `str` 

449 Extra strings to be included in the base name, using the default 

450 delimiter for collection names. Usually this is the name of the 

451 ticket on which the calibration collection is being created. 

452 

453 Returns 

454 ------- 

455 name : `str` 

456 Run collection name. 

457 """ 

458 return self.makeCollectionName("calib", *labels, "unbounded") 

459 

460 def makeCuratedCalibrationRunName(self, calibDate: str, *labels: str) -> str: 

461 """Make a RUN collection name appropriate for inserting curated 

462 calibration datasets with the given ``CALIBDATE`` metadata value. 

463 

464 Parameters 

465 ---------- 

466 calibDate : `str` 

467 The ``CALIBDATE`` metadata value. 

468 *labels : `str` 

469 Strings to be included in the collection name (before 

470 ``calibDate``, but after all other terms), using the default 

471 delimiter for collection names. Usually this is the name of the 

472 ticket on which the calibration collection is being created. 

473 

474 Returns 

475 ------- 

476 name : `str` 

477 Run collection name. 

478 """ 

479 return self.makeCollectionName("calib", *labels, "curated", self.formatCollectionTimestamp(calibDate)) 

480 

481 def makeCalibrationCollectionName(self, *labels: str) -> str: 

482 """Make a CALIBRATION collection name appropriate for associating 

483 calibration datasets with validity ranges. 

484 

485 Parameters 

486 ---------- 

487 *labels : `str` 

488 Strings to be appended to the base name, using the default 

489 delimiter for collection names. Usually this is the name of the 

490 ticket on which the calibration collection is being created. 

491 

492 Returns 

493 ------- 

494 name : `str` 

495 Calibration collection name. 

496 """ 

497 return self.makeCollectionName("calib", *labels) 

498 

499 @staticmethod 

500 def makeRefCatCollectionName(*labels: str) -> str: 

501 """Return a global (not instrument-specific) name for a collection that 

502 holds reference catalogs. 

503 

504 With no arguments, this returns the name of the collection that holds 

505 all reference catalogs (usually a ``CHAINED`` collection, at least in 

506 long-lived repos that may contain more than one reference catalog). 

507 

508 Parameters 

509 ---------- 

510 *labels : `str` 

511 Strings to be added to the global collection name, in order to 

512 define a collection name for one or more reference catalogs being 

513 ingested at the same time. 

514 

515 Returns 

516 ------- 

517 name : `str` 

518 Collection name. 

519 

520 Notes 

521 ----- 

522 This is a ``staticmethod``, not a ``classmethod``, because it should 

523 be the same for all instruments. 

524 """ 

525 return "/".join(("refcats",) + labels) 

526 

527 def makeUmbrellaCollectionName(self) -> str: 

528 """Return the name of the umbrella ``CHAINED`` collection for this 

529 instrument that combines all standard recommended input collections. 

530 

531 This method should almost never be overridden by derived classes. 

532 

533 Returns 

534 ------- 

535 name : `str` 

536 Name for the umbrella collection. 

537 """ 

538 return self.makeCollectionName("defaults") 

539 

540 def makeCollectionName(self, *labels: str) -> str: 

541 """Get the instrument-specific collection string to use as derived 

542 from the supplied labels. 

543 

544 Parameters 

545 ---------- 

546 *labels : `str` 

547 Strings to be combined with the instrument name to form a 

548 collection name. 

549 

550 Returns 

551 ------- 

552 name : `str` 

553 Collection name to use that includes the instrument's recommended 

554 prefix. 

555 """ 

556 return "/".join((self.collection_prefix,) + labels) 

557 

558 @staticmethod 

559 def make_dimension_packer_config_field( 

560 doc: str = ( 

561 "How to pack visit+detector or exposure+detector data IDs into integers. " 

562 "The default (None) is to delegate to the Instrument class for which " 

563 "registered implementation to use (but still use the nested configuration " 

564 "for that implementation)." 

565 ), 

566 ) -> RegistryField: 

567 """Make an `lsst.pex.config.Field` that can be used to configure how 

568 data IDs for this instrument are packed. 

569 

570 Parameters 

571 ---------- 

572 doc : `str`, optional 

573 Documentation for the config field. 

574 

575 Returns 

576 ------- 

577 field : `lsst.pex.config.RegistryField` 

578 A config field for which calling ``apply`` on the instance 

579 attribute constructs an `lsst.daf.butler.DimensionPacker` that 

580 defaults to the appropriate one for this instrument. 

581 

582 Notes 

583 ----- 

584 This method is expected to be used whenever code requires a single 

585 integer that represents the combination of a detector and either a 

586 visit or exposure, but in most cases the `lsst.meas.base.IdGenerator` 

587 class and its helper configs provide a simpler high-level interface 

588 that should be used instead of calling this method directly. 

589 

590 This system is designed to work best when the configuration for the ID 

591 packer is not overridden at all, allowing the appropriate instrument 

592 class to determine the behavior for each data ID encountered. When the 

593 configuration does need to be modified (most often when the scheme for 

594 packing an instrument's data IDs is undergoing an upgrade), it is 

595 important to ensure the overrides are only applied to data IDs with the 

596 desired instrument value. 

597 

598 Unit tests of code that use a field produced by this method will often 

599 want to explicitly set the packer to "observation" and manually set 

600 its ``n_detectors`` and ``n_observations`` fields; this will make it 

601 unnecessary for tests to provide expanded data IDs. 

602 """ 

603 # The control flow here bounces around a bit when this RegistryField's 

604 # apply() method is called, so it merits a thorough walkthrough 

605 # somewhere, and that might as well be here: 

606 # 

607 # - If the config field's name is not `None`, that kind of packer is 

608 # constructed and returned with the arguments to `apply`, in just the 

609 # way it works with most RegistryFields or ConfigurableFields. But 

610 # this is expected to be rare. 

611 # 

612 # - If the config fields' name is `None`, the `apply` method (which 

613 # actually lives on the `pex.config.RegistryInstanceDict` class, 

614 # since `RegistryField` is a descriptor), calls 

615 # `_make_default_dimension_packer_dispatch` (which is final, and 

616 # hence the base class implementation just below is the only one). 

617 # 

618 # - `_make_default_dimension_packer_dispatch` instantiates an 

619 # `Instrument` instance of the type pointed at by the data ID (i.e. 

620 # calling `Instrument.from_data_id`), then calls 

621 # `_make_default_dimension_packer` on that. 

622 # 

623 # - The default implementation of `_make_default_dimension_packer` here 

624 # in the base class picks the "observation" dimension packer, so if 

625 # it's not overridden by a derived class everything proceeds as if 

626 # the config field's name was set to that. Note that this sets which 

627 # item in the registry is used, but it still pays attention to the 

628 # configuration for that entry in the registry field. 

629 # 

630 # - A subclass implementation of `_make_default_dimension_packer` will 

631 # take precedence over the base class, but it's expected that these 

632 # will usually just delegate back to ``super()`` while changing the 

633 # ``default`` argument to something other than "observation". Once 

634 # again, this will control which packer entry in the registry is used 

635 # but the result will still reflect the configuration for that packer 

636 # in the registry field. 

637 # 

638 return observation_packer_registry.makeField( 

639 doc, default=None, optional=True, on_none=Instrument._make_default_dimension_packer_dispatch 

640 ) 

641 

642 @staticmethod 

643 @final 

644 def make_default_dimension_packer( 

645 data_id: DataCoordinate, is_exposure: bool | None = None 

646 ) -> DimensionPacker: 

647 """Return the default dimension packer for the given data ID. 

648 

649 Parameters 

650 ---------- 

651 data_id : `lsst.daf.butler.DataCoordinate` 

652 Data ID that identifies at least the ``instrument`` dimension. Must 

653 have dimension records attached. 

654 is_exposure : `bool`, optional 

655 If `False`, construct a packer for visit+detector data IDs. If 

656 `True`, construct a packer for exposure+detector data IDs. If 

657 `None`, this is determined based on whether ``visit`` or 

658 ``exposure`` is present in ``data_id``, with ``visit`` checked 

659 first and hence used if both are present. 

660 

661 Returns 

662 ------- 

663 packer : `lsst.daf.butler.DimensionPacker` 

664 Object that packs {visit, detector} or {exposure, detector} data 

665 IDs into integers. 

666 

667 Notes 

668 ----- 

669 When using a dimension packer in task code, using 

670 `make_dimension_packer_config_field` to make the packing algorithm 

671 configurable is preferred over this method. 

672 

673 When obtaining a dimension packer to unpack IDs that were packed by 

674 task code, it is similarly preferable to load the configuration for 

675 that task and the existing packer configuration field there, to ensure 

676 any config overrides are respected. That is sometimes quite difficult, 

677 however, and since config overrides for dimension packers are expected 

678 to be exceedingly rare, using this simpler method will almost always 

679 work. 

680 """ 

681 

682 class _DummyConfig(Config): 

683 packer = Instrument.make_dimension_packer_config_field() 

684 

685 config = _DummyConfig() 

686 

687 return config.packer.apply(data_id, is_exposure=is_exposure) # type: ignore 

688 

689 @staticmethod 

690 @final 

691 def _make_default_dimension_packer_dispatch( 

692 config_dict: Any, data_id: DataCoordinate, is_exposure: bool | None = None 

693 ) -> DimensionPacker: 

694 """Dispatch method used to invoke `_make_dimension_packer`. 

695 

696 This method constructs the appropriate `Instrument` subclass from 

697 config and then calls its `_make_default_dimension_packer`. 

698 It is called when (as usual) the field returned by 

699 `make_dimension_packer_config_field` is left to its default selection 

700 of `None`. 

701 

702 All arguments and return values are the same as 

703 `_make_default_dimension_packer.` 

704 """ 

705 instrument = Instrument.from_data_id(data_id) 

706 return instrument._make_default_dimension_packer(config_dict, data_id, is_exposure=is_exposure) 

707 

708 def _make_default_dimension_packer( 

709 self, 

710 config_dict: Any, 

711 data_id: DataCoordinate, 

712 is_exposure: bool | None = None, 

713 default: str = "observation", 

714 ) -> DimensionPacker: 

715 """Construct return the default dimension packer for this instrument. 

716 

717 This method is a protected hook for subclasses to override the behavior 

718 of `make_dimension_packer_config_field` when the packer is not selected 

719 explicitly via configuration. 

720 

721 Parameters 

722 ---------- 

723 config_dict 

724 Mapping attribute of a `lsst.pex.config.Config` instance that 

725 corresponds to a field created by `make_dimension_packer_config` 

726 (the actual type of this object is a `lsst.pex.config` 

727 implementation detail). 

728 data_id : `lsst.daf.butler.DataCoordinate` 

729 Data ID that identifies at least the ``instrument`` dimension. For 

730 most configurations this must have dimension records attached. 

731 is_exposure : `bool`, optional 

732 If `False`, construct a packer for visit+detector data IDs. If 

733 `True`, construct a packer for exposure+detector data IDs. If 

734 `None`, this is determined based on whether ``visit`` or 

735 ``exposure`` is present in ``data_id``, with ``visit`` checked 

736 first and hence used if both are present. 

737 default : `str`, optional 

738 Registered name of the dimension packer to select when the 

739 configured packer is `None` (as is usually the case). This is 

740 intended primarily for derived classes delegating to `super` in 

741 reimplementations of this method. 

742 

743 Returns 

744 ------- 

745 packer : `lsst.daf.butler.DimensionPacker` 

746 Object that packs {visit, detector} or {exposure, detector} data 

747 IDs into integers. 

748 """ 

749 return config_dict.apply_with(default, data_id, is_exposure=is_exposure)