Coverage for python/lsst/obs/base/defineVisits.py: 34%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

231 statements  

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__ = [ 

25 "DefineVisitsConfig", 

26 "DefineVisitsTask", 

27 "GroupExposuresConfig", 

28 "GroupExposuresTask", 

29 "VisitDefinitionData", 

30] 

31 

32from abc import ABCMeta, abstractmethod 

33from collections import defaultdict 

34import itertools 

35import dataclasses 

36from typing import Any, Dict, Iterable, List, Optional, Tuple 

37from multiprocessing import Pool 

38 

39from lsst.daf.butler import ( 

40 Butler, 

41 DataCoordinate, 

42 DataId, 

43 DimensionGraph, 

44 DimensionRecord, 

45 Progress, 

46 Timespan, 

47) 

48 

49import lsst.geom 

50from lsst.geom import Box2D 

51from lsst.pex.config import Config, Field, makeRegistry, registerConfigurable 

52from lsst.afw.cameraGeom import FOCAL_PLANE, PIXELS 

53from lsst.pipe.base import Task 

54from lsst.sphgeom import ConvexPolygon, Region, UnitVector3d 

55from ._instrument import loadCamera, Instrument 

56 

57 

58@dataclasses.dataclass 

59class VisitDefinitionData: 

60 """Struct representing a group of exposures that will be used to define a 

61 visit. 

62 """ 

63 

64 instrument: str 

65 """Name of the instrument this visit will be associated with. 

66 """ 

67 

68 id: int 

69 """Integer ID of the visit. 

70 

71 This must be unique across all visit systems for the instrument. 

72 """ 

73 

74 name: str 

75 """String name for the visit. 

76 

77 This must be unique across all visit systems for the instrument. 

78 """ 

79 

80 exposures: List[DimensionRecord] = dataclasses.field(default_factory=list) 

81 """Dimension records for the exposures that are part of this visit. 

82 """ 

83 

84 

85@dataclasses.dataclass 

86class _VisitRecords: 

87 """Struct containing the dimension records associated with a visit. 

88 """ 

89 

90 visit: DimensionRecord 

91 """Record for the 'visit' dimension itself. 

92 """ 

93 

94 visit_definition: List[DimensionRecord] 

95 """Records for 'visit_definition', which relates 'visit' to 'exposure'. 

96 """ 

97 

98 visit_detector_region: List[DimensionRecord] 

99 """Records for 'visit_detector_region', which associates the combination 

100 of a 'visit' and a 'detector' with a region on the sky. 

101 """ 

102 

103 

104class GroupExposuresConfig(Config): 

105 pass 

106 

107 

108class GroupExposuresTask(Task, metaclass=ABCMeta): 

109 """Abstract base class for the subtask of `DefineVisitsTask` that is 

110 responsible for grouping exposures into visits. 

111 

112 Subclasses should be registered with `GroupExposuresTask.registry` to 

113 enable use by `DefineVisitsTask`, and should generally correspond to a 

114 particular 'visit_system' dimension value. They are also responsible for 

115 defining visit IDs and names that are unique across all visit systems in 

116 use by an instrument. 

117 

118 Parameters 

119 ---------- 

120 config : `GroupExposuresConfig` 

121 Configuration information. 

122 **kwargs 

123 Additional keyword arguments forwarded to the `Task` constructor. 

124 """ 

125 def __init__(self, config: GroupExposuresConfig, **kwargs: Any): 

126 Task.__init__(self, config=config, **kwargs) 

127 

128 ConfigClass = GroupExposuresConfig 

129 

130 _DefaultName = "groupExposures" 

131 

132 registry = makeRegistry( 

133 doc="Registry of algorithms for grouping exposures into visits.", 

134 configBaseType=GroupExposuresConfig, 

135 ) 

136 

137 @abstractmethod 

138 def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]: 

139 """Group the given exposures into visits. 

140 

141 Parameters 

142 ---------- 

143 exposures : `list` [ `DimensionRecord` ] 

144 DimensionRecords (for the 'exposure' dimension) describing the 

145 exposures to group. 

146 

147 Returns 

148 ------- 

149 visits : `Iterable` [ `VisitDefinitionData` ] 

150 Structs identifying the visits and the exposures associated with 

151 them. This may be an iterator or a container. 

152 """ 

