lsst.obs.base  20.0.0-59-gb502cbb+0e9af1ef10
defineVisits.py
Go to the documentation of this file.
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 
22 from __future__ import annotations
23 
24 __all__ = [
25  "DefineVisitsConfig",
26  "DefineVisitsTask",
27  "GroupExposuresConfig",
28  "GroupExposuresTask",
29  "VisitDefinitionData",
30 ]
31 
32 from abc import ABCMeta, abstractmethod
33 from collections import defaultdict
34 import itertools
35 import dataclasses
36 from typing import Any, Dict, Iterable, List, Optional, Tuple
37 from multiprocessing import Pool
38 
39 from lsst.daf.butler import (
40  Butler,
41  DataCoordinate,
42  DataId,
43  DimensionGraph,
44  DimensionRecord,
45  Timespan,
46 )
47 
48 import lsst.geom
49 from lsst.geom import Box2D
50 from lsst.pex.config import Config, Field, makeRegistry, registerConfigurable
51 from lsst.afw.cameraGeom import FOCAL_PLANE, PIXELS
52 from lsst.pipe.base import Task
53 from lsst.sphgeom import ConvexPolygon, Region, UnitVector3d
54 from ._instrument import loadCamera, Instrument
55 
56 
57 @dataclasses.dataclass
59  """Struct representing a group of exposures that will be used to define a
60  visit.
61  """
62 
63  instrument: str
64  """Name of the instrument this visit will be associated with.
65  """
66 
67  id: int
68  """Integer ID of the visit.
69 
70  This must be unique across all visit systems for the instrument.
71  """
72 
73  name: str
74  """String name for the visit.
75 
76  This must be unique across all visit systems for the instrument.
77  """
78 
79  exposures: List[DimensionRecord] = dataclasses.field(default_factory=list)
80  """Dimension records for the exposures that are part of this visit.
81  """
82 
83 
84 @dataclasses.dataclass
86  """Struct containing the dimension records associated with a visit.
87  """
88 
89  visit: DimensionRecord
90  """Record for the 'visit' dimension itself.
91  """
92 
93  visit_definition: List[DimensionRecord]
94  """Records for 'visit_definition', which relates 'visit' to 'exposure'.
95  """
96 
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  """
101 
102 
103 class GroupExposuresConfig(Config):
104  pass
105 
106 
107 class GroupExposuresTask(Task, metaclass=ABCMeta):
108  """Abstract base class for the subtask of `DefineVisitsTask` that is
109  responsible for grouping exposures into visits.
110 
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.
116 
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)
126 
127  ConfigClass = GroupExposuresConfig
128 
129  _DefaultName = "groupExposures"
130 
131  registry = makeRegistry(
132  doc="Registry of algorithms for grouping exposures into visits.",
133  configBaseType=GroupExposuresConfig,
134  )
135 
136  @abstractmethod
137  def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
138  """Group the given exposures into visits.
139 
140  Parameters
141  ----------
142  exposures : `list` [ `DimensionRecord` ]
143  DimensionRecords (for the 'exposure' dimension) describing the
144  exposures to group.
145 
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()
153 
154  @abstractmethod
155  def getVisitSystem(self) -> Tuple[int, str]:
156  """Return identifiers for the 'visit_system' dimension this
157  algorithm implements.
158 
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()
168 
169 
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  )
178 
179 
180 class 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.
184 
185  Subclasses should be registered with `ComputeVisitRegionsTask.registry` to
186  enable use by `DefineVisitsTask`.
187 
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 = {}
201 
202  ConfigClass = ComputeVisitRegionsConfig
203 
204  _DefaultName = "computeVisitRegions"
205 
206  registry = makeRegistry(
207  doc=("Registry of algorithms for computing on-sky regions for visits "
208  "and visit+detector combinations."),
209  configBaseType=ComputeVisitRegionsConfig,
210  )
211 
212  def getInstrument(self, instrumentName) -> Instrument:
213  """Retrieve an `~lsst.obs.base.Instrument` associated with this
214  instrument name.
215 
216  Parameters
217  ----------
218  instrumentName : `str`
219  The name of the instrument.
220 
221  Returns
222  -------
223  instrument : `~lsst.obs.base.Instrument`
224  The associated instrument object.
225 
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
235 
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.
240 
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.
250 
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()
260 
261 
262 class 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  )
279 
280 
281 class DefineVisitsTask(Task):
282  """Driver Task for defining visits (and their spatial regions) in Gen3
283  Butler repositories.
284 
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.
295 
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.
302 
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).
312 
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  Defining the same visit the same way multiple times (e.g. via multiple
319  invocations of this task on the same exposures, with the same
320  configuration) is safe, but it may be inefficient, as most of the work must
321  be done before new visits can be compared to existing visits.
322  """
323  def __init__(self, config: Optional[DefineVisitsConfig] = None, *, butler: Butler, **kwargs: Any):
324  config.validate() # Not a CmdlineTask nor PipelineTask, so have to validate the config here.
325  super().__init__(config, **kwargs)
326  self.butler = butler
327  self.universe = self.butler.registry.dimensions
328  self.makeSubtask("groupExposures")
329  self.makeSubtask("computeVisitRegions", butler=self.butler)
330 
331  def _reduce_kwargs(self):
332  # Add extra parameters to pickle
333  return dict(**super()._reduce_kwargs(), butler=self.butler)
334 
335  ConfigClass = DefineVisitsConfig
336 
337  _DefaultName = "defineVisits"
338 
339  def _buildVisitRecords(self, definition: VisitDefinitionData, *,
340  collections: Any = None) -> _VisitRecords:
341  """Build the DimensionRecords associated with a visit.
342 
343  Parameters
344  ----------
345  definition : `VisitDefinition`
346  Struct with identifiers for the visit and records for its
347  constituent exposures.
348  collections : Any, optional
349  Collections to be searched for raws and camera geometry, overriding
350  ``self.butler.collections``.
351  Can be any of the types supported by the ``collections`` argument
352  to butler construction.
353 
354  Results
355  -------
356  records : `_VisitRecords`
357  Struct containing DimensionRecords for the visit, including
358  associated dimension elements.
359  """
360  # Compute all regions.
361  visitRegion, visitDetectorRegions = self.computeVisitRegions.compute(definition,
362  collections=collections)
363  # Aggregate other exposure quantities.
364  timespan = Timespan(
365  begin=_reduceOrNone(min, (e.timespan.begin for e in definition.exposures)),
366  end=_reduceOrNone(max, (e.timespan.end for e in definition.exposures)),
367  )
368  exposure_time = _reduceOrNone(sum, (e.exposure_time for e in definition.exposures))
369  physical_filter = _reduceOrNone(lambda a, b: a if a == b else None,
370  (e.physical_filter for e in definition.exposures))
371  target_name = _reduceOrNone(lambda a, b: a if a == b else None,
372  (e.target_name for e in definition.exposures))
373  science_program = _reduceOrNone(lambda a, b: a if a == b else None,
374  (e.science_program for e in definition.exposures))
375  observation_reason = _reduceOrNone(lambda a, b: a if a == b else None,
376  (e.observation_reason for e in definition.exposures))
377  if observation_reason is None:
378  # Be explicit about there being multiple reasons
379  observation_reason = "various"
380 
381  # Use the mean zenith angle as an approximation
382  zenith_angle = _reduceOrNone(sum, (e.zenith_angle for e in definition.exposures))
383  if zenith_angle is not None:
384  zenith_angle /= len(definition.exposures)
385 
386  # Construct the actual DimensionRecords.
387  return _VisitRecords(
388  visit=self.universe["visit"].RecordClass(
389  instrument=definition.instrument,
390  id=definition.id,
391  name=definition.name,
392  physical_filter=physical_filter,
393  target_name=target_name,
394  science_program=science_program,
395  observation_reason=observation_reason,
396  zenith_angle=zenith_angle,
397  visit_system=self.groupExposures.getVisitSystem()[0],
398  exposure_time=exposure_time,
399  timespan=timespan,
400  region=visitRegion,
401  # TODO: no seeing value in exposure dimension records, so we
402  # can't set that here. But there are many other columns that
403  # both dimensions should probably have as well.
404  ),
405  visit_definition=[
406  self.universe["visit_definition"].RecordClass(
407  instrument=definition.instrument,
408  visit=definition.id,
409  exposure=exposure.id,
410  visit_system=self.groupExposures.getVisitSystem()[0],
411  )
412  for exposure in definition.exposures
413  ],
414  visit_detector_region=[
415  self.universe["visit_detector_region"].RecordClass(
416  instrument=definition.instrument,
417  visit=definition.id,
418  detector=detectorId,
419  region=detectorRegion,
420  )
421  for detectorId, detectorRegion in visitDetectorRegions.items()
422  ]
423  )
424 
425  def _expandExposureId(self, dataId: DataId) -> DataCoordinate:
426  """Return the expanded version of an exposure ID.
427 
428  A private method to allow ID expansion in a pool without resorting
429  to local callables.
430 
431  Parameters
432  ----------
433  dataId : `dict` or `DataCoordinate`
434  Exposure-level data ID.
435 
436  Returns
437  -------
438  expanded : `DataCoordinate`
439  A data ID that includes full metadata for all exposure dimensions.
440  """
441  dimensions = DimensionGraph(self.universe, names=["exposure"])
442  return self.butler.registry.expandDataId(dataId, graph=dimensions)
443 
444  def _buildVisitRecordsSingle(self, args) -> _VisitRecords:
445  """Build the DimensionRecords associated with a visit and collection.
446 
447  A wrapper for `_buildVisitRecords` to allow it to be run as part of
448  a pool without resorting to local callables.
449 
450  Parameters
451  ----------
452  args : `tuple` [`VisitDefinition`, any]
453  A tuple consisting of the ``definition`` and ``collections``
454  arguments to `_buildVisitRecords`, in that order.
455 
456  Results
457  -------
458  records : `_VisitRecords`
459  Struct containing DimensionRecords for the visit, including
460  associated dimension elements.
461  """
462  return self._buildVisitRecords(args[0], collections=args[1])
463 
464  def run(self, dataIds: Iterable[DataId], *,
465  pool: Optional[Pool] = None,
466  processes: int = 1,
467  collections: Optional[str] = None):
468  """Add visit definitions to the registry for the given exposures.
469 
470  Parameters
471  ----------
472  dataIds : `Iterable` [ `dict` or `DataCoordinate` ]
473  Exposure-level data IDs. These must all correspond to the same
474  instrument, and are expected to be on-sky science exposures.
475  pool : `multiprocessing.Pool`, optional
476  If not `None`, a process pool with which to parallelize some
477  operations.
478  processes : `int`, optional
479  The number of processes to use. Ignored if ``pool`` is not `None`.
480  collections : Any, optional
481  Collections to be searched for raws and camera geometry, overriding
482  ``self.butler.collections``.
483  Can be any of the types supported by the ``collections`` argument
484  to butler construction.
485 
486  Raises
487  ------
488  lsst.daf.butler.registry.ConflictingDefinitionError
489  Raised if a visit ID conflict is detected and the existing visit
490  differs from the new one.
491  """
492  # Set up multiprocessing, if desired.
493  if pool is None and processes > 1:
494  pool = Pool(processes)
495  mapFunc = map if pool is None else pool.imap_unordered
496  # Normalize, expand, and deduplicate data IDs.
497  self.log.info("Preprocessing data IDs.")
498  dataIds = set(mapFunc(self._expandExposureId, dataIds))
499  if not dataIds:
500  raise RuntimeError("No exposures given.")
501  # Extract exposure DimensionRecords, check that there's only one
502  # instrument in play, and check for non-science exposures.
503  exposures = []
504  instruments = set()
505  for dataId in dataIds:
506  record = dataId.records["exposure"]
507  if record.observation_type != "science":
508  if self.config.ignoreNonScienceExposures:
509  continue
510  else:
511  raise RuntimeError(f"Input exposure {dataId} has observation_type "
512  f"{record.observation_type}, not 'science'.")
513  instruments.add(dataId["instrument"])
514  exposures.append(record)
515  if not exposures:
516  self.log.info("No science exposures found after filtering.")
517  return
518  if len(instruments) > 1:
519  raise RuntimeError(
520  f"All data IDs passed to DefineVisitsTask.run must be "
521  f"from the same instrument; got {instruments}."
522  )
523  instrument, = instruments
524  # Ensure the visit_system our grouping algorithm uses is in the
525  # registry, if it wasn't already.
526  visitSystemId, visitSystemName = self.groupExposures.getVisitSystem()
527  self.log.info("Registering visit_system %d: %s.", visitSystemId, visitSystemName)
528  self.butler.registry.syncDimensionData(
529  "visit_system",
530  {"instrument": instrument, "id": visitSystemId, "name": visitSystemName}
531  )
532  # Group exposures into visits, delegating to subtask.
533  self.log.info("Grouping %d exposure(s) into visits.", len(exposures))
534  definitions = list(self.groupExposures.group(exposures))
535  # Compute regions and build DimensionRecords for each visit.
536  # This is the only parallel step, but it _should_ be the most expensive
537  # one (unless DB operations are slow).
538  self.log.info("Computing regions and other metadata for %d visit(s).", len(definitions))
539  allRecords = mapFunc(self._buildVisitRecordsSingle,
540  zip(definitions, itertools.repeat(collections)))
541  # Iterate over visits and insert dimension data, one transaction per
542  # visit. If a visit already exists, we skip all other inserts.
543  for visitRecords in allRecords:
544  with self.butler.registry.transaction():
545  if self.butler.registry.syncDimensionData("visit", visitRecords.visit):
546  self.butler.registry.insertDimensionData("visit_definition",
547  *visitRecords.visit_definition)
548  self.butler.registry.insertDimensionData("visit_detector_region",
549  *visitRecords.visit_detector_region)
550 
551 
552 def _reduceOrNone(func, iterable):
553  """Apply a binary function to pairs of elements in an iterable until a
554  single value is returned, but return `None` if any element is `None` or
555  there are no elements.
556  """
557  r = None
558  for v in iterable:
559  if v is None:
560  return None
561  if r is None:
562  r = v
563  else:
564  r = func(r, v)
565  return r
566 
567 
569  visitSystemId = Field(
570  doc=("Integer ID of the visit_system implemented by this grouping "
571  "algorithm."),
572  dtype=int,
573  default=0,
574  )
575  visitSystemName = Field(
576  doc=("String name of the visit_system implemented by this grouping "
577  "algorithm."),
578  dtype=str,
579  default="one-to-one",
580  )
581 
582 
583 @registerConfigurable("one-to-one", GroupExposuresTask.registry)
585  """An exposure grouping algorithm that simply defines one visit for each
586  exposure, reusing the exposures identifiers for the visit.
587  """
588 
589  ConfigClass = _GroupExposuresOneToOneConfig
590 
591  def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
592  # Docstring inherited from GroupExposuresTask.
593  for exposure in exposures:
594  yield VisitDefinitionData(
595  instrument=exposure.instrument,
596  id=exposure.id,
597  name=exposure.name,
598  exposures=[exposure],
599  )
600 
601  def getVisitSystem(self) -> Tuple[int, str]:
602  # Docstring inherited from GroupExposuresTask.
603  return (self.config.visitSystemId, self.config.visitSystemName)
604 
605 
607  visitSystemId = Field(
608  doc=("Integer ID of the visit_system implemented by this grouping "
609  "algorithm."),
610  dtype=int,
611  default=1,
612  )
613  visitSystemName = Field(
614  doc=("String name of the visit_system implemented by this grouping "
615  "algorithm."),
616  dtype=str,
617  default="by-group-metadata",
618  )
619 
620 
621 @registerConfigurable("by-group-metadata", GroupExposuresTask.registry)
623  """An exposure grouping algorithm that uses exposure.group_name and
624  exposure.group_id.
625 
626  This algorithm _assumes_ exposure.group_id (generally populated from
627  `astro_metadata_translator.ObservationInfo.visit_id`) is not just unique,
628  but disjoint from all `ObservationInfo.exposure_id` values - if it isn't,
629  it will be impossible to ever use both this grouping algorithm and the
630  one-to-one algorithm for a particular camera in the same data repository.
631  """
632 
633  ConfigClass = _GroupExposuresByGroupMetadataConfig
634 
635  def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
636  # Docstring inherited from GroupExposuresTask.
637  groups = defaultdict(list)
638  for exposure in exposures:
639  groups[exposure.group_name].append(exposure)
640  for visitName, exposuresInGroup in groups.items():
641  instrument = exposuresInGroup[0].instrument
642  visitId = exposuresInGroup[0].group_id
643  assert all(e.group_id == visitId for e in exposuresInGroup), \
644  "Grouping by exposure.group_name does not yield consistent group IDs"
645  yield VisitDefinitionData(instrument=instrument, id=visitId, name=visitName,
646  exposures=exposuresInGroup)
647 
648  def getVisitSystem(self) -> Tuple[int, str]:
649  # Docstring inherited from GroupExposuresTask.
650  return (self.config.visitSystemId, self.config.visitSystemName)
651 
652 
654  mergeExposures = Field(
655  doc=("If True, merge per-detector regions over all exposures in a "
656  "visit (via convex hull) instead of using the first exposure and "
657  "assuming its regions are valid for all others."),
658  dtype=bool,
659  default=False,
660  )
661  detectorId = Field(
662  doc=("Load the WCS for the detector with this ID. If None, use an "
663  "arbitrary detector (the first found in a query of the data "
664  "repository for each exposure (or all exposures, if "
665  "mergeExposures is True)."),
666  dtype=int,
667  optional=True,
668  default=None
669  )
670  requireVersionedCamera = Field(
671  doc=("If True, raise LookupError if version camera geometry cannot be "
672  "loaded for an exposure. If False, use the nominal camera from "
673  "the Instrument class instead."),
674  dtype=bool,
675  optional=False,
676  default=False,
677  )
678 
679 
680 @registerConfigurable("single-raw-wcs", ComputeVisitRegionsTask.registry)
682  """A visit region calculator that uses a single raw WCS and a camera to
683  project the bounding boxes of all detectors onto the sky, relating
684  different detectors by their positions in focal plane coordinates.
685 
686  Notes
687  -----
688  Most instruments should have their raw WCSs determined from a combination
689  of boresight angle, rotator angle, and camera geometry, and hence this
690  algorithm should produce stable results regardless of which detector the
691  raw corresponds to. If this is not the case (e.g. because a per-file FITS
692  WCS is used instead), either the ID of the detector should be fixed (see
693  the ``detectorId`` config parameter) or a different algorithm used.
694  """
695 
696  ConfigClass = _ComputeVisitRegionsFromSingleRawWcsConfig
697 
698  def computeExposureBounds(self, exposure: DimensionRecord, *, collections: Any = None
699  ) -> Dict[int, List[UnitVector3d]]:
700  """Compute the lists of unit vectors on the sphere that correspond to
701  the sky positions of detector corners.
702 
703  Parameters
704  ----------
705  exposure : `DimensionRecord`
706  Dimension record for the exposure.
707  collections : Any, optional
708  Collections to be searched for raws and camera geometry, overriding
709  ``self.butler.collections``.
710  Can be any of the types supported by the ``collections`` argument
711  to butler construction.
712 
713  Returns
714  -------
715  bounds : `dict`
716  Dictionary mapping detector ID to a list of unit vectors on the
717  sphere representing that detector's corners projected onto the sky.
718  """
719  if collections is None:
720  collections = self.butler.collections
721  camera, versioned = loadCamera(self.butler, exposure.dataId, collections=collections)
722  if not versioned and self.config.requireVersionedCamera:
723  raise LookupError(f"No versioned camera found for exposure {exposure.dataId}.")
724 
725  # Derive WCS from boresight information -- if available in registry
726  use_registry = True
727  try:
728  orientation = lsst.geom.Angle(exposure.sky_angle, lsst.geom.degrees)
729  radec = lsst.geom.SpherePoint(lsst.geom.Angle(exposure.tracking_ra, lsst.geom.degrees),
730  lsst.geom.Angle(exposure.tracking_dec, lsst.geom.degrees))
731  except AttributeError:
732  use_registry = False
733 
734  if use_registry:
735  if self.config.detectorId is None:
736  detectorId = next(camera.getIdIter())
737  else:
738  detectorId = self.config.detectorId
739  wcsDetector = camera[detectorId]
740 
741  # Ask the raw formatter to create the relevant WCS
742  # This allows flips to be taken into account
743  instrument = self.getInstrument(exposure.instrument)
744  rawFormatter = instrument.getRawFormatter({"detector": detectorId})
745  wcs = rawFormatter.makeRawSkyWcsFromBoresight(radec, orientation, wcsDetector)
746 
747  else:
748  if self.config.detectorId is None:
749  wcsRefsIter = self.butler.registry.queryDatasets("raw.wcs", dataId=exposure.dataId,
750  collections=collections)
751  if not wcsRefsIter:
752  raise LookupError(f"No raw.wcs datasets found for data ID {exposure.dataId} "
753  f"in collections {collections}.")
754  wcsRef = next(iter(wcsRefsIter))
755  wcsDetector = camera[wcsRef.dataId["detector"]]
756  wcs = self.butler.getDirect(wcsRef)
757  else:
758  wcsDetector = camera[self.config.detectorId]
759  wcs = self.butler.get("raw.wcs", dataId=exposure.dataId, detector=self.config.detectorId,
760  collections=collections)
761  fpToSky = wcsDetector.getTransform(FOCAL_PLANE, PIXELS).then(wcs.getTransform())
762  bounds = {}
763  for detector in camera:
764  pixelsToSky = detector.getTransform(PIXELS, FOCAL_PLANE).then(fpToSky)
765  pixCorners = Box2D(detector.getBBox().dilatedBy(self.config.padding)).getCorners()
766  bounds[detector.getId()] = [
767  skyCorner.getVector() for skyCorner in pixelsToSky.applyForward(pixCorners)
768  ]
769  return bounds
770 
771  def compute(self, visit: VisitDefinitionData, *, collections: Any = None
772  ) -> Tuple[Region, Dict[int, Region]]:
773  # Docstring inherited from ComputeVisitRegionsTask.
774  if self.config.mergeExposures:
775  detectorBounds = defaultdict(list)
776  for exposure in visit.exposures:
777  exposureDetectorBounds = self.computeExposureBounds(exposure, collections=collections)
778  for detectorId, bounds in exposureDetectorBounds.items():
779  detectorBounds[detectorId].extend(bounds)
780  else:
781  detectorBounds = self.computeExposureBounds(visit.exposures[0], collections=collections)
782  visitBounds = []
783  detectorRegions = {}
784  for detectorId, bounds in detectorBounds.items():
785  detectorRegions[detectorId] = ConvexPolygon.convexHull(bounds)
786  visitBounds.extend(bounds)
787  return ConvexPolygon.convexHull(visitBounds), detectorRegions
lsst.obs.base.defineVisits.ComputeVisitRegionsConfig
Definition: defineVisits.py:170
lsst.obs.base._instrument.loadCamera
Tuple[Camera, bool] loadCamera(Butler butler, DataId dataId, *Any collections=None)
Definition: _instrument.py:754
lsst.obs.base.defineVisits.DefineVisitsTask._buildVisitRecords
_VisitRecords _buildVisitRecords(self, VisitDefinitionData definition, *Any collections=None)
Definition: defineVisits.py:339
lsst.obs.base.defineVisits._ComputeVisitRegionsFromSingleRawWcsConfig
Definition: defineVisits.py:653
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataTask
Definition: defineVisits.py:622
lsst.obs.base.defineVisits.DefineVisitsTask._expandExposureId
DataCoordinate _expandExposureId(self, DataId dataId)
Definition: defineVisits.py:425
lsst.obs.base.defineVisits.DefineVisitsTask.butler
butler
Definition: defineVisits.py:326
lsst.obs.base.defineVisits.ComputeVisitRegionsTask.instrumentMap
instrumentMap
Definition: defineVisits.py:200
lsst.obs.base.defineVisits.DefineVisitsTask.universe
universe
Definition: defineVisits.py:327
lsst.obs.base.defineVisits.ComputeVisitRegionsTask
Definition: defineVisits.py:180
lsst.obs.base.defineVisits.ComputeVisitRegionsTask.compute
Tuple[Region, Dict[int, Region]] compute(self, VisitDefinitionData visit, *Any collections=None)
Definition: defineVisits.py:237
lsst.obs.base.defineVisits._ComputeVisitRegionsFromSingleRawWcsTask
Definition: defineVisits.py:681
lsst.obs.base.defineVisits.ComputeVisitRegionsTask.getInstrument
Instrument getInstrument(self, instrumentName)
Definition: defineVisits.py:212
lsst.obs.base.defineVisits.DefineVisitsTask.__init__
def __init__(self, Optional[DefineVisitsConfig] config=None, *Butler butler, **Any kwargs)
Definition: defineVisits.py:323
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataTask.getVisitSystem
Tuple[int, str] getVisitSystem(self)
Definition: defineVisits.py:648
lsst.obs.base.defineVisits.GroupExposuresTask.__init__
def __init__(self, GroupExposuresConfig config, **Any kwargs)
Definition: defineVisits.py:124
lsst.obs.base.defineVisits._GroupExposuresOneToOneTask.group
Iterable[VisitDefinitionData] group(self, List[DimensionRecord] exposures)
Definition: defineVisits.py:591
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataTask.group
Iterable[VisitDefinitionData] group(self, List[DimensionRecord] exposures)
Definition: defineVisits.py:635
lsst.obs.base.defineVisits.DefineVisitsTask._buildVisitRecordsSingle
_VisitRecords _buildVisitRecordsSingle(self, args)
Definition: defineVisits.py:444
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataConfig
Definition: defineVisits.py:606
lsst.obs.base.defineVisits.GroupExposuresConfig
Definition: defineVisits.py:103
lsst.obs.base.defineVisits.ComputeVisitRegionsTask.__init__
def __init__(self, ComputeVisitRegionsConfig config, *Butler butler, **Any kwargs)
Definition: defineVisits.py:197
lsst.obs.base.defineVisits._ComputeVisitRegionsFromSingleRawWcsTask.computeExposureBounds
Dict[int, List[UnitVector3d]] computeExposureBounds(self, DimensionRecord exposure, *Any collections=None)
Definition: defineVisits.py:698
lsst.obs.base.defineVisits.GroupExposuresTask.group
Iterable[VisitDefinitionData] group(self, List[DimensionRecord] exposures)
Definition: defineVisits.py:137
lsst.obs.base.defineVisits._ComputeVisitRegionsFromSingleRawWcsTask.compute
Tuple[Region, Dict[int, Region]] compute(self, VisitDefinitionData visit, *Any collections=None)
Definition: defineVisits.py:771
lsst.obs.base.defineVisits.ComputeVisitRegionsTask.butler
butler
Definition: defineVisits.py:199
lsst.obs.base.defineVisits.DefineVisitsTask
Definition: defineVisits.py:281
lsst.obs.base.defineVisits.GroupExposuresTask
Definition: defineVisits.py:107
lsst.obs.base.defineVisits.VisitDefinitionData
Definition: defineVisits.py:58
lsst.obs.base.defineVisits.GroupExposuresTask.getVisitSystem
Tuple[int, str] getVisitSystem(self)
Definition: defineVisits.py:155
lsst.obs.base.defineVisits._GroupExposuresOneToOneTask
Definition: defineVisits.py:584
lsst.obs.base.defineVisits._VisitRecords
Definition: defineVisits.py:85
lsst.obs.base.defineVisits.DefineVisitsConfig
Definition: defineVisits.py:262
lsst.obs.base.defineVisits.DefineVisitsTask.run
def run(self, Iterable[DataId] dataIds, *Optional[Pool] pool=None, int processes=1, Optional[str] collections=None)
Definition: defineVisits.py:464
lsst.obs.base.defineVisits._GroupExposuresOneToOneConfig
Definition: defineVisits.py:568
lsst.obs.base.defineVisits._GroupExposuresOneToOneTask.getVisitSystem
Tuple[int, str] getVisitSystem(self)
Definition: defineVisits.py:601