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

222 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 

32import dataclasses 

33from abc import ABCMeta, abstractmethod 

34from collections import defaultdict 

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

36 

37import lsst.geom 

38from lsst.afw.cameraGeom import FOCAL_PLANE, PIXELS 

39from lsst.daf.butler import Butler, DataId, DimensionGraph, DimensionRecord, Progress, Timespan 

40from lsst.geom import Box2D 

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

42from lsst.pipe.base import Task 

43from lsst.sphgeom import ConvexPolygon, Region, UnitVector3d 

44 

45from ._instrument import Instrument, loadCamera 

46 

47 

48@dataclasses.dataclass 

49class VisitDefinitionData: 

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

51 visit. 

52 """ 

53 

54 instrument: str 

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

56 """ 

57 

58 id: int 

59 """Integer ID of the visit. 

60 

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

62 """ 

63 

64 name: str 

65 """String name for the visit. 

66 

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

68 """ 

69 

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

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

72 """ 

73 

74 

75@dataclasses.dataclass 

76class _VisitRecords: 

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

78 

79 visit: DimensionRecord 

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

81 """ 

82 

83 visit_definition: List[DimensionRecord] 

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

85 """ 

86 

87 visit_detector_region: List[DimensionRecord] 

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

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

90 """ 

91 

92 

93class GroupExposuresConfig(Config): 

94 pass 

95 

96 

97class GroupExposuresTask(Task, metaclass=ABCMeta): 

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

99 responsible for grouping exposures into visits. 

100 

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

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

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

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

105 use by an instrument. 

106 

107 Parameters 

108 ---------- 

109 config : `GroupExposuresConfig` 

110 Configuration information. 

111 **kwargs 

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

113 """ 

114 

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

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

117 

118 ConfigClass = GroupExposuresConfig 

119 

120 _DefaultName = "groupExposures" 

121 

122 registry = makeRegistry( 

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

124 configBaseType=GroupExposuresConfig, 

125 ) 

126 

127 @abstractmethod 

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

129 """Group the given exposures into visits. 

130 

131 Parameters 

132 ---------- 

133 exposures : `list` [ `DimensionRecord` ] 

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

135 exposures to group. 

136 

137 Returns 

138 ------- 

139 visits : `Iterable` [ `VisitDefinitionData` ] 

140 Structs identifying the visits and the exposures associated with 

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

142 """ 

143 raise NotImplementedError() 

144 

145 @abstractmethod 

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

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

148 algorithm implements. 

149 

150 Returns 

151 ------- 

152 id : `int` 

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

154 name : `str` 

155 Unique string identifier for the visit system (given an 

156 instrument). 

157 """ 

158 raise NotImplementedError() 

159 

160 

161class ComputeVisitRegionsConfig(Config): 

162 padding = Field( 

163 dtype=int, 

164 default=250, 

165 doc=( 

166 "Pad raw image bounding boxes with specified number of pixels " 

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

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

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

170 "the value set here." 

171 ), 

172 ) 

173 

174 

175class ComputeVisitRegionsTask(Task, metaclass=ABCMeta): 

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

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

178 combinations. 

179 

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

181 enable use by `DefineVisitsTask`. 

182 

183 Parameters 

184 ---------- 

185 config : `ComputeVisitRegionsConfig` 

186 Configuration information. 

187 butler : `lsst.daf.butler.Butler` 

188 The butler to use. 

189 **kwargs 

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

191 """ 

192 

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

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

195 self.butler = butler 

196 self.instrumentMap = {} 

197 

198 ConfigClass = ComputeVisitRegionsConfig 

199 

200 _DefaultName = "computeVisitRegions" 

201 

202 registry = makeRegistry( 

203 doc=( 

204 "Registry of algorithms for computing on-sky regions for visits " 

205 "and visit+detector combinations." 

206 ), 

207 configBaseType=ComputeVisitRegionsConfig, 

208 ) 

209 

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

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

212 instrument name. 

213 

214 Parameters 

215 ---------- 

216 instrumentName : `str` 

217 The name of the instrument. 

218 

219 Returns 

220 ------- 

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

222 The associated instrument object. 

223 

224 Notes 

225 ----- 

226 The result is cached. 

227 """ 

228 instrument = self.instrumentMap.get(instrumentName) 

229 if instrument is None: 

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

231 self.instrumentMap[instrumentName] = instrument 

232 return instrument 

233 

234 @abstractmethod 

