Coverage for python/lsst/images/ndf/_input_archive.py: 81%
309 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 08:46 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 08:46 +0000
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.
152 """
153 ospath = ResourcePath(path).ospath
155 with h5py.File(ospath, "r") as handle:
157 def get_primitive(component_path: str) -> HdsPrimitive | None:
158 node = handle.get(component_path)
159 return HdsPrimitive.from_hdf5(node) if isinstance(node, h5py.Dataset) else None
161 return _read_archive_info(get_primitive, repr(path))
163 @classmethod
164 @contextmanager
165 def open_tree(
166 cls,
167 path: ResourcePathExpression,
168 *,
169 partial: bool = True,
170 **backend_kwargs: Any,
171 ) -> Iterator[tuple[Self, ArchiveTree, ArchiveInfo]]:
172 """Open the NDF file and yield ``(archive, tree, info)``.
174 The schema is read from the open document's ``DATA_MODEL`` rather than
175 a separate `get_basic_info` open. Requires the symmetric LSST JSON
176 tree; ``partial`` is accepted but not meaningful, since h5py reads
177 lazily regardless.
178 """
179 with cls.open(path) as archive:
180 if archive._get_main_json_path() is None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 raise ArchiveReadError(
182 f"{path!r} has no LSST JSON tree; only the symmetric read path is supported."
183 )
184 info = archive.info
185 tree_cls = tree_class_for_info(info, path)
186 parameterized = parameterize_tree(tree_cls, NdfPointerModel)
187 tree = archive.get_tree(parameterized)
188 yield archive, tree, info
190 @classmethod
191 @contextmanager
192 def open(cls, path: ResourcePathExpression) -> Iterator[Self]:
193 """Open an NDF file for reading and yield an `NdfInputArchive`.
195 Remote ResourcePaths are materialised locally first; fsspec-direct
196 h5py reads are a deferred follow-up.
197 """
198 rp = ResourcePath(path)
199 with rp.as_local() as local:
200 with h5py.File(local.ospath, "r") as f:
201 yield cls(f)
203 def get_tree[T: ArchiveTree](self, model_type: type[T]) -> T:
204 """Read and validate the main Pydantic tree at ``/MORE/LSST/JSON``."""
205 json_path = self._get_main_json_path()
206 if json_path is None:
207 raise ArchiveReadError(
208 "File has no /MORE/LSST/JSON tree; this is either a "
209 "Starlink-only NDF (use ndf.read_starlink() for auto-detect) or "
210 "the file was written by an unrelated tool."
211 )
212 json_text = _read_json_record(self._get_primitive(json_path), json_path)
213 return model_type.model_validate_json(json_text)
215 def deserialize_pointer[U: ArchiveTree, V](
216 self,
217 pointer: NdfPointerModel,
218 model_type: type[U],
219 deserializer: Callable[[U, InputArchive[NdfPointerModel]], V],
220 ) -> V:
221 # Cache by pointer.path so repeated dereferences reuse the same
222 # deserialised result and don't re-run the deserializer.
223 if (cached := self._deserialized_pointer_cache.get(pointer.path)) is not None:
224 return cached
225 if not self._has_model_path(pointer.path): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 raise ArchiveReadError(f"Pointer reference {pointer.path!r} not found in NDF file.")
227 primitive = self._get_primitive(pointer.path)
228 json_text = _read_json_record(primitive, pointer.path)
229 model = model_type.model_validate_json(json_text)
230 result = deserializer(model, self)
231 self._deserialized_pointer_cache[pointer.path] = result
232 if isinstance(result, FrameSet):
233 self._frame_set_cache[pointer.path] = result
234 return result
236 def get_frame_set(self, pointer: NdfPointerModel) -> FrameSet:
237 try:
238 return self._frame_set_cache[pointer.path]
239 except KeyError:
240 raise AssertionError(
241 f"Frame set at {pointer.path!r} must be deserialised via "
242 f"deserialize_pointer before any dependent transform can be."
243 ) from None
245 def get_array(
246 self,
247 model: ArrayReferenceModel | InlineArrayModel,
248 *,
249 slices: tuple[slice, ...] | EllipsisType = ...,
250 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
251 ) -> np.ndarray:
252 if isinstance(model, InlineArrayModel):
253 data: np.ndarray = np.array(model.data, dtype=model.datatype.to_numpy())
254 return data if slices is ... else data[slices]
255 if not isinstance(model.source, str) or not model.source.startswith("ndf:"):
256 raise ArchiveReadError(
257 f"NdfInputArchive cannot resolve array source {model.source!r}; "
258 f"expected an 'ndf:<HDF5-path>' reference."
259 )
260 path = model.source[len("ndf:") :]
261 if not self._has_model_path(path): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 raise ArchiveReadError(f"Array reference {path!r} not in file.")
263 primitive = self._get_primitive(path)
264 # h5py supports lazy slicing via dataset[slices].
265 if isinstance(primitive.data, h5py.Dataset): 265 ↛ 267line 265 didn't jump to line 267 because the condition on line 265 was always true
266 return primitive.data[()] if slices is ... else primitive.data[slices]
267 data = primitive.read_array()
268 return data if slices is ... else data[slices]
270 def get_table(
271 self,
272 model: TableModel,
273 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
274 ) -> astropy.table.Table:
275 result = astropy.table.Table(meta=model.meta)
276 for column_model in model.columns:
277 if isinstance(column_model.data, InlineArrayModel): 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 data: Any = column_model.data.data
279 else:
280 data = self.get_array(column_model.data, strip_header=strip_header)
281 result[column_model.name] = astropy.table.Column(
282 data,
283 name=column_model.name,
284 dtype=column_model.data.datatype.to_numpy(),
285 unit=column_model.unit,
286 description=column_model.description,
287 meta=column_model.meta,
288 )
289 return result
291 def get_structured_array(
292 self,
293 model: TableModel,
294 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
295 ) -> np.ndarray:
296 return self.get_table(model, strip_header).as_array()
298 def _read_opaque_fits_metadata(self) -> None:
299 if not self._has_model_path("/MORE/FITS"):
300 return
301 cards = self._get_primitive("/MORE/FITS").read_char_array()
302 # FITS Header.fromstring expects fixed-width 80-char cards
303 # concatenated; pad each card defensively so readers tolerate
304 # files written with shorter widths.
305 header = astropy.io.fits.Header.fromstring("".join(c.ljust(80) for c in cards))
306 self._opaque_metadata.add_header(header, name="", ver=1)
308 def get_opaque_metadata(self) -> FitsOpaqueMetadata:
309 return self._opaque_metadata
311 @property
312 def info(self) -> ArchiveInfo:
313 """Schema/format info read from the open document's ``DATA_MODEL``.
314 (`.serialization.ArchiveInfo`)
315 """
316 return _read_archive_info(self._get_optional_primitive, repr(self._file.filename))
318 def _get_main_json_path(self) -> str | None:
319 """Return the path of the main LSST JSON tree, if present."""
320 for prefix in _LSST_EXTENSION_PREFIXES:
321 path = f"{prefix}/JSON"
322 if self._has_model_path(path):
323 return path
324 return None
326 def _check_format_version(self) -> None:
327 """Read FORMAT_VERSION from the NDF top-level structure and check it.
329 DATA_MODEL is informational only on read; the JSON tree's
330 ``schema_version`` / ``min_read_version`` drive data-model
331 compatibility.
332 """
333 _check_format_version("ndf", _read_format_version(self._get_optional_primitive), _NDF_FORMAT_VERSION)
335 def _has_model_path(self, path: str) -> bool:
336 """Return `True` if a path exists in the NDF document model."""
337 try:
338 self._document.get(path)
339 except KeyError:
340 return False
341 return True
343 def _get_primitive(self, path: str) -> HdsPrimitive:
344 """Return a primitive component from the NDF document model."""
345 node = self._document.get(path)
346 if not isinstance(node, HdsPrimitive): 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 raise ArchiveReadError(f"NDF reference {path!r} is not a primitive dataset.")
348 return node
350 def _get_optional_primitive(self, path: str) -> HdsPrimitive | None:
351 """Return a primitive from the document model, or `None` if there is
352 no primitive at ``path``.
353 """
354 try:
355 node = self._document.get(path)
356 except KeyError:
357 return None
358 return node if isinstance(node, HdsPrimitive) else None
361def read_starlink[T: Any](cls: type[T], path: ResourcePathExpression) -> T:
362 """Reconstruct an `~lsst.images.Image` or `~lsst.images.MaskedImage`
363 from a schema-less Starlink NDF.
365 Files written by this package carry a ``/MORE/LSST/JSON`` tree and are
366 read through the generic `lsst.images.serialization.read` /
367 `lsst.images.serialization.open`. A Starlink-produced NDF has no such
368 tree and therefore no schema, so it cannot go through that path; this
369 function auto-detects a minimal recognised-component set
370 (``DATA_ARRAY``, ``VARIANCE``, ``QUALITY``, ``MORE.FITS``) instead.
371 ``WCS`` is reconstructed when possible; other components are
372 logged-and-dropped.
374 Parameters
375 ----------
376 cls
377 Expected return type; `~lsst.images.Image` and
378 `~lsst.images.MaskedImage` are the only types the auto-detect path
379 can produce.
380 path
381 File path or `lsst.resources.ResourcePathExpression`.
383 Returns
384 -------
385 object
386 The deserialized ``cls`` instance.
388 Raises
389 ------
390 ArchiveReadError
391 If the file has an LSST JSON tree (use the generic ``read`` instead)
392 or no recognised ``DATA_ARRAY`` component.
393 """
394 with NdfInputArchive.open(path) as archive:
395 if archive._get_main_json_path() is not None: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true
396 raise ArchiveReadError(
397 f"{path!r} has an LSST JSON tree; read it with serialization.read()/open()."
398 )
399 return _read_auto_detect(cls, archive)
402def _read_auto_detect[T: Any](cls: type[T], archive: NdfInputArchive) -> T:
403 """Reconstruct an `Image` (or `MaskedImage`) from a Starlink NDF.
405 Recognised components: ``DATA_ARRAY`` (in either simple or complex
406 form), ``VARIANCE``, ``QUALITY``, ``MORE.FITS``. Other components
407 (``WCS``, ``HISTORY``, ``AXIS``, ``LABEL``, custom ``MORE.*``,
408 ``_LOGICAL`` primitives) are warned-and-dropped.
409 """
410 f = archive._file
411 ndf_group = _locate_ndf_root(f)
413 # DATA_ARRAY is required.
414 if "DATA_ARRAY" not in ndf_group:
415 raise ArchiveReadError(f"Auto-detect read of {f.filename!r}: no DATA_ARRAY component.")
416 data_arr, bbox = _read_data_array_with_bbox(ndf_group["DATA_ARRAY"])
418 # VARIANCE / QUALITY are optional.
419 variance_arr: np.ndarray | None = None
420 variance_bbox: Any | None = None
421 if "VARIANCE" in ndf_group:
422 variance_arr, variance_bbox = _read_data_array_with_bbox(ndf_group["VARIANCE"])
423 quality_arr: np.ndarray | None = None
424 quality_bbox: Any | None = None
425 quality_badbits = 255
426 if "QUALITY" in ndf_group and isinstance(ndf_group["QUALITY"], h5py.Group):
427 q = ndf_group["QUALITY"]
428 quality_badbits = _read_quality_badbits(q)
429 if "QUALITY" in q and isinstance(q["QUALITY"], h5py.Dataset): 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true
430 quality_arr = _validate_quality_array(_hds.read_array(q["QUALITY"]))
431 quality_bbox = _make_bbox(x_min=0, y_min=0, array=quality_arr)
432 elif "QUALITY" in q and isinstance(q["QUALITY"], h5py.Group): 432 ↛ 436line 432 didn't jump to line 436 because the condition on line 432 was always true
433 quality_arr, quality_bbox = _read_data_array_with_bbox(q["QUALITY"])
434 quality_arr = _validate_quality_array(quality_arr)
436 sky_projection: SkyProjection | None = None
437 if "WCS" in ndf_group:
438 try:
439 wcs_group = ndf_group["WCS"]
440 if isinstance(wcs_group, h5py.Group) and "DATA" in wcs_group: 440 ↛ 458line 440 didn't jump to line 458 because the condition on line 440 was always true
441 wcs_lines = _hds.read_char_array(wcs_group["DATA"])
442 wcs_text = _hds.decode_ndf_ast_data(wcs_lines)
443 ast_obj = astshim.Object.fromString(wcs_text)
444 if isinstance(ast_obj, astshim.FrameSet): 444 ↛ 458line 444 didn't jump to line 458 because the condition on line 444 was always true
445 pixel_frame = GeneralFrame(unit=u.pix)
446 sky_projection = SkyProjection.from_ast_frame_set(
447 ast_obj,
448 pixel_frame,
449 pixel_bounds=bbox,
450 )
451 except Exception:
452 _LOG.warning(
453 "Could not reconstruct Projection from WCS in %s; dropping.",
454 f.filename,
455 exc_info=True,
456 )
458 unit = _read_ndf_units(ndf_group)
460 # Anything unrecognised: warn-and-drop.
461 recognised = {
462 "DATA_ARRAY",
463 "VARIANCE",
464 "QUALITY",
465 "WCS",
466 "MORE",
467 "TITLE",
468 "LABEL",
469 "UNITS",
470 "HISTORY",
471 "AXIS",
472 }
473 for name in ndf_group:
474 if name not in recognised: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 _LOG.warning(
476 "Ignoring unrecognised NDF component %s/%s during auto-detect read.",
477 ndf_group.name,
478 name,
479 )
481 # Build the requested in-memory object. Any NDF can be read as an Image;
482 # MaskedImage construction uses whatever VARIANCE/QUALITY are present and
483 # lets the MaskedImage constructor provide defaults for missing planes.
484 image = Image(data_arr, bbox=bbox, unit=unit, sky_projection=sky_projection)
485 obj: Any
486 if cls is Image:
487 obj = image
488 elif issubclass(cls, MaskedImage):
489 if quality_arr is not None:
490 schema = _make_quality_mask_schema(quality_badbits)
491 mask = Mask(quality_arr[:, :, np.newaxis], schema=schema, bbox=quality_bbox)
492 else:
493 schema = MaskSchema([MaskPlane(name="BAD", description="Bad pixel.")])
494 mask = None
495 variance = Image(variance_arr, bbox=variance_bbox) if variance_arr is not None else None
496 obj = cls(
497 image=image,
498 mask=mask,
499 mask_schema=schema if mask is None else None,
500 variance=variance,
501 )
502 else:
503 raise ArchiveReadError(
504 f"Auto-detect can produce Image or MaskedImage, but caller asked for {cls.__name__}."
505 )
506 obj._opaque_metadata = archive.get_opaque_metadata()
507 return obj
510def _read_ndf_units(ndf_group: h5py.Group) -> u.UnitBase | None:
511 """Read the NDF UNITS component, if present."""
512 if "UNITS" not in ndf_group or not isinstance(ndf_group["UNITS"], h5py.Dataset):
513 return None
514 dataset = ndf_group["UNITS"]
515 if dataset.dtype.kind != "S": 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true
516 _LOG.warning("Ignoring non-character NDF UNITS component in %s.", ndf_group.name)
517 return None
518 if dataset.ndim == 0: 518 ↛ 526line 518 didn't jump to line 526 because the condition on line 518 was always true
519 raw = dataset[()]
520 if isinstance(raw, np.bytes_): 520 ↛ 522line 520 didn't jump to line 522 because the condition on line 520 was always true
521 raw = bytes(raw)
522 if not isinstance(raw, bytes): 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true
523 return None
524 units_text = raw.decode("ascii").rstrip(" ")
525 else:
526 records = _hds.read_char_array(dataset)
527 units_text = records[0] if records else ""
528 if not units_text: 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true
529 return None
530 for kwargs in ({"format": "fits"}, {}): 530 ↛ 535line 530 didn't jump to line 535 because the loop on line 530 didn't complete
531 try:
532 return u.Unit(units_text, **kwargs)
533 except ValueError:
534 continue
535 _LOG.warning("Could not parse NDF UNITS value %r in %s.", units_text, ndf_group.name)
536 return None
539def _read_quality_badbits(quality_group: h5py.Group) -> int:
540 """Read the scalar NDF QUALITY.BADBITS value."""
541 badbits = quality_group.get("BADBITS")
542 if not isinstance(badbits, h5py.Dataset): 542 ↛ 543line 542 didn't jump to line 543 because the condition on line 542 was never true
543 return 255
544 value = np.asarray(_hds.read_array(badbits)).reshape(-1)
545 if value.size == 0: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true
546 return 255
547 return int(value[0])
550def _validate_quality_array(quality: np.ndarray) -> np.ndarray:
551 """Return an NDF QUALITY array as a `numpy.uint8` mask plane."""
552 if quality.dtype != np.dtype(np.uint8): 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true
553 raise ArchiveReadError(f"NDF QUALITY array has dtype {quality.dtype}; expected uint8.")
554 return quality
557def _make_quality_mask_schema(badbits: int) -> MaskSchema:
558 """Create a fallback `MaskSchema` for an unnamed 8-bit QUALITY array."""
559 planes = []
560 for bit in range(8):
561 mask = 1 << bit
562 description = f"NDF QUALITY bit {bit}."
563 if badbits & mask:
564 description += " Selected by BADBITS."
565 planes.append(MaskPlane(name=f"MASK{bit}", description=description))
566 return MaskSchema(planes, dtype=np.uint8)
569def _locate_ndf_root(f: h5py.File) -> h5py.Group:
570 """Return the group representing the top-level NDF.
572 Most files have the NDF at the root group itself. A few wrap it
573 in a single-child container at the root; we accept that shape
574 too. Anything more elaborate raises.
575 """
576 root_class = f["/"].attrs.get(_hds.ATTR_CLASS)
577 if isinstance(root_class, bytes):
578 root_class = root_class.decode("ascii")
579 if root_class == "NDF": 579 ↛ 582line 579 didn't jump to line 582 because the condition on line 579 was always true
580 return f["/"]
581 # Maybe a one-level container.
582 candidates = []
583 for name, child in f["/"].items():
584 if isinstance(child, h5py.Group):
585 cls_attr = child.attrs.get(_hds.ATTR_CLASS)
586 if isinstance(cls_attr, bytes):
587 cls_attr = cls_attr.decode("ascii")
588 if cls_attr == "NDF":
589 candidates.append(name)
590 if len(candidates) == 1:
591 return f[candidates[0]]
592 raise ArchiveReadError(
593 f"Could not locate top-level NDF in {f.filename!r}; "
594 f"expected the root group or a single NDF-typed child."
595 )
598def _read_data_array_with_bbox(
599 obj: h5py.Group | h5py.Dataset,
600) -> tuple[np.ndarray, Any]:
601 """Read a DATA_ARRAY component in either simple or complex form.
603 The complex form (what our writer always produces) is an HDS
604 ARRAY structure (h5py group with CLASS="ARRAY") containing
605 ``DATA`` and ``ORIGIN`` primitives. The simple form is a bare
606 primitive dataset.
608 Returns
609 -------
610 array, bbox : tuple
611 ``array`` is the C-order numpy data (shape ``(height, width)``
612 for 2D images). ``bbox`` is constructed from the ORIGIN if
613 present, else from a default origin of (0, 0).
614 """
615 if isinstance(obj, h5py.Dataset): 615 ↛ 617line 615 didn't jump to line 617 because the condition on line 615 was never true
616 # Simple form.
617 array = _hds.read_array(obj)
618 bbox = _make_bbox(x_min=0, y_min=0, array=array)
619 return array, bbox
620 # Complex form: an HDS structure with DATA + ORIGIN.
621 data = _hds.read_array(obj["DATA"])
622 if "ORIGIN" in obj:
623 origin = _hds.read_array(obj["ORIGIN"])
624 bbox = _make_bbox(x_min=int(origin[0]), y_min=int(origin[1]), array=data)
625 else:
626 bbox = _make_bbox(x_min=0, y_min=0, array=data)
627 return data, bbox
630def _read_json_record(primitive: HdsPrimitive, path: str) -> str:
631 """Read a JSON document stored as a single _CHAR*N record.
633 Our writer always emits JSON trees as a single-element character
634 array sized to the document. Joining multiple records would lose
635 trailing whitespace inside JSON string values, since
636 `read_char_array` strips trailing spaces per record.
637 """
638 records = primitive.read_char_array()
639 if len(records) != 1: 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true
640 raise ArchiveReadError(f"Expected a single _CHAR*N record at {path!r}, got {len(records)}.")
641 return records[0]
644def _make_bbox(*, x_min: int, y_min: int, array: np.ndarray) -> Any:
645 """Build an lsst.images.Box for a 2D image array.
647 The array is C-order ``(height, width)``. NDF stores ``ORIGIN``
648 in Fortran axis order ``(x_min, y_min)``.
649 """
650 if array.ndim != 2: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true
651 raise ArchiveReadError(f"Auto-detect read only supports 2D arrays, got ndim={array.ndim}.")
652 # Box.from_shape takes (height, width) and start=(y_start, x_start).
653 return Box.from_shape(array.shape, start=(y_min, x_min))