153 raise NotImplementedError() 

154 

155 @abstractmethod 

156 def getVisitSystem(self) -> Tuple[int, str]: 

157 """Return identifiers for the 'visit_system' dimension this 

158 algorithm implements. 

159 

160 Returns 

161 ------- 

162 id : `int` 

163 Integer ID for the visit system (given an instrument). 

164 name : `str` 

165 Unique string identifier for the visit system (given an 

166 instrument). 

167 """ 

168 raise NotImplementedError() 

169 

170 

171class ComputeVisitRegionsConfig(Config): 

172 padding = Field( 

173 dtype=int, 

174 default=250, 

175 doc=("Pad raw image bounding boxes with specified number of pixels " 

176 "when calculating their (conservatively large) region on the " 

177 "sky. Note that the config value for pixelMargin of the " 

178 "reference object loaders in meas_algorithms should be <= " 

179 "the value set here."), 

180 ) 

181 

182 

183class ComputeVisitRegionsTask(Task, metaclass=ABCMeta): 

184 """Abstract base class for the subtask of `DefineVisitsTask` that is 

185 responsible for extracting spatial regions for visits and visit+detector 

186 combinations. 

187 

188 Subclasses should be registered with `ComputeVisitRegionsTask.registry` to 

189 enable use by `DefineVisitsTask`. 

190 

191 Parameters 

192 ---------- 

193 config : `ComputeVisitRegionsConfig` 

194 Configuration information. 

195 butler : `lsst.daf.butler.Butler` 

196 The butler to use. 

197 **kwargs 

198 Additional keyword arguments forwarded to the `Task` constructor. 

199 """ 

200 def __init__(self, config: ComputeVisitRegionsConfig, *, butler: Butler, **kwargs: Any): 

201 Task.__init__(self, config=config, **kwargs) 

202 self.butler = butler 

203 self.instrumentMap = {} 

204 

205 ConfigClass = ComputeVisitRegionsConfig 

206 

207 _DefaultName = "computeVisitRegions" 

208 

209 registry = makeRegistry( 

210 doc=("Registry of algorithms for computing on-sky regions for visits " 

211 "and visit+detector combinations."), 

212 configBaseType=ComputeVisitRegionsConfig, 

213 ) 

214 

215 def getInstrument(self, instrumentName) -> Instrument: 

216 """Retrieve an `~lsst.obs.base.Instrument` associated with this 

217 instrument name. 

218 

219 Parameters 

220 ---------- 

221 instrumentName : `str` 

222 The name of the instrument. 

223 

224 Returns 

225 ------- 

226 instrument : `~lsst.obs.base.Instrument` 

227 The associated instrument object. 

228 

229 Notes 

230 ----- 

231 The result is cached. 

232 """ 

233 instrument = self.instrumentMap.get(instrumentName) 

234 if instrument is None: 

235 instrument = Instrument.fromName(instrumentName, self.butler.registry) 

236 self.instrumentMap[instrumentName] = instrument 

237 return instrument 

238 

239 @abstractmethod 

240 def compute(self, visit: VisitDefinitionData, *, collections: Any = None 

241 ) -> Tuple[Region, Dict[int, Region]]: 

242 """Compute regions for the given visit and all detectors in that visit. 

243 

244 Parameters 

245 ---------- 

246 visit : `VisitDefinitionData` 

247 Struct describing the visit and the exposures associated with it. 

248 collections : Any, optional 

249 Collections to be searched for raws and camera geometry, overriding 

250 ``self.butler.collections``. 

251 Can be any of the types supported by the ``collections`` argument 

252 to butler construction. 

253 

254 Returns 

255 ------- 

256 visitRegion : `lsst.sphgeom.Region` 

257 Region for the full visit. 

258 visitDetectorRegions : `dict` [ `int`, `lsst.sphgeom.Region` ] 

259 Dictionary mapping detector ID to the region for that detector. 

260 Should include all detectors in the visit. 

261 """ 

262 raise NotImplementedError() 

263 