235 def compute( 

236 self, visit: VisitDefinitionData, *, collections: Any = None 

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

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

239 

240 Parameters 

241 ---------- 

242 visit : `VisitDefinitionData` 

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

244 collections : Any, optional 

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

246 ``self.butler.collections``. 

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

248 to butler construction. 

249 

250 Returns 

251 ------- 

252 visitRegion : `lsst.sphgeom.Region` 

253 Region for the full visit. 

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

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

256 Should include all detectors in the visit. 

257 """ 

258 raise NotImplementedError() 

259 

260 

261class DefineVisitsConfig(Config): 

262 groupExposures = GroupExposuresTask.registry.makeField( 

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

264 default="one-to-one", 

265 ) 

266 computeVisitRegions = ComputeVisitRegionsTask.registry.makeField( 

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

268 default="single-raw-wcs", 

269 ) 

270 ignoreNonScienceExposures = Field( 

271 doc=( 

272 "If True, silently ignore input exposures that do not have " 

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

274 "encountered." 

275 ), 

276 dtype=bool, 

277 optional=False, 

278 default=True, 

279 ) 

280 

281 

282class DefineVisitsTask(Task): 

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

284 Butler repositories. 

285 

286 Parameters 

287 ---------- 

288 config : `DefineVisitsConfig` 

289 Configuration for the task. 

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

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

292 datasets and insert/sync dimension data. 

293 **kwargs 

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

295 constructor. 

296 

297 Notes 

298 ----- 

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

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

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

302 system and instrument. 

303 

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

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

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

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

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

309 implementations can be created and configured for instruments for which 

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

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

312 be consistent with camera geomery). 

313 

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

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

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

317 a single raw for each exposure is sufficient. 

318 

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

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

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

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

323 """ 

324 

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

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

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

328 self.butler = butler 

329 self.universe = self.butler.registry.dimensions 

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

331 self.makeSubtask("groupExposures") 

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

333 

334 def _reduce_kwargs(self): 

335 # Add extra parameters to pickle 

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

337 

338 ConfigClass = DefineVisitsConfig 

339 

340 _DefaultName = "defineVisits" 

341 

342 def _buildVisitRecords( 

343 self, definition: VisitDefinitionData, *, collections: Any = None 

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

366 definition, collections=collections 

367 ) 

368 # Aggregate other exposure quantities. 

369 timespan = Timespan( 

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

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

372 ) 

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

374 physical_filter = _reduceOrNone( 

375 lambda a, b: a if a == b else None, (e.physical_filter for e in definition.exposures) 

376 ) 

377 target_name = _reduceOrNone( 

378 lambda a, b: a if a == b else None, (e.target_name for e in definition.exposures) 

379 ) 

380 science_program = _reduceOrNone( 

381 lambda a, b: a if a == b else None, (e.science_program for e in definition.exposures) 

382 ) 

383 

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

385 # of the visit 

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

387 observation_reason = _reduceOrNone( 

388 lambda a, b: a if a == b else None, (e.observation_reason for e in definition.exposures) 

389 ) 

390 if observation_reason is None: 

391 # Be explicit about there being multiple reasons 

392 observation_reason = "various" 

393 

394 # Use the mean zenith angle as an approximation 

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

396 if zenith_angle is not None: 

397 zenith_angle /= len(definition.exposures) 

398 

399 # Construct the actual DimensionRecords. 

400 return _VisitRecords( 

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

402 instrument=definition.instrument, 

403 id=definition.id, 

404 name=definition.name, 

405 physical_filter=physical_filter, 

406 target_name=target_name, 

407 science_program=science_program, 

408 observation_reason=observation_reason, 

409 day_obs=observing_day, 

410 zenith_angle=zenith_angle, 

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

412 exposure_time=exposure_time, 

413 timespan=timespan, 

414 region=visitRegion, 

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

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

417 # both dimensions should probably have as well. 

418 ), 

419 visit_definition=[ 

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

421 instrument=definition.instrument, 

422 visit=definition.id, 

423 exposure=exposure.id, 

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

425 ) 

426 for exposure in definition.exposures 

427 ], 

428 visit_detector_region=[ 

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

430 instrument=definition.instrument, 

431 visit=definition.id, 

432 detector=detectorId, 

433 region=detectorRegion, 

434 ) 

435 for detectorId, detectorRegion in visitDetectorRegions.items() 

436 ], 

437 ) 

438 

439 def run( 

440 self, 

441 dataIds: Iterable[DataId], 

442 *, 

443 collections: Optional[str] = None, 

444 update_records: bool = False, 

445 ): 

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

447 

448 Parameters 

449 ---------- 

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

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

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

453 collections : Any, optional 

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

455 ``self.butler.collections``. 

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

457 to butler construction. 

458 update_records : `bool`, optional 

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

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

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

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

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

464 DETECTORS FROM A VISIT. 

465 

466 Raises 

467 ------ 

468 lsst.daf.butler.registry.ConflictingDefinitionError 

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

