Coverage for tests/test_cameras.py: 72%
145 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 09:31 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 09:31 +0000
1# This file is part of lsst-images.
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# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14import os
15from typing import Any
17import numpy as np
18import pytest
20from lsst.images import YX
21from lsst.images.cameras import (
22 Amplifier,
23 AmplifierCalibrations,
24 AmplifierRawGeometry,
25 Detector,
26 ReadoutCorner,
27)
28from lsst.images.describe import DescribableMixin, Report
29from lsst.images.serialization import read_archive
30from lsst.images.tests import (
31 DP2_VISIT_DETECTOR_DATA_ID,
32 RoundtripFits,
33 compare_amplifier_to_legacy,
34 compare_detector_to_legacy,
35 reset_afw_mask_planes, # noqa: F401
36)
38LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
40EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
43@pytest.fixture
44def legacy_camera_data(reset_afw_mask_planes: None) -> dict[str, Any]: # noqa: F811
45 """Return a dict with legacy_camera and legacy_detector loaded from the
46 test data directory.
47 """
48 if EXTERNAL_DATA_DIR is None: 48 ↛ 51line 48 didn't jump to line 51 because the condition on line 48 was always true
49 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
50 # reset_afw_mask_planes will have already skipped if afw is not available.
51 from lsst.afw.cameraGeom import Camera as LegacyCamera
52 from lsst.afw.image import ExposureFitsReader
54 legacy_camera = LegacyCamera.readFits(os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "camera.fits"))
55 legacy_detector = ExposureFitsReader(
56 os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
57 ).readDetector()
58 return {"legacy_camera": legacy_camera, "legacy_detector": legacy_detector}
61def test_visit_image_detector_legacy_conversions(legacy_camera_data: dict[str, Any]) -> None:
62 """Test converting a visit-image detector from legacy and back with
63 serialization in between.
64 """
65 legacy_detector = legacy_camera_data["legacy_detector"]
66 detector = Detector.from_legacy(
67 legacy_detector,
68 instrument=DP2_VISIT_DETECTOR_DATA_ID["instrument"],
69 visit=DP2_VISIT_DETECTOR_DATA_ID["visit"],
70 )
71 compare_detector_to_legacy(detector, legacy_detector, is_raw_assembled=True)
72 with RoundtripFits(detector) as roundtrip:
73 pass
74 compare_detector_to_legacy(roundtrip.result, legacy_detector, is_raw_assembled=True)
75 compare_detector_to_legacy(detector, roundtrip.result.to_legacy(), is_raw_assembled=True)
78def test_camera_detector_legacy_conversions(legacy_camera_data: dict[str, Any]) -> None:
79 """Test converting one detector of each type from a legacy Camera and
80 back.
81 """
82 legacy_camera = legacy_camera_data["legacy_camera"]
83 detector_types_seen = set()
84 for legacy_detector_1 in legacy_camera:
85 if legacy_detector_1.getType() in detector_types_seen:
86 continue
87 detector_types_seen.add(legacy_detector_1.getType())
88 detector = Detector.from_legacy(
89 legacy_detector_1,
90 instrument=DP2_VISIT_DETECTOR_DATA_ID["instrument"],
91 )
92 compare_detector_to_legacy(detector, legacy_detector_1, is_raw_assembled=False)
93 with RoundtripFits(detector) as roundtrip:
94 pass
95 compare_detector_to_legacy(roundtrip.result, legacy_detector_1, is_raw_assembled=False)
96 legacy_detector_2 = roundtrip.result.to_legacy()
97 compare_detector_to_legacy(detector, legacy_detector_2, is_raw_assembled=False)
100def test_expanded_detector_roundtrip(legacy_camera_data: dict[str, Any]) -> None:
101 """Test round-tripping a detector that holds both assembled and
102 unassembled raw amplifier geometry.
103 """
104 legacy_camera = legacy_camera_data["legacy_camera"]
105 legacy_detector = legacy_camera_data["legacy_detector"]
106 legacy_camera_detector = legacy_camera[DP2_VISIT_DETECTOR_DATA_ID["detector"]]
107 detector = Detector.from_legacy(
108 legacy_camera_detector,
109 instrument=DP2_VISIT_DETECTOR_DATA_ID["instrument"],
110 visit=DP2_VISIT_DETECTOR_DATA_ID["visit"],
111 is_raw_assembled=False,
112 )
113 for amplifier, legacy_assembled_amplifier in zip(
114 detector.amplifiers, legacy_detector.getAmplifiers(), strict=True
115 ):
116 assert amplifier.unassembled_raw_geometry is not None
117 assert amplifier.assembled_raw_geometry is None
118 amplifier.assembled_raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(
119 legacy_assembled_amplifier
120 )
121 with RoundtripFits(detector) as roundtrip:
122 pass
123 compare_detector_to_legacy(roundtrip.result, legacy_camera_detector, is_raw_assembled=False)
124 compare_detector_to_legacy(roundtrip.result, legacy_detector, is_raw_assembled=True)
127def test_as_flips() -> None:
128 """Tes that ReadoutCorner.as_flips returns the correct YX flip flags for
129 each corner.
130 """
131 assert ReadoutCorner.LL.as_flips() == YX(y=False, x=False)
132 assert ReadoutCorner.LR.as_flips() == YX(y=False, x=True)
133 assert ReadoutCorner.UL.as_flips() == YX(y=True, x=False)
134 assert ReadoutCorner.UR.as_flips() == YX(y=True, x=True)
137def test_flips_roundtrip() -> None:
138 """Test that ReadoutCorner.from_flips is the inverse of as_flips for all
139 corners.
140 """
141 for corner in ReadoutCorner:
142 flips = corner.as_flips()
143 assert ReadoutCorner.from_flips(y=flips.y, x=flips.x) is corner
146def test_apply_flips() -> None:
147 """Test that ReadoutCorner.apply_flips with identity returns the same
148 corner, and a few specific flips are correct.
149 """
150 for corner in ReadoutCorner:
151 assert corner.apply_flips(y=False, x=False) is corner
152 assert ReadoutCorner.LL.apply_flips(y=True, x=True) is ReadoutCorner.UR
153 assert ReadoutCorner.LR.apply_flips(y=False, x=True) is ReadoutCorner.LL
154 assert ReadoutCorner.UL.apply_flips(y=True, x=False) is ReadoutCorner.LL
155 assert ReadoutCorner.UR.apply_flips(y=True, x=True) is ReadoutCorner.LL
158def test_detector_describe() -> None:
159 """Detector._describe returns a Report with the expected fields."""
160 detector = read_archive(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector)
161 assert isinstance(detector, DescribableMixin)
162 report = detector._describe()
163 assert isinstance(report, Report)
164 assert report.type_name == "Detector"
165 labels = {f.label for f in report.fields}
166 assert "instrument" in labels
167 assert "name" in labels
168 assert "id" in labels
169 assert "type" in labels
170 assert "serial" in labels
171 assert "bbox" in labels
172 name_field = next(f for f in report.fields if f.label == "name")
173 assert name_field.value == detector.name
176def _make_legacy_amplifier(*, has_prescan: bool) -> Any:
177 """Return a legacy amplifier with a simple raw geometry.
179 The raw regions tile the raw bounding box: 4 columns of prescan, 100
180 columns of data and 10 columns of serial overscan make up its 114
181 columns, and 50 rows of data plus 6 rows of parallel overscan make up
182 its 56 rows.
184 Parameters
185 ----------
186 has_prescan
187 Whether to give the amplifier a prescan region. DECam amplifiers
188 have none, and afw reports a missing region as an empty box.
189 """
190 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
191 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner
192 from lsst.geom import Box2I, Extent2I, Point2I
194 builder = LegacyAmplifier.Builder()
195 builder.setName("C00")
196 builder.setBBox(Box2I(Point2I(0, 0), Extent2I(100, 50)))
197 builder.setRawBBox(Box2I(Point2I(0, 0), Extent2I(114, 56)))
198 builder.setRawDataBBox(Box2I(Point2I(4, 0), Extent2I(100, 50)))
199 builder.setRawSerialOverscanBBox(Box2I(Point2I(104, 0), Extent2I(10, 50)))
200 builder.setRawParallelOverscanBBox(Box2I(Point2I(4, 50), Extent2I(100, 6)))
201 if has_prescan:
202 builder.setRawPrescanBBox(Box2I(Point2I(0, 0), Extent2I(4, 50)))
203 # LL plus a flip in x puts the readout corner of the trimmed image at
204 # LR, so the tests can tell whether the corner was converted.
205 builder.setReadoutCorner(LegacyReadoutCorner.LL)
206 builder.setRawFlipX(True)
207 builder.setRawXYOffset(Extent2I(0, 0))
208 return builder.finish()
211def test_amplifier_calibrations_nan_sentinels_equal() -> None:
212 """Test that two calibrations whose NaN sentinels (an unset suspect
213 level, unused linearity coefficients) survived a JSON round trip
214 compare equal, so a round-tripped block does not spuriously compare
215 unequal to its source.
216 """
217 calibrations = AmplifierCalibrations(
218 gain=3.43,
219 read_noise=1.5,
220 saturation=50000.0,
221 suspect_level=np.nan,
222 linearity_coefficients=np.array([1.6e-7, 0.0, np.nan, np.nan]),
223 linearity_type="Squared",
224 )
225 round_tripped = AmplifierCalibrations.model_validate_json(calibrations.model_dump_json())
226 assert round_tripped == calibrations
229def test_amplifier_with_full_raw_geometry(reset_afw_mask_planes: None) -> None: # noqa: F811
230 """Test converting a legacy amplifier whose raw geometry can be fully
231 represented.
232 """
233 legacy_amplifier = _make_legacy_amplifier(has_prescan=True)
234 amplifier = Amplifier.from_legacy(legacy_amplifier, is_raw_assembled=True)
235 raw_geometry = amplifier.assembled_raw_geometry
236 assert raw_geometry is not None
237 assert amplifier.unassembled_raw_geometry is None
238 # The legacy readout corner belongs to the raw image, which is flipped in
239 # x relative to the assembled, trimmed image.
240 assert raw_geometry.readout_corner == ReadoutCorner.LL
241 assert amplifier.readout_corner == ReadoutCorner.LR
242 compare_amplifier_to_legacy(amplifier, legacy_amplifier, is_raw_assembled=True)
243 round_tripped = amplifier.to_legacy_builder(True).finish()
244 compare_amplifier_to_legacy(amplifier, round_tripped, is_raw_assembled=True)
247def test_amplifier_without_prescan(reset_afw_mask_planes: None) -> None: # noqa: F811
248 """Test converting a legacy amplifier whose prescan region is empty.
250 `Box` requires a positive size, so the prescan box is `None` while the
251 rest of the raw geometry is kept.
252 """
253 legacy_amplifier = _make_legacy_amplifier(has_prescan=False)
254 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier)
255 assert raw_geometry.prescan_bbox is None
256 assert raw_geometry.serial_prescan_bbox is None
257 assert raw_geometry.horizontal_prescan_bbox is None
258 amplifier = Amplifier.from_legacy(legacy_amplifier, is_raw_assembled=True)
259 assert amplifier.assembled_raw_geometry is not None
260 assert amplifier.assembled_raw_geometry.prescan_bbox is None
261 assert amplifier.unassembled_raw_geometry is None
262 assert amplifier.assembled_raw_geometry.readout_corner == ReadoutCorner.LL
263 assert amplifier.readout_corner == ReadoutCorner.LR
264 compare_amplifier_to_legacy(amplifier, legacy_amplifier, is_raw_assembled=True)
265 # Everything but the prescan survives the round trip, and the prescan
266 # comes back as the empty box afw uses for an amplifier that has none.
267 round_tripped = amplifier.to_legacy_builder(True).finish()
268 assert round_tripped.getRawPrescanBBox().isEmpty()
269 assert round_tripped.getRawBBox() == legacy_amplifier.getRawBBox()
270 assert round_tripped.getRawDataBBox() == legacy_amplifier.getRawDataBBox()
271 assert round_tripped.getRawSerialOverscanBBox() == legacy_amplifier.getRawSerialOverscanBBox()
272 assert round_tripped.getRawParallelOverscanBBox() == legacy_amplifier.getRawParallelOverscanBBox()
273 assert round_tripped.getRawFlipX() == legacy_amplifier.getRawFlipX()
274 assert round_tripped.getReadoutCorner() == legacy_amplifier.getReadoutCorner()
275 compare_amplifier_to_legacy(amplifier, round_tripped, is_raw_assembled=True)
278def test_amplifier_wrong_raw_geometry_kind(reset_afw_mask_planes: None) -> None: # noqa: F811
279 """Test that asking for the raw geometry the amplifier does not have is
280 still an error.
281 """
282 amplifier = Amplifier.from_legacy(_make_legacy_amplifier(has_prescan=True), is_raw_assembled=True)
283 with pytest.raises(ValueError, match="unassembled_raw_geometry is None"):
284 amplifier.to_legacy_builder(False)