264 

265class DefineVisitsConfig(Config): 

266 groupExposures = GroupExposuresTask.registry.makeField( 

267 doc="Algorithm for grouping exposures into visits.", 

268 default="one-to-one", 

269 ) 

270 computeVisitRegions = ComputeVisitRegionsTask.registry.makeField( 

271 doc="Algorithm from computing visit and visit+detector regions.", 

272 default="single-raw-wcs", 

273 ) 

274 ignoreNonScienceExposures = Field( 

275 doc=("If True, silently ignore input exposures that do not have " 

276 "observation_type=SCIENCE. If False, raise an exception if one " 

277 "encountered."), 

278 dtype=bool, 

279 optional=False, 

280 default=True, 

281 ) 

282 

283 

284class DefineVisitsTask(Task): 

285 """Driver Task for defining visits (and their spatial regions) in Gen3 

286 Butler repositories. 

287 

288 Parameters 

289 ---------- 

290 config : `DefineVisitsConfig` 

291 Configuration for the task. 

292 butler : `~lsst.daf.butler.Butler` 

293 Writeable butler instance. Will be used to read `raw.wcs` and `camera` 

294 datasets and insert/sync dimension data. 

295 **kwargs 

296 Additional keyword arguments are forwarded to the `lsst.pipe.base.Task` 

297 constructor. 

298 

299 Notes 

300 ----- 

301 Each instance of `DefineVisitsTask` reads from / writes to the same Butler. 

302 Each invocation of `DefineVisitsTask.run` processes an independent group of 

303 exposures into one or more new vists, all belonging to the same visit 

304 system and instrument. 

305 

306 The actual work of grouping exposures and computing regions is delegated 

307 to pluggable subtasks (`GroupExposuresTask` and `ComputeVisitRegionsTask`), 

308 respectively. The defaults are to create one visit for every exposure, 

309 and to use exactly one (arbitrary) detector-level raw dataset's WCS along 

310 with camera geometry to compute regions for all detectors. Other 

311 implementations can be created and configured for instruments for which 

312 these choices are unsuitable (e.g. because visits and exposures are not 

313 one-to-one, or because ``raw.wcs`` datasets for different detectors may not 

314 be consistent with camera geomery). 

315 

316 It is not necessary in general to ingest all raws for an exposure before 

317 defining a visit that includes the exposure; this depends entirely on the 

318 `ComputeVisitRegionTask` subclass used. For the default configuration, 

319 a single raw for each exposure is sufficient. 

320 

321 Defining the same visit the same way multiple times (e.g. via multiple 

322 invocations of this task on the same exposures, with the same 

323 configuration) is safe, but it may be inefficient, as most of the work must 

324 be done before new visits can be compared to existing visits. 

325 """ 

326 def __init__(self, config: Optional[DefineVisitsConfig] = None, *, butler: Butler, **kwargs: Any): 

327 config.validate() # Not a CmdlineTask nor PipelineTask, so have to validate the config here. 

328 super().__init__(config, **kwargs) 

329 self.butler = butler 

330 self.universe = self.butler.registry.dimensions 

331 self.progress = Progress("obs.base.DefineVisitsTask") 

332 self.makeSubtask("groupExposures") 

333 self.makeSubtask("computeVisitRegions", butler=self.butler) 

334 

335 def _reduce_kwargs(self): 

336 # Add extra parameters to pickle 

337 return dict(**super()._reduce_kwargs(), butler=self.butler) 

338 

339 ConfigClass = DefineVisitsConfig 

340 

341 _DefaultName = "defineVisits" 

342 

343 def _buildVisitRecords(self, definition: VisitDefinitionData, *, 

344 collections: Any = None) -> _VisitRecords: 

345 """Build the DimensionRecords associated with a visit. 

346 

347 Parameters 

348 ---------- 

349 definition : `VisitDefinition` 

350 Struct with identifiers for the visit and records for its 

351 constituent exposures. 

352 collections : Any, optional 

353 Collections to be searched for raws and camera geometry, overriding 

354 ``self.butler.collections``. 

355 Can be any of the types supported by the ``collections`` argument 

356 to butler construction. 

357 

358 Results 

359 ------- 

360 records : `_VisitRecords` 

361 Struct containing DimensionRecords for the visit, including 

362 associated dimension elements. 

363 """ 

