Coverage for python / lsst / obs / base / instrument_tests.py: 51%
118 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-12 01:23 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-12 01:23 -0800
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/>.
22"""Helpers for writing tests against subclassses of Instrument.
24These are not tests themselves, but can be subclassed (plus unittest.TestCase)
25to get a functional test of an Instrument.
26"""
28from __future__ import annotations
30__all__ = [
31 "CuratedCalibration",
32 "DummyCam",
33 "DummyCamYamlWcsFormatter",
34 "InstrumentTestData",
35 "InstrumentTests",
36]
38import abc
39import dataclasses
40from collections.abc import Callable, Sequence
41from functools import lru_cache
42from typing import TYPE_CHECKING, Any, ClassVar
44from pydantic import BaseModel
46from lsst.daf.butler import CollectionType, DataId, DatasetType
47from lsst.daf.butler.formatters.yaml import YamlFormatter
48from lsst.daf.butler.tests.utils import create_populated_sqlite_registry
49from lsst.obs.base import FilterDefinition, FilterDefinitionCollection, Instrument
50from lsst.obs.base.yamlCamera import makeCamera
51from lsst.resources import ResourcePath
52from lsst.utils.introspection import get_full_type_name
54from .utils import createInitialSkyWcsFromBoresight
56if TYPE_CHECKING:
57 import lsst.afw.cameraGeom
58 import lsst.afw.geom
59 import lsst.geom
60 import lsst.pex.config
61 from lsst.daf.butler import Butler, Formatter, FormatterV2, Registry
63DUMMY_FILTER_DEFINITIONS = FilterDefinitionCollection(
64 FilterDefinition(physical_filter="dummy_u", band="u"),
65 FilterDefinition(physical_filter="dummy_g", band="g"),
66)
69class DummyCamYamlWcsFormatter(YamlFormatter):
70 """Specialist formatter for tests that can make a WCS."""
72 @classmethod
73 def makeRawSkyWcsFromBoresight(
74 cls,
75 boresight: lsst.geom.SpherePoint,
76 orientation: lsst.geom.Angle,
77 detector: lsst.afw.cameraGeom.Detector,
78 ) -> lsst.afw.geom.SkyWcs:
79 """Class method to make a raw sky WCS from boresight and detector.
81 This uses the API expected by define-visits. A working example
82 can be found in `~lsst.obs.base.FitsRawFormatterBase`.
84 Notes
85 -----
86 This makes no attempt to create a proper WCS from geometry.
87 """
88 return createInitialSkyWcsFromBoresight(boresight, orientation, detector, flipX=False)
91class CuratedCalibration(BaseModel):
92 """Class that implements minimal read/write interface needed to support
93 curated calibration ingest.
94 """
96 metadata: dict[str, Any]
97 values: list[int]
99 @classmethod
100 def readText(cls, path: str) -> CuratedCalibration:
101 with open(path) as f:
102 data = f.read()
103 return cls.model_validate_json(data)
105 def writeText(self, path: str) -> None:
106 with open(path, "w") as f:
107 print(self.model_dump_json(), file=f)
109 def getMetadata(self) -> dict[str, Any]:
110 return self.metadata
113class DummyCam(Instrument):
114 """Instrument class used for testing."""
116 filterDefinitions: FilterDefinitionCollection = DUMMY_FILTER_DEFINITIONS
117 additionalCuratedDatasetTypes = frozenset(["testCalib"])
118 policyName = "dummycam"
119 dataPackageDir: str | None = ""
120 raw_definition = (
121 "raw_dict",
122 ("instrument", "detector", "exposure"),
123 "StructuredDataDict",
124 )
126 @classmethod
127 def getName(cls) -> str:
128 return "DummyCam"
130 @classmethod
131 @lru_cache # For mypy
132 def getObsDataPackageDir(cls) -> str | None:
133 return cls.dataPackageDir
135 def getCamera(self) -> lsst.afw.cameraGeom.Camera:
136 # Return something that can be indexed by detector number
137 # but also has to support getIdIter.
138 with ResourcePath("resource://lsst.obs.base/test/dummycam.yaml").as_local() as local_file:
139 return makeCamera(local_file.ospath)
141 def register(self, registry: Registry, *, update: bool = False) -> None:
142 """Insert Instrument, physical_filter, and detector entries into a
143 `~lsst.daf.butler.Registry`.
144 """
145 detector_max = 2
146 dataId = {
147 "instrument": self.getName(),
148 "class_name": get_full_type_name(DummyCam),
149 "detector_max": detector_max,
150 "visit_max": 1_000_000,
151 "exposure_max": 1_000_000,
152 }
154 with registry.transaction():
155 registry.syncDimensionData("instrument", dataId, update=update)
156 self._registerFilters(registry, update=update)
157 for d in range(detector_max):
158 registry.syncDimensionData(
159 "detector",
160 dict(
161 instrument=self.getName(),
162 id=d,
163 full_name=f"RXX_S0{d}",
164 ),
165 update=update,
166 )
168 def getRawFormatter(self, dataId: DataId) -> type[Formatter | FormatterV2]:
169 # Docstring inherited from Instrument.getRawFormatter.
170 return DummyCamYamlWcsFormatter
172 def applyConfigOverrides(self, name: str, config: lsst.pex.config.Config) -> None:
173 pass
175 def writeAdditionalCuratedCalibrations(
176 self, butler: Butler, collection: str | None = None, labels: Sequence[str] = ()
177 ) -> None:
178 # We want to test the standard curated calibration ingest
179 # but we do not have a standard class to use. There is no way
180 # at the moment to inject a new class into the standard list
181 # that is a package constant, so instead use this "Additional"
182 # method but call the standard curated calibration code.
183 if collection is None:
184 collection = self.makeCalibrationCollectionName(*labels)
185 butler.registry.registerCollection(collection, type=CollectionType.CALIBRATION)
187 datasetType = DatasetType(
188 "testCalib",
189 universe=butler.dimensions,
190 isCalibration=True,
191 dimensions=("instrument", "detector"),
192 storageClass="CuratedCalibration",
193 )
194 runs: set[str] = set()
195 self._writeSpecificCuratedCalibrationDatasets(
196 butler, datasetType, collection, runs=runs, labels=labels
197 )
200@dataclasses.dataclass
201class InstrumentTestData:
202 """Values to test against in subclasses of `InstrumentTests`."""
204 name: str
205 """The name of the Camera this instrument describes."""
207 nDetectors: int
208 """The number of detectors in the Camera."""
210 firstDetectorName: str
211 """The name of the first detector in the Camera."""
213 physical_filters: set[str]
214 """A subset of the physical filters should be registered."""
217class InstrumentTests(metaclass=abc.ABCMeta):
218 """Tests of subclasses of Instrument.
220 TestCase subclasses must derive from this, then `unittest.TestCase`, and
221 override ``data`` and ``instrument``.
222 """
224 if TYPE_CHECKING:
225 # When run will also inherit from unittest.TestCase.
226 assertEqual: Callable
227 assertFalse: Callable
228 assertIn: Callable
229 assertGreaterEqual: Callable
231 data: ClassVar[InstrumentTestData | None] = None
232 """`InstrumentTestData` containing the values to test against."""
234 instrument: ClassVar[Instrument | None] = None
235 """The `~lsst.obs.base.Instrument` to be tested."""
237 def test_name(self) -> None:
238 assert self.instrument is not None, "Subclass must initialize instrument property"
239 assert self.data is not None, "Subclass must initialize data property"
240 self.assertEqual(self.instrument.getName(), self.data.name)
242 def test_getCamera(self) -> None:
243 """Test that getCamera() returns a reasonable Camera definition."""
244 assert self.instrument is not None, "Subclass must initialize instrument property"
245 assert self.data is not None, "Subclass must initialize data property"
246 camera = self.instrument.getCamera()
247 self.assertEqual(camera.getName(), self.instrument.getName())
248 self.assertEqual(len(camera), self.data.nDetectors)
249 self.assertEqual(next(iter(camera)).getName(), self.data.firstDetectorName)
251 def test_register(self) -> None:
252 """Test that register() sets appropriate Dimensions."""
253 assert self.instrument is not None, "Subclass must initialize instrument property"
254 assert self.data is not None, "Subclass must initialize data property"
255 with create_populated_sqlite_registry() as butler:
256 registry = butler.registry
257 # Check that the registry starts out empty.
258 self.assertFalse(registry.queryDataIds(["instrument"]).toSequence())
259 self.assertFalse(registry.queryDataIds(["detector"]).toSequence())
260 self.assertFalse(registry.queryDataIds(["physical_filter"]).toSequence())
262 # Register the instrument and check that certain dimensions appear.
263 self.instrument.register(registry)
264 instrumentDataIds = registry.queryDataIds(["instrument"]).toSequence()
265 self.assertEqual(len(instrumentDataIds), 1)
266 instrumentNames = {dataId["instrument"] for dataId in instrumentDataIds}
267 self.assertEqual(instrumentNames, {self.data.name})
268 detectorDataIds = registry.queryDataIds(["detector"]).expanded().toSequence()
269 self.assertEqual(len(detectorDataIds), self.data.nDetectors)
270 detectorNames = {
271 dataId.records["detector"].full_name # type: ignore[union-attr]
272 for dataId in detectorDataIds
273 }
274 self.assertIn(self.data.firstDetectorName, detectorNames)
275 physicalFilterDataIds = registry.queryDataIds(["physical_filter"]).toSequence()
276 filterNames = {dataId["physical_filter"] for dataId in physicalFilterDataIds}
277 self.assertGreaterEqual(filterNames, self.data.physical_filters)
279 # Check that the instrument class can be retrieved.
280 registeredInstrument = Instrument.fromName(self.instrument.getName(), registry)
281 self.assertEqual(type(registeredInstrument), type(self.instrument))
283 # Check that re-registration is not an error.
284 self.instrument.register(registry)