Coverage for tests/test_ndf_input_archive.py: 99%

282 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -0700

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 pathlib import Path 

16 

17import astropy.io.fits 

18import astropy.units as u 

19import numpy as np 

20import pydantic 

21import pytest 

22 

23from lsst.images import Box, Image, ImageSerializationModel, Mask, MaskedImage 

24from lsst.images._transforms import FrameSet 

25from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

26from lsst.images.serialization import ( 

27 ArchiveReadError, 

28 ArrayReferenceModel, 

29 InlineArrayModel, 

30 NumberType, 

31 read_archive, 

32) 

33 

34try: 

35 import h5py 

36 

37 from lsst.images.ndf import ( 

38 NdfInputArchive, 

39 NdfOutputArchive, 

40 NdfPointerModel, 

41 _hds, 

42 read_starlink, 

43 write, 

44 ) 

45 

46 HAVE_H5PY = True 

47except ImportError: 

48 HAVE_H5PY = False 

49 

50skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed") 

51 

52 

53@skip_no_h5py 

54def test_open_round_trips_image_tree(tmp_path: Path) -> None: 

55 """Verify NdfInputArchive.open round-trips an Image's ArchiveTree.""" 

56 image = Image( 

57 np.arange(20, dtype=np.float32).reshape(4, 5), 

58 bbox=Box.factory[10:14, 20:25], 

59 ) 

60 path = tmp_path / "test.sdf" 

61 written_tree = write(image, path) 

62 with NdfInputArchive.open(path) as archive: 

63 tree = archive.get_tree(type(written_tree)) 

64 assert tree.model_dump_json() == written_tree.model_dump_json() 

65 

66 

67@skip_no_h5py 

68def test_get_tree_raises_when_main_json_missing(tmp_path: Path) -> None: 

69 """Verify get_tree raises ArchiveReadError when /MORE/LSST/JSON is 

70 absent. 

71 """ 

72 path = tmp_path / "test.sdf" 

73 with h5py.File(path, "w") as f: 

74 f["/"].attrs["CLASS"] = "NDF" 

75 with NdfInputArchive.open(path) as archive: 

76 model_type = ImageSerializationModel[NdfPointerModel] 

77 with pytest.raises(ArchiveReadError): 

78 archive.get_tree(model_type) 

79 

80 

81@skip_no_h5py 

82def test_get_array_reads_image_array(tmp_path: Path) -> None: 

83 """Verify get_array returns the full image array from the NDF file.""" 

84 image = Image(np.arange(20, dtype=np.float32).reshape(4, 5)) 

85 path = tmp_path / "test.sdf" 

86 tree = write(image, path) 

87 with NdfInputArchive.open(path) as archive: 

88 arr = archive.get_array(tree.data) 

89 np.testing.assert_array_equal(arr, image.array) 

90 

91 

92@skip_no_h5py 

93def test_get_array_supports_slicing(tmp_path: Path) -> None: 

94 """Verify get_array returns the correct sub-array when slices are given.""" 

95 image = Image(np.arange(20, dtype=np.float32).reshape(4, 5)) 

96 path = tmp_path / "test.sdf" 

97 tree = write(image, path) 

98 with NdfInputArchive.open(path) as archive: 

99 arr = archive.get_array(tree.data, slices=(slice(0, 2), slice(1, 4))) 

100 np.testing.assert_array_equal(arr, image.array[:2, 1:4]) 

101 

102 

103@skip_no_h5py 

104def test_get_array_handles_inline_array(tmp_path: Path) -> None: 

105 """Verify get_array converts an InlineArrayModel to a numpy array.""" 

106 inline = InlineArrayModel(data=[1.0, 2.0, 3.0], datatype=NumberType.float64) 

107 image = Image(np.zeros((2, 2), dtype=np.float32)) 

108 path = tmp_path / "test.sdf" 

109 write(image, path) 

110 with NdfInputArchive.open(path) as archive: 

111 arr = archive.get_array(inline) 

112 np.testing.assert_array_equal(arr, np.array([1.0, 2.0, 3.0])) 

113 

114 

115@skip_no_h5py 

116def test_get_array_unrecognised_source_raises(tmp_path: Path) -> None: 

