Coverage for tests/test_obs_info_from_legacy.py: 96%

204 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 09:26 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# 

5# This product includes software developed by the LSST Project 

6# (https://www.lsst.org). 

7# See the COPYRIGHT file at the top-level directory of this distribution 

8# for details of code ownership. 

9# 

10# Use of this source code is governed by a 3-clause BSD-style 

11# license that can be found in the LICENSE file. 

12 

13"""Tests for ObservationInfo->VisitInfo->ObservationInfo round-trips. 

14 

15Neither ObservationInfo nor VisitInfo live in `lsst.images`, but these tests 

16live here because the reconstruction logic they exercise lives in 

17`lsst.images.VisitImage.from_legacy` and `~lsst.images.VisitImage.read_legacy`. 

18""" 

19 

20from __future__ import annotations 

21 

22import dataclasses 

23import logging 

24import warnings 

25from functools import cached_property 

26from pathlib import Path 

27from typing import Any 

28 

29import astropy.coordinates as coords 

30import astropy.io.fits 

31import astropy.units as u 

32import pytest 

33from astro_metadata_translator import FITS_FLAT_DICT_PREFIX, FITS_FLAT_DICT_SEP, ObservationInfo 

34 

35from lsst.images import VisitImage 

36from lsst.images.cameras import Detector 

37from lsst.images.fits import ExtensionKey 

38from lsst.images.serialization import read_archive 

39from lsst.images.tests import ( 

40 TemporaryButler, 

41 annotate_errors, 

42 assert_obs_metadata_fields_equal, 

43) 

44 

45try: 

46 from lsst.afw.cameraGeom import Detector as LegacyDetector 

47 from lsst.afw.detection import GaussianPsf as LegacyGaussianPsf 

48 from lsst.afw.geom import makeCdMatrix, makeSkyWcs 

49 from lsst.afw.image import ExposureF as LegacyExposureF 

50 from lsst.afw.image import FilterLabel, stripVisitInfoKeywords 

51 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

52 from lsst.daf.base import PropertyList 

53 from lsst.daf.butler import DimensionUniverse 

54 from lsst.geom import Point2D, SpherePoint, arcseconds, degrees 

55 from lsst.obs.base import MakeRawVisitInfoViaObsInfo, makeExposureRecordFromObsInfo 

56 

57 HAVE_LEGACY = True 

58except ImportError: 

59 type LegacyVisitInfo = Any # type: ignore[no-redef] 

60 HAVE_LEGACY = False 

61 

62skip_no_legacy = pytest.mark.skipif(not HAVE_LEGACY, reason="afw etc could not be imported.") 

63 

64DATA_DIR = Path(__file__).parent / "data" / "headers" 

65OBS_INFO_DIR = Path(__file__).parent / "data" / "obs_info" 

66DETECTORS_DIR = Path(__file__).parent / "data" / "detectors" 

67 

68 

69@dataclasses.dataclass(frozen=True) 

70class InstrumentCase: 

71 """Definition of a per-instrument test fixture and helpers for loading its 

72 data. 

73 """ 

74 

75 label: str 

76 test_data_filename: str 

77 obs_info_filename: str 

78 detector_filename: str 

79 

80 def get_full_header(self) -> astropy.io.fits.Header: 

81 return astropy.io.fits.Header.fromtextfile(str(DATA_DIR / self.test_data_filename)) 

82 

83 def get_stripped_header(self) -> astropy.io.fits.Header: 

84 md = PropertyList.from_astropy_header(self.get_full_header()) 

85 stripVisitInfoKeywords(md) 

86 for key in [ # extra cards stripped in ExposureFitsReader 

87 "MJD-OBS", 

88 "DATE-OBS", 

89 "DETNAME", 

90 "DETSER", 

91 "EXPID", 

92 ]: 

93 md.pop(key, ...) 

94 return md.to_astropy_header() 

95 

96 @cached_property 

97 def true_obs_info(self) -> ObservationInfo: 