364 # Compute all regions. 

365 visitRegion, visitDetectorRegions = self.computeVisitRegions.compute(definition, 

366 collections=collections) 

367 # Aggregate other exposure quantities. 

368 timespan = Timespan( 

369 begin=_reduceOrNone(min, (e.timespan.begin for e in definition.exposures)), 

370 end=_reduceOrNone(max, (e.timespan.end for e in definition.exposures)), 

371 ) 

372 exposure_time = _reduceOrNone(sum, (e.exposure_time for e in definition.exposures)) 

373 physical_filter = _reduceOrNone(lambda a, b: a if a == b else None, 

374 (e.physical_filter for e in definition.exposures)) 

375 target_name = _reduceOrNone(lambda a, b: a if a == b else None, 

376 (e.target_name for e in definition.exposures)) 

377 science_program = _reduceOrNone(lambda a, b: a if a == b else None, 

378 (e.science_program for e in definition.exposures)) 

379 

380 # observing day for a visit is defined by the earliest observation 

381 # of the visit 

382 observing_day = _reduceOrNone(min, (e.day_obs for e in definition.exposures)) 

383 observation_reason = _reduceOrNone(lambda a, b: a if a == b else None, 

384 (e.observation_reason for e in definition.exposures)) 

385 if observation_reason is None: 

386 # Be explicit about there being multiple reasons 

387 observation_reason = "various" 

388 

389 # Use the mean zenith angle as an approximation 

390 zenith_angle = _reduceOrNone(sum, (e.zenith_angle for e in definition.exposures)) 

391 if zenith_angle is not None: 

392 zenith_angle /= len(definition.exposures) 

393 

394 # Construct the actual DimensionRecords. 

395 return _VisitRecords( 

396 visit=self.universe["visit"].RecordClass( 

397 instrument=definition.instrument, 

398 id=definition.id, 

399 name=definition.name, 

400 physical_filter=physical_filter, 

401 target_name=target_name, 

402 science_program=science_program, 

403 observation_reason=observation_reason, 

404 day_obs=observing_day, 

405 zenith_angle=zenith_angle, 

406 visit_system=self.groupExposures.getVisitSystem()[0], 

407 exposure_time=exposure_time, 

408 timespan=timespan, 

409 region=visitRegion, 

410 # TODO: no seeing value in exposure dimension records, so we 

411 # can't set that here. But there are many other columns that 

412 # both dimensions should probably have as well. 

413 ), 

414 visit_definition=[ 

415 self.universe["visit_definition"].RecordClass( 

416 instrument=definition.instrument, 

417 visit=definition.id, 

418 exposure=exposure.id, 

419 visit_system=self.groupExposures.getVisitSystem()[0], 

420 ) 

421 for exposure in definition.exposures 

422 ], 

423 visit_detector_region=[ 

424 self.universe["visit_detector_region"].RecordClass( 

425 instrument=definition.instrument, 

426 visit=definition.id, 

427 detector=detectorId, 

428 region=detectorRegion, 

429 ) 

430 for detectorId, detectorRegion in visitDetectorRegions.items() 

431 ] 

432 ) 

433 

434 def _expandExposureId(self, dataId: DataId) -> DataCoordinate: 

435 """Return the expanded version of an exposure ID. 

436 

437 A private method to allow ID expansion in a pool without resorting 

438 to local callables. 

439 

440 Parameters 

441 ---------- 

442 dataId : `dict` or `DataCoordinate` 

443 Exposure-level data ID. 

444 

445 Returns 

446 ------- 

447 expanded : `DataCoordinate` 

448 A data ID that includes full metadata for all exposure dimensions. 

449 """ 

450 dimensions = DimensionGraph(self.universe, names=["exposure"]) 

451 return self.butler.registry.expandDataId(dataId, graph=dimensions) 

452 

453 def _buildVisitRecordsSingle(self, args) -> _VisitRecords: 