117 """Verify get_array raises ArchiveReadError for an unrecognised source 

118 scheme. 

119 """ 

120 image = Image(np.zeros((2, 2), dtype=np.float32)) 

121 bogus = ArrayReferenceModel(source="fits:NOTUS", shape=[2, 2], datatype=NumberType.float32) 

122 path = tmp_path / "test.sdf" 

123 write(image, path) 

124 with NdfInputArchive.open(path) as archive: 

125 with pytest.raises(ArchiveReadError): 

126 archive.get_array(bogus) 

127 

128 

129@skip_no_h5py 

130def test_deserialize_pointer_round_trips_subtree(tmp_path: Path) -> None: 

131 """Verify deserialize_pointer reconstructs a hoisted sub-tree correctly.""" 

132 

133 class TinyTree(pydantic.BaseModel): 

134 name: str 

135 

136 path = tmp_path / "test.sdf" 

137 with h5py.File(path, "w") as f: 

138 arch = NdfOutputArchive(f) 

139 ptr = arch.serialize_pointer("psf", lambda nested: TinyTree(name="hello"), key=("psf", 1)) 

140 with NdfInputArchive.open(path) as archive: 

141 result = archive.deserialize_pointer(ptr, TinyTree, lambda m, _a: m) 

142 assert result.name == "hello" 

143 

144 

145@skip_no_h5py 

146def test_deserialize_pointer_caches_by_ref(tmp_path: Path) -> None: 

147 """Verify deserialize_pointer calls the deserializer only once for the same 

148 ref. 

149 """ 

150 

151 class TinyTree(pydantic.BaseModel): 

152 name: str 

153 

154 calls: list[TinyTree] = [] 

155 

156 def deserializer(model: TinyTree, _archive: NdfInputArchive) -> TinyTree: 

157 calls.append(model) 

158 return model 

159 

160 path = tmp_path / "test.sdf" 

161 with h5py.File(path, "w") as f: 

162 arch = NdfOutputArchive(f) 

163 ptr = arch.serialize_pointer("psf", lambda nested: TinyTree(name="x"), key=("psf", 1)) 

164 with NdfInputArchive.open(path) as archive: 

165 first = archive.deserialize_pointer(ptr, TinyTree, deserializer) 

166 second = archive.deserialize_pointer(ptr, TinyTree, deserializer) 

167 assert first is second 

168 assert len(calls) == 1 

169 

170 

171@skip_no_h5py 

172def test_deserialize_pointer_caches_frame_set_for_get_frame_set(tmp_path: Path) -> None: 

173 """Verify a deserialized FrameSet is returned by get_frame_set via the 

174 shared cache. 

175 """ 

176 

177 class TinyTree(pydantic.BaseModel): 

178 name: str 

179 

180 class DummyFrameSet(FrameSet): 

181 def __contains__(self, frame: object) -> bool: 

182 return False 

183 

184 def __getitem__(self, key: object) -> object: 

185 raise AssertionError("DummyFrameSet should not be indexed in this test.") 

186 

187 sentinel = DummyFrameSet() 

188 

189 path = tmp_path / "test.sdf" 

190 with h5py.File(path, "w") as f: 

191 arch = NdfOutputArchive(f) 

192 ptr = arch.serialize_frame_set( 

193 "frames", 

194 sentinel, 

195 lambda nested: TinyTree(name="frames"), 

196 key=("frames", 1), 

197 ) 

198 with NdfInputArchive.open(path) as archive: 

199 result = archive.deserialize_pointer(ptr, TinyTree, lambda _m, _a: sentinel) 

200 assert result is sentinel 

201 assert archive.get_frame_set(ptr) is sentinel 

202 

203 

204@skip_no_h5py 

205def test_get_frame_set_returns_cached_value(tmp_path: Path) -> None: 

206 """Verify get_frame_set returns a sentinel previously placed in the 

207 cache. 

208 """ 

209 sentinel = object() 

210 path = tmp_path / "test.sdf" 

211 write(Image(np.zeros((2, 2), dtype=np.float32)), path) 

212 with NdfInputArchive.open(path) as archive: 

