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