Coverage for tests/test_ndf_output_archive.py: 99%
451 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« 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.
12from __future__ import annotations
14import json
15from pathlib import Path
16from unittest import mock
18import astropy.io.fits
19import astropy.table
20import astropy.units as u
21import numpy as np
22import pydantic
23import pytest
25from lsst.images import Box, Image, MaskedImage, MaskPlane, MaskSchema
26from lsst.images._transforms import FrameLookupError, FrameSet, Transform
27from lsst.images._transforms._frames import DetectorFrame, Frame
28from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata
29from lsst.images.serialization import (
30 ArrayReferenceModel,
31 InlineArrayModel,
32 open_archive,
33 read_archive,
34)
35from lsst.images.tests import make_random_sky_projection
37try:
38 import h5py
40 from lsst.images.ndf import (
41 NdfInputArchive,
42 NdfOutputArchive,
43 _hds,
44 write,
45 )
46 from lsst.images.ndf._hds import DAT__SZNAM
48 HAVE_H5PY = True
49except ImportError:
50 HAVE_H5PY = False
52skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
55class TinyFrameSet(FrameSet):
56 """Minimal concrete frame-set for archive bookkeeping tests."""
58 def __contains__(self, frame: Frame) -> bool:
59 return False
61 def __getitem__[I: Frame, O: Frame](self, key: tuple[I, O]) -> Transform[I, O]:
62 raise FrameLookupError(key)
65class TinyTree(pydantic.BaseModel):
66 """A trivial Pydantic model used as a serialization stand-in."""
68 name: str
71@skip_no_h5py
72def test_serialize_direct_calls_serializer_with_nested_archive(tmp_path: Path) -> None:
73 """Verify serialize_direct invokes the serializer and returns its
74 result.
75 """
76 path = str(tmp_path / "test.sdf")
77 with h5py.File(path, "w") as f:
78 arch = NdfOutputArchive(f)
79 tree = arch.serialize_direct("top", lambda nested: TinyTree(name="hello"))
80 assert tree.name == "hello"
83@skip_no_h5py
84def test_constructor_marks_root_as_ndf(tmp_path: Path) -> None:
85 """Verify the NdfOutputArchive constructor sets CLASS=NDF on the root
86 group.
87 """
88 path = str(tmp_path / "test.sdf")
89 with h5py.File(path, "w") as f:
90 NdfOutputArchive(f)
91 with h5py.File(path, "r") as f:
92 assert f["/"].attrs["CLASS"] == b"NDF"
95@skip_no_h5py
96def test_top_level_image_routes_to_data_array(tmp_path: Path) -> None:
97 """Verify add_array routes a top-level image array to /DATA_ARRAY/DATA."""
98 data = np.arange(20, dtype=np.float32).reshape(4, 5)
99 path = str(tmp_path / "test.sdf")
100 with h5py.File(path, "w") as f:
101 arch = NdfOutputArchive(f)
102 ref = arch.add_array(data, name="image")
103 assert ref.source == "ndf:/DATA_ARRAY/DATA"
104 with h5py.File(path, "r") as f:
105 ds = f["/DATA_ARRAY/DATA"]
106 assert ds.dtype == np.float32
107 np.testing.assert_array_equal(ds[()], data)
108 assert f["/DATA_ARRAY"].attrs["CLASS"] == b"ARRAY"
109 origin = f["/DATA_ARRAY/ORIGIN"]
110 assert origin.dtype == np.int64
111 assert origin.shape == (2,)
114@skip_no_h5py
115def test_top_level_variance_routes_to_variance(tmp_path: Path) -> None:
116 """Verify add_array routes a top-level variance array to /VARIANCE/DATA."""
117 data = np.full((3, 3), 0.5, dtype=np.float64)
118 path = str(tmp_path / "test.sdf")
119 with h5py.File(path, "w") as f:
120 arch = NdfOutputArchive(f)
121 ref = arch.add_array(data, name="variance")
122 assert ref.source == "ndf:/VARIANCE/DATA"
123 with h5py.File(path, "r") as f:
124 assert f["/VARIANCE"].attrs["CLASS"] == b"ARRAY"
125 assert f["/VARIANCE/DATA"].dtype == np.float64
128@skip_no_h5py
129def test_top_level_compatible_mask_routes_to_quality(tmp_path: Path) -> None:
130 """Verify add_array routes a 2D uint8 mask to /QUALITY/QUALITY/DATA."""
131 data = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.uint8)
132 path = str(tmp_path / "test.sdf")
133 with h5py.File(path, "w") as f:
134 arch = NdfOutputArchive(f)
135 ref = arch.add_array(data, name="mask")
136 assert ref.source == "ndf:/QUALITY/QUALITY/DATA"
137 with h5py.File(path, "r") as f:
138 assert f["/QUALITY"].attrs["CLASS"] == b"QUALITY"
139 assert f["/QUALITY/QUALITY"].attrs["CLASS"] == b"ARRAY"
140 assert f["/QUALITY/QUALITY/DATA"].dtype == np.uint8
141 np.testing.assert_array_equal(f["/QUALITY/QUALITY/DATA"][()], data)
142 assert f["/QUALITY/QUALITY/ORIGIN"].dtype == np.int32
143 assert f["/QUALITY/QUALITY/ORIGIN"].shape == (2,)
144 assert f["/QUALITY/QUALITY/BAD_PIXEL"].id.get_type().get_class() == h5py.h5t.BITFIELD
145 assert not _hds.read_array(f["/QUALITY/QUALITY/BAD_PIXEL"])
146 assert f["/QUALITY/BADBITS"][()] == 255
149@skip_no_h5py
150def test_top_level_incompatible_mask_routes_to_more_lsst(tmp_path: Path) -> None:
151 """Verify add_array hoists a 3D uint8 mask to /MORE/LSST/MASK as a sub-
152 NDF.
153 """
154 data = np.zeros((2, 3, 4), dtype=np.uint8)
155 data[0, 1, 2] = 4
156 data[1, 2, 3] = 8
157 expected_quality = np.any(data != 0, axis=0).astype(np.uint8)
158 path = str(tmp_path / "test.sdf")
159 with h5py.File(path, "w") as f:
160 arch = NdfOutputArchive(f)
161 ref = arch.add_array(data, name="mask")
162 assert ref.source == "ndf:/MORE/LSST/MASK/DATA_ARRAY/DATA"
163 with h5py.File(path, "r") as f:
164 assert f["/MORE/LSST/MASK"].attrs["CLASS"] == b"NDF"
165 assert f["/MORE/LSST/MASK/DATA_ARRAY"].attrs["CLASS"] == b"ARRAY"
166 assert f["/MORE/LSST/MASK/DATA_ARRAY/DATA"].shape == data.shape
167 assert f["/QUALITY/QUALITY"].attrs["CLASS"] == b"ARRAY"
168 np.testing.assert_array_equal(f["/QUALITY/QUALITY/DATA"][()], expected_quality)
169 assert f["/QUALITY/BADBITS"][()] == 255
170 origin = f["/MORE/LSST/MASK/DATA_ARRAY/ORIGIN"]
171 assert origin.dtype == np.int64
172 assert origin.shape == (3,)
175@skip_no_h5py
176def test_long_hoisted_component_is_shrunk(tmp_path: Path) -> None:
177 """Verify HDS component names exceeding DAT__SZNAM are shrunk in the stored
178 path.
179 """
180 data = np.array([[1.0, 2.0]], dtype=np.float32)
181 path = str(tmp_path / "test.sdf")
182 with h5py.File(path, "w") as f:
183 arch = NdfOutputArchive(f)
184 ref = arch.add_array(data, name="noise_realizations/0")
185 assert ref.source.startswith("ndf:/MORE/LSST/")
186 assert ref.source.endswith("/DATA_ARRAY/DATA")
187 with h5py.File(path, "r") as f:
188 hdf5_path = ref.source[len("ndf:") :]
189 for component in hdf5_path.strip("/").split("/"):
190 assert len(component) <= DAT__SZNAM
191 assert hdf5_path in f
194@skip_no_h5py
195def test_long_name_round_trips_through_input_archive(tmp_path: Path) -> None:
196 """Verify a long-named array can be read back via NdfInputArchive."""
197 data = np.arange(6, dtype=np.float32).reshape(2, 3)
198 path = str(tmp_path / "test.sdf")
199 with h5py.File(path, "w") as f:
200 arch = NdfOutputArchive(f)
201 ref = arch.add_array(data, name="noise_realizations/0")
202 with NdfInputArchive.open(path) as inp:
203 read_back = inp.get_array(ref)
204 np.testing.assert_array_equal(read_back, data)
207@skip_no_h5py
208def test_repeated_long_name_gets_distinct_versioned_paths(tmp_path: Path) -> None:
209 """Verify two identically-named long arrays receive distinct versioned
210 paths.
211 """
212 data = np.array([[1.0]], dtype=np.float32)
213 path = str(tmp_path / "test.sdf")
214 with h5py.File(path, "w") as f:
215 arch = NdfOutputArchive(f)
216 first = arch.add_array(data, name="noise_realizations_value")
217 second = arch.add_array(data, name="noise_realizations_value")
218 assert first.source != second.source
219 second_leaf = second.source[len("ndf:") :].split("/")[-3]
220 assert second_leaf.endswith("_2")
221 with h5py.File(path, "r") as f:
222 assert first.source[len("ndf:") :] in f
223 assert second.source[len("ndf:") :] in f
226@skip_no_h5py
227def test_nested_array_hoists_as_sub_ndf(tmp_path: Path) -> None:
228 """Verify nested array names produce a CLASS=NDF sub-structure under
229 /MORE/LSST.
230 """
231 data = np.array([[1.0, 2.0]], dtype=np.float32)
232 path = str(tmp_path / "test.sdf")
233 with h5py.File(path, "w") as f:
234 arch = NdfOutputArchive(f)
235 ref = arch.add_array(data, name="psf/coefficients")
236 assert ref.source == "ndf:/MORE/LSST/PSF/COEFFICIENTS/DATA_ARRAY/DATA"
237 with h5py.File(path, "r") as f:
238 assert "MORE" in f
239 assert "LSST" in f["/MORE"]
240 assert "PSF" in f["/MORE/LSST"]
241 assert "COEFFICIENTS" in f["/MORE/LSST/PSF"]
242 sub = f["/MORE/LSST/PSF/COEFFICIENTS"]
243 assert sub.attrs["CLASS"] == b"NDF"
244 assert sub["DATA_ARRAY"].attrs["CLASS"] == b"ARRAY"
245 np.testing.assert_array_equal(sub["DATA_ARRAY/DATA"][()], data)
246 origin = sub["DATA_ARRAY/ORIGIN"]
247 assert origin.dtype == np.int64
248 assert origin.shape == (data.ndim,)
251@skip_no_h5py
252def test_colliding_shrunk_names_raise(tmp_path: Path) -> None:
253 """Verify add_array raises ValueError when two long names shrink to the
254 same token.
255 """
256 data = np.array([[1.0]], dtype=np.float32)
257 path = str(tmp_path / "test.sdf")
258 with h5py.File(path, "w") as f:
259 arch = NdfOutputArchive(f)
260 with mock.patch.object(
261 arch._name_shrinker,
262 "shrink",
263 side_effect=lambda name, *a, **k: name.upper() if len(name) <= DAT__SZNAM else "CLASH",
264 ):
265 arch.add_array(data, name="long_component_name_one")
266 with pytest.raises(ValueError, match="name collision"):
267 arch.add_array(data, name="long_component_name_two")
270@skip_no_h5py
271def test_serialize_pointer_writes_subtree_and_returns_pointer(tmp_path: Path) -> None:
272 """Verify serialize_pointer stores the sub-tree JSON and returns the
273 correct pointer.
274 """
275 path = str(tmp_path / "test.sdf")
276 with h5py.File(path, "w") as f:
277 arch = NdfOutputArchive(f)
278 ptr = arch.serialize_pointer(
279 "psf",
280 lambda nested: TinyTree(name="gaussian"),
281 key=("psf", 1),
282 )
283 assert ptr.path == "/MORE/LSST/PSF/JSON"
284 with h5py.File(path, "r") as f:
285 raw = f["/MORE/LSST/PSF/JSON"][()]
286 joined = b"".join(raw).decode("ascii").rstrip(" ")
287 assert '"name":"gaussian"' in joined.replace(" ", "")
290@skip_no_h5py
291def test_serialize_pointer_caches_by_key(tmp_path: Path) -> None:
292 """Verify serialize_pointer returns the cached pointer and does not re-run
293 the serializer.
294 """
295 path = str(tmp_path / "test.sdf")
296 with h5py.File(path, "w") as f:
297 arch = NdfOutputArchive(f)
298 ptr1 = arch.serialize_pointer(
299 "psf",
300 lambda nested: TinyTree(name="first"),
301 key=("psf", 1),
302 )
303 ptr2 = arch.serialize_pointer(
304 "psf",
305 lambda nested: TinyTree(name="second"),
306 key=("psf", 1),
307 )
308 assert ptr1 == ptr2
309 with h5py.File(path, "r") as f:
310 raw = f["/MORE/LSST/PSF/JSON"][()]
311 joined = b"".join(raw).decode("ascii").rstrip(" ")
312 assert "first" in joined
313 assert "second" not in joined
316@skip_no_h5py
317def test_serialize_pointer_preserves_nested_arrays(tmp_path: Path) -> None:
318 """Verify serialize_pointer does not clobber nested arrays written by the
319 serializer.
320 """
322 class TreeWithArray(pydantic.BaseModel):
323 name: str
324 data: ArrayReferenceModel
326 payload = np.arange(6, dtype=np.float32).reshape(2, 3)
327 path = str(tmp_path / "test.sdf")
328 with h5py.File(path, "w") as f:
329 arch = NdfOutputArchive(f)
330 ptr = arch.serialize_pointer(
331 "psf",
332 lambda nested: TreeWithArray(
333 name="gaussian",
334 data=nested.add_array(payload, name="parameters"),
335 ),
336 key=("psf", 1),
337 )
338 assert ptr.path == "/MORE/LSST/PSF/JSON"
339 with h5py.File(path, "r") as f:
340 assert "/MORE/LSST/PSF/JSON" in f
341 assert "/MORE/LSST/PSF/PARAMETERS/DATA_ARRAY/DATA" in f
342 np.testing.assert_array_equal(f["/MORE/LSST/PSF/PARAMETERS/DATA_ARRAY/DATA"][()], payload)
343 assert f["/MORE/LSST/PSF"].attrs["CLASS"] == b"PSF"
344 assert f["/MORE/LSST"].attrs["CLASS"] == b"LSST"
347@skip_no_h5py
348def test_serialize_frame_set_records_for_iter(tmp_path: Path) -> None:
349 """Verify serialize_frame_set records the (FrameSet, pointer) pair for
350 iter_frame_sets.
351 """
352 frame_set = TinyFrameSet()
353 path = str(tmp_path / "test.sdf")
354 with h5py.File(path, "w") as f:
355 arch = NdfOutputArchive(f)
356 ptr = arch.serialize_frame_set(
357 "wcs/pixel_to_sky",
358 frame_set,
359 lambda nested: TinyTree(name="proj"),
360 key=("frame_set", 1),
361 )
362 assert ptr.path == "/MORE/LSST/WCS/PIXEL_TO_SKY/JSON"
363 recorded = list(arch.iter_frame_sets())
364 assert len(recorded) == 1
365 assert recorded[0][0] is frame_set
366 assert recorded[0][1].path == "/MORE/LSST/WCS/PIXEL_TO_SKY/JSON"
369@skip_no_h5py
370def test_add_table_returns_inline_table_model(tmp_path: Path) -> None:
371 """Verify add_table returns an inline table model for a simple astropy
372 Table.
373 """
374 t = astropy.table.Table({"a": [1, 2, 3], "b": [4.0, 5.0, 6.0]})
375 path = str(tmp_path / "test.sdf")
376 with h5py.File(path, "w") as f:
377 arch = NdfOutputArchive(f)
378 model = arch.add_table(t, name="some_table")
379 assert len(model.columns) == 2
380 assert isinstance(model.columns[0].data, InlineArrayModel)
383@skip_no_h5py
384def test_add_structured_array_writes_column_ndfs_with_units(tmp_path: Path) -> None:
385 """Verify add_structured_array stores each column as a sub-NDF with correct
386 units.
387 """
388 rec = np.zeros(3, dtype=[("x", np.float64), ("y", np.int32)])
389 rec["x"] = [1.0, 2.0, 3.0]
390 rec["y"] = [10, 20, 30]
391 path = str(tmp_path / "test.sdf")
392 with h5py.File(path, "w") as f:
393 arch = NdfOutputArchive(f)
394 model = arch.add_structured_array(
395 rec,
396 name="rec",
397 units={"x": u.m},
398 descriptions={"y": "the y values"},
399 )
400 assert len(model.columns) == 2
401 assert isinstance(model.columns[0].data, ArrayReferenceModel)
402 col_x = next(c for c in model.columns if c.name == "x")
403 col_y = next(c for c in model.columns if c.name == "y")
404 assert col_x.unit == u.m
405 assert col_y.description == "the y values"
406 assert col_x.data.source == "ndf:/MORE/LSST/REC/X/DATA_ARRAY/DATA"
407 assert col_y.data.source == "ndf:/MORE/LSST/REC/Y/DATA_ARRAY/DATA"
408 with h5py.File(path, "r") as f:
409 assert f["/MORE/LSST/REC/X"].attrs["CLASS"] == b"NDF"
410 np.testing.assert_array_equal(f["/MORE/LSST/REC/X/DATA_ARRAY/DATA"][()], rec["x"])
411 assert f["/MORE/LSST/REC/Y"].attrs["CLASS"] == b"NDF"
412 np.testing.assert_array_equal(f["/MORE/LSST/REC/Y/DATA_ARRAY/DATA"][()], rec["y"])
413 with NdfInputArchive.open(path) as archive:
414 recovered = archive.get_structured_array(model)
415 np.testing.assert_array_equal(recovered, rec)
418@skip_no_h5py
419def test_add_single_column_structured_array_uses_table_name(tmp_path: Path) -> None:
420 """Verify a single-column structured array uses the table path as its NDF
421 component name.
422 """
423 rec = np.zeros(1, dtype=[("solution", np.float64, (4,))])
424 rec["solution"] = [[1.0, 2.0, 3.0, 4.0]]
425 path = str(tmp_path / "test.sdf")
426 with h5py.File(path, "w") as f:
427 arch = NdfOutputArchive(f)
428 model = arch.add_structured_array(rec, name="psf/piff/interp/solution")
429 assert len(model.columns) == 1
430 column = model.columns[0]
431 assert isinstance(column.data, ArrayReferenceModel)
432 assert column.data.source == "ndf:/MORE/LSST/PSF/PIFF/INTERP/SOLUTION/DATA_ARRAY/DATA"
433 assert column.data.shape == [4]
434 with h5py.File(path, "r") as f:
435 assert "PSF" in f["/MORE/LSST"]
436 assert "PIFF" in f["/MORE/LSST/PSF"]
437 assert "INTERP" in f["/MORE/LSST/PSF/PIFF"]
438 assert "SOLUTION" in f["/MORE/LSST/PSF/PIFF/INTERP"]
439 np.testing.assert_array_equal(
440 f["/MORE/LSST/PSF/PIFF/INTERP/SOLUTION/DATA_ARRAY/DATA"][()],
441 rec["solution"],
442 )
445@skip_no_h5py
446def test_structured_array_long_name_is_shrunk_and_versioned(tmp_path: Path) -> None:
447 """Verify long structured-array names are shrunk and repeated names get
448 versioned paths.
449 """
450 dtype = np.dtype([("alpha", "f8"), ("beta", "i4")])
451 arr = np.zeros(3, dtype=dtype)
452 path = str(tmp_path / "test.sdf")
453 with h5py.File(path, "w") as f:
454 arch = NdfOutputArchive(f)
455 first = arch.add_structured_array(arr, name="catalog_of_long_named_sources")
456 second = arch.add_structured_array(arr, name="catalog_of_long_named_sources")
457 for model in (first, second):
458 for column in model.columns:
459 token = column.data.source[len("ndf:") :]
460 for component in token.strip("/").split("/"):
461 assert len(component) <= DAT__SZNAM
462 assert first.columns[0].data.source != second.columns[0].data.source
463 second_parent = second.columns[0].data.source[len("ndf:") :].strip("/").split("/")[-4]
464 assert second_parent.endswith("_2")
465 with h5py.File(path, "r") as f:
466 for model in (first, second):
467 for column in model.columns:
468 assert column.data.source[len("ndf:") :] in f
471@skip_no_h5py
472def test_write_with_projection_creates_wcs_component(tmp_path: Path) -> None:
473 """Verify write() creates a /WCS/DATA component when the image has a
474 sky_projection.
475 """
476 rng = np.random.default_rng(42)
477 det_frame = DetectorFrame(instrument="TestInst", detector=4, bbox=Box.factory[1:4096, 1:4096])
478 bbox = Box.factory[10:14, 20:25]
479 sky_projection = make_random_sky_projection(rng, det_frame, Box.factory[1:4096, 1:4096])
480 image = Image(
481 np.arange(20, dtype=np.float32).reshape(4, 5),
482 bbox=bbox,
483 sky_projection=sky_projection,
484 )
485 path = str(tmp_path / "test.sdf")
486 write(image, path)
487 with h5py.File(path, "r") as f:
488 assert "WCS" in f
489 assert f["/WCS"].attrs["CLASS"] == b"WCS"
490 wcs_data = f["/WCS/DATA"]
491 assert wcs_data.dtype == np.dtype("|S32")
492 records = [s.decode("ascii").rstrip(" ") for s in wcs_data[()]]
493 assert all(record[0] in {" ", "+"} for record in records)
494 assert not any(record.startswith("#") for record in records)
495 text = _hds.decode_ndf_ast_data(records)
496 stripped = [line.lstrip() for line in text.splitlines()]
497 assert any(s.startswith("Begin FrameSet") for s in stripped)
498 assert any(s.startswith("End FrameSet") for s in stripped)
499 assert 'Domain = "GRID"' in stripped
500 assert 'Domain = "PIXEL"' in stripped
501 assert "Sft1 = -19" in stripped
502 assert "Sft2 = -9" in stripped
505@skip_no_h5py
506def test_write_without_projection_omits_wcs_component(tmp_path: Path) -> None:
507 """Verify write() omits /WCS when the image has no sky_projection."""
508 image = Image(np.zeros((2, 2), dtype=np.float32))
509 path = str(tmp_path / "test.sdf")
510 write(image, path)
511 with h5py.File(path, "r") as f:
512 assert "WCS" not in f
515@skip_no_h5py
516def test_mask_sub_ndf_gets_3d_wcs(tmp_path: Path) -> None:
517 """Verify an incompatible mask hoisted to /MORE/LSST/MASK carries a 3D
518 /WCS.
519 """
520 rng = np.random.default_rng(42)
521 det_frame = DetectorFrame(instrument="TestInst", detector=4, bbox=Box.factory[1:4096, 1:4096])
522 bbox = Box.factory[10:14, 20:25]
523 sky_projection = make_random_sky_projection(rng, det_frame, Box.factory[1:4096, 1:4096])
524 planes = [MaskPlane(f"P{i}", f"Plane {i}") for i in range(12)]
525 image = Image(
526 np.arange(20, dtype=np.float32).reshape(4, 5),
527 bbox=bbox,
528 sky_projection=sky_projection,
529 )
530 masked = MaskedImage(image, mask_schema=MaskSchema(planes))
531 path = str(tmp_path / "test.sdf")
532 write(masked, path)
533 with h5py.File(path, "r") as f:
534 assert "WCS" in f
535 top_lines = [s.decode("ascii") for s in f["/WCS/DATA"][()]]
536 assert "MASK" in f["/MORE/LSST"]
537 assert "WCS" in f["/MORE/LSST/MASK"]
538 assert f["/MORE/LSST/MASK/WCS"].attrs["CLASS"] == b"WCS"
539 mask_lines = [s.decode("ascii") for s in f["/MORE/LSST/MASK/WCS/DATA"][()]]
540 assert top_lines != mask_lines
541 mask_text = _hds.decode_ndf_ast_data(mask_lines)
542 stripped = [line.lstrip() for line in mask_text.splitlines()]
543 assert "Naxes = 3" in stripped
544 assert 'Domain = "GRID"' in stripped
545 assert 'Domain = "PIXEL"' in stripped
546 assert "Sft1 = -19" in stripped
547 assert "Sft2 = -9" in stripped
548 assert "Sft3 = 1" in stripped
549 assert "Begin CmpFrame" in stripped
550 assert "Begin SkyFrame" in stripped
551 assert 'Domain = "MASK"' in stripped
552 assert "Begin CmpMap" in stripped
553 assert "Series = 0" in stripped
556@skip_no_h5py
557def test_mask_sub_ndf_no_wcs_when_image_has_no_projection(tmp_path: Path) -> None:
558 """Verify /MORE/LSST/MASK does not carry /WCS when the image has no
559 sky_projection.
560 """
561 planes = [MaskPlane(f"P{i}", f"Plane {i}") for i in range(12)]
562 masked = MaskedImage(
563 Image(np.zeros((4, 5), dtype=np.float32)),
564 mask_schema=MaskSchema(planes),
565 )
566 path = str(tmp_path / "test.sdf")
567 write(masked, path)
568 with h5py.File(path, "r") as f:
569 assert "WCS" not in f
570 assert "MASK" in f["/MORE/LSST"]
571 assert "WCS" not in f["/MORE/LSST/MASK"]
574@skip_no_h5py
575def test_write_image_produces_valid_layout(tmp_path: Path) -> None:
576 """Verify write() produces a valid NDF layout with DATA_ARRAY, ORIGIN, and
577 /MORE/LSST/JSON.
578 """
579 image = Image(
580 np.arange(20, dtype=np.float32).reshape(4, 5),
581 bbox=Box.factory[10:14, 20:25],
582 )
583 path = str(tmp_path / "test.sdf")
584 tree = write(image, path)
585 assert tree is not None
586 with h5py.File(path, "r") as f:
587 assert f["/"].attrs["CLASS"] == b"NDF"
588 assert "HDS_ROOT_NAME" in f["/"].attrs
589 assert f["/DATA_ARRAY"].attrs["CLASS"] == b"ARRAY"
590 np.testing.assert_array_equal(f["/DATA_ARRAY/DATA"][()], image.array)
591 origin = f["/DATA_ARRAY/ORIGIN"][()]
592 assert origin.dtype == np.int64
593 assert len(origin) == 2
594 assert not (origin == 0).all()
595 assert "MORE" in f
596 assert "LSST" in f["/MORE"]
597 assert "JSON" in f["/MORE/LSST"]
600@skip_no_h5py
601def test_write_image_preserves_opaque_fits_metadata(tmp_path: Path) -> None:
602 """Verify write() stores opaque FITS headers in /MORE/FITS and they survive
603 a round-trip.
604 """
605 image = Image(np.zeros((2, 2), dtype=np.float32))
606 primary = astropy.io.fits.Header()
607 primary["FOO"] = ("bar", "test card")
608 long_value = "x" * 100
609 primary["LONGSTR"] = (long_value, "long string value")
610 opaque = FitsOpaqueMetadata()
611 opaque.add_header(primary, name="", ver=1)
612 image._opaque_metadata = opaque
613 path = str(tmp_path / "test.sdf")
614 write(image, path)
615 with h5py.File(path, "r") as f:
616 assert "FITS" in f["/MORE"]
617 cards = [c.decode("ascii").rstrip(" ") for c in f["/MORE/FITS"][()]]
618 assert any(c.startswith("FOO") for c in cards)
619 assert any(c.startswith("CONTINUE") for c in cards)
620 assert all(len(c.encode("ascii")) <= 80 for c in cards)
621 result = read_archive(path, Image)
622 recovered = result._opaque_metadata.headers[ExtensionKey()]
623 assert recovered["LONGSTR"] == long_value
626@skip_no_h5py
627def test_write_image_main_json_round_trips_back(tmp_path: Path) -> None:
628 """Verify the main JSON tree at /MORE/LSST/JSON matches write()'s returned
629 ArchiveTree.
630 """
631 image = Image(np.arange(6, dtype=np.float32).reshape(2, 3))
632 path = str(tmp_path / "test.sdf")
633 tree = write(image, path)
634 with h5py.File(path, "r") as f:
635 raw = f["/MORE/LSST/JSON"][()]
636 joined = b"".join(raw).decode("ascii").rstrip(" ")
637 recovered = json.loads(joined)
638 assert json.loads(tree.model_dump_json()) == recovered
641@skip_no_h5py
642def test_write_image_with_unit_creates_units_component(tmp_path: Path) -> None:
643 """Verify write() creates a /UNITS component and it round-trips back as the
644 correct unit.
645 """
646 image = Image(np.arange(6, dtype=np.float32).reshape(2, 3), unit=u.ct)
647 path = str(tmp_path / "test.sdf")
648 write(image, path)
649 with h5py.File(path, "r") as f:
650 assert "UNITS" in f
651 assert f["/UNITS"].shape == ()
652 assert f["/UNITS"][()].decode("ascii").rstrip(" ") == "count"
653 result = read_archive(path, Image)
654 assert result.unit == u.ct
657@skip_no_h5py
658def test_write_propagates_metadata(tmp_path: Path) -> None:
659 """Verify write() stores caller-supplied metadata and it is readable via
660 open_archive.
661 """
662 image = Image(np.arange(6, dtype=np.float32).reshape(2, 3))
663 extra = {"test_key": 42, "another": "hello"}
664 path = str(tmp_path / "test.sdf")
665 tree = write(image, path, metadata=extra)
666 assert tree.metadata["test_key"] == 42
667 assert tree.metadata["another"] == "hello"
668 with open_archive(path, Image) as reader:
669 assert reader.metadata["test_key"] == 42
670 assert reader.metadata["another"] == "hello"