213 archive._frame_set_cache["/MORE/LSST/PIXEL_TO_SKY"] = sentinel 

214 pointer = NdfPointerModel(path="/MORE/LSST/PIXEL_TO_SKY") 

215 assert archive.get_frame_set(pointer) is sentinel 

216 

217 

218@skip_no_h5py 

219def test_get_frame_set_raises_if_not_cached(tmp_path: Path) -> None: 

220 """Verify get_frame_set raises AssertionError when the pointer is not in 

221 the cache. 

222 """ 

223 path = tmp_path / "test.sdf" 

224 write(Image(np.zeros((2, 2), dtype=np.float32)), path) 

225 with NdfInputArchive.open(path) as archive: 

226 pointer = NdfPointerModel(path="/MORE/LSST/UNKNOWN") 

227 with pytest.raises(AssertionError): 

228 archive.get_frame_set(pointer) 

229 

230 

231@skip_no_h5py 

232def test_more_fits_round_trips_via_opaque_metadata(tmp_path: Path) -> None: 

233 """Verify /MORE/FITS headers are recovered by get_opaque_metadata.""" 

234 image = Image(np.zeros((2, 2), dtype=np.float32)) 

235 primary = astropy.io.fits.Header() 

236 primary["FOO"] = ("bar", "test card") 

237 opaque = FitsOpaqueMetadata() 

238 opaque.add_header(primary, name="", ver=1) 

239 image._opaque_metadata = opaque 

240 path = tmp_path / "test.sdf" 

241 write(image, path) 

242 with NdfInputArchive.open(path) as archive: 

243 recovered = archive.get_opaque_metadata() 

244 assert ExtensionKey() in recovered.headers 

245 assert recovered.headers[ExtensionKey()]["FOO"] == "bar" 

246 

247 

248@skip_no_h5py 

249def test_get_opaque_metadata_empty_when_no_more_fits(tmp_path: Path) -> None: 

250 """Verify get_opaque_metadata returns an empty FitsOpaqueMetadata when 

251 /MORE/FITS is absent. 

252 """ 

253 image = Image(np.zeros((2, 2), dtype=np.float32)) 

254 path = tmp_path / "test.sdf" 

255 write(image, path) 

256 with NdfInputArchive.open(path) as archive: 

257 recovered = archive.get_opaque_metadata() 

258 assert isinstance(recovered, FitsOpaqueMetadata) 

259 assert not recovered.headers 

260 

261 

262@skip_no_h5py 

263def test_read_round_trips_image(tmp_path: Path) -> None: 

264 """Verify read_archive() round-trips an Image through an NDF file.""" 

265 image = Image( 

266 np.arange(20, dtype=np.float32).reshape(4, 5), 

267 bbox=Box.factory[10:14, 20:25], 

268 ) 

269 path = tmp_path / "test.sdf" 

270 write(image, path) 

271 result = read_archive(path, Image) 

272 assert isinstance(result, Image) 

273 np.testing.assert_array_equal(result.array, image.array) 

274 assert result.bbox == image.bbox 

275 

276 

277@skip_no_h5py 

278def test_read_starlink_file_auto_detects_image(tmp_path: Path) -> None: 

279 """Verify read_starlink auto-detects an Image from a schema-less NDF 

280 fixture. 

281 """ 

282 example_path = os.path.join(os.path.dirname(__file__), "data", "example-ndf.sdf") 

283 result = read_starlink(Image, example_path) 

284 assert isinstance(result, Image) 

285 assert result.array.shape == (611, 609) 

286 assert result.array.dtype == np.int16 

287 assert result.sky_projection is not None 

288 

289 

290@skip_no_h5py 

291def test_read_starlink_file_recovers_opaque_fits_metadata(tmp_path: Path) -> None: 

292 """Verify read_starlink recovers /MORE/FITS opaque metadata from a Starlink 

293 NDF. 

294 """ 

295 example_path = os.path.join(os.path.dirname(__file__), "data", "example-ndf.sdf") 

296 result = read_starlink(Image, example_path) 

297 opaque = result._opaque_metadata 

298 assert ExtensionKey() in opaque.headers 

299 primary = opaque.headers[ExtensionKey()] 