98 """The ground-truth ObservationInfo (translated from raw).""" 

99 return ObservationInfo.model_validate_json((OBS_INFO_DIR / self.obs_info_filename).read_text()) 

100 

101 def make_record(self) -> Any: 

102 """Build the `exposure` dimension record from the true 

103 `ObservationInfo`. 

104 """ 

105 return makeExposureRecordFromObsInfo(self.true_obs_info, DimensionUniverse()) 

106 

107 @cached_property 

108 def legacy_detector(self) -> LegacyDetector: 

109 """The legacy `lsst.afw.cameraGeom.Detector` for this test case.""" 

110 assert self.true_obs_info.detector_num is not None 

111 detector = read_archive(str(DETECTORS_DIR / self.detector_filename), Detector) 

112 assert detector.id == self.true_obs_info.detector_num 

113 return detector.to_legacy() 

114 

115 def make_stripped_exposure(self) -> LegacyExposureF: 

116 """Build a test ``ExposureF`` with metadata content mimicking a legacy 

117 visit image that has been roundtripped through FITS at least once. 

118 """ 

119 header = self.get_stripped_header() 

120 exposure = LegacyExposureF(16, 16) 

121 if self.true_obs_info.detector_exposure_id is not None: 121 ↛ 123line 121 didn't jump to line 123 because the condition on line 121 was always true

122 exposure.info.setId(self.true_obs_info.detector_exposure_id) 

123 exposure.setMetadata(PropertyList.from_astropy_header(header)) 

124 visit_info = MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.true_obs_info) 

125 exposure.info.setVisitInfo(visit_info) 

126 wcs = makeSkyWcs( 

127 crpix=Point2D(8.0, 8.0), 

128 crval=SpherePoint(42.0 * degrees, -11.0 * degrees), # arbitrary ICRS position; not asserted on 

129 cdMatrix=makeCdMatrix(scale=0.2 * arcseconds), 

130 ) 

131 exposure.setWcs(wcs) 

132 exposure.setDetector(self.legacy_detector) 

133 exposure.setPsf(LegacyGaussianPsf(11, 11, 2.0)) 

134 exposure.info.setFilter( 

135 FilterLabel(band="g", physical=self.true_obs_info.physical_filter or "UNKNOWN") 

136 ) 

137 return exposure 

138 

139 def make_embedded_exposure(self) -> LegacyExposureF: 

140 """Build a test ``ExposureF`` whose metadata carries the true 

141 `ObservationInfo` as flat ``OBS_INFO`` cards, as written by the raw 

142 formatter, in addition to the stripped legacy header content. 

143 """ 

144 exposure = self.make_stripped_exposure() 

145 md = exposure.getMetadata() 

146 md.update(self.true_obs_info.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)) 

147 return exposure 

148 

149 

150# Fields that we can't recover from any of the inputs to obs_info_from_legacy, 

151# mapped to what we get instead. 

152UNRECOVERABLE_FIELDS = { 

153 "translator_name": "VisitInfo", 

154 "detector_name": None, 

155 "detector_group": None, 

156 "observing_day_offset": None, 

157 "altaz_end": None, 

158} 

159 

160 

161def assert_obs_info_equal( 

162 actual: ObservationInfo, 

163 expected: ObservationInfo, 

164 path: str, 

165 *, 

166 detector_serial: str | None, 

167 detector_unique_name: str, 

168) -> None: 

169 """Assert that two ObservationInfo structs are as equal as they can be 

170 after a from-legacy conversion. 

171 """ 

172 for field in ObservationInfo.model_fields: 

173 if field == "warnings": 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 continue 

175 expected_value = getattr(expected, field) 

176 if expected_value is None: 

177 continue 

178 actual_value = getattr(actual, field, None) 

179 if field in UNRECOVERABLE_FIELDS: 

180 if actual_value != UNRECOVERABLE_FIELDS[field]: 