454 """Build the DimensionRecords associated with a visit and collection. 

455 

456 A wrapper for `_buildVisitRecords` to allow it to be run as part of 

457 a pool without resorting to local callables. 

458 

459 Parameters 

460 ---------- 

461 args : `tuple` [`VisitDefinition`, any] 

462 A tuple consisting of the ``definition`` and ``collections`` 

463 arguments to `_buildVisitRecords`, in that order. 

464 

465 Results 

466 ------- 

467 records : `_VisitRecords` 

468 Struct containing DimensionRecords for the visit, including 

469 associated dimension elements. 

470 """ 

471 return self._buildVisitRecords(args[0], collections=args[1]) 

472 

473 def run(self, dataIds: Iterable[DataId], *, 

474 pool: Optional[Pool] = None, 

475 processes: int = 1, 

476 collections: Optional[str] = None, 

477 update_records: bool = False): 

478 """Add visit definitions to the registry for the given exposures. 

479 

480 Parameters 

481 ---------- 

482 dataIds : `Iterable` [ `dict` or `DataCoordinate` ] 

483 Exposure-level data IDs. These must all correspond to the same 

484 instrument, and are expected to be on-sky science exposures. 

485 pool : `multiprocessing.Pool`, optional 

486 If not `None`, a process pool with which to parallelize some 

487 operations. 

488 processes : `int`, optional 

489 The number of processes to use. Ignored if ``pool`` is not `None`. 

490 collections : Any, optional 

491 Collections to be searched for raws and camera geometry, overriding 

492 ``self.butler.collections``. 

493 Can be any of the types supported by the ``collections`` argument 

494 to butler construction. 

495 update_records : `bool`, optional 

496 If `True` (`False` is default), update existing visit records that 

497 conflict with the new ones instead of rejecting them (and when this 

498 occurs, update visit_detector_region as well). THIS IS AN ADVANCED 

499 OPTION THAT SHOULD ONLY BE USED TO FIX REGIONS AND/OR METADATA THAT 

500 ARE KNOWN TO BE BAD, AND IT CANNOT BE USED TO REMOVE EXPOSURES OR 

501 DETECTORS FROM A VISIT. 

502 

503 Raises 

504 ------ 

505 lsst.daf.butler.registry.ConflictingDefinitionError 

506 Raised if a visit ID conflict is detected and the existing visit 

507 differs from the new one. 

508 """ 

509 # Set up multiprocessing, if desired. 

510 if pool is None and processes > 1: 

511 pool = Pool(processes) 

512 mapFunc = map if pool is None else pool.imap_unordered 

513 # Normalize, expand, and deduplicate data IDs. 

514 self.log.info("Preprocessing data IDs.") 

515 dataIds = set(mapFunc(self._expandExposureId, dataIds)) 

516 if not dataIds: 

517 raise RuntimeError("No exposures given.") 

518 # Extract exposure DimensionRecords, check that there's only one 

519 # instrument in play, and check for non-science exposures. 

520 exposures = [] 

521 instruments = set() 

522 for dataId in dataIds: 

523 record = dataId.records["exposure"] 

524 if record.observation_type != "science": 

525 if self.config.ignoreNonScienceExposures: 

526 continue 

527 else: 

528 raise RuntimeError(f"Input exposure {dataId} has observation_type " 

529 f"{record.observation_type}, not 'science'.") 

530 instruments.add(dataId["instrument"]) 

531 exposures.append(record) 

532 if not exposures: 

533 self.log.info("No science exposures found after filtering.") 

534 return 

535 if len(instruments) > 1: 

536 raise RuntimeError( 

537 f"All data IDs passed to DefineVisitsTask.run must be " 

538 f"from the same instrument; got {instruments}." 

539 ) 

540 instrument, = instruments 

541 # Ensure the visit_system our grouping algorithm uses is in the 

542 # registry, if it wasn't already. 

543 visitSystemId, visitSystemName = self.groupExposures.getVisitSystem() 

544 self.log.info("Registering visit_system %d: %s.", visitSystemId, visitSystemName) 

545 self.butler.registry.syncDimensionData( 

546 "visit_system", 

547 {"instrument": instrument, "id": visitSystemId, "name": visitSystemName} 

548 ) 

