Coverage for tests/test_ndf_output_archive.py: 99%
444 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:27 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:27 -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
15import tempfile
16import unittest
17from unittest import mock
19import astropy.io.fits
20import astropy.table
21import astropy.units as u
22import numpy as np
23import pydantic
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
53class TinyFrameSet(FrameSet):
54 """Minimal concrete frame-set for archive bookkeeping tests."""
56 def __contains__(self, frame: Frame) -> bool:
57 return False
59 def __getitem__[I: Frame, O: Frame](self, key: tuple[I, O]) -> Transform[I, O]:
60 raise FrameLookupError(key)
63class TinyTree(pydantic.BaseModel):
64 """A trivial Pydantic model used as a serialization stand-in."""
66 name: str
69@unittest.skipUnless(HAVE_H5PY, "h5py is not installed")
70class NdfOutputArchiveBasicsTestCase(unittest.TestCase):
71 """Tests for `NdfOutputArchive` constructor and `serialize_direct`."""
73 def test_serialize_direct_calls_serializer_with_nested_archive(self):
74 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
75 with h5py.File(tmp.name, "w") as f:
76 arch = NdfOutputArchive(f)
77 tree = arch.serialize_direct("top", lambda nested: TinyTree(name="hello"))
78 self.assertEqual(tree.name, "hello")
80 def test_constructor_marks_root_as_ndf(self):
81 """The constructor should set CLASS=NDF on the root group so that
82 Starlink tools recognise the file as an NDF.
83 """
84 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
85 with h5py.File(tmp.name, "w") as f:
86 NdfOutputArchive(f)
87 with h5py.File(tmp.name, "r") as f:
88 self.assertEqual(f["/"].attrs["CLASS"], b"NDF")
91@unittest.skipUnless(HAVE_H5PY, "h5py is not installed")
92class NdfOutputArchiveAddArrayTestCase(unittest.TestCase):
93 """Tests for `NdfOutputArchive.add_array` routing."""
95 def test_top_level_image_routes_to_data_array(self):
96 data = np.arange(20, dtype=np.float32).reshape(4, 5)
97 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
98 with h5py.File(tmp.name, "w") as f:
99 arch = NdfOutputArchive(f)
100 ref = arch.add_array(data, name="image")
101 self.assertEqual(ref.source, "ndf:/DATA_ARRAY/DATA")
102 with h5py.File(tmp.name, "r") as f:
103 ds = f["/DATA_ARRAY/DATA"]
104 self.assertEqual(ds.dtype, np.float32)
105 np.testing.assert_array_equal(ds[()], data)
106 self.assertEqual(f["/DATA_ARRAY"].attrs["CLASS"], b"ARRAY")
107 origin = f["/DATA_ARRAY/ORIGIN"]
108 self.assertEqual(origin.dtype, np.int64)
109 self.assertEqual(origin.shape, (2,))
111 def test_top_level_variance_routes_to_variance(self):
112 data = np.full((3, 3), 0.5, dtype=np.float64)
113 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
114 with h5py.File(tmp.name, "w") as f:
115 arch = NdfOutputArchive(f)
116 ref = arch.add_array(data, name="variance")
117 self.assertEqual(ref.source, "ndf:/VARIANCE/DATA")
118 with h5py.File(tmp.name, "r") as f:
119 self.assertEqual(f["/VARIANCE"].attrs["CLASS"], b"ARRAY")
120 self.assertEqual(f["/VARIANCE/DATA"].dtype, np.float64)
122 def test_top_level_compatible_mask_routes_to_quality(self):
123 data = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.uint8)
124 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
125 with h5py.File(tmp.name, "w") as f:
126 arch = NdfOutputArchive(f)
127 ref = arch.add_array(data, name="mask")
128 self.assertEqual(ref.source, "ndf:/QUALITY/QUALITY/DATA")
129 with h5py.File(tmp.name, "r") as f:
130 self.assertEqual(f["/QUALITY"].attrs["CLASS"], b"QUALITY")
131 self.assertEqual(f["/QUALITY/QUALITY"].attrs["CLASS"], b"ARRAY")
132 self.assertEqual(f["/QUALITY/QUALITY/DATA"].dtype, np.uint8)
133 np.testing.assert_array_equal(f["/QUALITY/QUALITY/DATA"][()], data)
134 self.assertEqual(f["/QUALITY/QUALITY/ORIGIN"].dtype, np.int32)
135 self.assertEqual(f["/QUALITY/QUALITY/ORIGIN"].shape, (2,))
136 self.assertEqual(f["/QUALITY/QUALITY/BAD_PIXEL"].id.get_type().get_class(), h5py.h5t.BITFIELD)
137 self.assertFalse(_hds.read_array(f["/QUALITY/QUALITY/BAD_PIXEL"]))
138 self.assertEqual(f["/QUALITY/BADBITS"][()], 255)
140 def test_top_level_incompatible_mask_routes_to_more_lsst(self):
141 # 3D mask array in NDF storage order (mask-byte, y, x) is hoisted
142 # as a sub-NDF inside /MORE/LSST/MASK, with a compressed 2D view
143 # exposed as /QUALITY/QUALITY for standard NDF applications.
144 data = np.zeros((2, 3, 4), dtype=np.uint8)
145 data[0, 1, 2] = 4
146 data[1, 2, 3] = 8
147 expected_quality = np.any(data != 0, axis=0).astype(np.uint8)
148 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
149 with h5py.File(tmp.name, "w") as f:
150 arch = NdfOutputArchive(f)
151 ref = arch.add_array(data, name="mask")
152 self.assertEqual(ref.source, "ndf:/MORE/LSST/MASK/DATA_ARRAY/DATA")
153 with h5py.File(tmp.name, "r") as f:
154 # /MORE/LSST/MASK is a real NDF: top-level CLASS="NDF"
155 # containing a DATA_ARRAY structure with DATA + ORIGIN.
156 self.assertEqual(f["/MORE/LSST/MASK"].attrs["CLASS"], b"NDF")
157 self.assertEqual(f["/MORE/LSST/MASK/DATA_ARRAY"].attrs["CLASS"], b"ARRAY")
158 self.assertEqual(f["/MORE/LSST/MASK/DATA_ARRAY/DATA"].shape, data.shape)
159 self.assertEqual(f["/QUALITY/QUALITY"].attrs["CLASS"], b"ARRAY")
160 np.testing.assert_array_equal(f["/QUALITY/QUALITY/DATA"][()], expected_quality)
161 self.assertEqual(f["/QUALITY/BADBITS"][()], 255)
162 origin = f["/MORE/LSST/MASK/DATA_ARRAY/ORIGIN"]
163 self.assertEqual(origin.dtype, np.int64)
164 self.assertEqual(origin.shape, (3,))
166 def test_long_hoisted_component_is_shrunk(self):
167 # Regression for the cell_coadd failure: the /noise_realizations/0
168 # archive path contains an 18-character component.
169 data = np.array([[1.0, 2.0]], dtype=np.float32)
170 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
171 with h5py.File(tmp.name, "w") as f:
172 arch = NdfOutputArchive(f)
173 ref = arch.add_array(data, name="noise_realizations/0")
174 # The reported path is exactly what is stored in the JSON.
175 self.assertTrue(ref.source.startswith("ndf:/MORE/LSST/"))
176 self.assertTrue(ref.source.endswith("/DATA_ARRAY/DATA"))
177 with h5py.File(tmp.name, "r") as f:
178 # Every HDS component is within the limit.
179 hdf5_path = ref.source[len("ndf:") :]
180 for component in hdf5_path.strip("/").split("/"):
181 self.assertLessEqual(len(component), DAT__SZNAM)
182 # The node the JSON points at actually exists.
183 self.assertIn(hdf5_path, f)
185 def test_long_name_round_trips_through_input_archive(self):
186 from lsst.images.ndf import NdfInputArchive
188 data = np.arange(6, dtype=np.float32).reshape(2, 3)
189 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
190 with h5py.File(tmp.name, "w") as f:
191 arch = NdfOutputArchive(f)
192 ref = arch.add_array(data, name="noise_realizations/0")
193 with NdfInputArchive.open(tmp.name) as inp:
194 read_back = inp.get_array(ref)
195 np.testing.assert_array_equal(read_back, data)
197 def test_repeated_long_name_gets_distinct_versioned_paths(self):
198 data = np.array([[1.0]], dtype=np.float32)
199 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
200 with h5py.File(tmp.name, "w") as f:
201 arch = NdfOutputArchive(f)
202 first = arch.add_array(data, name="noise_realizations_value")
203 second = arch.add_array(data, name="noise_realizations_value")
204 self.assertNotEqual(first.source, second.source)
205 # The second occurrence keeps a visible _2 version suffix.
206 second_leaf = second.source[len("ndf:") :].split("/")[-3]
207 self.assertTrue(second_leaf.endswith("_2"))
208 with h5py.File(tmp.name, "r") as f:
209 self.assertIn(first.source[len("ndf:") :], f)
210 self.assertIn(second.source[len("ndf:") :], f)
212 def test_nested_array_hoists_as_sub_ndf(self):
213 # Hoisted numeric arrays land under /MORE/LSST as hierarchical
214 # sub-NDFs (CLASS="NDF" with DATA_ARRAY/DATA + ORIGIN inside) so
215 # Starlink tools can inspect them as ordinary NDFs while each HDS
216 # component stays short.
217 data = np.array([[1.0, 2.0]], dtype=np.float32)
218 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
219 with h5py.File(tmp.name, "w") as f:
220 arch = NdfOutputArchive(f)
221 ref = arch.add_array(data, name="psf/coefficients")
222 self.assertEqual(ref.source, "ndf:/MORE/LSST/PSF/COEFFICIENTS/DATA_ARRAY/DATA")
223 with h5py.File(tmp.name, "r") as f:
224 self.assertIn("MORE", f)
225 self.assertIn("LSST", f["/MORE"])
226 self.assertIn("PSF", f["/MORE/LSST"])
227 self.assertIn("COEFFICIENTS", f["/MORE/LSST/PSF"])
228 sub = f["/MORE/LSST/PSF/COEFFICIENTS"]
229 self.assertEqual(sub.attrs["CLASS"], b"NDF")
230 self.assertEqual(sub["DATA_ARRAY"].attrs["CLASS"], b"ARRAY")
231 np.testing.assert_array_equal(sub["DATA_ARRAY/DATA"][()], data)
232 origin = sub["DATA_ARRAY/ORIGIN"]
233 self.assertEqual(origin.dtype, np.int64)
234 self.assertEqual(origin.shape, (data.ndim,))
236 def test_colliding_shrunk_names_raise(self):
237 data = np.array([[1.0]], dtype=np.float32)
238 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
239 with h5py.File(tmp.name, "w") as f:
240 arch = NdfOutputArchive(f)
241 # Force both long names to shrink to the same HDS token.
242 with mock.patch.object(
243 arch._name_shrinker,
244 "shrink",
245 side_effect=lambda name, *a, **k: name.upper() if len(name) <= DAT__SZNAM else "CLASH",
246 ):
247 arch.add_array(data, name="long_component_name_one")
248 with self.assertRaisesRegex(ValueError, "name collision"):
249 arch.add_array(data, name="long_component_name_two")
252@unittest.skipUnless(HAVE_H5PY, "h5py is not installed")
253class NdfOutputArchivePointerTestCase(unittest.TestCase):
254 """Tests for `NdfOutputArchive.serialize_pointer` and
255 `serialize_frame_set`.
256 """
258 def test_serialize_pointer_writes_subtree_and_returns_pointer(self):
259 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
260 with h5py.File(tmp.name, "w") as f:
261 arch = NdfOutputArchive(f)
262 ptr = arch.serialize_pointer(
263 "psf",
264 lambda nested: TinyTree(name="gaussian"),
265 key=("psf", 1),
266 )
267 self.assertEqual(ptr.path, "/MORE/LSST/PSF/JSON")
268 with h5py.File(tmp.name, "r") as f:
269 # The hoisted sub-tree is stored as a "JSON" _CHAR*N
270 # child of the target structure.
271 raw = f["/MORE/LSST/PSF/JSON"][()]
272 joined = b"".join(raw).decode("ascii").rstrip(" ")
273 self.assertIn('"name":"gaussian"', joined.replace(" ", ""))
275 def test_serialize_pointer_caches_by_key(self):
276 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
277 with h5py.File(tmp.name, "w") as f:
278 arch = NdfOutputArchive(f)
279 ptr1 = arch.serialize_pointer(
280 "psf",
281 lambda nested: TinyTree(name="first"),
282 key=("psf", 1),
283 )
284 # Same key -> returns cached pointer; serializer not re-run
285 # (we'd otherwise overwrite the file content with "second").
286 ptr2 = arch.serialize_pointer(
287 "psf",
288 lambda nested: TinyTree(name="second"),
289 key=("psf", 1),
290 )
291 self.assertEqual(ptr1, ptr2)
292 with h5py.File(tmp.name, "r") as f:
293 raw = f["/MORE/LSST/PSF/JSON"][()]
294 joined = b"".join(raw).decode("ascii").rstrip(" ")
295 self.assertIn("first", joined)
296 self.assertNotIn("second", joined)
298 def test_serialize_pointer_preserves_nested_arrays(self):
299 # Regression test: a pointer target that writes a nested array via
300 # the nested archive must round-trip with that array still in the
301 # file. Previously the pointer JSON was written at the target path
302 # itself, clobbering any nested data the serializer produced.
303 class TreeWithArray(pydantic.BaseModel):
304 name: str
305 data: ArrayReferenceModel
307 payload = np.arange(6, dtype=np.float32).reshape(2, 3)
308 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
309 with h5py.File(tmp.name, "w") as f:
310 arch = NdfOutputArchive(f)
311 ptr = arch.serialize_pointer(
312 "psf",
313 lambda nested: TreeWithArray(
314 name="gaussian",
315 data=nested.add_array(payload, name="parameters"),
316 ),
317 key=("psf", 1),
318 )
319 self.assertEqual(ptr.path, "/MORE/LSST/PSF/JSON")
320 with h5py.File(tmp.name, "r") as f:
321 # JSON stayed at <path>/JSON; the nested array is still
322 # accessible at the sub-NDF path the serializer wrote.
323 self.assertIn("/MORE/LSST/PSF/JSON", f)
324 self.assertIn("/MORE/LSST/PSF/PARAMETERS/DATA_ARRAY/DATA", f)
325 np.testing.assert_array_equal(f["/MORE/LSST/PSF/PARAMETERS/DATA_ARRAY/DATA"][()], payload)
326 # The pointer-target structure is typed after its leaf
327 # name, not the generic EXT.
328 self.assertEqual(f["/MORE/LSST/PSF"].attrs["CLASS"], b"PSF")
329 self.assertEqual(f["/MORE/LSST"].attrs["CLASS"], b"LSST")
331 def test_serialize_frame_set_records_for_iter(self):
332 # serialize_frame_set is delegated to serialize_pointer plus
333 # recording the (FrameSet, pointer) pair for iter_frame_sets,
334 # mirroring how FITS and JSON archives behave.
335 frame_set = TinyFrameSet()
336 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
337 with h5py.File(tmp.name, "w") as f:
338 arch = NdfOutputArchive(f)
339 ptr = arch.serialize_frame_set(
340 "wcs/pixel_to_sky",
341 frame_set,
342 lambda nested: TinyTree(name="proj"),
343 key=("frame_set", 1),
344 )
345 self.assertEqual(ptr.path, "/MORE/LSST/WCS/PIXEL_TO_SKY/JSON")
346 recorded = list(arch.iter_frame_sets())
347 self.assertEqual(len(recorded), 1)
348 self.assertIs(recorded[0][0], frame_set)
349 self.assertEqual(recorded[0][1].path, "/MORE/LSST/WCS/PIXEL_TO_SKY/JSON")
352@unittest.skipUnless(HAVE_H5PY, "h5py is not installed")
353class NdfOutputArchiveAddTableTestCase(unittest.TestCase):
354 """Tests for `NdfOutputArchive.add_table` and `add_structured_array`."""
356 def test_add_table_returns_inline_table_model(self):
357 t = astropy.table.Table({"a": [1, 2, 3], "b": [4.0, 5.0, 6.0]})
358 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
359 with h5py.File(tmp.name, "w") as f:
360 arch = NdfOutputArchive(f)
361 model = arch.add_table(t, name="some_table")
362 self.assertEqual(len(model.columns), 2)
363 # v1 stores tables inline in the JSON tree.
364 self.assertIsInstance(model.columns[0].data, InlineArrayModel)
366 def test_add_structured_array_writes_column_ndfs_with_units(self):
367 rec = np.zeros(3, dtype=[("x", np.float64), ("y", np.int32)])
368 rec["x"] = [1.0, 2.0, 3.0]
369 rec["y"] = [10, 20, 30]
370 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
371 with h5py.File(tmp.name, "w") as f:
372 arch = NdfOutputArchive(f)
373 model = arch.add_structured_array(
374 rec,
375 name="rec",
376 units={"x": u.m},
377 descriptions={"y": "the y values"},
378 )
379 self.assertEqual(len(model.columns), 2)
380 self.assertIsInstance(model.columns[0].data, ArrayReferenceModel)
381 # Confirm units/descriptions were applied.
382 col_x = next(c for c in model.columns if c.name == "x")
383 col_y = next(c for c in model.columns if c.name == "y")
384 self.assertEqual(col_x.unit, u.m)
385 self.assertEqual(col_y.description, "the y values")
386 self.assertEqual(col_x.data.source, "ndf:/MORE/LSST/REC/X/DATA_ARRAY/DATA")
387 self.assertEqual(col_y.data.source, "ndf:/MORE/LSST/REC/Y/DATA_ARRAY/DATA")
388 with h5py.File(tmp.name, "r") as f:
389 self.assertEqual(f["/MORE/LSST/REC/X"].attrs["CLASS"], b"NDF")
390 np.testing.assert_array_equal(f["/MORE/LSST/REC/X/DATA_ARRAY/DATA"][()], rec["x"])
391 self.assertEqual(f["/MORE/LSST/REC/Y"].attrs["CLASS"], b"NDF")
392 np.testing.assert_array_equal(f["/MORE/LSST/REC/Y/DATA_ARRAY/DATA"][()], rec["y"])
393 with NdfInputArchive.open(tmp.name) as archive:
394 recovered = archive.get_structured_array(model)
395 np.testing.assert_array_equal(recovered, rec)
397 def test_add_single_column_structured_array_uses_table_name(self):
398 rec = np.zeros(1, dtype=[("solution", np.float64, (4,))])
399 rec["solution"] = [[1.0, 2.0, 3.0, 4.0]]
400 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
401 with h5py.File(tmp.name, "w") as f:
402 arch = NdfOutputArchive(f)
403 model = arch.add_structured_array(rec, name="psf/piff/interp/solution")
404 self.assertEqual(len(model.columns), 1)
405 column = model.columns[0]
406 self.assertIsInstance(column.data, ArrayReferenceModel)
407 self.assertEqual(
408 column.data.source,
409 "ndf:/MORE/LSST/PSF/PIFF/INTERP/SOLUTION/DATA_ARRAY/DATA",
410 )
411 self.assertEqual(column.data.shape, [4])
412 with h5py.File(tmp.name, "r") as f:
413 self.assertIn("PSF", f["/MORE/LSST"])
414 self.assertIn("PIFF", f["/MORE/LSST/PSF"])
415 self.assertIn("INTERP", f["/MORE/LSST/PSF/PIFF"])
416 self.assertIn("SOLUTION", f["/MORE/LSST/PSF/PIFF/INTERP"])
417 np.testing.assert_array_equal(
418 f["/MORE/LSST/PSF/PIFF/INTERP/SOLUTION/DATA_ARRAY/DATA"][()],
419 rec["solution"],
420 )
422 def test_structured_array_long_name_is_shrunk_and_versioned(self):
423 dtype = np.dtype([("alpha", "f8"), ("beta", "i4")])
424 arr = np.zeros(3, dtype=dtype)
425 with tempfile.NamedTemporaryFile(suffix=".sdf") as tmp:
426 with h5py.File(tmp.name, "w") as f:
427 arch = NdfOutputArchive(f)
428 first = arch.add_structured_array(arr, name="catalog_of_long_named_sources")
429 second = arch.add_structured_array(arr, name="catalog_of_long_named_sources")
430 for model in (first, second):
431 for column in model.columns:
432 token = column.data.source[len("ndf:") :]
433 for component in token.strip("/").split("/"):
434 self.assertLessEqual(len(component), DAT__SZNAM)
435 # The two structured arrays land in distinct sub-trees.
436 self.assertNotEqual(
437 first.columns[0].data.source,
438 second.columns[0].data.source,
439 )
440 # The second structured array's parent token keeps a
441 # visible _2 version suffix.
442 second_parent = second.columns[0].data.source[len("ndf:") :].strip("/").split("/")[-4]
443 self.assertTrue(second_parent.endswith("_2"))
444 with h5py.File(tmp.name, "r") as f:
445 for model in (first, second):
446 for column in model.columns:
447 self.assertIn(column.data.source[len("ndf:") :], f)
450@unittest.skipUnless(HAVE_H5PY, "h5py is not installed")
451class NdfWriteWcsTestCase(unittest.TestCase):
452 """Tests for /WCS/DATA serialization in ndf.write()."""
454 def test_write_with_projection_creates_wcs_component(self):
455 rng = np.random.default_rng(42)
456 det_frame = DetectorFrame(instrument="TestInst", detector=4, bbox=Box.factory[1:4096, 1:4096])
457 bbox = Box.factory[10:14, 20:25]
458 sky_projection = make_random_sky_projection(rng, det_frame, Box.factory[1:4096, 1:4096])
459 image = Image(
460 np.arange(20, dtype=np.float32).reshape(4, 5),
461 bbox=bbox,
462 sky_projection=sky_projection,
463 )
464 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
465 tmp.close()
466 write(image, tmp.name)
467 with h5py.File(tmp.name, "r") as f:
468 self.assertIn("WCS", f)
469 self.assertEqual(f["/WCS"].attrs["CLASS"], b"WCS")
470 wcs_data = f["/WCS/DATA"]
471 self.assertEqual(wcs_data.dtype, np.dtype("|S32"))
472 records = [s.decode("ascii").rstrip(" ") for s in wcs_data[()]]
473 self.assertTrue(all(record[0] in {" ", "+"} for record in records))
474 self.assertFalse(any(record.startswith("#") for record in records))
475 text = _hds.decode_ndf_ast_data(records)
476 stripped = [line.lstrip() for line in text.splitlines()]
477 self.assertTrue(any(s.startswith("Begin FrameSet") for s in stripped))
478 self.assertTrue(any(s.startswith("End FrameSet") for s in stripped))
479 self.assertIn('Domain = "GRID"', stripped)
480 self.assertIn('Domain = "PIXEL"', stripped)
481 self.assertIn("Sft1 = -19", stripped)
482 self.assertIn("Sft2 = -9", stripped)
484 def test_write_without_projection_omits_wcs_component(self):
485 # Image with no sky_projection -> no /WCS in the file.
486 image = Image(np.zeros((2, 2), dtype=np.float32))
487 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
488 tmp.close()
489 write(image, tmp.name)
490 with h5py.File(tmp.name, "r") as f:
491 self.assertNotIn("WCS", f)
493 def test_mask_sub_ndf_gets_3d_wcs(self):
494 # When an incompatible mask is hoisted to /MORE/LSST/MASK as a
495 # sub-NDF, it should carry its own 3D /WCS. The first two axes
496 # retain the parent image sky_projection while the third axis is
497 # a generic mask-byte coordinate.
498 rng = np.random.default_rng(42)
499 det_frame = DetectorFrame(instrument="TestInst", detector=4, bbox=Box.factory[1:4096, 1:4096])
500 bbox = Box.factory[10:14, 20:25]
501 sky_projection = make_random_sky_projection(rng, det_frame, Box.factory[1:4096, 1:4096])
502 # 12-plane schema -> native 3D uint8 mask, hoisted to /MORE/LSST/MASK.
503 planes = [MaskPlane(f"P{i}", f"Plane {i}") for i in range(12)]
504 image = Image(
505 np.arange(20, dtype=np.float32).reshape(4, 5),
506 bbox=bbox,
507 sky_projection=sky_projection,
508 )
509 masked = MaskedImage(image, mask_schema=MaskSchema(planes))
510 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
511 tmp.close()
512 write(masked, tmp.name)
513 with h5py.File(tmp.name, "r") as f:
514 # Top-level WCS is present (existing behaviour).
515 self.assertIn("WCS", f)
516 top_lines = [s.decode("ascii") for s in f["/WCS/DATA"][()]]
517 self.assertIn("MASK", f["/MORE/LSST"])
518 self.assertIn("WCS", f["/MORE/LSST/MASK"])
519 self.assertEqual(f["/MORE/LSST/MASK/WCS"].attrs["CLASS"], b"WCS")
520 mask_lines = [s.decode("ascii") for s in f["/MORE/LSST/MASK/WCS/DATA"][()]]
521 self.assertNotEqual(top_lines, mask_lines)
522 mask_text = _hds.decode_ndf_ast_data(mask_lines)
523 stripped = [line.lstrip() for line in mask_text.splitlines()]
524 self.assertIn("Naxes = 3", stripped)
525 self.assertIn('Domain = "GRID"', stripped)
526 self.assertIn('Domain = "PIXEL"', stripped)
527 self.assertIn("Sft1 = -19", stripped)
528 self.assertIn("Sft2 = -9", stripped)
529 self.assertIn("Sft3 = 1", stripped)
530 self.assertIn("Begin CmpFrame", stripped)
531 self.assertIn("Begin SkyFrame", stripped)
532 self.assertIn('Domain = "MASK"', stripped)
533 self.assertIn("Begin CmpMap", stripped)
534 self.assertIn("Series = 0", stripped)
536 def test_mask_sub_ndf_no_wcs_when_image_has_no_projection(self):
537 planes = [MaskPlane(f"P{i}", f"Plane {i}") for i in range(12)]
538 masked = MaskedImage(
539 Image(np.zeros((4, 5), dtype=np.float32)),
540 mask_schema=MaskSchema(planes),
541 )
542 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
543 tmp.close()
544 write(masked, tmp.name)
545 with h5py.File(tmp.name, "r") as f:
546 self.assertNotIn("WCS", f)
547 self.assertIn("MASK", f["/MORE/LSST"])
548 self.assertNotIn("WCS", f["/MORE/LSST/MASK"])
551@unittest.skipUnless(HAVE_H5PY, "h5py is not installed")
552class NdfWriteFunctionTestCase(unittest.TestCase):
553 """End-to-end tests for the module-level `write()` function."""
555 def test_write_image_produces_valid_layout(self):
556 image = Image(
557 np.arange(20, dtype=np.float32).reshape(4, 5),
558 bbox=Box.factory[10:14, 20:25],
559 )
560 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
561 tmp.close()
562 tree = write(image, tmp.name)
563 self.assertIsNotNone(tree)
564 with h5py.File(tmp.name, "r") as f:
565 # Root is an NDF with a name.
566 self.assertEqual(f["/"].attrs["CLASS"], b"NDF")
567 self.assertIn("HDS_ROOT_NAME", f["/"].attrs)
568 # DATA_ARRAY uses the complex form (DATA + ORIGIN).
569 self.assertEqual(f["/DATA_ARRAY"].attrs["CLASS"], b"ARRAY")
570 np.testing.assert_array_equal(f["/DATA_ARRAY/DATA"][()], image.array)
571 origin = f["/DATA_ARRAY/ORIGIN"][()]
572 self.assertEqual(origin.dtype, np.int64)
573 self.assertEqual(len(origin), 2)
574 # ORIGIN encodes bbox lower bounds in Fortran order. The exact
575 # values depend on Box's API; just verify it isn't the
576 # all-zeros placeholder when the bbox is non-trivial.
577 self.assertFalse((origin == 0).all())
578 # Main JSON tree at /MORE/LSST/JSON.
579 self.assertIn("MORE", f)
580 self.assertIn("LSST", f["/MORE"])
581 self.assertIn("JSON", f["/MORE/LSST"])
583 def test_write_image_preserves_opaque_fits_metadata(self):
584 image = Image(np.zeros((2, 2), dtype=np.float32))
585 # Attach an opaque-metadata primary header to the image.
586 primary = astropy.io.fits.Header()
587 primary["FOO"] = ("bar", "test card")
588 long_value = "x" * 100
589 primary["LONGSTR"] = (long_value, "long string value")
590 opaque = FitsOpaqueMetadata()
591 opaque.add_header(primary, name="", ver=1)
592 image._opaque_metadata = opaque
593 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
594 tmp.close()
595 write(image, tmp.name)
596 with h5py.File(tmp.name, "r") as f:
597 self.assertIn("FITS", f["/MORE"])
598 cards = [c.decode("ascii").rstrip(" ") for c in f["/MORE/FITS"][()]]
599 self.assertTrue(any(c.startswith("FOO") for c in cards))
600 self.assertTrue(any(c.startswith("CONTINUE") for c in cards))
601 self.assertTrue(all(len(c.encode("ascii")) <= 80 for c in cards))
602 result = read_archive(tmp.name, Image)
603 recovered = result._opaque_metadata.headers[ExtensionKey()]
604 self.assertEqual(recovered["LONGSTR"], long_value)
606 def test_write_image_main_json_round_trips_back(self):
607 # Sanity: the main JSON tree at /MORE/LSST/JSON should parse as the
608 # in-memory ArchiveTree and contain the array reference for DATA_ARRAY.
609 image = Image(np.arange(6, dtype=np.float32).reshape(2, 3))
610 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
611 tmp.close()
612 tree = write(image, tmp.name)
613 with h5py.File(tmp.name, "r") as f:
614 raw = f["/MORE/LSST/JSON"][()]
615 joined = b"".join(raw).decode("ascii").rstrip(" ")
616 recovered = json.loads(joined)
617 # The exact structure depends on Image's serialization model; we
618 # just check the JSON is parseable and the ArchiveTree object the
619 # write() function returned dumps to the same JSON.
620 self.assertEqual(json.loads(tree.model_dump_json()), recovered)
622 def test_write_image_with_unit_creates_units_component(self):
623 image = Image(np.arange(6, dtype=np.float32).reshape(2, 3), unit=u.ct)
624 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
625 tmp.close()
626 write(image, tmp.name)
627 with h5py.File(tmp.name, "r") as f:
628 self.assertIn("UNITS", f)
629 self.assertEqual(f["/UNITS"].shape, ())
630 self.assertEqual(f["/UNITS"][()].decode("ascii").rstrip(" "), "count")
631 result = read_archive(tmp.name, Image)
632 self.assertEqual(result.unit, u.ct)
634 def test_write_propagates_metadata(self):
635 image = Image(np.arange(6, dtype=np.float32).reshape(2, 3))
636 extra = {"test_key": 42, "another": "hello"}
637 with tempfile.NamedTemporaryFile(suffix=".sdf", delete_on_close=False) as tmp:
638 tmp.close()
639 tree = write(image, tmp.name, metadata=extra)
640 self.assertEqual(tree.metadata["test_key"], 42)
641 self.assertEqual(tree.metadata["another"], "hello")
642 with open_archive(tmp.name, Image) as reader:
643 self.assertEqual(reader.metadata["test_key"], 42)
644 self.assertEqual(reader.metadata["another"], "hello")