181 raise AssertionError( 

182 f"{field} via {path}: expected fallback " 

183 f"{UNRECOVERABLE_FIELDS[field]!r}, got {actual_value!r}" 

184 ) 

185 continue 

186 if field == "visit_id": 

187 if actual_value != actual.exposure_id: 

188 raise AssertionError( 

189 f"visit_id via {path}: {actual_value!r} != exposure_id {actual.exposure_id!r}" 

190 ) 

191 continue 

192 if field == "boresight_rotation_coord": 

193 if isinstance(expected_value, str) and isinstance(actual_value, str): 193 ↛ 199line 193 didn't jump to line 199 because the condition on line 193 was always true

194 if actual_value.casefold() != expected_value.casefold(): 

195 raise AssertionError( 

196 f"{field} via {path}: {actual_value!r} != {expected_value!r} (ignoring case)" 

197 ) 

198 continue 

199 if field == "detector_serial": 

200 expected_value = detector_serial 

201 if field == "detector_unique_name": 

202 expected_value = detector_unique_name 

203 if actual_value is None: 

204 raise AssertionError(f"{field} was lost via {path}: expected {expected_value}") 

205 with annotate_errors(field): 

206 assert_obs_metadata_fields_equal(actual_value, expected_value, label=field) 

207 

208 

209def _point_to_astropy(point: Any) -> coords.SkyCoord: 

210 """Convert an `lsst.geom.SpherePoint` to an Astropy ICRS `SkyCoord`.""" 

211 return coords.SkyCoord( 

212 ra=point.getRa().asRadians() * u.rad, 

213 dec=point.getDec().asRadians() * u.rad, 

214 ) 

215 

216 

217def _point_to_altaz(point: Any) -> coords.AltAz: 

218 """Convert an `lsst.geom.SpherePoint` conventionally holding az/alt to 

219 an Astropy `AltAz` frame. 

220 """ 

221 return coords.AltAz( 

222 az=coords.Longitude(point.getLongitude().asRadians(), u.rad), 

223 alt=coords.Latitude(point.getLatitude().asRadians(), u.rad), 

224 ) 

225 

226 

227def assert_visit_info_equal(actual: LegacyVisitInfo, expected: LegacyVisitInfo) -> None: 

228 """Assert that two VisitInfo structs are equal after a roundtrip 

229 through `lsst.images.VisitImage`. 

230 """ 

231 for name in ( 

232 "id", 

233 "date", 

234 "ut1", 

235 "instrumentLabel", 

236 "scienceProgram", 

237 "observationType", 

238 "observationReason", 

239 "object", 

240 "observatory", 

241 "rotType", 

242 "weather", 

243 "exposureTime", 

244 "darkTime", 

245 "boresightAirmass", 

246 "focusZ", 

247 "era", 

248 "localEra", 

249 "boresightHourAngle", 

250 "boresightParAngle", 

251 "boresightRotAngle", 

252 "hasSimulatedContent", 

253 "isPersistable", 

254 ): 

255 with annotate_errors(name): 

256 assert_obs_metadata_fields_equal(getattr(actual, name), getattr(expected, name), label=name) 

257 with annotate_errors("boresightRaDec"): 

258 assert_obs_metadata_fields_equal( 

259 _point_to_astropy(actual.boresightRaDec), 

260 _point_to_astropy(expected.boresightRaDec), 

261 label="boresightRaDec", 

262 ) 

263 with annotate_errors("boresightAzAlt"): 

264 assert_obs_metadata_fields_equal( 

265 _point_to_altaz(actual.boresightAzAlt), 

266 _point_to_altaz(expected.boresightAzAlt), 

267 label="boresightAzAlt", 

268 ) 

269 

270 