470 differs from the new one. 

471 """ 

472 # Normalize, expand, and deduplicate data IDs. 

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

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

475 dataIds = {self.butler.registry.expandDataId(d, graph=dimensions) for d in dataIds} 

476 if not dataIds: 

477 raise RuntimeError("No exposures given.") 

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

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

480 exposures = [] 

481 instruments = set() 

482 for dataId in dataIds: 

483 record = dataId.records["exposure"] 

484 if record.observation_type != "science": 

485 if self.config.ignoreNonScienceExposures: 

486 continue 

487 else: 

488 raise RuntimeError( 

489 f"Input exposure {dataId} has observation_type " 

490 f"{record.observation_type}, not 'science'." 

491 ) 

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

493 exposures.append(record) 

494 if not exposures: 

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

496 return 

497 if len(instruments) > 1: 

498 raise RuntimeError( 

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

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

501 ) 

502 (instrument,) = instruments 

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

504 # registry, if it wasn't already. 

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

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

507 self.butler.registry.syncDimensionData( 

508 "visit_system", {"instrument": instrument, "id": visitSystemId, "name": visitSystemName} 

509 ) 

510 # Group exposures into visits, delegating to subtask. 

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

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

513 # Iterate over visits, compute regions, and insert dimension data, one 

514 # transaction per visit. If a visit already exists, we skip all other 

515 # inserts. 

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

517 for visitDefinition in self.progress.wrap( 

518 definitions, total=len(definitions), desc="Computing regions and inserting visits" 

519 ): 

520 visitRecords = self._buildVisitRecords(visitDefinition, collections=collections) 

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

522 inserted_or_updated = self.butler.registry.syncDimensionData( 

523 "visit", 

524 visitRecords.visit, 

525 update=update_records, 

526 ) 

527 if inserted_or_updated: 

528 if inserted_or_updated is True: 

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

530 # one, so insert visit definition. 

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

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

533 # visit_definitions first and also worry about what 

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

535 self.butler.registry.insertDimensionData( 

536 "visit_definition", *visitRecords.visit_definition 

537 ) 

538 # [Re]Insert visit_detector_region records for both inserts 

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

540 # region calculations. 

541 self.butler.registry.insertDimensionData( 

542 "visit_detector_region", *visitRecords.visit_detector_region, replace=update_records 

543 ) 

544 

545 

546def _reduceOrNone(func, iterable): 

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

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

549 there are no elements. 

550 """ 

551 r = None 

552 for v in iterable: 

553 if v is None: 

554 return None 

555 if r is None: 

556 r = v 

557 else: 

558 r = func(r, v) 

559 return r 

560 

561 

562class _GroupExposuresOneToOneConfig(GroupExposuresConfig): 

563 visitSystemId = Field( 

564 doc="Integer ID of the visit_system implemented by this grouping algorithm.", 

565 dtype=int, 

566 default=0, 

567 ) 

568 visitSystemName = Field( 

569 doc="String name of the visit_system implemented by this grouping algorithm.", 

570 dtype=str, 

571 default="one-to-one", 

572 ) 

573 

574 

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

576class _GroupExposuresOneToOneTask(GroupExposuresTask, metaclass=ABCMeta): 

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

578 exposure, reusing the exposures identifiers for the visit. 

579 """ 

580 

581 ConfigClass = _GroupExposuresOneToOneConfig 

582 

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

584 # Docstring inherited from GroupExposuresTask. 

585 for exposure in exposures: 

586 yield VisitDefinitionData( 

587 instrument=exposure.instrument, 

588 id=exposure.id, 

589 name=exposure.obs_id, 

590 exposures=[exposure], 

591 ) 

592 

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

594 # Docstring inherited from GroupExposuresTask. 

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

596 

597 

598class _GroupExposuresByGroupMetadataConfig(GroupExposuresConfig): 

599 visitSystemId = Field( 

600 doc="Integer ID of the visit_system implemented by this grouping algorithm.", 

601 dtype=int, 

602 default=1, 

603 ) 

604 visitSystemName = Field( 

605 doc="String name of the visit_system implemented by this grouping algorithm.", 

606 dtype=str, 

607 default="by-group-metadata", 

608 ) 

609 

610 

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

612class _GroupExposuresByGroupMetadataTask(GroupExposuresTask, metaclass=ABCMeta): 

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

614 exposure.group_id. 

615 

616 This algorithm _assumes_ exposure.group_id (generally populated from 

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

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

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

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

621 """ 

622 

623 ConfigClass = _GroupExposuresByGroupMetadataConfig 

624 

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

626 # Docstring inherited from GroupExposuresTask. 

627 groups = defaultdict(list) 