549 # Group exposures into visits, delegating to subtask. 

550 self.log.info("Grouping %d exposure(s) into visits.", len(exposures)) 

551 definitions = list(self.groupExposures.group(exposures)) 

552 # Compute regions and build DimensionRecords for each visit. 

553 # This is the only parallel step, but it _should_ be the most expensive 

554 # one (unless DB operations are slow). 

555 self.log.info("Computing regions and other metadata for %d visit(s).", len(definitions)) 

556 allRecords = mapFunc(self._buildVisitRecordsSingle, 

557 zip(definitions, itertools.repeat(collections))) 

558 # Iterate over visits and insert dimension data, one transaction per 

559 # visit. If a visit already exists, we skip all other inserts. 

560 for visitRecords in self.progress.wrap(allRecords, total=len(definitions), 

561 desc="Computing regions and inserting visits"): 

562 with self.butler.registry.transaction(): 

563 inserted_or_updated = self.butler.registry.syncDimensionData( 

564 "visit", 

565 visitRecords.visit, 

566 update=update_records, 

567 ) 

568 if inserted_or_updated: 

569 if inserted_or_updated is True: 

570 # This is a new visit, not an update to an existing 

571 # one, so insert visit definition. 

572 # We don't allow visit definitions to change even when 

573 # asked to update, because we'd have to delete the old 

574 # visit_definitions first and also worry about what 

575 # this does to datasets that already use the visit. 

576 self.butler.registry.insertDimensionData("visit_definition", 

577 *visitRecords.visit_definition) 

578 # [Re]Insert visit_detector_region records for both inserts 

579 # and updates, because we do allow updating to affect the 

580 # region calculations. 

581 self.butler.registry.insertDimensionData("visit_detector_region", 

582 *visitRecords.visit_detector_region, 

583 replace=update_records) 

584 

585 

586def _reduceOrNone(func, iterable): 

587 """Apply a binary function to pairs of elements in an iterable until a 

588 single value is returned, but return `None` if any element is `None` or 

589 there are no elements. 

590 """ 

591 r = None 

592 for v in iterable: 

593 if v is None: 

594 return None 

595 if r is None: 

596 r = v 

597 else: 

598 r = func(r, v) 

599 return r 

600 

601 

602class _GroupExposuresOneToOneConfig(GroupExposuresConfig): 

603 visitSystemId = Field( 

604 doc=("Integer ID of the visit_system implemented by this grouping " 

605 "algorithm."), 

606 dtype=int, 

607 default=0, 

608 ) 

609 visitSystemName = Field( 

610 doc=("String name of the visit_system implemented by this grouping " 

611 "algorithm."), 

612 dtype=str, 

613 default="one-to-one", 

614 ) 

615 

616 

617@registerConfigurable("one-to-one", GroupExposuresTask.registry) 

618class _GroupExposuresOneToOneTask(GroupExposuresTask, metaclass=ABCMeta): 

619 """An exposure grouping algorithm that simply defines one visit for each 

620 exposure, reusing the exposures identifiers for the visit. 

621 """ 

622 

623 ConfigClass = _GroupExposuresOneToOneConfig 

624 

625 def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]: 

626 # Docstring inherited from GroupExposuresTask. 

627 for exposure in exposures: 

628 yield VisitDefinitionData( 

629 instrument=exposure.instrument, 

630 id=exposure.id, 

631 name=exposure.obs_id, 

632 exposures=[exposure], 

633 ) 

634 

635 def getVisitSystem(self) -> Tuple[int, str]: 

636 # Docstring inherited from GroupExposuresTask. 

637 return (self.config.visitSystemId, self.config.visitSystemName) 

638 

639 

640class _GroupExposuresByGroupMetadataConfig(GroupExposuresConfig): 

641 visitSystemId = Field( 

642 doc=("Integer ID of the visit_system implemented by this grouping " 

643 "algorithm."), 

644 dtype=int, 

645 default=1, 

646 ) 

647 visitSystemName = Field( 

648 doc=("String name of the visit_system implemented by this grouping " 

649 "algorithm."), 

650 dtype=str, 

651 default="by-group-metadata", 

652 ) 