271INSTRUMENT_CASES = [ 

272 InstrumentCase( 

273 label="DECam", 

274 test_data_filename="decam-c4d_150218_052850-N25.hdr", 

275 obs_info_filename="decam-c4d_150218_052850-N25.json", 

276 detector_filename="decam-c4d_150218_052850-N25.json", 

277 ), 

278 InstrumentCase( 

279 label="HSC", 

280 test_data_filename="hsc-HSCA90333426.hdr", 

281 obs_info_filename="hsc-HSCA90333426.json", 

282 detector_filename="hsc-HSCA90333426.json", 

283 ), 

284 InstrumentCase( 

285 label="LSSTCam", 

286 test_data_filename="lsstcam-MC_O_20250501_000292_R21_S11.hdr", 

287 obs_info_filename="lsstcam-MC_O_20250501_000292_R21_S11.json", 

288 detector_filename="lsstcam-MC_O_20250501_000292_R21_S11.json", 

289 ), 

290] 

291INSTRUMENT_IDS = [case.label for case in INSTRUMENT_CASES] 

292 

293parametrize_instruments = pytest.mark.parametrize("instrument_case", INSTRUMENT_CASES, ids=INSTRUMENT_IDS) 

294 

295 

296@skip_no_legacy 

297@parametrize_instruments 

298def test_from_and_read_legacy_preserve_obs_info(instrument_case: InstrumentCase) -> None: 

299 """ObservationInfo fields must survive from_legacy on a stripped header.""" 

300 assert instrument_case.true_obs_info.instrument == instrument_case.label 

301 assert instrument_case.true_obs_info.exposure_id is not None 

302 exposure = instrument_case.make_stripped_exposure() 

303 exposure_record = instrument_case.make_record() 

304 with warnings.catch_warnings(): 

305 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

306 result1 = VisitImage.from_legacy(exposure, exposure_record=exposure_record) 

307 assert_obs_info_equal( 

308 result1.obs_info, 

309 instrument_case.true_obs_info, 

310 "VisitImage.from_legacy on an in-memory stripped header", 

311 detector_serial=instrument_case.legacy_detector.getSerial(), 

312 detector_unique_name=instrument_case.legacy_detector.getName(), 

313 ) 

314 roundtrip1 = result1.to_legacy() 

315 assert_visit_info_equal(roundtrip1.info.getVisitInfo(), exposure.info.getVisitInfo()) 

316 with TemporaryButler(legacy="ExposureF") as helper: 

317 helper.butler.put(exposure, helper.legacy) 

318 visit_image_ref = helper.legacy.overrideStorageClass("VisitImage") 

319 with warnings.catch_warnings(): 

320 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

321 try: 

322 result2 = helper.butler.get(visit_image_ref, parameters={"exposure_record": exposure_record}) 

323 except Exception as err: 

324 pytest.fail( 

325 f"butler.get as VisitImage raised {type(err).__name__}: {err} " 

326 "-- every ObservationInfo field was lost." 

327 ) 

328 roundtrip2 = result2.to_legacy() 

329 assert_visit_info_equal(roundtrip2.info.getVisitInfo(), exposure.info.getVisitInfo()) 

330 assert_obs_info_equal( 

331 result2.obs_info, 

332 instrument_case.true_obs_info, 

333 "butler.get of an ExposureF dataset as VisitImage", 

334 detector_serial=instrument_case.legacy_detector.getSerial(), 

335 detector_unique_name=instrument_case.legacy_detector.getName(), 

336 ) 

337 with TemporaryButler(legacy="ExposureF") as helper: 

338 helper.butler.put(exposure, helper.legacy) 

339 visit_image_ref = helper.legacy.overrideStorageClass("VisitImage") 

340 with pytest.raises(ValueError, match="exposure_record"): 

341 helper.butler.get(visit_image_ref) 

342 

343 

344def _assert_obs_info_not_in_header(visit_image: VisitImage) -> None: 

345 """Assert that no flat ``OBS_INFO`` cards leaked into the opaque primary 

346 header attached to a converted image. 

347 """ 

348 header = visit_image._opaque_metadata.headers[ExtensionKey()] 