628 for exposure in exposures: 

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

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

631 instrument = exposuresInGroup[0].instrument 

632 visitId = exposuresInGroup[0].group_id 

633 assert all( 

634 e.group_id == visitId for e in exposuresInGroup 

635 ), "Grouping by exposure.group_name does not yield consistent group IDs" 

636 yield VisitDefinitionData( 

637 instrument=instrument, id=visitId, name=visitName, exposures=exposuresInGroup 

638 ) 

639 

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

641 # Docstring inherited from GroupExposuresTask. 

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

643 

644 

645class _ComputeVisitRegionsFromSingleRawWcsConfig(ComputeVisitRegionsConfig): 

646 mergeExposures = Field( 

647 doc=( 

648 "If True, merge per-detector regions over all exposures in a " 

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

650 "assuming its regions are valid for all others." 

651 ), 

652 dtype=bool, 

653 default=False, 

654 ) 

655 detectorId = Field( 

656 doc=( 

657 "Load the WCS for the detector with this ID. If None, use an " 

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

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

660 "mergeExposures is True)." 

661 ), 

662 dtype=int, 

663 optional=True, 

664 default=None, 

665 ) 

666 requireVersionedCamera = Field( 

667 doc=( 

668 "If True, raise LookupError if version camera geometry cannot be " 

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

670 "the Instrument class instead." 

671 ), 

672 dtype=bool, 

673 optional=False, 

674 default=False, 

675 ) 

676 

677 

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

679class _ComputeVisitRegionsFromSingleRawWcsTask(ComputeVisitRegionsTask): 

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

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

682 different detectors by their positions in focal plane coordinates. 

683 

684 Notes 

685 ----- 

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

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

688 algorithm should produce stable results regardless of which detector the 

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

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

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

692 """ 

693 

694 ConfigClass = _ComputeVisitRegionsFromSingleRawWcsConfig 

695 

696 def computeExposureBounds( 

697 self, exposure: DimensionRecord, *, collections: Any = None 

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

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

700 the sky positions of detector corners. 

701 

702 Parameters 

703 ---------- 

704 exposure : `DimensionRecord` 

705 Dimension record for the exposure. 

706 collections : Any, optional 

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

708 ``self.butler.collections``. 

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

710 to butler construction. 

711 

712 Returns 

713 ------- 

714 bounds : `dict` 

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

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

717 """ 

718 if collections is None: 

719 collections = self.butler.collections 

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

721 if not versioned and self.config.requireVersionedCamera: 

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

723 

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

725 use_registry = True 

726 try: 

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

728 radec = lsst.geom.SpherePoint( 

729 lsst.geom.Angle(exposure.tracking_ra, lsst.geom.degrees), 

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

731 ) 

732 except AttributeError: 

733 use_registry = False 

734 

735 if use_registry: 

736 if self.config.detectorId is None: 

737 detectorId = next(camera.getIdIter()) 

738 else: 

739 detectorId = self.config.detectorId 

740 wcsDetector = camera[detectorId] 

741 

742 # Ask the raw formatter to create the relevant WCS 

743 # This allows flips to be taken into account 

744 instrument = self.getInstrument(exposure.instrument) 

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

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

747 

748 else: 

749 if self.config.detectorId is None: 

750 wcsRefsIter = self.butler.registry.queryDatasets( 

751 "raw.wcs", dataId=exposure.dataId, collections=collections 

752 ) 

753 if not wcsRefsIter: 

754 raise LookupError( 

755 f"No raw.wcs datasets found for data ID {exposure.dataId} " 

756 f"in collections {collections}." 

757 ) 

758 wcsRef = next(iter(wcsRefsIter)) 

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

760 wcs = self.butler.getDirect(wcsRef) 

761 else: 

762 wcsDetector = camera[self.config.detectorId] 

763 wcs = self.butler.get( 

764 "raw.wcs", 

765 dataId=exposure.dataId, 

766 detector=self.config.detectorId, 

767 collections=collections, 

768 ) 

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

770 bounds = {} 

771 for detector in camera: 

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

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

774 bounds[detector.getId()] = [ 

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

776 ] 

777 return bounds 

778 

779 def compute( 

780 self, visit: VisitDefinitionData, *, collections: Any = None 

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

782 # Docstring inherited from ComputeVisitRegionsTask. 

783 if self.config.mergeExposures: 

784 detectorBounds = defaultdict(list) 

785 for exposure in visit.exposures: 

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

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

788 detectorBounds[detectorId].extend(bounds) 

789 else: 

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

791 visitBounds = [] 

792 detectorRegions = {} 

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

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

795 visitBounds.extend(bounds) 

796 return ConvexPolygon.convexHull(visitBounds), detectorRegions