653 

654 

655@registerConfigurable("by-group-metadata", GroupExposuresTask.registry) 

656class _GroupExposuresByGroupMetadataTask(GroupExposuresTask, metaclass=ABCMeta): 

657 """An exposure grouping algorithm that uses exposure.group_name and 

658 exposure.group_id. 

659 

660 This algorithm _assumes_ exposure.group_id (generally populated from 

661 `astro_metadata_translator.ObservationInfo.visit_id`) is not just unique, 

662 but disjoint from all `ObservationInfo.exposure_id` values - if it isn't, 

663 it will be impossible to ever use both this grouping algorithm and the 

664 one-to-one algorithm for a particular camera in the same data repository. 

665 """ 

666 

667 ConfigClass = _GroupExposuresByGroupMetadataConfig 

668 

669 def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]: 

670 # Docstring inherited from GroupExposuresTask. 

671 groups = defaultdict(list) 

672 for exposure in exposures: 

673 groups[exposure.group_name].append(exposure) 

674 for visitName, exposuresInGroup in groups.items(): 

675 instrument = exposuresInGroup[0].instrument 

676 visitId = exposuresInGroup[0].group_id 

677 assert all(e.group_id == visitId for e in exposuresInGroup), \ 

678 "Grouping by exposure.group_name does not yield consistent group IDs" 

679 yield VisitDefinitionData(instrument=instrument, id=visitId, name=visitName, 

680 exposures=exposuresInGroup) 

681 

682 def getVisitSystem(self) -> Tuple[int, str]: 

683 # Docstring inherited from GroupExposuresTask. 

684 return (self.config.visitSystemId, self.config.visitSystemName) 

685 

686 

687class _ComputeVisitRegionsFromSingleRawWcsConfig(ComputeVisitRegionsConfig): 

688 mergeExposures = Field( 

689 doc=("If True, merge per-detector regions over all exposures in a " 

690 "visit (via convex hull) instead of using the first exposure and " 

691 "assuming its regions are valid for all others."), 

692 dtype=bool, 

693 default=False, 

694 ) 

695 detectorId = Field( 

696 doc=("Load the WCS for the detector with this ID. If None, use an " 

697 "arbitrary detector (the first found in a query of the data " 

698 "repository for each exposure (or all exposures, if " 

699 "mergeExposures is True)."), 

700 dtype=int, 

701 optional=True, 

702 default=None 

703 ) 

704 requireVersionedCamera = Field( 

705 doc=("If True, raise LookupError if version camera geometry cannot be " 

706 "loaded for an exposure. If False, use the nominal camera from " 

707 "the Instrument class instead."), 

708 dtype=bool, 

709 optional=False, 

710 default=False, 

711 ) 

712 

713 

714@registerConfigurable("single-raw-wcs", ComputeVisitRegionsTask.registry) 

715class _ComputeVisitRegionsFromSingleRawWcsTask(ComputeVisitRegionsTask): 

716 """A visit region calculator that uses a single raw WCS and a camera to 

717 project the bounding boxes of all detectors onto the sky, relating 

718 different detectors by their positions in focal plane coordinates. 

719 

720 Notes 

721 ----- 

722 Most instruments should have their raw WCSs determined from a combination 

723 of boresight angle, rotator angle, and camera geometry, and hence this 

724 algorithm should produce stable results regardless of which detector the 

725 raw corresponds to. If this is not the case (e.g. because a per-file FITS 

726 WCS is used instead), either the ID of the detector should be fixed (see 

727 the ``detectorId`` config parameter) or a different algorithm used. 

728 """ 

729 

730 ConfigClass = _ComputeVisitRegionsFromSingleRawWcsConfig 

731 

732 def computeExposureBounds(self, exposure: DimensionRecord, *, collections: Any = None 

733 ) -> Dict[int, List[UnitVector3d]]: 

