Coverage for tests/test_cameras.py: 72%

145 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 09:44 +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. 

11 

12from __future__ import annotations 

13 

14import os 

15from typing import Any 

16 

17import numpy as np 

18import pytest 

19 

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) 

37 

38LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") 

39 

40EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

41 

42 

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 

53 

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} 

59 

60 

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) 

76 

77 

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) 

98 

99 

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) 

125 

126 

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) 

135 

136 

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 

144 

145 

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 

156 

157 

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 

174 

175 

176def _make_legacy_amplifier(*, has_prescan: bool) -> Any: 

177 """Return a legacy amplifier with a simple raw geometry. 

178 

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. 

183 

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 

193 

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() 

209 

210 

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 

227 

228 

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) 

245 

246 

247def test_amplifier_without_prescan(reset_afw_mask_planes: None) -> None: # noqa: F811 

248 """Test converting a legacy amplifier whose prescan region is empty. 

249 

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) 

276 

277 

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)