Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DM-21855: Move daf.butler.instrument to obs_base #179

Merged
merged 1 commit into from
Nov 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions python/lsst/obs/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .mapping import *
from .cameraMapper import *
from .exposureIdInfo import *
from .instrument import *
from .makeRawVisitInfo import *
from .makeRawVisitInfoViaObsInfo import *
from .fitsRawFormatterBase import *
Expand Down
2 changes: 1 addition & 1 deletion python/lsst/obs/base/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class FilterDefinition:

This class is used to interface between the `~lsst.afw.image.Filter` class
and the Gen2 `~lsst.daf.persistence.CameraMapper` and Gen3
`~lsst.daf.butler.Instruments` and ``physical_filter``/``abstract_filter``
`~lsst.obs.base.Instruments` and ``physical_filter``/``abstract_filter``
`~lsst.daf.butler.Dimension`.

This class is likely temporary, until we have a better versioned filter
Expand Down
2 changes: 1 addition & 1 deletion python/lsst/obs/base/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
DimensionRecord,
FileDataset,
)
from lsst.daf.butler.instrument import makeExposureRecordFromObsInfo, makeVisitRecordFromObsInfo
from lsst.obs.base.instrument import makeExposureRecordFromObsInfo, makeVisitRecordFromObsInfo
from lsst.geom import Box2D
from lsst.pex.config import Config, Field, ChoiceField
from lsst.pipe.base import Task
Expand Down
236 changes: 236 additions & 0 deletions python/lsst/obs/base/instrument.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
# This file is part of obs_base.
#
# Developed for the LSST Data Management System.
# This product includes software developed by the LSST Project
# (http://www.lsst.org).
# See the COPYRIGHT file at the top-level directory of this distribution
# for details of code ownership.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

__all__ = ("Instrument", "makeExposureRecordFromObsInfo", "makeVisitRecordFromObsInfo",
"addUnboundedCalibrationLabel")

import os.path
from datetime import datetime
from abc import ABCMeta, abstractmethod


class Instrument(metaclass=ABCMeta):
"""Base class for instrument-specific logic for the Gen3 Butler.

Concrete instrument subclasses should be directly constructable with no
arguments.
"""

configPaths = []
"""Paths to config files to read for specific Tasks.

The paths in this list should contain files of the form `task.py`, for
each of the Tasks that requires special configuration.
"""

@property
@abstractmethod
def filterDefinitions(self):
"""`~lsst.obs.base.FilterDefinitionCollection`, defining the filters
for this instrument.
"""
return None

def __init__(self, *args, **kwargs):
self.filterDefinitions.defineFilters()

@classmethod
@abstractmethod
def getName(cls):
raise NotImplementedError()

@abstractmethod
def getCamera(self):
"""Retrieve the cameraGeom representation of this instrument.

This is a temporary API that should go away once obs_ packages have
a standardized approach to writing versioned cameras to a Gen3 repo.
"""
raise NotImplementedError()

@abstractmethod
def register(self, registry):
"""Insert instrument, physical_filter, and detector entries into a
`Registry`.
"""
raise NotImplementedError()

def _registerFilters(self, registry):
"""Register the physical and abstract filter Dimension relationships.
This should be called in the ``register`` implementation.

Parameters
----------
registry : `lsst.daf.butler.core.Registry`
The registry to add dimensions to.
"""
registry.insertDimensionData(
"physical_filter",
*[
{
"instrument": self.getName(),
"name": filter.physical_filter,
"abstract_filter": filter.abstract_filter,
}
for filter in self.filterDefinitions
]
)

@abstractmethod
def getRawFormatter(self, dataId):
"""Return the Formatter class that should be used to read a particular
raw file.

Parameters
----------
dataId : `DataCoordinate`
Dimension-based ID for the raw file or files being ingested.

Returns
-------
formatter : `Formatter` class
Class to be used that reads the file into an
`lsst.afw.image.Exposure` instance.
"""
raise NotImplementedError()

@abstractmethod
def writeCuratedCalibrations(self, butler):
"""Write human-curated calibration Datasets to the given Butler with
the appropriate validity ranges.

This is a temporary API that should go away once obs_ packages have
a standardized approach to this problem.
"""
raise NotImplementedError()

def applyConfigOverrides(self, name, config):
"""Apply instrument-specific overrides for a task config.

Parameters
----------
name : `str`
Name of the object being configured; typically the _DefaultName
of a Task.
config : `lsst.pex.config.Config`
Config instance to which overrides should be applied.
"""
for root in self.configPaths:
path = os.path.join(root, f"{name}.py")
if os.path.exists(path):
config.load(path)


def makeExposureRecordFromObsInfo(obsInfo, universe):
"""Construct an exposure DimensionRecord from
`astro_metadata_translator.ObservationInfo`.

Parameters
----------
obsInfo : `astro_metadata_translator.ObservationInfo`
A `~astro_metadata_translator.ObservationInfo` object corresponding to
the exposure.
universe : `DimensionUniverse`
Set of all known dimensions.

Returns
-------
record : `DimensionRecord`
A record containing exposure metadata, suitable for insertion into
a `Registry`.
"""
dimension = universe["exposure"]
return dimension.RecordClass.fromDict({
"instrument": obsInfo.instrument,
"id": obsInfo.exposure_id,
"name": obsInfo.observation_id,
"datetime_begin": obsInfo.datetime_begin.to_datetime(),
"datetime_end": obsInfo.datetime_end.to_datetime(),
"exposure_time": obsInfo.exposure_time.to_value("s"),
"dark_time": obsInfo.dark_time.to_value("s"),
"observation_type": obsInfo.observation_type,
"physical_filter": obsInfo.physical_filter,
"visit": obsInfo.visit_id,
})