300 assert "NAXIS" in primary 

301 

302 

303@skip_no_h5py 

304def test_read_auto_detects_nested_quality_array(tmp_path: Path) -> None: 

305 """Verify read_starlink maps a QUALITY array to a MaskedImage mask.""" 

306 image_array = np.arange(6, dtype=np.float32).reshape(2, 3) 

307 quality_array = np.array([[0, 1, 0], [1, 0, 1]], dtype=np.uint8) 

308 

309 path = tmp_path / "test.sdf" 

310 with h5py.File(path, "w") as f: 

311 _hds.set_root_name(f, "TEST", "NDF") 

312 data_array = _hds.create_structure(f, "DATA_ARRAY", "ARRAY") 

313 _hds.write_array(data_array, "DATA", image_array) 

314 quality = _hds.create_structure(f, "QUALITY", "QUALITY") 

315 quality_array_struct = _hds.create_structure(quality, "QUALITY", "ARRAY") 

316 _hds.write_array(quality_array_struct, "DATA", quality_array) 

317 _hds.write_array(quality_array_struct, "ORIGIN", np.array([0, 0], dtype=np.int32)) 

318 _hds.write_array(quality_array_struct, "BAD_PIXEL", np.array(False, dtype=np.bool_)) 

319 _hds.write_array(quality, "BADBITS", np.array(1, dtype=np.uint8)) 

320 result = read_starlink(MaskedImage, path) 

321 assert isinstance(result, MaskedImage) 

322 np.testing.assert_array_equal(result.mask.array[:, :, 0], quality_array) 

323 assert set(result.mask.schema.names) == {f"MASK{i}" for i in range(8)} 

324 image_result = read_starlink(Image, path) 

325 assert isinstance(image_result, Image) 

326 np.testing.assert_array_equal(image_result.array, image_array) 

327 

328 

329@skip_no_h5py 

330def test_read_auto_detect_preserves_quality_bits(tmp_path: Path) -> None: 

331 """Verify read_starlink correctly maps BADBITS-selected quality planes.""" 

332 image_array = np.arange(6, dtype=np.float32).reshape(2, 3) 

333 quality_array = np.array([[0, 2, 4], [2, 0, 6]], dtype=np.uint8) 

334 expected_mask1 = np.array([[0, 1, 0], [1, 0, 1]], dtype=bool) 

335 expected_mask2 = np.array([[0, 0, 1], [0, 0, 1]], dtype=bool) 

336 

337 path = tmp_path / "test.sdf" 

338 with h5py.File(path, "w") as f: 

339 _hds.set_root_name(f, "TEST", "NDF") 

340 data_array = _hds.create_structure(f, "DATA_ARRAY", "ARRAY") 

341 _hds.write_array(data_array, "DATA", image_array) 

342 quality = _hds.create_structure(f, "QUALITY", "QUALITY") 

343 quality_array_struct = _hds.create_structure(quality, "QUALITY", "ARRAY") 

344 _hds.write_array(quality_array_struct, "DATA", quality_array) 

345 _hds.write_array(quality_array_struct, "ORIGIN", np.array([0, 0], dtype=np.int32)) 

346 _hds.write_array(quality_array_struct, "BAD_PIXEL", np.array(False, dtype=np.bool_)) 

347 _hds.write_array(quality, "BADBITS", np.array(2, dtype=np.uint8)) 

348 result = read_starlink(MaskedImage, path) 

349 assert isinstance(result, MaskedImage) 

350 mask = result.mask 

351 np.testing.assert_array_equal(mask.array[:, :, 0], quality_array) 

352 np.testing.assert_array_equal(mask.get("MASK1"), expected_mask1) 

353 np.testing.assert_array_equal(mask.get("MASK2"), expected_mask2) 

354 assert "Selected by BADBITS" in mask.schema.descriptions["MASK1"] 

355 assert "Selected by BADBITS" not in mask.schema.descriptions["MASK2"] 

356 

357 

358@skip_no_h5py 

359def test_read_auto_detected_data_only_as_masked_image_uses_defaults(tmp_path: Path) -> None: 

360 """Verify read_starlink fills in default mask and variance when only 

361 DATA_ARRAY is present. 

362 """ 