734 """Compute the lists of unit vectors on the sphere that correspond to 

735 the sky positions of detector corners. 

736 

737 Parameters 

738 ---------- 

739 exposure : `DimensionRecord` 

740 Dimension record for the exposure. 

741 collections : Any, optional 

742 Collections to be searched for raws and camera geometry, overriding 

743 ``self.butler.collections``. 

744 Can be any of the types supported by the ``collections`` argument 

745 to butler construction. 

746 

747 Returns 

748 ------- 

749 bounds : `dict` 

750 Dictionary mapping detector ID to a list of unit vectors on the 

751 sphere representing that detector's corners projected onto the sky. 

752 """ 

753 if collections is None: 

754 collections = self.butler.collections 

755 camera, versioned = loadCamera(self.butler, exposure.dataId, collections=collections) 

756 if not versioned and self.config.requireVersionedCamera: 

757 raise LookupError(f"No versioned camera found for exposure {exposure.dataId}.") 

758 

759 # Derive WCS from boresight information -- if available in registry 

760 use_registry = True 

761 try: 

762 orientation = lsst.geom.Angle(exposure.sky_angle, lsst.geom.degrees) 

763 radec = lsst.geom.SpherePoint(lsst.geom.Angle(exposure.tracking_ra, lsst.geom.degrees), 

764 lsst.geom.Angle(exposure.tracking_dec, lsst.geom.degrees)) 

765 except AttributeError: 

766 use_registry = False 

767 

768 if use_registry: 

769 if self.config.detectorId is None: 

770 detectorId = next(camera.getIdIter()) 

771 else: 

772 detectorId = self.config.detectorId 

773 wcsDetector = camera[detectorId] 

774 

775 # Ask the raw formatter to create the relevant WCS 

776 # This allows flips to be taken into account 

777 instrument = self.getInstrument(exposure.instrument) 

778 rawFormatter = instrument.getRawFormatter({"detector": detectorId}) 

779 wcs = rawFormatter.makeRawSkyWcsFromBoresight(radec, orientation, wcsDetector) 

780 

781 else: 

782 if self.config.detectorId is None: 

783 wcsRefsIter = self.butler.registry.queryDatasets("raw.wcs", dataId=exposure.dataId, 

784 collections=collections) 

785 if not wcsRefsIter: 

786 raise LookupError(f"No raw.wcs datasets found for data ID {exposure.dataId} " 

787 f"in collections {collections}.") 

788 wcsRef = next(iter(wcsRefsIter)) 

789 wcsDetector = camera[wcsRef.dataId["detector"]] 

790 wcs = self.butler.getDirect(wcsRef) 

791 else: 

792 wcsDetector = camera[self.config.detectorId] 

793 wcs = self.butler.get("raw.wcs", dataId=exposure.dataId, detector=self.config.detectorId, 

794 collections=collections) 

795 fpToSky = wcsDetector.getTransform(FOCAL_PLANE, PIXELS).then(wcs.getTransform()) 

796 bounds = {} 

797 for detector in camera: 

798 pixelsToSky = detector.getTransform(PIXELS, FOCAL_PLANE).then(fpToSky) 

799 pixCorners = Box2D(detector.getBBox().dilatedBy(self.config.padding)).getCorners() 

800 bounds[detector.getId()] = [ 

801 skyCorner.getVector() for skyCorner in pixelsToSky.applyForward(pixCorners) 

802 ] 

803 return bounds 

804 

805 def compute(self, visit: VisitDefinitionData, *, collections: Any = None 

806 ) -> Tuple[Region, Dict[int, Region]]: 

807 # Docstring inherited from ComputeVisitRegionsTask. 

808 if self.config.mergeExposures: 

809 detectorBounds = defaultdict(list) 

810 for exposure in visit.exposures: 

811 exposureDetectorBounds = self.computeExposureBounds(exposure, collections=collections) 

812 for detectorId, bounds in exposureDetectorBounds.items(): 

813 detectorBounds[detectorId].extend(bounds) 

814 else: 

815 detectorBounds = self.computeExposureBounds(visit.exposures[0], collections=collections) 

816 visitBounds = [] 

817 detectorRegions = {} 

818 for detectorId, bounds in detectorBounds.items(): 

819 detectorRegions[detectorId] = ConvexPolygon.convexHull(bounds) 

820 visitBounds.extend(bounds) 

821 return ConvexPolygon.convexHull(visitBounds), detectorRegions