Coverage for tests/test_ndf_hds.py: 97%
226 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 os
15from pathlib import Path
17import numpy as np
18import pytest
20try:
21 import h5py
23 from lsst.images.ndf import _hds
25 HAVE_H5PY = True
26except ImportError:
27 HAVE_H5PY = False
29skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
31EXAMPLE = os.path.join(os.path.dirname(__file__), "data", "example-ndf.sdf")
34def _attr_str(value: object) -> str | None:
35 """Return an h5py attribute decoded to str, or None if not string-like."""
36 if isinstance(value, bytes): 36 ↛ 38line 36 didn't jump to line 38 because the condition on line 36 was always true
37 return value.decode("ascii")
38 if isinstance(value, str):
39 return value
40 return None
43@skip_no_h5py
44def test_real_array_round_trip(tmp_path: Path) -> None:
45 """Verify float32 arrays round-trip through _hds write_array /
46 read_array.
47 """
48 path = tmp_path / "test.sdf"
49 data = np.arange(12, dtype=np.float32).reshape(3, 4)
50 with h5py.File(path, "w") as f:
51 _hds.write_array(f, "DATA", data)
52 with h5py.File(path, "r") as f:
53 ds = f["DATA"]
54 assert ds.dtype == np.float32
55 assert ds.shape == (3, 4)
56 assert dict(ds.attrs) == {}
57 np.testing.assert_array_equal(_hds.read_array(ds), data)
60@skip_no_h5py
61def test_double_array_round_trip(tmp_path: Path) -> None:
62 """Verify float64 arrays round-trip through _hds write_array /
63 read_array.
64 """
65 path = tmp_path / "test.sdf"
66 data = np.linspace(0, 1, 6, dtype=np.float64).reshape(2, 3)
67 with h5py.File(path, "w") as f:
68 _hds.write_array(f, "DATA", data)
69 with h5py.File(path, "r") as f:
70 assert f["DATA"].dtype == np.float64
71 np.testing.assert_array_equal(_hds.read_array(f["DATA"]), data)
74@skip_no_h5py
75def test_ubyte_and_integer(tmp_path: Path) -> None:
76 """Verify uint8 and int32 arrays preserve dtype through _hds round-trip."""
77 path = tmp_path / "test.sdf"
78 data_u = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8)
79 data_i = np.array([10, 20, 30], dtype=np.int32)
80 with h5py.File(path, "w") as f:
81 _hds.write_array(f, "Q", data_u)
82 _hds.write_array(f, "I", data_i)
83 with h5py.File(path, "r") as f:
84 assert f["Q"].dtype == np.uint8
85 assert f["I"].dtype == np.int32
86 np.testing.assert_array_equal(_hds.read_array(f["Q"]), data_u)
87 np.testing.assert_array_equal(_hds.read_array(f["I"]), data_i)
90@skip_no_h5py
91def test_logical_uses_hdf5_bitfield(tmp_path: Path) -> None:
92 """Verify boolean arrays are stored as HDF5 BITFIELD, not integer."""
93 path = tmp_path / "test.sdf"
94 with h5py.File(path, "w") as f:
95 _hds.write_array(f, "SCALAR", np.array(False, dtype=np.bool_))
96 _hds.write_array(f, "ARRAY", np.array([True, False], dtype=np.bool_))
97 with h5py.File(path, "r") as f:
98 assert f["SCALAR"].id.get_type().get_class() == h5py.h5t.BITFIELD
99 assert f["SCALAR"].id.get_type().get_size() == 1
100 assert not _hds.read_array(f["SCALAR"])
101 assert f["ARRAY"].id.get_type().get_class() == h5py.h5t.BITFIELD
102 assert f["ARRAY"].id.get_type().get_size() == 1
103 np.testing.assert_array_equal(_hds.read_array(f["ARRAY"]), np.array([True, False]))
106@skip_no_h5py
107def test_unsupported_dtype_raises_on_write(tmp_path: Path) -> None:
108 """Verify write_array raises NotImplementedError for complex128."""
109 path = tmp_path / "test.sdf"
110 data = np.array([1.0], dtype=np.complex128)
111 with h5py.File(path, "w") as f:
112 with pytest.raises(NotImplementedError):
113 _hds.write_array(f, "X", data)
116@skip_no_h5py
117def test_unsupported_dtype_raises_on_read(tmp_path: Path) -> None:
118 """Verify read_array raises NotImplementedError when reading complex128."""
119 path = tmp_path / "test.sdf"
120 with h5py.File(path, "w") as f:
121 # Write directly with h5py, bypassing write_array's check.
122 f.create_dataset("X", data=np.array([1.0], dtype=np.complex128))
123 with h5py.File(path, "r") as f:
124 with pytest.raises(NotImplementedError):
125 _hds.read_array(f["X"])
128@skip_no_h5py
129def test_read_array_rejects_char_dataset(tmp_path: Path) -> None:
130 """Verify read_array raises ValueError when given a char (string)
131 dataset.
132 """
133 path = tmp_path / "test.sdf"
134 with h5py.File(path, "w") as f:
135 _hds.write_char_array(f, "WCS", ["hello", "world"], width=16)
136 with h5py.File(path, "r") as f:
137 with pytest.raises(ValueError):
138 _hds.read_array(f["WCS"])
141@skip_no_h5py
142def test_char_array_round_trip(tmp_path: Path) -> None:
143 """Verify string lines round-trip through write_char_array /
144 read_char_array.
145 """
146 path = tmp_path / "test.sdf"
147 lines = ["Begin FrameSet", "Nframe = 5", "End FrameSet"]
148 with h5py.File(path, "w") as f:
149 _hds.write_char_array(f, "DATA", lines, width=80)
150 with h5py.File(path, "r") as f:
151 ds = f["DATA"]
152 assert ds.dtype == np.dtype("|S80")
153 assert ds.shape == (3,)
154 assert dict(ds.attrs) == {}
155 assert _hds.read_char_array(ds) == lines
158@skip_no_h5py
159def test_char_array_pads_and_strips(tmp_path: Path) -> None:
160 """Verify write_char_array space-pads to width and read_char_array strips
161 trailing spaces.
162 """
163 path = tmp_path / "test.sdf"
164 with h5py.File(path, "w") as f:
165 _hds.write_char_array(f, "X", ["short"], width=80)
166 with h5py.File(path, "r") as f:
167 # Raw data should be space-padded to 80 characters.
168 assert f["X"][0] == b"short" + b" " * 75
169 # read_char_array strips trailing spaces.
170 assert _hds.read_char_array(f["X"]) == ["short"]
173@skip_no_h5py
174def test_char_array_rejects_long_lines(tmp_path: Path) -> None:
175 """Verify write_char_array raises ValueError when a line exceeds the
176 width.
177 """
178 path = tmp_path / "test.sdf"
179 with h5py.File(path, "w") as f:
180 with pytest.raises(ValueError):
181 _hds.write_char_array(f, "X", ["too long"], width=3)
184@skip_no_h5py
185def test_char_array_rejects_non_ascii(tmp_path: Path) -> None:
186 """Verify write_char_array raises ValueError for non-ASCII content."""
187 path = tmp_path / "test.sdf"
188 with h5py.File(path, "w") as f:
189 with pytest.raises(ValueError):
190 _hds.write_char_array(f, "X", ["not ascii: \N{LATIN SMALL LETTER E WITH ACUTE}"], width=80)
193@skip_no_h5py
194def test_ndf_ast_data_encoding_uses_flagged_fixed_width_records() -> None:
195 """Verify encode_ndf_ast_data produces fixed-width flagged records that
196 decode correctly.
197 """
198 text = (
199 ' Begin FrameSet\n# Title = "demo"\n VeryLongAttribute = 12345678901234567890\n End FrameSet\n'
200 )
201 expected = 'Begin FrameSet\n# Title = "demo"\nVeryLongAttribute = 12345678901234567890\nEnd FrameSet\n'
203 records = _hds.encode_ndf_ast_data(text)
205 assert all(len(record) <= _hds.NDF_AST_DATA_WIDTH for record in records)
206 assert all(record[0] in {" ", "+"} for record in records)
207 assert ' # Title = "demo"' in records
208 assert any(record.startswith("+") for record in records)
209 assert _hds.decode_ndf_ast_data(records) == expected
212@skip_no_h5py
213def test_read_char_array_rejects_numeric_dataset(tmp_path: Path) -> None:
214 """Verify read_char_array raises ValueError when given a numeric
215 dataset.
216 """
217 path = tmp_path / "test.sdf"
218 with h5py.File(path, "w") as f:
219 _hds.write_array(f, "DATA", np.zeros((2,), dtype=np.float32))
220 with h5py.File(path, "r") as f:
221 with pytest.raises(ValueError):
222 _hds.read_char_array(f["DATA"])
225@skip_no_h5py
226def test_hds_type_for_dtype() -> None:
227 """Verify hds_type_for_dtype maps numpy dtypes to correct HDS type
228 strings.
229 """
230 assert _hds.hds_type_for_dtype(np.dtype(np.bool_)) == "_LOGICAL"
231 assert _hds.hds_type_for_dtype(np.dtype(np.float32)) == "_REAL"
232 assert _hds.hds_type_for_dtype(np.dtype(np.float64)) == "_DOUBLE"
233 assert _hds.hds_type_for_dtype(np.dtype(np.uint8)) == "_UBYTE"
234 assert _hds.hds_type_for_dtype(np.dtype(np.int32)) == "_INTEGER"
235 assert _hds.hds_type_for_dtype(np.dtype("|S80")) == "_CHAR*80"
236 with pytest.raises(NotImplementedError):
237 _hds.hds_type_for_dtype(np.dtype(np.complex128))
240@skip_no_h5py
241def test_create_open_structure(tmp_path: Path) -> None:
242 """Verify create_structure and open_structure round-trip CLASS and child
243 names.
244 """
245 path = tmp_path / "test.sdf"
246 with h5py.File(path, "w") as f:
247 ndf = _hds.create_structure(f, "ROOT", "NDF")
248 _hds.create_structure(ndf, "DATA_ARRAY", "ARRAY")
249 with h5py.File(path, "r") as f:
250 root_obj = f["ROOT"]
251 assert _attr_str(root_obj.attrs["CLASS"]) == "NDF"
252 root, root_type = _hds.open_structure(f, "ROOT")
253 assert root_type == "NDF"
254 child_names = sorted(name for name, _ in _hds.iter_children(root))
255 assert child_names == ["DATA_ARRAY"]
256 _, child_type = _hds.open_structure(root, "DATA_ARRAY")
257 assert child_type == "ARRAY"
260@skip_no_h5py
261def test_open_structure_missing_class_raises(tmp_path: Path) -> None:
262 """Verify open_structure raises ValueError when the CLASS attribute is
263 absent.
264 """
265 path = tmp_path / "test.sdf"
266 with h5py.File(path, "w") as f:
267 f.create_group("BAD")
268 with h5py.File(path, "r") as f:
269 with pytest.raises(ValueError):
270 _hds.open_structure(f, "BAD")
273@skip_no_h5py
274def test_open_structure_accepts_legacy_hdstype(tmp_path: Path) -> None:
275 """Verify open_structure accepts the legacy HDSTYPE attribute in place
276 of CLASS.
277 """
278 path = tmp_path / "test.sdf"
279 with h5py.File(path, "w") as f:
280 g = f.create_group("LEGACY")
281 g.attrs["HDSTYPE"] = b"NDF"
282 with h5py.File(path, "r") as f:
283 _, t = _hds.open_structure(f, "LEGACY")
284 assert t == "NDF"
287@skip_no_h5py
288def test_set_root_name(tmp_path: Path) -> None:
289 """Verify set_root_name writes HDS_ROOT_NAME and CLASS to the root
290 group.
291 """
292 path = tmp_path / "test.sdf"
293 with h5py.File(path, "w") as f:
294 _hds.set_root_name(f, "MYNDF", "NDF")
295 with h5py.File(path, "r") as f:
296 assert _attr_str(f["/"].attrs["HDS_ROOT_NAME"]) == "MYNDF"
297 assert _attr_str(f["/"].attrs["CLASS"]) == "NDF"
300@skip_no_h5py
301def test_root_is_ndf_with_root_name() -> None:
302 """Verify the canonical example NDF has CLASS=NDF and the expected root
303 name.
304 """
305 with h5py.File(EXAMPLE, "r") as f:
306 assert _attr_str(f["/"].attrs["CLASS"]) == "NDF"
307 assert _attr_str(f["/"].attrs["HDS_ROOT_NAME"]) == "M57"
310@skip_no_h5py
311def test_data_array_is_array_structure() -> None:
312 """Verify the example NDF's DATA_ARRAY is an ARRAY structure with
313 correct dtype.
314 """
315 with h5py.File(EXAMPLE, "r") as f:
316 data_array, hds_type = _hds.open_structure(f, "DATA_ARRAY")
317 assert hds_type == "ARRAY"
318 data = data_array["DATA"]
319 assert data.dtype == np.int16
320 assert data.shape == (611, 609)
321 assert _hds.hds_type_for_dtype(data.dtype) == "_WORD"
322 arr = _hds.read_array(data)
323 assert arr.shape == (611, 609)
324 origin = _hds.read_array(data_array["ORIGIN"])
325 assert origin.dtype == np.int64
326 assert origin.shape == (2,)
329@skip_no_h5py
330def test_wcs_is_structure_with_ast_text() -> None:
331 """Verify the example NDF's WCS structure contains a valid AST FrameSet
332 text dump.
333 """
334 with h5py.File(EXAMPLE, "r") as f:
335 wcs, hds_type = _hds.open_structure(f, "WCS")
336 assert hds_type == "WCS"
337 lines = _hds.read_char_array(wcs["DATA"])
338 text = _hds.decode_ndf_ast_data(lines)
339 stripped = [line.lstrip() for line in text.splitlines()]
340 assert any(s.startswith("Begin FrameSet") for s in stripped)
341 assert any(s.startswith("End FrameSet") for s in stripped)
344@skip_no_h5py
345def test_more_fits_present() -> None:
346 """Verify the example NDF's MORE/FITS extension contains FITS cards
347 including NAXIS.
348 """
349 with h5py.File(EXAMPLE, "r") as f:
350 more, hds_type = _hds.open_structure(f, "MORE")
351 assert hds_type == "EXT"
352 cards = _hds.read_char_array(more["FITS"])
353 assert len(cards) > 0
354 assert any(c.startswith("NAXIS") for c in cards)