363 image_array = np.arange(6, dtype=np.float32).reshape(2, 3) 

364 

365 path = tmp_path / "test.sdf" 

366 with h5py.File(path, "w") as f: 

367 _hds.set_root_name(f, "TEST", "NDF") 

368 data_array = _hds.create_structure(f, "DATA_ARRAY", "ARRAY") 

369 _hds.write_array(data_array, "DATA", image_array) 

370 _hds.write_array(data_array, "ORIGIN", np.array([5, 4], dtype=np.int32)) 

371 result = read_starlink(MaskedImage, path) 

372 assert isinstance(result, MaskedImage) 

373 assert result.bbox == Box.factory[4:6, 5:8] 

374 np.testing.assert_array_equal(result.image.array, image_array) 

375 np.testing.assert_array_equal(result.mask.array, np.zeros((2, 3, 1), dtype=np.uint8)) 

376 np.testing.assert_array_equal(result.variance.array, np.ones((2, 3), dtype=np.float32)) 

377 

378 

379@skip_no_h5py 

380def test_read_auto_detected_variance_as_masked_image_keeps_variance(tmp_path: Path) -> None: 

381 """Verify read_starlink preserves a VARIANCE component in the 

382 MaskedImage. 

383 """ 

384 image_array = np.arange(6, dtype=np.float32).reshape(2, 3) 

385 variance_array = np.full((2, 3), 2.5, dtype=np.float32) 

386 

387 path = tmp_path / "test.sdf" 

388 with h5py.File(path, "w") as f: 

389 _hds.set_root_name(f, "TEST", "NDF") 

390 data_array = _hds.create_structure(f, "DATA_ARRAY", "ARRAY") 

391 _hds.write_array(data_array, "DATA", image_array) 

392 _hds.write_array(data_array, "ORIGIN", np.array([5, 4], dtype=np.int32)) 

393 variance = _hds.create_structure(f, "VARIANCE", "ARRAY") 

394 _hds.write_array(variance, "DATA", variance_array) 

395 _hds.write_array(variance, "ORIGIN", np.array([5, 4], dtype=np.int32)) 

396 result = read_starlink(MaskedImage, path) 

397 assert isinstance(result, MaskedImage) 

398 np.testing.assert_array_equal(result.variance.array, variance_array) 

399 np.testing.assert_array_equal(result.mask.array, np.zeros((2, 3, 1), dtype=np.uint8)) 

400 

401 

402@skip_no_h5py 

403def test_read_auto_detected_units_component(tmp_path: Path) -> None: 

404 """Verify read_starlink maps a UNITS NDF component to the Image unit.""" 

405 image_array = np.arange(6, dtype=np.float32).reshape(2, 3) 

406 

407 path = tmp_path / "test.sdf" 

408 with h5py.File(path, "w") as f: 

409 _hds.set_root_name(f, "TEST", "NDF") 

410 data_array = _hds.create_structure(f, "DATA_ARRAY", "ARRAY") 

411 _hds.write_array(data_array, "DATA", image_array) 

412 f.create_dataset("UNITS", data=np.bytes_("count")) 

413 result = read_starlink(Image, path) 

414 assert result.unit == u.ct 

415 

416 

417@skip_no_h5py 

418def test_read_missing_data_array_raises(tmp_path: Path) -> None: 

419 """Verify read_starlink raises ArchiveReadError for an NDF with no 

420 DATA_ARRAY or JSON. 

421 """ 

422 path = tmp_path / "test.sdf" 

423 with h5py.File(path, "w") as f: 

424 f["/"].attrs["CLASS"] = "NDF" 

425 with pytest.raises(ArchiveReadError): 

426 read_starlink(Image, path) 

427 

428 

429@skip_no_h5py 

430def test_read_auto_detect_wrong_target_type_raises(tmp_path: Path) -> None: 

431 """Verify read_starlink raises ArchiveReadError when the target type is 

432 unsupported. 

433 """ 

434 example_path = os.path.join(os.path.dirname(__file__), "data", "example-ndf.sdf") 

435 with pytest.raises(ArchiveReadError): 

436 read_starlink(Mask, example_path)