def makeVisitRecordFromObsInfo(obsInfo, universe, *, region=None):
"""Construct a visit `DimensionRecord` from
`astro_metadata_translator.ObservationInfo`.

Parameters
----------
obsInfo : `astro_metadata_translator.ObservationInfo`
A `~astro_metadata_translator.ObservationInfo` object corresponding to
the exposure.
universe : `DimensionUniverse`
Set of all known dimensions.
region : `lsst.sphgeom.Region`, optional
Spatial region for the visit.

Returns
-------
record : `DimensionRecord`
A record containing visit metadata, suitable for insertion into a
`Registry`.
"""
dimension = universe["visit"]
return dimension.RecordClass.fromDict({
"instrument": obsInfo.instrument,
"id": obsInfo.visit_id,
"name": obsInfo.observation_id,
"datetime_begin": obsInfo.datetime_begin.to_datetime(),
"datetime_end": obsInfo.datetime_end.to_datetime(),
"exposure_time": obsInfo.exposure_time.to_value("s"),
"physical_filter": obsInfo.physical_filter,
"region": region,
})


def addUnboundedCalibrationLabel(registry, instrumentName):
"""Add a special 'unbounded' calibration_label dimension entry for the
given camera that is valid for any exposure.

If such an entry already exists, this function just returns a `DataId`
for the existing entry.

Parameters
----------
registry : `Registry`
Registry object in which to insert the dimension entry.
instrumentName : `str`
Name of the instrument this calibration label is associated with.

Returns
-------
dataId : `DataId`
New or existing data ID for the unbounded calibration.
"""
d = dict(instrument=instrumentName, calibration_label="unbounded")
try:
return registry.expandDataId(d)
except LookupError:
pass
entry = d.copy()
entry["datetime_begin"] = datetime.min
entry["datetime_end"] = datetime.max
registry.insertDimensionData("calibration_label", entry)
return registry.expandDataId(d)
101 changes: 101 additions & 0 deletions python/lsst/obs/base/instrument_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# This file is part of obs_base.
#
# Developed for the LSST Data Management System.
# This product includes software developed by the LSST Project
# (http://www.lsst.org).
# See the COPYRIGHT file at the top-level directory of this distribution
# for details of code ownership.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Helpers for writing tests against subclassses of Instrument.

These are not tests themselves, but can be subclassed (plus unittest.TestCase)
to get a functional test of an Instrument.
"""

import abc
import dataclasses
import os

from lsst.daf.butler import Registry
from lsst.daf.butler import ButlerConfig
from lsst.utils import getPackageDir


@dataclasses.dataclass
class InstrumentTestData:
"""Values to test against in sublcasses of `InstrumentTests`.
"""

name: str
"""The name of the Camera this instrument describes."""

nDetectors: int
"""The number of detectors in the Camera."""

firstDetectorName: str
"""The name of the first detector in the Camera."""

physical_filters: {str}
"""A subset of the physical filters should be registered."""


class InstrumentTests(metaclass=abc.ABCMeta):
"""Tests of sublcasses of Instrument.

TestCase subclasses must derive from this, then `TestCase`, and override
``data`` and ``instrument``.
"""

data = None
"""`InstrumentTestData` containing the values to test against."""

instrument = None
"""The `~lsst.obs.base.Instrument` to be tested."""

def test_name(self):
self.assertEqual(self.instrument.getName(), self.data.name)

def test_getCamera(self):
"""Test that getCamera() returns a reasonable Camera definition.
"""
camera = self.instrument.getCamera()
self.assertEqual(camera.getName(), self.instrument.getName())
self.assertEqual(len(camera), self.data.nDetectors)
self.assertEqual(next(iter(camera)).getName(), self.data.firstDetectorName)

def test_register(self):
"""Test that register() sets appropriate Dimensions.
"""
registryConfigPath = os.path.join(getPackageDir("daf_butler"), "tests/config/basic/butler.yaml")
registry = Registry.fromConfig(ButlerConfig(registryConfigPath))
# check that the registry starts out empty
self.assertEqual(list(registry.queryDimensions(["instrument"])), [])
self.assertEqual(list(registry.queryDimensions(["detector"])), [])
self.assertEqual(list(registry.queryDimensions(["physical_filter"])), [])

# register the instrument and check that certain dimensions appear
self.instrument.register(registry)
instrumentDataIds = list(registry.queryDimensions(["instrument"]))
self.assertEqual(len(instrumentDataIds), 1)
instrumentNames = {dataId["instrument"] for dataId in instrumentDataIds}
self.assertEqual(instrumentNames, {self.data.name})
detectorDataIds = list(registry.queryDimensions(["detector"]))
self.assertEqual(len(detectorDataIds), self.data.nDetectors)
detectorNames = {dataId.records["detector"].full_name for dataId in detectorDataIds}
self.assertIn(self.data.firstDetectorName, detectorNames)
physicalFilterDataIds = list(registry.queryDimensions(["physical_filter"]))
filterNames = {dataId['physical_filter'] for dataId in physicalFilterDataIds}
self.assertGreaterEqual(filterNames, self.data.physical_filters)