Coverage for python/lsst/images/ndf/_input_archive.py: 81%
309 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-04 10:14 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-04 10:14 -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
14__all__ = ("NdfInputArchive", "read_starlink")
16import logging
17from collections.abc import Callable, Iterator
18from contextlib import contextmanager
19from types import EllipsisType
20from typing import Any, Self
22import astropy.io.fits
23import astropy.table
24import astropy.units as u
25import h5py
26import numpy as np
28from lsst.resources import ResourcePath, ResourcePathExpression
30from .._geom import Box
31from .._image import Image
32from .._mask import Mask, MaskPlane, MaskSchema
33from .._masked_image import MaskedImage
34from .._transforms import FrameSet, SkyProjection
35from .._transforms import _ast as astshim
36from .._transforms._frames import GeneralFrame
37from ..fits._common import FitsOpaqueMetadata
38from ..serialization import (
39 ArchiveInfo,
40 ArchiveReadError,
41 ArchiveTree,
42 ArrayReferenceModel,
43 InlineArrayModel,
44 InputArchive,
45 TableModel,
46 no_header_updates,
47 parameterize_tree,
48 tree_class_for_info,
49)
50from ..serialization._common import _check_format_version
51from . import _hds
52from ._common import NdfPointerModel
53from ._model import HdsPrimitive, NdfDocument
55_LOG = logging.getLogger(__name__)
57_NDF_FORMAT_VERSION = 1
58"""Container layout version this release of `NdfInputArchive` understands."""
60_LSST_EXTENSION_PREFIXES = ("/MORE/LSST", "/LSST")
61"""Fixed locations of the LSST extension structure within an NDF.
63Only these top-level locations are searched; nested pointer trees can
64therefore never be mistaken for the main one.
65"""
68def _get_lsst_primitive(
69 get_primitive: Callable[[str], HdsPrimitive | None], name: str
70) -> HdsPrimitive | None:
71 """Find a named primitive in the LSST extension structure.
73 Parameters
74 ----------
75 get_primitive
76 Callable returning the primitive at an absolute path, or `None` if
77 there is no primitive there.
78 name
79 Component name within the LSST extension, e.g. ``DATA_MODEL``.
80 """
81 for prefix in _LSST_EXTENSION_PREFIXES:
82 if (primitive := get_primitive(f"{prefix}/{name}")) is not None:
83 return primitive
84 return None
87def _read_format_version(get_primitive: Callable[[str], HdsPrimitive | None]) -> int:
88 """Read the container-layout ``FORMAT_VERSION`` from the LSST extension.
90 Absence is treated as ``1`` (legacy default).
91 """
92 primitive = _get_lsst_primitive(get_primitive, "FORMAT_VERSION")
93 if primitive is None:
94 return 1
95 # The writer emits the version as a 0-d int32 numpy array; .item()
96 # unwraps to a Python int.
97 return int(primitive.read_array().item())
100def _read_archive_info(get_primitive: Callable[[str], HdsPrimitive | None], source: str) -> ArchiveInfo:
101 """Read the schema URL and format version from the LSST extension.
103 Parameters
104 ----------
105 get_primitive
106 Callable returning the primitive at an absolute path, or `None` if
107 there is no primitive there.
108 source
109 Description of the file being read, used in error messages.
110 """
111 schema_url: str | None = None
112 if (data_model := _get_lsst_primitive(get_primitive, "DATA_MODEL")) is not None: 112 ↛ 115line 112 didn't jump to line 115 because the condition on line 112 was always true
113 lines = data_model.read_char_array()
114 schema_url = lines[0].strip() if lines else None
115 if not schema_url: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 raise ArchiveReadError(
117 f"Could not read the schema of {source} from /MORE/LSST/DATA_MODEL or /LSST/DATA_MODEL."
118 )
119 return ArchiveInfo.from_schema_url(schema_url, format_version=_read_format_version(get_primitive))
122class NdfInputArchive(InputArchive[NdfPointerModel]):
123 """Reads HDS-on-HDF5 NDF files written by `NdfOutputArchive`.
125 Instances should only be constructed via the :meth:`open` context
126 manager.
128 Parameters
129 ----------
130 file
131 Open `h5py.File` handle. Owned by the caller of :meth:`open`;
132 the archive does not close it.
133 """
135 def __init__(self, file: h5py.File) -> None:
136 self._file = file
137 self._document = NdfDocument.from_hdf5(file)
138 self._opaque_metadata = FitsOpaqueMetadata()
139 self._deserialized_pointer_cache: dict[str, Any] = {}
140 self._frame_set_cache: dict[str, FrameSet] = {}
141 self._read_opaque_fits_metadata()
142 self._check_format_version()
144 @classmethod
145 def get_basic_info(cls, path: ResourcePathExpression) -> ArchiveInfo:
146 """Read the schema URL from the ``DATA_MODEL`` scalar and the
147 ``FORMAT_VERSION`` primitive without deserializing pixel data.
149 Reading the datasets directly from the HDF5 file avoids building
150 the full internal model, which would eagerly read the
151 (potentially large) JSON tree.
153 Parameters
154 ----------
155 path
156 Path to the archive to read.
157 """
158 ospath = ResourcePath(path).ospath
160 with h5py.File(ospath, "r") as handle:
162 def get_primitive(component_path: str) -> HdsPrimitive | None:
163 node = handle.get(component_path)
164 return HdsPrimitive.from_hdf5(node) if isinstance(node, h5py.Dataset) else None
166 return _read_archive_info(get_primitive, repr(path))
168 @classmethod
169 @contextmanager
170 def open_tree(
171 cls,
172 path: ResourcePathExpression,
173 *,
174 partial: bool = True,
175 **backend_kwargs: Any,
176 ) -> Iterator[tuple[Self, ArchiveTree, ArchiveInfo]]:
177 """Open the NDF file and yield ``(archive, tree, info)``.
179 The schema is read from the open document's ``DATA_MODEL`` rather than
180 a separate `get_basic_info` open. Requires the symmetric LSST JSON
181 tree; ``partial`` is accepted but not meaningful, since h5py reads
182 lazily regardless.
184 Parameters
185 ----------
186 path
187 The file resource to open.
188 partial
189 Accepted for interface compatibility but not meaningful; h5py
190 reads lazily regardless.
191 **backend_kwargs
192 Backend-specific options; none are currently used.
193 """
194 with cls.open(path) as archive:
195 if archive._get_main_json_path() is None: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true
196 raise ArchiveReadError(
197 f"{path!r} has no LSST JSON tree; only the symmetric read path is supported."
198 )
199 info = archive.info
200 tree_cls = tree_class_for_info(info, path)
201 parameterized = parameterize_tree(tree_cls, NdfPointerModel)
202 tree = archive.get_tree(parameterized)
203 yield archive, tree, info
205 @classmethod
206 @contextmanager
207 def open(cls, path: ResourcePathExpression) -> Iterator[Self]:
208 """Open an NDF file for reading and yield an `NdfInputArchive`.
210 Remote ResourcePaths are materialised locally first; fsspec-direct
211 h5py reads are a deferred follow-up.
213 Parameters
214 ----------
215 path
216 Path to the NDF file to open.
217 """
218 rp = ResourcePath(path)
219 with rp.as_local() as local:
220 with h5py.File(local.ospath, "r") as f:
221 yield cls(f)
223 def get_tree[T: ArchiveTree](self, model_type: type[T]) -> T:
224 """Read and validate the main Pydantic tree at ``/MORE/LSST/JSON``.
226 Parameters
227 ----------
228 model_type
229 Archive tree model type to validate the JSON tree against.
230 """
231 json_path = self._get_main_json_path()
232 if json_path is None:
233 raise ArchiveReadError(
234 "File has no /MORE/LSST/JSON tree; this is either a "
235 "Starlink-only NDF (use ndf.read_starlink() for auto-detect) or "
236 "the file was written by an unrelated tool."
237 )
238 json_text = _read_json_record(self._get_primitive(json_path), json_path)
239 return model_type.model_validate_json(json_text)
241 def deserialize_pointer[U: ArchiveTree, V](
242 self,
243 pointer: NdfPointerModel,
244 model_type: type[U],
245 deserializer: Callable[[U, InputArchive[NdfPointerModel]], V],
246 ) -> V:
247 # Cache by pointer.path so repeated dereferences reuse the same
248 # deserialised result and don't re-run the deserializer.
249 if (cached := self._deserialized_pointer_cache.get(pointer.path)) is not None:
250 return cached
251 if not self._has_model_path(pointer.path): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 raise ArchiveReadError(f"Pointer reference {pointer.path!r} not found in NDF file.")
253 primitive = self._get_primitive(pointer.path)
254 json_text = _read_json_record(primitive, pointer.path)
255 model = model_type.model_validate_json(json_text)
256 result = deserializer(model, self)
257 self._deserialized_pointer_cache[pointer.path] = result
258 if isinstance(result, FrameSet):
259 self._frame_set_cache[pointer.path] = result
260 return result
262 def get_frame_set(self, pointer: NdfPointerModel) -> FrameSet:
263 try:
264 return self._frame_set_cache[pointer.path]
265 except KeyError:
266 raise AssertionError(
267 f"Frame set at {pointer.path!r} must be deserialised via "
268 f"deserialize_pointer before any dependent transform can be."
269 ) from None
271 def get_array(
272 self,
273 model: ArrayReferenceModel | InlineArrayModel,
274 *,
275 slices: tuple[slice, ...] | EllipsisType = ...,
276 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
277 ) -> np.ndarray:
278 if isinstance(model, InlineArrayModel):
279 data: np.ndarray = np.array(model.data, dtype=model.datatype.to_numpy())
280 return data if slices is ... else data[slices]
281 if not isinstance(model.source, str) or not model.source.startswith("ndf:"):
282 raise ArchiveReadError(
283 f"NdfInputArchive cannot resolve array source {model.source!r}; "
284 f"expected an 'ndf:<HDF5-path>' reference."
285 )
286 path = model.source[len("ndf:") :]
287 if not self._has_model_path(path): 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 raise ArchiveReadError(f"Array reference {path!r} not in file.")
289 primitive = self._get_primitive(path)
290 # h5py supports lazy slicing via dataset[slices].
291 if isinstance(primitive.data, h5py.Dataset): 291 ↛ 293line 291 didn't jump to line 293 because the condition on line 291 was always true
292 return primitive.data[()] if slices is ... else primitive.data[slices]
293 data = primitive.read_array()
294 return data if slices is ... else data[slices]
296 def get_table(
297 self,
298 model: TableModel,
299 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
300 ) -> astropy.table.Table:
301 result = astropy.table.Table(meta=model.meta)
302 for column_model in model.columns:
303 if isinstance(column_model.data, InlineArrayModel): 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 data: Any = column_model.data.data
305 else:
306 data = self.get_array(column_model.data, strip_header=strip_header)
307 result[column_model.name] = astropy.table.Column(
308 data,
309 name=column_model.name,
310 dtype=column_model.data.datatype.to_numpy(),
311 unit=column_model.unit,
312 description=column_model.description,
313 meta=column_model.meta,
314 )
315 return result
317 def get_structured_array(
318 self,
319 model: TableModel,
320 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
321 ) -> np.ndarray:
322 return self.get_table(model, strip_header).as_array()
324 def _read_opaque_fits_metadata(self) -> None:
325 if not self._has_model_path("/MORE/FITS"):
326 return
327 cards = self._get_primitive("/MORE/FITS").read_char_array()
328 # FITS Header.fromstring expects fixed-width 80-char cards
329 # concatenated; pad each card defensively so readers tolerate
330 # files written with shorter widths.
331 header = astropy.io.fits.Header.fromstring("".join(c.ljust(80) for c in cards))
332 self._opaque_metadata.add_header(header, name="", ver=1)
334 def get_opaque_metadata(self) -> FitsOpaqueMetadata:
335 return self._opaque_metadata
337 @property
338 def info(self) -> ArchiveInfo:
339 """Schema/format info read from the open document's ``DATA_MODEL``
340 (`.serialization.ArchiveInfo`).
341 """
342 return _read_archive_info(self._get_optional_primitive, repr(self._file.filename))
344 def _get_main_json_path(self) -> str | None:
345 """Return the path of the main LSST JSON tree, if present."""
346 for prefix in _LSST_EXTENSION_PREFIXES:
347 path = f"{prefix}/JSON"
348 if self._has_model_path(path):
349 return path
350 return None
352 def _check_format_version(self) -> None:
353 """Read FORMAT_VERSION from the NDF top-level structure and check it.
355 DATA_MODEL is informational only on read; the JSON tree's
356 ``schema_version`` / ``min_read_version`` drive data-model
357 compatibility.
358 """
359 _check_format_version("ndf", _read_format_version(self._get_optional_primitive), _NDF_FORMAT_VERSION)
361 def _has_model_path(self, path: str) -> bool:
362 """Return `True` if a path exists in the NDF document model."""
363 try:
364 self._document.get(path)
365 except KeyError:
366 return False
367 return True
369 def _get_primitive(self, path: str) -> HdsPrimitive:
370 """Return a primitive component from the NDF document model."""
371 node = self._document.get(path)
372 if not isinstance(node, HdsPrimitive): 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true
373 raise ArchiveReadError(f"NDF reference {path!r} is not a primitive dataset.")
374 return node
376 def _get_optional_primitive(self, path: str) -> HdsPrimitive | None:
377 """Return a primitive from the document model, or `None` if there is
378 no primitive at ``path``.
379 """
380 try:
381 node = self._document.get(path)
382 except KeyError:
383 return None
384 return node if isinstance(node, HdsPrimitive) else None
387def read_starlink[T: Any](cls_: type[T], path: ResourcePathExpression) -> T:
388 """Reconstruct an `~lsst.images.Image` or `~lsst.images.MaskedImage`
389 from a schema-less Starlink NDF.
391 Files written by this package carry a ``/MORE/LSST/JSON`` tree and are
392 read through the generic `lsst.images.serialization.read` /
393 `lsst.images.serialization.open`. A Starlink-produced NDF has no such
394 tree and therefore no schema, so it cannot go through that path; this
395 function auto-detects a minimal recognised-component set
396 (``DATA_ARRAY``, ``VARIANCE``, ``QUALITY``, ``MORE.FITS``) instead.
397 ``WCS`` is reconstructed when possible; other components are
398 logged-and-dropped.
400 Parameters
401 ----------
402 cls_
403 Expected return type; `~lsst.images.Image` and
404 `~lsst.images.MaskedImage` are the only types the auto-detect path
405 can produce.
406 path
407 File path or `lsst.resources.ResourcePathExpression`.
409 Returns
410 -------
411 object
412 The deserialized ``cls`` instance.
414 Raises
415 ------
416 ArchiveReadError
417 If the file has an LSST JSON tree (use the generic ``read`` instead)
418 or no recognised ``DATA_ARRAY`` component.
419 """
420 with NdfInputArchive.open(path) as archive:
421 if archive._get_main_json_path() is not None: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true
422 raise ArchiveReadError(
423 f"{path!r} has an LSST JSON tree; read it with serialization.read()/open()."
424 )
425 return _read_auto_detect(cls_, archive)
428def _read_auto_detect[T: Any](cls: type[T], archive: NdfInputArchive) -> T:
429 """Reconstruct an `Image` (or `MaskedImage`) from a Starlink NDF.
431 Recognised components: ``DATA_ARRAY`` (in either simple or complex
432 form), ``VARIANCE``, ``QUALITY``, ``MORE.FITS``. Other components
433 (``WCS``, ``HISTORY``, ``AXIS``, ``LABEL``, custom ``MORE.*``,
434 ``_LOGICAL`` primitives) are warned-and-dropped.
435 """
436 f = archive._file
437 ndf_group = _locate_ndf_root(f)
439 # DATA_ARRAY is required.
440 if "DATA_ARRAY" not in ndf_group:
441 raise ArchiveReadError(f"Auto-detect read of {f.filename!r}: no DATA_ARRAY component.")
442 data_arr, bbox = _read_data_array_with_bbox(ndf_group["DATA_ARRAY"])
444 # VARIANCE / QUALITY are optional.
445 variance_arr: np.ndarray | None = None
446 variance_bbox: Any | None = None
447 if "VARIANCE" in ndf_group:
448 variance_arr, variance_bbox = _read_data_array_with_bbox(ndf_group["VARIANCE"])
449 quality_arr: np.ndarray | None = None
450 quality_bbox: Any | None = None
451 quality_badbits = 255
452 if "QUALITY" in ndf_group and isinstance(ndf_group["QUALITY"], h5py.Group):
453 q = ndf_group["QUALITY"]
454 quality_badbits = _read_quality_badbits(q)
455 if "QUALITY" in q and isinstance(q["QUALITY"], h5py.Dataset): 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 quality_arr = _validate_quality_array(_hds.read_array(q["QUALITY"]))
457 quality_bbox = _make_bbox(x_min=0, y_min=0, array=quality_arr)
458 elif "QUALITY" in q and isinstance(q["QUALITY"], h5py.Group): 458 ↛ 462line 458 didn't jump to line 462 because the condition on line 458 was always true
459 quality_arr, quality_bbox = _read_data_array_with_bbox(q["QUALITY"])
460 quality_arr = _validate_quality_array(quality_arr)
462 sky_projection: SkyProjection | None = None
463 if "WCS" in ndf_group:
464 try:
465 wcs_group = ndf_group["WCS"]
466 if isinstance(wcs_group, h5py.Group) and "DATA" in wcs_group: 466 ↛ 484line 466 didn't jump to line 484 because the condition on line 466 was always true
467 wcs_lines = _hds.read_char_array(wcs_group["DATA"])
468 wcs_text = _hds.decode_ndf_ast_data(wcs_lines)
469 ast_obj = astshim.Object.fromString(wcs_text)
470 if isinstance(ast_obj, astshim.FrameSet): 470 ↛ 484line 470 didn't jump to line 484 because the condition on line 470 was always true
471 pixel_frame = GeneralFrame(unit=u.pix)
472 sky_projection = SkyProjection.from_ast_frame_set(
473 ast_obj,
474 pixel_frame,
475 pixel_bounds=bbox,
476 )
477 except Exception:
478 _LOG.warning(
479 "Could not reconstruct Projection from WCS in %s; dropping.",
480 f.filename,
481 exc_info=True,
482 )
484 unit = _read_ndf_units(ndf_group)
486 # Anything unrecognised: warn-and-drop.
487 recognised = {
488 "DATA_ARRAY",
489 "VARIANCE",
490 "QUALITY",
491 "WCS",
492 "MORE",
493 "TITLE",
494 "LABEL",
495 "UNITS",
496 "HISTORY",
497 "AXIS",
498 }
499 for name in ndf_group:
500 if name not in recognised: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true
501 _LOG.warning(
502 "Ignoring unrecognised NDF component %s/%s during auto-detect read.",
503 ndf_group.name,
504 name,
505 )
507 # Build the requested in-memory object. Any NDF can be read as an Image;
508 # MaskedImage construction uses whatever VARIANCE/QUALITY are present and
509 # lets the MaskedImage constructor provide defaults for missing planes.
510 image = Image(data_arr, bbox=bbox, unit=unit, sky_projection=sky_projection)
511 obj: Any
512 if cls is Image:
513 obj = image
514 elif issubclass(cls, MaskedImage):
515 if quality_arr is not None:
516 schema = _make_quality_mask_schema(quality_badbits)
517 mask = Mask(quality_arr[:, :, np.newaxis], schema=schema, bbox=quality_bbox)
518 else:
519 schema = MaskSchema([MaskPlane(name="BAD", description="Bad pixel.")])
520 mask = None
521 variance = Image(variance_arr, bbox=variance_bbox) if variance_arr is not None else None
522 obj = cls(
523 image=image,
524 mask=mask,
525 mask_schema=schema if mask is None else None,
526 variance=variance,
527 )
528 else:
529 raise ArchiveReadError(
530 f"Auto-detect can produce Image or MaskedImage, but caller asked for {cls.__name__}."
531 )
532 obj._opaque_metadata = archive.get_opaque_metadata()
533 return obj
536def _read_ndf_units(ndf_group: h5py.Group) -> u.UnitBase | None:
537 """Read the NDF UNITS component, if present."""
538 if "UNITS" not in ndf_group or not isinstance(ndf_group["UNITS"], h5py.Dataset):
539 return None
540 dataset = ndf_group["UNITS"]
541 if dataset.dtype.kind != "S": 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 _LOG.warning("Ignoring non-character NDF UNITS component in %s.", ndf_group.name)
543 return None
544 if dataset.ndim == 0: 544 ↛ 552line 544 didn't jump to line 552 because the condition on line 544 was always true
545 raw = dataset[()]
546 if isinstance(raw, np.bytes_): 546 ↛ 548line 546 didn't jump to line 548 because the condition on line 546 was always true
547 raw = bytes(raw)
548 if not isinstance(raw, bytes): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true
549 return None
550 units_text = raw.decode("ascii").rstrip(" ")
551 else:
552 records = _hds.read_char_array(dataset)
553 units_text = records[0] if records else ""
554 if not units_text: 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true
555 return None
556 for kwargs in ({"format": "fits"}, {}): 556 ↛ 561line 556 didn't jump to line 561 because the loop on line 556 didn't complete
557 try:
558 return u.Unit(units_text, **kwargs)
559 except ValueError:
560 continue
561 _LOG.warning("Could not parse NDF UNITS value %r in %s.", units_text, ndf_group.name)
562 return None
565def _read_quality_badbits(quality_group: h5py.Group) -> int:
566 """Read the scalar NDF QUALITY.BADBITS value."""
567 badbits = quality_group.get("BADBITS")
568 if not isinstance(badbits, h5py.Dataset): 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true
569 return 255
570 value = np.asarray(_hds.read_array(badbits)).reshape(-1)
571 if value.size == 0: 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true
572 return 255
573 return int(value[0])
576def _validate_quality_array(quality: np.ndarray) -> np.ndarray:
577 """Return an NDF QUALITY array as a `numpy.uint8` mask plane."""
578 if quality.dtype != np.dtype(np.uint8): 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 raise ArchiveReadError(f"NDF QUALITY array has dtype {quality.dtype}; expected uint8.")
580 return quality
583def _make_quality_mask_schema(badbits: int) -> MaskSchema:
584 """Create a fallback `MaskSchema` for an unnamed 8-bit QUALITY array."""
585 planes = []
586 for bit in range(8):
587 mask = 1 << bit
588 description = f"NDF QUALITY bit {bit}."
589 if badbits & mask:
590 description += " Selected by BADBITS."
591 planes.append(MaskPlane(name=f"MASK{bit}", description=description))
592 return MaskSchema(planes, dtype=np.uint8)
595def _locate_ndf_root(f: h5py.File) -> h5py.Group:
596 """Return the group representing the top-level NDF.
598 Most files have the NDF at the root group itself. A few wrap it
599 in a single-child container at the root; we accept that shape
600 too. Anything more elaborate raises.
601 """
602 root_class = f["/"].attrs.get(_hds.ATTR_CLASS)
603 if isinstance(root_class, bytes):
604 root_class = root_class.decode("ascii")
605 if root_class == "NDF": 605 ↛ 608line 605 didn't jump to line 608 because the condition on line 605 was always true
606 return f["/"]
607 # Maybe a one-level container.
608 candidates = []
609 for name, child in f["/"].items():
610 if isinstance(child, h5py.Group):
611 cls_attr = child.attrs.get(_hds.ATTR_CLASS)
612 if isinstance(cls_attr, bytes):
613 cls_attr = cls_attr.decode("ascii")
614 if cls_attr == "NDF":
615 candidates.append(name)
616 if len(candidates) == 1:
617 return f[candidates[0]]
618 raise ArchiveReadError(
619 f"Could not locate top-level NDF in {f.filename!r}; "
620 f"expected the root group or a single NDF-typed child."
621 )
624def _read_data_array_with_bbox(
625 obj: h5py.Group | h5py.Dataset,
626) -> tuple[np.ndarray, Any]:
627 """Read a DATA_ARRAY component in either simple or complex form.
629 The complex form (what our writer always produces) is an HDS
630 ARRAY structure (h5py group with CLASS="ARRAY") containing
631 ``DATA`` and ``ORIGIN`` primitives. The simple form is a bare
632 primitive dataset.
634 Returns
635 -------
636 array, bbox : tuple
637 ``array`` is the C-order numpy data (shape ``(height, width)``
638 for 2D images). ``bbox`` is constructed from the ORIGIN if
639 present, else from a default origin of (0, 0).
640 """
641 if isinstance(obj, h5py.Dataset): 641 ↛ 643line 641 didn't jump to line 643 because the condition on line 641 was never true
642 # Simple form.
643 array = _hds.read_array(obj)
644 bbox = _make_bbox(x_min=0, y_min=0, array=array)
645 return array, bbox
646 # Complex form: an HDS structure with DATA + ORIGIN.
647 data = _hds.read_array(obj["DATA"])
648 if "ORIGIN" in obj:
649 origin = _hds.read_array(obj["ORIGIN"])
650 bbox = _make_bbox(x_min=int(origin[0]), y_min=int(origin[1]), array=data)
651 else:
652 bbox = _make_bbox(x_min=0, y_min=0, array=data)
653 return data, bbox
656def _read_json_record(primitive: HdsPrimitive, path: str) -> str:
657 """Read a JSON document stored as a single _CHAR*N record.
659 Our writer always emits JSON trees as a single-element character
660 array sized to the document. Joining multiple records would lose
661 trailing whitespace inside JSON string values, since
662 `read_char_array` strips trailing spaces per record.
663 """
664 records = primitive.read_char_array()
665 if len(records) != 1: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true
666 raise ArchiveReadError(f"Expected a single _CHAR*N record at {path!r}, got {len(records)}.")
667 return records[0]
670def _make_bbox(*, x_min: int, y_min: int, array: np.ndarray) -> Any:
671 """Build an lsst.images.Box for a 2D image array.
673 The array is C-order ``(height, width)``. NDF stores ``ORIGIN``
674 in Fortran axis order ``(x_min, y_min)``.
675 """
676 if array.ndim != 2: 676 ↛ 677line 676 didn't jump to line 677 because the condition on line 676 was never true
677 raise ArchiveReadError(f"Auto-detect read only supports 2D arrays, got ndim={array.ndim}.")
678 # Box.from_shape takes (height, width) and start=(y_start, x_start).
679 return Box.from_shape(array.shape, start=(y_min, x_min))