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

Hot-keys 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
1# This file is part of obs_base.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22from __future__ import annotations
24__all__ = [
25 "DefineVisitsConfig",
26 "DefineVisitsTask",
27 "GroupExposuresConfig",
28 "GroupExposuresTask",
29 "VisitDefinitionData",
30]
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
39from lsst.daf.butler import (
40 Butler,
41 DataCoordinate,
42 DataId,
43 DimensionGraph,
44 DimensionRecord,
45 Timespan,
46)
48import lsst.geom
49from lsst.geom import Box2D
50from lsst.pex.config import Config, Field, makeRegistry, registerConfigurable
51from lsst.afw.cameraGeom import FOCAL_PLANE, PIXELS
52from lsst.pipe.base import Task
53from lsst.sphgeom import ConvexPolygon, Region, UnitVector3d
54from ._instrument import loadCamera, Instrument
57@dataclasses.dataclass
58class VisitDefinitionData:
59 """Struct representing a group of exposures that will be used to define a
60 visit.
61 """
63 instrument: str
64 """Name of the instrument this visit will be associated with.
65 """
67 id: int
68 """Integer ID of the visit.
70 This must be unique across all visit systems for the instrument.
71 """
73 name: str
74 """String name for the visit.
76 This must be unique across all visit systems for the instrument.
77 """
79 exposures: List[DimensionRecord] = dataclasses.field(default_factory=list)
80 """Dimension records for the exposures that are part of this visit.
81 """
84@dataclasses.dataclass
85class _VisitRecords:
86 """Struct containing the dimension records associated with a visit.
87 """
89 visit: DimensionRecord
90 """Record for the 'visit' dimension itself.
91 """
93 visit_definition: List[DimensionRecord]
94 """Records for 'visit_definition', which relates 'visit' to 'exposure'.
95 """
97 visit_detector_region: List[DimensionRecord]
98 """Records for 'visit_detector_region', which associates the combination
99 of a 'visit' and a 'detector' with a region on the sky.
100 """
103class GroupExposuresConfig(Config):
104 pass
107class GroupExposuresTask(Task, metaclass=ABCMeta):
108 """Abstract base class for the subtask of `DefineVisitsTask` that is
109 responsible for grouping exposures into visits.
111 Subclasses should be registered with `GroupExposuresTask.registry` to
112 enable use by `DefineVisitsTask`, and should generally correspond to a
113 particular 'visit_system' dimension value. They are also responsible for
114 defining visit IDs and names that are unique across all visit systems in
115 use by an instrument.
117 Parameters
118 ----------
119 config : `GroupExposuresConfig`
120 Configuration information.
121 **kwargs
122 Additional keyword arguments forwarded to the `Task` constructor.
123 """
124 def __init__(self, config: GroupExposuresConfig, **kwargs: Any):
125 Task.__init__(self, config=config, **kwargs)
127 ConfigClass = GroupExposuresConfig
129 _DefaultName = "groupExposures"
131 registry = makeRegistry(
132 doc="Registry of algorithms for grouping exposures into visits.",
133 configBaseType=GroupExposuresConfig,
134 )
136 @abstractmethod
137 def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
138 """Group the given exposures into visits.
140 Parameters
141 ----------
142 exposures : `list` [ `DimensionRecord` ]
143 DimensionRecords (for the 'exposure' dimension) describing the
144 exposures to group.
146 Returns
147 -------
148 visits : `Iterable` [ `VisitDefinitionData` ]
149 Structs identifying the visits and the exposures associated with
150 them. This may be an iterator or a container.
151 """
152 raise NotImplementedError()
154 @abstractmethod
155 def getVisitSystem(self) -> Tuple[int, str]:
156 """Return identifiers for the 'visit_system' dimension this
157 algorithm implements.
159 Returns
160 -------
161 id : `int`
162 Integer ID for the visit system (given an instrument).
163 name : `str`
164 Unique string identifier for the visit system (given an
165 instrument).
166 """
167 raise NotImplementedError()
170class ComputeVisitRegionsConfig(Config):
171 padding = Field(
172 dtype=int,
173 default=0,
174 doc=("Pad raw image bounding boxes with specified number of pixels "
175 "when calculating their (conservatively large) region on the "
176 "sky."),
177 )
180class ComputeVisitRegionsTask(Task, metaclass=ABCMeta):
181 """Abstract base class for the subtask of `DefineVisitsTask` that is
182 responsible for extracting spatial regions for visits and visit+detector
183 combinations.
185 Subclasses should be registered with `ComputeVisitRegionsTask.registry` to
186 enable use by `DefineVisitsTask`.
188 Parameters
189 ----------
190 config : `ComputeVisitRegionsConfig`
191 Configuration information.
192 butler : `lsst.daf.butler.Butler`
193 The butler to use.
194 **kwargs
195 Additional keyword arguments forwarded to the `Task` constructor.
196 """
197 def __init__(self, config: ComputeVisitRegionsConfig, *, butler: Butler, **kwargs: Any):
198 Task.__init__(self, config=config, **kwargs)
199 self.butler = butler
200 self.instrumentMap = {}
202 ConfigClass = ComputeVisitRegionsConfig
204 _DefaultName = "computeVisitRegions"
206 registry = makeRegistry(
207 doc=("Registry of algorithms for computing on-sky regions for visits "
208 "and visit+detector combinations."),
209 configBaseType=ComputeVisitRegionsConfig,
210 )
212 def getInstrument(self, instrumentName) -> Instrument:
213 """Retrieve an `~lsst.obs.base.Instrument` associated with this
214 instrument name.
216 Parameters
217 ----------
218 instrumentName : `str`
219 The name of the instrument.
221 Returns
222 -------
223 instrument : `~lsst.obs.base.Instrument`
224 The associated instrument object.
226 Notes
227 -----
228 The result is cached.
229 """
230 instrument = self.instrumentMap.get(instrumentName)
231 if instrument is None:
232 instrument = Instrument.fromName(instrumentName, self.butler.registry)
233 self.instrumentMap[instrumentName] = instrument
234 return instrument
236 @abstractmethod
237 def compute(self, visit: VisitDefinitionData, *, collections: Any = None
238 ) -> Tuple[Region, Dict[int, Region]]:
239 """Compute regions for the given visit and all detectors in that visit.
241 Parameters
242 ----------
243 visit : `VisitDefinitionData`
244 Struct describing the visit and the exposures associated with it.
245 collections : Any, optional
246 Collections to be searched for raws and camera geometry, overriding
247 ``self.butler.collections``.
248 Can be any of the types supported by the ``collections`` argument
249 to butler construction.
251 Returns
252 -------
253 visitRegion : `lsst.sphgeom.Region`
254 Region for the full visit.
255 visitDetectorRegions : `dict` [ `int`, `lsst.sphgeom.Region` ]
256 Dictionary mapping detector ID to the region for that detector.
257 Should include all detectors in the visit.
258 """
259 raise NotImplementedError()
262class DefineVisitsConfig(Config):
263 groupExposures = GroupExposuresTask.registry.makeField(
264 doc="Algorithm for grouping exposures into visits.",
265 default="one-to-one",
266 )
267 computeVisitRegions = ComputeVisitRegionsTask.registry.makeField(
268 doc="Algorithm from computing visit and visit+detector regions.",
269 default="single-raw-wcs",
270 )
271 ignoreNonScienceExposures = Field(
272 doc=("If True, silently ignore input exposures that do not have "
273 "observation_type=SCIENCE. If False, raise an exception if one "
274 "encountered."),
275 dtype=bool,
276 optional=False,
277 default=True,
278 )
281class DefineVisitsTask(Task):
282 """Driver Task for defining visits (and their spatial regions) in Gen3
283 Butler repositories.
285 Parameters
286 ----------
287 config : `DefineVisitsConfig`
288 Configuration for the task.
289 butler : `~lsst.daf.butler.Butler`
290 Writeable butler instance. Will be used to read `raw.wcs` and `camera`
291 datasets and insert/sync dimension data.
292 **kwargs
293 Additional keyword arguments are forwarded to the `lsst.pipe.base.Task`
294 constructor.
296 Notes
297 -----
298 Each instance of `DefineVisitsTask` reads from / writes to the same Butler.
299 Each invocation of `DefineVisitsTask.run` processes an independent group of
300 exposures into one or more new vists, all belonging to the same visit
301 system and instrument.
303 The actual work of grouping exposures and computing regions is delegated
304 to pluggable subtasks (`GroupExposuresTask` and `ComputeVisitRegionsTask`),
305 respectively. The defaults are to create one visit for every exposure,
306 and to use exactly one (arbitrary) detector-level raw dataset's WCS along
307 with camera geometry to compute regions for all detectors. Other
308 implementations can be created and configured for instruments for which
309 these choices are unsuitable (e.g. because visits and exposures are not
310 one-to-one, or because ``raw.wcs`` datasets for different detectors may not
311 be consistent with camera geomery).
313 It is not necessary in general to ingest all raws for an exposure before
314 defining a visit that includes the exposure; this depends entirely on the
315 `ComputeVisitRegionTask` subclass used. For the default configuration,
316 a single raw for each exposure is sufficient.
317 """
318 def __init__(self, config: Optional[DefineVisitsConfig] = None, *, butler: Butler, **kwargs: Any):
319 config.validate() # Not a CmdlineTask nor PipelineTask, so have to validate the config here.
320 super().__init__(config, **kwargs)
321 self.butler = butler
322 self.universe = self.butler.registry.dimensions
323 self.makeSubtask("groupExposures")
324 self.makeSubtask("computeVisitRegions", butler=self.butler)
326 @classmethod
327 # WARNING: this method hardcodes the parameters to pipe.base.Task.__init__.
328 # Nobody seems to know a way to delegate them to Task code.
329 def _makeTask(cls, config: DefineVisitsConfig, butler: Butler, name: str, parentTask: Task):
330 """Construct a DefineVisitsTask using only positional arguments.
332 Parameters
333 ----------
334 All parameters are as for `DefineVisitsTask`.
335 """
336 return cls(config=config, butler=butler, name=name, parentTask=parentTask)
338 # Overrides Task.__reduce__
339 def __reduce__(self):
340 return (self._makeTask, (self.config, self.butler, self._name, self._parentTask))
342 ConfigClass = DefineVisitsConfig
344 _DefaultName = "defineVisits"
346 def _buildVisitRecords(self, definition: VisitDefinitionData, *,
347 collections: Any = None) -> _VisitRecords:
348 """Build the DimensionRecords associated with a visit.
350 Parameters
351 ----------
352 definition : `VisitDefinition`
353 Struct with identifiers for the visit and records for its
354 constituent exposures.
355 collections : Any, optional
356 Collections to be searched for raws and camera geometry, overriding
357 ``self.butler.collections``.
358 Can be any of the types supported by the ``collections`` argument
359 to butler construction.
361 Results
362 -------
363 records : `_VisitRecords`
364 Struct containing DimensionRecords for the visit, including
365 associated dimension elements.
366 """
367 # Compute all regions.
368 visitRegion, visitDetectorRegions = self.computeVisitRegions.compute(definition,
369 collections=collections)
370 # Aggregate other exposure quantities.
371 timespan = Timespan(
372 begin=_reduceOrNone(min, (e.timespan.begin for e in definition.exposures)),
373 end=_reduceOrNone(max, (e.timespan.end for e in definition.exposures)),
374 )
375 exposure_time = _reduceOrNone(sum, (e.exposure_time for e in definition.exposures))
376 physical_filter = _reduceOrNone(lambda a, b: a if a == b else None,
377 (e.physical_filter for e in definition.exposures))
378 target_name = _reduceOrNone(lambda a, b: a if a == b else None,
379 (e.target_name for e in definition.exposures))
380 science_program = _reduceOrNone(lambda a, b: a if a == b else None,
381 (e.science_program for e in definition.exposures))
382 observation_reason = _reduceOrNone(lambda a, b: a if a == b else None,
383 (e.observation_reason for e in definition.exposures))
384 if observation_reason is None:
385 # Be explicit about there being multiple reasons
386 observation_reason = "various"
388 # Use the mean zenith angle as an approximation
389 zenith_angle = _reduceOrNone(sum, (e.zenith_angle for e in definition.exposures))
390 if zenith_angle is not None:
391 zenith_angle /= len(definition.exposures)
393 # Construct the actual DimensionRecords.
394 return _VisitRecords(
395 visit=self.universe["visit"].RecordClass(
396 instrument=definition.instrument,
397 id=definition.id,
398 name=definition.name,
399 physical_filter=physical_filter,
400 target_name=target_name,
401 science_program=science_program,
402 observation_reason=observation_reason,
403 zenith_angle=zenith_angle,
404 visit_system=self.groupExposures.getVisitSystem()[0],
405 exposure_time=exposure_time,
406 timespan=timespan,
407 region=visitRegion,
408 # TODO: no seeing value in exposure dimension records, so we can't
409 # set that here. But there are many other columns that both
410 # dimensions should probably have as well.
411 ),
412 visit_definition=[
413 self.universe["visit_definition"].RecordClass(
414 instrument=definition.instrument,
415 visit=definition.id,
416 exposure=exposure.id,
417 visit_system=self.groupExposures.getVisitSystem()[0],
418 )
419 for exposure in definition.exposures
420 ],
421 visit_detector_region=[
422 self.universe["visit_detector_region"].RecordClass(
423 instrument=definition.instrument,
424 visit=definition.id,
425 detector=detectorId,
426 region=detectorRegion,
427 )
428 for detectorId, detectorRegion in visitDetectorRegions.items()
429 ]
430 )
432 def _expandExposureId(self, dataId: DataId) -> DataCoordinate:
433 """Return the expanded version of an exposure ID.
435 A private method to allow ID expansion in a pool without resorting
436 to local callables.
438 Parameters
439 ----------
440 dataId : `dict` or `DataCoordinate`
441 Exposure-level data ID.
443 Returns
444 -------
445 expanded : `DataCoordinate`
446 A data ID that includes full metadata for all exposure dimensions.
447 """
448 dimensions = DimensionGraph(self.universe, names=["exposure"])
449 return self.butler.registry.expandDataId(dataId, graph=dimensions)
451 def _buildVisitRecordsSingle(self, args) -> _VisitRecords:
452 """Build the DimensionRecords associated with a visit and collection.
454 A wrapper for `_buildVisitRecords` to allow it to be run as part of
455 a pool without resorting to local callables.
457 Parameters
458 ----------
459 args : `tuple` [`VisitDefinition`, any]
460 A tuple consisting of the ``definition`` and ``collections``
461 arguments to `_buildVisitRecords`, in that order.
463 Results
464 -------
465 records : `_VisitRecords`
466 Struct containing DimensionRecords for the visit, including
467 associated dimension elements.
468 """
469 return self._buildVisitRecords(args[0], collections=args[1])
471 def run(self, dataIds: Iterable[DataId], *,
472 pool: Optional[Pool] = None,
473 processes: int = 1,
474 collections: Optional[str] = None):
475 """Add visit definitions to the registry for the given exposures.
477 Parameters
478 ----------
479 dataIds : `Iterable` [ `dict` or `DataCoordinate` ]
480 Exposure-level data IDs. These must all correspond to the same
481 instrument, and are expected to be on-sky science exposures.
482 pool : `multiprocessing.Pool`, optional
483 If not `None`, a process pool with which to parallelize some
484 operations.
485 processes : `int`, optional
486 The number of processes to use. Ignored if ``pool`` is not `None`.
487 collections : Any, optional
488 Collections to be searched for raws and camera geometry, overriding
489 ``self.butler.collections``.
490 Can be any of the types supported by the ``collections`` argument
491 to butler construction.
492 """
493 # Set up multiprocessing, if desired.
494 if pool is None and processes > 1:
495 pool = Pool(processes)
496 mapFunc = map if pool is None else pool.imap_unordered
497 # Normalize, expand, and deduplicate data IDs.
498 self.log.info("Preprocessing data IDs.")
499 dataIds = set(mapFunc(self._expandExposureId, dataIds))
500 if not dataIds:
501 raise RuntimeError("No exposures given.")
502 # Extract exposure DimensionRecords, check that there's only one
503 # instrument in play, and check for non-science exposures.
504 exposures = []
505 instruments = set()
506 for dataId in dataIds:
507 record = dataId.records["exposure"]
508 if record.observation_type != "science":
509 if self.config.ignoreNonScienceExposures:
510 continue
511 else:
512 raise RuntimeError(f"Input exposure {dataId} has observation_type "
513 f"{record.observation_type}, not 'science'.")
514 instruments.add(dataId["instrument"])
515 exposures.append(record)
516 if not exposures:
517 self.log.info("No science exposures found after filtering.")
518 return
519 if len(instruments) > 1:
520 raise RuntimeError(
521 f"All data IDs passed to DefineVisitsTask.run must be "
522 f"from the same instrument; got {instruments}."
523 )
524 instrument, = instruments
525 # Ensure the visit_system our grouping algorithm uses is in the
526 # registry, if it wasn't already.
527 visitSystemId, visitSystemName = self.groupExposures.getVisitSystem()
528 self.log.info("Registering visit_system %d: %s.", visitSystemId, visitSystemName)
529 self.butler.registry.syncDimensionData(
530 "visit_system",
531 {"instrument": instrument, "id": visitSystemId, "name": visitSystemName}
532 )
533 # Group exposures into visits, delegating to subtask.
534 self.log.info("Grouping %d exposure(s) into visits.", len(exposures))
535 definitions = list(self.groupExposures.group(exposures))
536 # Compute regions and build DimensionRecords for each visit.
537 # This is the only parallel step, but it _should_ be the most expensive
538 # one (unless DB operations are slow).
539 self.log.info("Computing regions and other metadata for %d visit(s).", len(definitions))
540 allRecords = mapFunc(self._buildVisitRecordsSingle,
541 zip(definitions, itertools.repeat(collections)))
542 # Iterate over visits and insert dimension data, one transaction per
543 # visit.
544 for visitRecords in allRecords:
545 with self.butler.registry.transaction():
546 self.butler.registry.insertDimensionData("visit", visitRecords.visit)
547 self.butler.registry.insertDimensionData("visit_definition",
548 *visitRecords.visit_definition)
549 self.butler.registry.insertDimensionData("visit_detector_region",
550 *visitRecords.visit_detector_region)
553def _reduceOrNone(func, iterable):
554 """Apply a binary function to pairs of elements in an iterable until a
555 single value is returned, but return `None` if any element is `None` or
556 there are no elements.
557 """
558 r = None
559 for v in iterable:
560 if v is None:
561 return None
562 if r is None:
563 r = v
564 else:
565 r = func(r, v)
566 return r
569class _GroupExposuresOneToOneConfig(GroupExposuresConfig):
570 visitSystemId = Field(
571 doc=("Integer ID of the visit_system implemented by this grouping "
572 "algorithm."),
573 dtype=int,
574 default=0,
575 )
576 visitSystemName = Field(
577 doc=("String name of the visit_system implemented by this grouping "
578 "algorithm."),
579 dtype=str,
580 default="one-to-one",
581 )
584@registerConfigurable("one-to-one", GroupExposuresTask.registry)
585class _GroupExposuresOneToOneTask(GroupExposuresTask, metaclass=ABCMeta):
586 """An exposure grouping algorithm that simply defines one visit for each
587 exposure, reusing the exposures identifiers for the visit.
588 """
590 ConfigClass = _GroupExposuresOneToOneConfig
592 def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
593 # Docstring inherited from GroupExposuresTask.
594 for exposure in exposures:
595 yield VisitDefinitionData(
596 instrument=exposure.instrument,
597 id=exposure.id,
598 name=exposure.name,
599 exposures=[exposure],
600 )
602 def getVisitSystem(self) -> Tuple[int, str]:
603 # Docstring inherited from GroupExposuresTask.
604 return (self.config.visitSystemId, self.config.visitSystemName)
607class _GroupExposuresByGroupMetadataConfig(GroupExposuresConfig):
608 visitSystemId = Field(
609 doc=("Integer ID of the visit_system implemented by this grouping "
610 "algorithm."),
611 dtype=int,
612 default=1,
613 )
614 visitSystemName = Field(
615 doc=("String name of the visit_system implemented by this grouping "
616 "algorithm."),
617 dtype=str,
618 default="by-group-metadata",
619 )
622@registerConfigurable("by-group-metadata", GroupExposuresTask.registry)
623class _GroupExposuresByGroupMetadataTask(GroupExposuresTask, metaclass=ABCMeta):
624 """An exposure grouping algorithm that uses exposure.group_name and
625 exposure.group_id.
627 This algorithm _assumes_ exposure.group_id (generally populated from
628 `astro_metadata_translator.ObservationInfo.visit_id`) is not just unique,
629 but disjoint from all `ObservationInfo.exposure_id` values - if it isn't,
630 it will be impossible to ever use both this grouping algorithm and the
631 one-to-one algorithm for a particular camera in the same data repository.
632 """
634 ConfigClass = _GroupExposuresByGroupMetadataConfig
636 def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
637 # Docstring inherited from GroupExposuresTask.
638 groups = defaultdict(list)
639 for exposure in exposures:
640 groups[exposure.group_name].append(exposure)
641 for visitName, exposuresInGroup in groups.items():
642 instrument = exposuresInGroup[0].instrument
643 visitId = exposuresInGroup[0].group_id
644 assert all(e.group_id == visitId for e in exposuresInGroup), \
645 "Grouping by exposure.group_name does not yield consistent group IDs"
646 yield VisitDefinitionData(instrument=instrument, id=visitId, name=visitName,
647 exposures=exposuresInGroup)
649 def getVisitSystem(self) -> Tuple[int, str]:
650 # Docstring inherited from GroupExposuresTask.
651 return (self.config.visitSystemId, self.config.visitSystemName)
654class _ComputeVisitRegionsFromSingleRawWcsConfig(ComputeVisitRegionsConfig):
655 mergeExposures = Field(
656 doc=("If True, merge per-detector regions over all exposures in a "
657 "visit (via convex hull) instead of using the first exposure and "
658 "assuming its regions are valid for all others."),
659 dtype=bool,
660 default=False,
661 )
662 detectorId = Field(
663 doc=("Load the WCS for the detector with this ID. If None, use an "
664 "arbitrary detector (the first found in a query of the data "
665 "repository for each exposure (or all exposures, if "
666 "mergeExposures is True)."),
667 dtype=int,
668 optional=True,
669 default=None
670 )
671 requireVersionedCamera = Field(
672 doc=("If True, raise LookupError if version camera geometry cannot be "
673 "loaded for an exposure. If False, use the nominal camera from "
674 "the Instrument class instead."),
675 dtype=bool,
676 optional=False,
677 default=False,
678 )
681@registerConfigurable("single-raw-wcs", ComputeVisitRegionsTask.registry)
682class _ComputeVisitRegionsFromSingleRawWcsTask(ComputeVisitRegionsTask):
683 """A visit region calculator that uses a single raw WCS and a camera to
684 project the bounding boxes of all detectors onto the sky, relating
685 different detectors by their positions in focal plane coordinates.
687 Notes
688 -----
689 Most instruments should have their raw WCSs determined from a combination
690 of boresight angle, rotator angle, and camera geometry, and hence this
691 algorithm should produce stable results regardless of which detector the
692 raw corresponds to. If this is not the case (e.g. because a per-file FITS
693 WCS is used instead), either the ID of the detector should be fixed (see
694 the ``detectorId`` config parameter) or a different algorithm used.
695 """
697 ConfigClass = _ComputeVisitRegionsFromSingleRawWcsConfig
699 def computeExposureBounds(self, exposure: DimensionRecord, *, collections: Any = None
700 ) -> Dict[int, List[UnitVector3d]]:
701 """Compute the lists of unit vectors on the sphere that correspond to
702 the sky positions of detector corners.
704 Parameters
705 ----------
706 exposure : `DimensionRecord`
707 Dimension record for the exposure.
708 collections : Any, optional
709 Collections to be searched for raws and camera geometry, overriding
710 ``self.butler.collections``.
711 Can be any of the types supported by the ``collections`` argument
712 to butler construction.
714 Returns
715 -------
716 bounds : `dict`
717 Dictionary mapping detector ID to a list of unit vectors on the
718 sphere representing that detector's corners projected onto the sky.
719 """
720 if collections is None:
721 collections = self.butler.collections
722 camera, versioned = loadCamera(self.butler, exposure.dataId, collections=collections)
723 if not versioned and self.config.requireVersionedCamera:
724 raise LookupError(f"No versioned camera found for exposure {exposure.dataId}.")
726 # Derive WCS from boresight information -- if available in registry
727 use_registry = True
728 try:
729 orientation = lsst.geom.Angle(exposure.sky_angle, lsst.geom.degrees)
730 radec = lsst.geom.SpherePoint(lsst.geom.Angle(exposure.tracking_ra, lsst.geom.degrees),
731 lsst.geom.Angle(exposure.tracking_dec, lsst.geom.degrees))
732 except AttributeError:
733 use_registry = False
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]
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)
748 else:
749 if self.config.detectorId is None:
750 wcsRefsIter = self.butler.registry.queryDatasets("raw.wcs", dataId=exposure.dataId,
751 collections=collections)
752 if not wcsRefsIter:
753 raise LookupError(f"No raw.wcs datasets found for data ID {exposure.dataId} "
754 f"in collections {collections}.")
755 wcsRef = next(iter(wcsRefsIter))
756 wcsDetector = camera[wcsRef.dataId["detector"]]
757 wcs = self.butler.getDirect(wcsRef)
758 else:
759 wcsDetector = camera[self.config.detectorId]
760 wcs = self.butler.get("raw.wcs", dataId=exposure.dataId, detector=self.config.detectorId,
761 collections=collections)
762 fpToSky = wcsDetector.getTransform(FOCAL_PLANE, PIXELS).then(wcs.getTransform())
763 bounds = {}
764 for detector in camera:
765 pixelsToSky = detector.getTransform(PIXELS, FOCAL_PLANE).then(fpToSky)
766 pixCorners = Box2D(detector.getBBox().dilatedBy(self.config.padding)).getCorners()
767 bounds[detector.getId()] = [
768 skyCorner.getVector() for skyCorner in pixelsToSky.applyForward(pixCorners)
769 ]
770 return bounds
772 def compute(self, visit: VisitDefinitionData, *, collections: Any = None
773 ) -> Tuple[Region, Dict[int, Region]]:
774 # Docstring inherited from ComputeVisitRegionsTask.
775 if self.config.mergeExposures:
776 detectorBounds = defaultdict(list)
777 for exposure in visit.exposures:
778 exposureDetectorBounds = self.computeExposureBounds(exposure, collections=collections)
779 for detectorId, bounds in exposureDetectorBounds.items():
780 detectorBounds[detectorId].extend(bounds)
781 else:
782 detectorBounds = self.computeExposureBounds(visit.exposures[0], collections=collections)
783 visitBounds = []
784 detectorRegions = {}
785 for detectorId, bounds in detectorBounds.items():
786 detectorRegions[detectorId] = ConvexPolygon.convexHull(bounds)
787 visitBounds.extend(bounds)
788 return ConvexPolygon.convexHull(visitBounds), detectorRegions