lsst.obs.base  20.0.0-57-g3dc6bd7+789989b279
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  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)
325 
326  def _reduce_kwargs(self):
327  # Add extra parameters to pickle
328  return dict(**super()._reduce_kwargs(), butler=self.butler)
329 
330  ConfigClass = DefineVisitsConfig
331 
332  _DefaultName = "defineVisits"
333 
334  def _buildVisitRecords(self, definition: VisitDefinitionData, *,
335  collections: Any = None) -> _VisitRecords:
336  """Build the DimensionRecords associated with a visit.
337 
338  Parameters
339  ----------
340  definition : `VisitDefinition`
341  Struct with identifiers for the visit and records for its
342  constituent exposures.
343  collections : Any, optional
344  Collections to be searched for raws and camera geometry, overriding
345  ``self.butler.collections``.
346  Can be any of the types supported by the ``collections`` argument
347  to butler construction.
348 
349  Results
350  -------
351  records : `_VisitRecords`
352  Struct containing DimensionRecords for the visit, including
353  associated dimension elements.
354  """
355  # Compute all regions.
356  visitRegion, visitDetectorRegions = self.computeVisitRegions.compute(definition,
357  collections=collections)
358  # Aggregate other exposure quantities.
359  timespan = Timespan(
360  begin=_reduceOrNone(min, (e.timespan.begin for e in definition.exposures)),
361  end=_reduceOrNone(max, (e.timespan.end for e in definition.exposures)),
362  )
363  exposure_time = _reduceOrNone(sum, (e.exposure_time for e in definition.exposures))
364  physical_filter = _reduceOrNone(lambda a, b: a if a == b else None,
365  (e.physical_filter for e in definition.exposures))
366  target_name = _reduceOrNone(lambda a, b: a if a == b else None,
367  (e.target_name for e in definition.exposures))
368  science_program = _reduceOrNone(lambda a, b: a if a == b else None,
369  (e.science_program for e in definition.exposures))
370  observation_reason = _reduceOrNone(lambda a, b: a if a == b else None,
371  (e.observation_reason for e in definition.exposures))
372  if observation_reason is None:
373  # Be explicit about there being multiple reasons
374  observation_reason = "various"
375 
376  # Use the mean zenith angle as an approximation
377  zenith_angle = _reduceOrNone(sum, (e.zenith_angle for e in definition.exposures))
378  if zenith_angle is not None:
379  zenith_angle /= len(definition.exposures)
380 
381  # Construct the actual DimensionRecords.
382  return _VisitRecords(
383  visit=self.universe["visit"].RecordClass(
384  instrument=definition.instrument,
385  id=definition.id,
386  name=definition.name,
387  physical_filter=physical_filter,
388  target_name=target_name,
389  science_program=science_program,
390  observation_reason=observation_reason,
391  zenith_angle=zenith_angle,
392  visit_system=self.groupExposures.getVisitSystem()[0],
393  exposure_time=exposure_time,
394  timespan=timespan,
395  region=visitRegion,
396  # TODO: no seeing value in exposure dimension records, so we
397  # can't set that here. But there are many other columns that
398  # both dimensions should probably have as well.
399  ),
400  visit_definition=[
401  self.universe["visit_definition"].RecordClass(
402  instrument=definition.instrument,
403  visit=definition.id,
404  exposure=exposure.id,
405  visit_system=self.groupExposures.getVisitSystem()[0],
406  )
407  for exposure in definition.exposures
408  ],
409  visit_detector_region=[
410  self.universe["visit_detector_region"].RecordClass(
411  instrument=definition.instrument,
412  visit=definition.id,
413  detector=detectorId,
414  region=detectorRegion,
415  )
416  for detectorId, detectorRegion in visitDetectorRegions.items()
417  ]
418  )
419 
420  def _expandExposureId(self, dataId: DataId) -> DataCoordinate:
421  """Return the expanded version of an exposure ID.
422 
423  A private method to allow ID expansion in a pool without resorting
424  to local callables.
425 
426  Parameters
427  ----------
428  dataId : `dict` or `DataCoordinate`
429  Exposure-level data ID.
430 
431  Returns
432  -------
433  expanded : `DataCoordinate`
434  A data ID that includes full metadata for all exposure dimensions.
435  """
436  dimensions = DimensionGraph(self.universe, names=["exposure"])
437  return self.butler.registry.expandDataId(dataId, graph=dimensions)
438 
439  def _buildVisitRecordsSingle(self, args) -> _VisitRecords:
440  """Build the DimensionRecords associated with a visit and collection.
441 
442  A wrapper for `_buildVisitRecords` to allow it to be run as part of
443  a pool without resorting to local callables.
444 
445  Parameters
446  ----------
447  args : `tuple` [`VisitDefinition`, any]
448  A tuple consisting of the ``definition`` and ``collections``
449  arguments to `_buildVisitRecords`, in that order.
450 
451  Results
452  -------
453  records : `_VisitRecords`
454  Struct containing DimensionRecords for the visit, including
455  associated dimension elements.
456  """
457  return self._buildVisitRecords(args[0], collections=args[1])
458 
459  def run(self, dataIds: Iterable[DataId], *,
460  pool: Optional[Pool] = None,
461  processes: int = 1,
462  collections: Optional[str] = None):
463  """Add visit definitions to the registry for the given exposures.
464 
465  Parameters
466  ----------
467  dataIds : `Iterable` [ `dict` or `DataCoordinate` ]
468  Exposure-level data IDs. These must all correspond to the same
469  instrument, and are expected to be on-sky science exposures.
470  pool : `multiprocessing.Pool`, optional
471  If not `None`, a process pool with which to parallelize some
472  operations.
473  processes : `int`, optional
474  The number of processes to use. Ignored if ``pool`` is not `None`.
475  collections : Any, optional
476  Collections to be searched for raws and camera geometry, overriding
477  ``self.butler.collections``.
478  Can be any of the types supported by the ``collections`` argument
479  to butler construction.
480  """
481  # Set up multiprocessing, if desired.
482  if pool is None and processes > 1:
483  pool = Pool(processes)
484  mapFunc = map if pool is None else pool.imap_unordered
485  # Normalize, expand, and deduplicate data IDs.
486  self.log.info("Preprocessing data IDs.")
487  dataIds = set(mapFunc(self._expandExposureId, dataIds))
488  if not dataIds:
489  raise RuntimeError("No exposures given.")
490  # Extract exposure DimensionRecords, check that there's only one
491  # instrument in play, and check for non-science exposures.
492  exposures = []
493  instruments = set()
494  for dataId in dataIds:
495  record = dataId.records["exposure"]
496  if record.observation_type != "science":
497  if self.config.ignoreNonScienceExposures:
498  continue
499  else:
500  raise RuntimeError(f"Input exposure {dataId} has observation_type "
501  f"{record.observation_type}, not 'science'.")
502  instruments.add(dataId["instrument"])
503  exposures.append(record)
504  if not exposures:
505  self.log.info("No science exposures found after filtering.")
506  return
507  if len(instruments) > 1:
508  raise RuntimeError(
509  f"All data IDs passed to DefineVisitsTask.run must be "
510  f"from the same instrument; got {instruments}."
511  )
512  instrument, = instruments
513  # Ensure the visit_system our grouping algorithm uses is in the
514  # registry, if it wasn't already.
515  visitSystemId, visitSystemName = self.groupExposures.getVisitSystem()
516  self.log.info("Registering visit_system %d: %s.", visitSystemId, visitSystemName)
517  self.butler.registry.syncDimensionData(
518  "visit_system",
519  {"instrument": instrument, "id": visitSystemId, "name": visitSystemName}
520  )
521  # Group exposures into visits, delegating to subtask.
522  self.log.info("Grouping %d exposure(s) into visits.", len(exposures))
523  definitions = list(self.groupExposures.group(exposures))
524  # Compute regions and build DimensionRecords for each visit.
525  # This is the only parallel step, but it _should_ be the most expensive
526  # one (unless DB operations are slow).
527  self.log.info("Computing regions and other metadata for %d visit(s).", len(definitions))
528  allRecords = mapFunc(self._buildVisitRecordsSingle,
529  zip(definitions, itertools.repeat(collections)))
530  # Iterate over visits and insert dimension data, one transaction per
531  # visit.
532  for visitRecords in allRecords:
533  with self.butler.registry.transaction():
534  self.butler.registry.insertDimensionData("visit", visitRecords.visit)
535  self.butler.registry.insertDimensionData("visit_definition",
536  *visitRecords.visit_definition)
537  self.butler.registry.insertDimensionData("visit_detector_region",
538  *visitRecords.visit_detector_region)
539 
540 
541 def _reduceOrNone(func, iterable):
542  """Apply a binary function to pairs of elements in an iterable until a
543  single value is returned, but return `None` if any element is `None` or
544  there are no elements.
545  """
546  r = None
547  for v in iterable:
548  if v is None:
549  return None
550  if r is None:
551  r = v
552  else:
553  r = func(r, v)
554  return r
555 
556 
558  visitSystemId = Field(
559  doc=("Integer ID of the visit_system implemented by this grouping "
560  "algorithm."),
561  dtype=int,
562  default=0,
563  )
564  visitSystemName = Field(
565  doc=("String name of the visit_system implemented by this grouping "
566  "algorithm."),
567  dtype=str,
568  default="one-to-one",
569  )
570 
571 
572 @registerConfigurable("one-to-one", GroupExposuresTask.registry)
574  """An exposure grouping algorithm that simply defines one visit for each
575  exposure, reusing the exposures identifiers for the visit.
576  """
577 
578  ConfigClass = _GroupExposuresOneToOneConfig
579 
580  def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
581  # Docstring inherited from GroupExposuresTask.
582  for exposure in exposures:
583  yield VisitDefinitionData(
584  instrument=exposure.instrument,
585  id=exposure.id,
586  name=exposure.name,
587  exposures=[exposure],
588  )
589 
590  def getVisitSystem(self) -> Tuple[int, str]:
591  # Docstring inherited from GroupExposuresTask.
592  return (self.config.visitSystemId, self.config.visitSystemName)
593 
594 
596  visitSystemId = Field(
597  doc=("Integer ID of the visit_system implemented by this grouping "
598  "algorithm."),
599  dtype=int,
600  default=1,
601  )
602  visitSystemName = Field(
603  doc=("String name of the visit_system implemented by this grouping "
604  "algorithm."),
605  dtype=str,
606  default="by-group-metadata",
607  )
608 
609 
610 @registerConfigurable("by-group-metadata", GroupExposuresTask.registry)
612  """An exposure grouping algorithm that uses exposure.group_name and
613  exposure.group_id.
614 
615  This algorithm _assumes_ exposure.group_id (generally populated from
616  `astro_metadata_translator.ObservationInfo.visit_id`) is not just unique,
617  but disjoint from all `ObservationInfo.exposure_id` values - if it isn't,
618  it will be impossible to ever use both this grouping algorithm and the
619  one-to-one algorithm for a particular camera in the same data repository.
620  """
621 
622  ConfigClass = _GroupExposuresByGroupMetadataConfig
623 
624  def group(self, exposures: List[DimensionRecord]) -> Iterable[VisitDefinitionData]:
625  # Docstring inherited from GroupExposuresTask.
626  groups = defaultdict(list)
627  for exposure in exposures:
628  groups[exposure.group_name].append(exposure)
629  for visitName, exposuresInGroup in groups.items():
630  instrument = exposuresInGroup[0].instrument
631  visitId = exposuresInGroup[0].group_id
632  assert all(e.group_id == visitId for e in exposuresInGroup), \
633  "Grouping by exposure.group_name does not yield consistent group IDs"
634  yield VisitDefinitionData(instrument=instrument, id=visitId, name=visitName,
635  exposures=exposuresInGroup)
636 
637  def getVisitSystem(self) -> Tuple[int, str]:
638  # Docstring inherited from GroupExposuresTask.
639  return (self.config.visitSystemId, self.config.visitSystemName)
640 
641 
643  mergeExposures = Field(
644  doc=("If True, merge per-detector regions over all exposures in a "
645  "visit (via convex hull) instead of using the first exposure and "
646  "assuming its regions are valid for all others."),
647  dtype=bool,
648  default=False,
649  )
650  detectorId = Field(
651  doc=("Load the WCS for the detector with this ID. If None, use an "
652  "arbitrary detector (the first found in a query of the data "
653  "repository for each exposure (or all exposures, if "
654  "mergeExposures is True)."),
655  dtype=int,
656  optional=True,
657  default=None
658  )
659  requireVersionedCamera = Field(
660  doc=("If True, raise LookupError if version camera geometry cannot be "
661  "loaded for an exposure. If False, use the nominal camera from "
662  "the Instrument class instead."),
663  dtype=bool,
664  optional=False,
665  default=False,
666  )
667 
668 
669 @registerConfigurable("single-raw-wcs", ComputeVisitRegionsTask.registry)
671  """A visit region calculator that uses a single raw WCS and a camera to
672  project the bounding boxes of all detectors onto the sky, relating
673  different detectors by their positions in focal plane coordinates.
674 
675  Notes
676  -----
677  Most instruments should have their raw WCSs determined from a combination
678  of boresight angle, rotator angle, and camera geometry, and hence this
679  algorithm should produce stable results regardless of which detector the
680  raw corresponds to. If this is not the case (e.g. because a per-file FITS
681  WCS is used instead), either the ID of the detector should be fixed (see
682  the ``detectorId`` config parameter) or a different algorithm used.
683  """
684 
685  ConfigClass = _ComputeVisitRegionsFromSingleRawWcsConfig
686 
687  def computeExposureBounds(self, exposure: DimensionRecord, *, collections: Any = None
688  ) -> Dict[int, List[UnitVector3d]]:
689  """Compute the lists of unit vectors on the sphere that correspond to
690  the sky positions of detector corners.
691 
692  Parameters
693  ----------
694  exposure : `DimensionRecord`
695  Dimension record for the exposure.
696  collections : Any, optional
697  Collections to be searched for raws and camera geometry, overriding
698  ``self.butler.collections``.
699  Can be any of the types supported by the ``collections`` argument
700  to butler construction.
701 
702  Returns
703  -------
704  bounds : `dict`
705  Dictionary mapping detector ID to a list of unit vectors on the
706  sphere representing that detector's corners projected onto the sky.
707  """
708  if collections is None:
709  collections = self.butler.collections
710  camera, versioned = loadCamera(self.butler, exposure.dataId, collections=collections)
711  if not versioned and self.config.requireVersionedCamera:
712  raise LookupError(f"No versioned camera found for exposure {exposure.dataId}.")
713 
714  # Derive WCS from boresight information -- if available in registry
715  use_registry = True
716  try:
717  orientation = lsst.geom.Angle(exposure.sky_angle, lsst.geom.degrees)
718  radec = lsst.geom.SpherePoint(lsst.geom.Angle(exposure.tracking_ra, lsst.geom.degrees),
719  lsst.geom.Angle(exposure.tracking_dec, lsst.geom.degrees))
720  except AttributeError:
721  use_registry = False
722 
723  if use_registry:
724  if self.config.detectorId is None:
725  detectorId = next(camera.getIdIter())
726  else:
727  detectorId = self.config.detectorId
728  wcsDetector = camera[detectorId]
729 
730  # Ask the raw formatter to create the relevant WCS
731  # This allows flips to be taken into account
732  instrument = self.getInstrument(exposure.instrument)
733  rawFormatter = instrument.getRawFormatter({"detector": detectorId})
734  wcs = rawFormatter.makeRawSkyWcsFromBoresight(radec, orientation, wcsDetector)
735 
736  else:
737  if self.config.detectorId is None:
738  wcsRefsIter = self.butler.registry.queryDatasets("raw.wcs", dataId=exposure.dataId,
739  collections=collections)
740  if not wcsRefsIter:
741  raise LookupError(f"No raw.wcs datasets found for data ID {exposure.dataId} "
742  f"in collections {collections}.")
743  wcsRef = next(iter(wcsRefsIter))
744  wcsDetector = camera[wcsRef.dataId["detector"]]
745  wcs = self.butler.getDirect(wcsRef)
746  else:
747  wcsDetector = camera[self.config.detectorId]
748  wcs = self.butler.get("raw.wcs", dataId=exposure.dataId, detector=self.config.detectorId,
749  collections=collections)
750  fpToSky = wcsDetector.getTransform(FOCAL_PLANE, PIXELS).then(wcs.getTransform())
751  bounds = {}
752  for detector in camera:
753  pixelsToSky = detector.getTransform(PIXELS, FOCAL_PLANE).then(fpToSky)
754  pixCorners = Box2D(detector.getBBox().dilatedBy(self.config.padding)).getCorners()
755  bounds[detector.getId()] = [
756  skyCorner.getVector() for skyCorner in pixelsToSky.applyForward(pixCorners)
757  ]
758  return bounds
759 
760  def compute(self, visit: VisitDefinitionData, *, collections: Any = None
761  ) -> Tuple[Region, Dict[int, Region]]:
762  # Docstring inherited from ComputeVisitRegionsTask.
763  if self.config.mergeExposures:
764  detectorBounds = defaultdict(list)
765  for exposure in visit.exposures:
766  exposureDetectorBounds = self.computeExposureBounds(exposure, collections=collections)
767  for detectorId, bounds in exposureDetectorBounds.items():
768  detectorBounds[detectorId].extend(bounds)
769  else:
770  detectorBounds = self.computeExposureBounds(visit.exposures[0], collections=collections)
771  visitBounds = []
772  detectorRegions = {}
773  for detectorId, bounds in detectorBounds.items():
774  detectorRegions[detectorId] = ConvexPolygon.convexHull(bounds)
775  visitBounds.extend(bounds)
776  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:736
lsst.obs.base.defineVisits.DefineVisitsTask._buildVisitRecords
_VisitRecords _buildVisitRecords(self, VisitDefinitionData definition, *Any collections=None)
Definition: defineVisits.py:334
lsst.obs.base.defineVisits._ComputeVisitRegionsFromSingleRawWcsConfig
Definition: defineVisits.py:642
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataTask
Definition: defineVisits.py:611
lsst.obs.base.defineVisits.DefineVisitsTask._expandExposureId
DataCoordinate _expandExposureId(self, DataId dataId)
Definition: defineVisits.py:420
lsst.obs.base.defineVisits.DefineVisitsTask.butler
butler
Definition: defineVisits.py:321
lsst.obs.base.defineVisits.ComputeVisitRegionsTask.instrumentMap
instrumentMap
Definition: defineVisits.py:200
lsst.obs.base.defineVisits.DefineVisitsTask.universe
universe
Definition: defineVisits.py:322
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:670
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:318
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataTask.getVisitSystem
Tuple[int, str] getVisitSystem(self)
Definition: defineVisits.py:637
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:580
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataTask.group
Iterable[VisitDefinitionData] group(self, List[DimensionRecord] exposures)
Definition: defineVisits.py:624
lsst.obs.base.defineVisits.DefineVisitsTask._buildVisitRecordsSingle
_VisitRecords _buildVisitRecordsSingle(self, args)
Definition: defineVisits.py:439
lsst.obs.base.defineVisits._GroupExposuresByGroupMetadataConfig
Definition: defineVisits.py:595
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:687
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:760
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:573
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:459
lsst.obs.base.defineVisits._GroupExposuresOneToOneConfig
Definition: defineVisits.py:557
lsst.obs.base.defineVisits._GroupExposuresOneToOneTask.getVisitSystem
Tuple[int, str] getVisitSystem(self)
Definition: defineVisits.py:590