349 assert not ObservationInfo.has_flat_dict(header, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP) 

350 leaked = [key for key in header if key.startswith(FITS_FLAT_DICT_PREFIX)] 

351 assert not leaked, leaked 

352 

353 

354@skip_no_legacy 

355@parametrize_instruments 

356def test_from_legacy_uses_embedded_obs_info( 

357 instrument_case: InstrumentCase, caplog: pytest.LogCaptureFixture 

358) -> None: 

359 """Embedded OBS_INFO cards are used verbatim, without a record, with no 

360 degradation warning, and are stripped from the attached header. 

361 """ 

362 exposure = instrument_case.make_embedded_exposure() 

363 with caplog.at_level(logging.WARNING, logger="lsst.images"), warnings.catch_warnings(): 

364 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

365 result = VisitImage.from_legacy(exposure, exposure_record=None) 

366 assert result.obs_info == instrument_case.true_obs_info 

367 degraded = [r.getMessage() for r in caplog.records if "exposure dimension record" in r.getMessage()] 

368 assert not degraded, degraded 

369 _assert_obs_info_not_in_header(result) 

370 # The exposure passed in is not modified. 

371 assert ObservationInfo.has_flat_dict( 

372 exposure.getMetadata(), prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP 

373 ) 

374 

375 

376@skip_no_legacy 

377@parametrize_instruments 

378def test_read_legacy_uses_embedded_obs_info(instrument_case: InstrumentCase, tmp_path: Path) -> None: 

379 """Embedded OBS_INFO cards survive a legacy FITS write and are used by 

380 read_legacy, both for the full image and the obs_info component. 

381 """ 

382 exposure = instrument_case.make_embedded_exposure() 

383 filename = str(tmp_path / "embedded.fits") 

384 exposure.writeFits(filename) 

385 with warnings.catch_warnings(): 

386 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

387 result = VisitImage.read_legacy(filename, exposure_record=None) 

388 component = VisitImage.read_legacy(filename, exposure_record=None, component="obs_info") 

389 assert result.obs_info == instrument_case.true_obs_info 

390 assert component == instrument_case.true_obs_info 

391 _assert_obs_info_not_in_header(result) 

392 

393 

394@skip_no_legacy 

395@parametrize_instruments 

396def test_butler_get_uses_embedded_obs_info(instrument_case: InstrumentCase) -> None: 

397 """A butler read of an ExposureF dataset as VisitImage uses the embedded 

398 OBS_INFO cards even though the exposure record must still be passed. 

399 """ 

400 exposure = instrument_case.make_embedded_exposure() 

401 exposure_record = instrument_case.make_record() 

402 with TemporaryButler(legacy="ExposureF") as helper: 

403 helper.butler.put(exposure, helper.legacy) 

404 visit_image_ref = helper.legacy.overrideStorageClass("VisitImage") 

405 with warnings.catch_warnings(): 

406 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

407 result = helper.butler.get(visit_image_ref, parameters={"exposure_record": exposure_record}) 

408 assert result.obs_info == instrument_case.true_obs_info 

409 _assert_obs_info_not_in_header(result) 

410 

411 

412@skip_no_legacy 

413@parametrize_instruments 

414def test_embedded_obs_info_rejects_mismatched_record(instrument_case: InstrumentCase) -> None: 

415 """An exposure record for a different exposure than the embedded 

416 ObservationInfo describes is an error. 

417 """ 

418 exposure = instrument_case.make_embedded_exposure() 

419 assert instrument_case.true_obs_info.exposure_id is not None 

420 with instrument_case.true_obs_info.edit_copy() as other_obs_info: 

421 other_obs_info.exposure_id = instrument_case.true_obs_info.exposure_id + 1 

422 other_record = makeExposureRecordFromObsInfo(other_obs_info, DimensionUniverse()) 

423 with pytest.raises(ValueError, match="embedded ObservationInfo"): 

424 VisitImage.from_legacy(exposure, exposure_record=other_record)