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