Coverage for python/lsst/images/ndf/_output_archive.py: 81%
355 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-22 01:54 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-22 01:54 -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__ = (
15 "NdfOutputArchive",
16 "write",
17)
19import os
20from collections.abc import Callable, Hashable, Iterator, Mapping, Sequence
21from contextlib import contextmanager
22from typing import Any, Self
24import astropy.io.fits
25import astropy.table
26import astropy.units
27import h5py
28import numpy as np
29import pydantic
31from .._color_image import ColorImage
32from .._transforms import FrameSet
33from .._transforms._ast import Channel, CmpFrame, CmpMap, ShiftMap, StringStream, UnitMap
34from .._transforms._ast import Frame as AstFrame
35from .._transforms._ast import FrameSet as AstFrameSet
36from .._transforms._transform import _prepend_ast_shift
37from ..fits._common import ExtensionKey, FitsOpaqueMetadata
38from ..serialization import (
39 ArchiveTree,
40 ArrayReferenceModel,
41 ButlerInfo,
42 MetadataValue,
43 NestedOutputArchive,
44 NumberType,
45 OutputArchive,
46 TableColumnModel,
47 TableModel,
48 no_header_updates,
49)
50from . import _hds
51from ._common import (
52 HdsNameShrinker,
53 NdfPointerModel,
54 archive_path_to_hdf5_path,
55 archive_path_to_hdf5_path_components,
56)
57from ._model import HdsPrimitive, HdsStructure, Ndf, NdfArray, NdfContainer, NdfDocument, NdfQuality, NdfWcs
59_NDF_FORMAT_VERSION = 1
60"""Container layout version for files written by `NdfOutputArchive`.
62Bumps when the NDF layout (NdfDocument shape, .MORE.LSST contents)
63changes. Independent of any data-model ``SCHEMA_VERSION``.
64"""
67def write(
68 obj: Any,
69 filename: str | None = None,
70 *,
71 metadata: dict[str, MetadataValue] | None = None,
72 butler_info: ButlerInfo | None = None,
73 compression_options: Mapping[str, Any] | None = None,
74) -> ArchiveTree:
75 """Write a serializable object to an NDF (HDS-on-HDF5) file.
77 Parameters
78 ----------
79 obj
80 Object with a ``serialize`` method. May carry an
81 ``_opaque_metadata`` attribute (a
82 `~lsst.images.fits.FitsOpaqueMetadata`)
83 whose primary-HDU header gets written to ``/MORE/FITS``. This
84 preserves FITS cards on objects that originated from a FITS read;
85 butler provenance is conveyed through ``butler_info`` instead.
86 filename
87 Path to write to. Must not already exist. If `None`, an
88 in-memory HDF5 file is used and the on-disk artefact is
89 discarded; the returned tree still reflects all the writes the
90 archive made (useful for tests).
91 metadata, butler_info
92 Optional caller-supplied entries that are written into the
93 returned `~lsst.images.serialization.ArchiveTree`.
94 compression_options
95 Optional dict forwarded to the archive constructor for h5py
96 dataset compression.
98 Returns
99 -------
100 `~lsst.images.serialization.ArchiveTree`
101 The Pydantic tree the object's ``serialize`` produced (with
102 ``metadata``/``butler_info`` applied).
103 """
104 opaque_metadata = getattr(obj, "_opaque_metadata", None)
105 if not isinstance(opaque_metadata, FitsOpaqueMetadata):
106 opaque_metadata = FitsOpaqueMetadata()
107 archive_default_name = getattr(obj, "_archive_default_name", None)
108 with NdfOutputArchive.open(
109 filename,
110 compression_options=compression_options,
111 opaque_metadata=opaque_metadata,
112 **_get_archive_layout(obj),
113 ) as archive:
114 if archive_default_name is not None:
115 tree = archive.serialize_direct(archive_default_name, obj.serialize)
116 else:
117 tree = obj.serialize(archive)
118 if metadata is not None:
119 tree.metadata.update(metadata)
120 if butler_info is not None:
121 tree.butler_info = butler_info
122 archive.add_tree(
123 tree,
124 sky_projection=getattr(obj, "sky_projection", None),
125 bbox=getattr(obj, "bbox", None),
126 unit=getattr(obj, "unit", None),
127 root_name=archive_default_name or type(obj).__name__,
128 )
129 return tree
132def _origin_from_bbox(bbox: Any) -> tuple[int, ...]:
133 """Extract NDF/Fortran-order origin tuple from an lsst.images Box.
135 The exact attribute names on Box depend on the version. Inspect the
136 object and pick whatever exposes the integer lower bounds. For a 2D
137 image with bbox lower bound (x_min, y_min) the returned tuple is
138 ``(x_min, y_min)``.
139 """
140 # Box exposes .x and .y properties returning Interval objects with a
141 # .start attribute (the lower bound).
142 if hasattr(bbox, "x") and hasattr(bbox, "y"): 142 ↛ 147line 142 didn't jump to line 147 because the condition on line 142 was always true
143 x = bbox.x
144 y = bbox.y
145 if hasattr(x, "start") and hasattr(y, "start"): 145 ↛ 147line 145 didn't jump to line 147 because the condition on line 145 was always true
146 return (int(x.start), int(y.start))
147 raise AttributeError(
148 f"Don't know how to extract origin from bbox of type {type(bbox).__name__!r}; "
149 "_origin_from_bbox needs updating."
150 )
153def _unit_to_ndf_string(unit: astropy.units.UnitBase) -> str:
154 """Return an ASCII unit string for the NDF UNITS component."""
155 try:
156 return unit.to_string(format="fits")
157 except ValueError:
158 return unit.to_string()
161def _fits_header_records(header: astropy.io.fits.Header) -> list[str]:
162 """Return fixed-width FITS records for an opaque NDF FITS extension.
164 NDF ``.MORE.FITS`` is a ``_CHAR*80`` vector. Use Astropy's FITS
165 header serializer to preserve multi-record logical cards such as
166 CONTINUE strings, but omit the FITS END card and 2880-byte padding
167 because this is an NDF extension component, not a complete FITS
168 header block.
169 """
170 block = header.tostring(sep="", endcard=False, padding=False)
171 encoded = block.encode("ascii")
172 if len(encoded) % 80: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 raise ValueError(
174 f"FITS header block is {len(encoded)} bytes, not a multiple of the 80-byte FITS record size."
175 )
176 return [block[n : n + 80] for n in range(0, len(block), 80)]
179def _get_archive_layout(obj: Any) -> dict[str, Any]:
180 """Return NDF document layout options for a top-level object."""
181 if isinstance(obj, ColorImage):
182 return {
183 "root": NdfContainer(),
184 "lsst_path": "/LSST",
185 "direct_ndf_array_paths": {
186 "red": "/RED",
187 "green": "/GREEN",
188 "blue": "/BLUE",
189 },
190 "wcs_ndf_paths": ("/RED", "/GREEN", "/BLUE"),
191 }
192 return {}
195def _show_ast_for_ndf(ast_frame_set: Any, bbox: Any | None) -> str:
196 """Return AST Channel text matching Starlink NDF WCS serialization.
198 Tags the original base frame with ``Domain="PIXEL"`` and prepends a
199 new ``Domain="GRID"`` base frame related to it by a `ShiftMap` whose
200 shift converts ``bbox``-origin pixel coordinates into 1-based grid
201 coordinates. The result is written via an abstraction-layer
202 ``Channel`` configured with the same options the Starlink C writer
203 uses (``Full=-1,Comment=0``; see ``ndf1Wwrt.c``) plus ``Indent=0`` so
204 each line is just the bare AST item with the single-space prefix
205 that ``ndf1Rdast`` strips back off on read.
206 """
207 if bbox is None: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 x_shift = 1.0
209 y_shift = 1.0
210 else:
211 x_shift = 1.0 - float(bbox.x.start)
212 y_shift = 1.0 - float(bbox.y.start)
214 saved_current = ast_frame_set.current
215 ast_frame_set.current = ast_frame_set.base
216 ast_frame_set.domain = "PIXEL"
217 ast_frame_set.current = saved_current
218 _prepend_ast_shift(ast_frame_set, x=x_shift, y=y_shift, ast_domain="GRID")
220 stream = StringStream()
221 channel = Channel(stream, options="Full=-1,Comment=0,Indent=0")
222 channel.write(ast_frame_set)
223 return stream.getSinkData()
226def _show_mask_ast_for_ndf(
227 parent_ast_frame_set: Any,
228 origin: Sequence[int],
229 *,
230 labels: Sequence[str] = (),
231) -> str:
232 """Return an NDF WCS for the 3D native mask sub-NDF.
234 The first two axes reuse the parent image's pixel-to-sky mapping. The
235 third axis is a generic mask-byte coordinate that passes through unchanged.
236 """
237 n_axes = len(origin)
238 ast_frame_set = AstFrameSet(AstFrame(n_axes, "Domain=GRID"))
239 pixel_frame = AstFrame(n_axes, "Domain=PIXEL")
240 for axis, label in enumerate(labels[:n_axes], start=1):
241 pixel_frame.setLabel(axis, label)
242 shifts = [1.0 - float(axis_origin) for axis_origin in origin]
243 ast_frame_set.addFrame(AstFrameSet.BASE, ShiftMap(shifts), pixel_frame)
245 parent_pixel_to_sky = parent_ast_frame_set.getMapping(
246 parent_ast_frame_set.base,
247 parent_ast_frame_set.current,
248 )
249 parent_sky_frame = parent_ast_frame_set.getFrame(parent_ast_frame_set.current)
250 mask_axis_frame = AstFrame(1, "Domain=MASK")
251 mask_axis_frame.setLabel(1, labels[2] if len(labels) > 2 else "mask-byte")
252 ast_frame_set.addFrame(
253 ast_frame_set.current,
254 CmpMap(parent_pixel_to_sky, UnitMap(1), False),
255 CmpFrame(parent_sky_frame, mask_axis_frame),
256 )
258 stream = StringStream()
259 channel = Channel(stream, options="Full=-1,Comment=0,Indent=0")
260 channel.write(ast_frame_set)
261 return stream.getSinkData()
264class NdfOutputArchive(OutputArchive[NdfPointerModel]):
265 """An `~lsst.images.serialization.OutputArchive` implementation
266 that writes HDS-on-HDF5 files compatible with the Starlink NDF data
267 model.
269 Parameters
270 ----------
271 file
272 An open `h5py.File` opened in a writable mode. The archive does
273 not close the file; the caller is responsible for that.
274 compression_options
275 Optional dict passed through to `h5py.Group.create_dataset` for image
276 arrays (e.g. ``{"compression": "gzip", "compression_opts": 4}``).
277 opaque_metadata
278 Optional `~lsst.images.fits.FitsOpaqueMetadata`; if its primary-HDU
279 header is non-empty its cards will be written to ``/MORE/FITS`` by the
280 top-level `write` function.
281 """
283 def __init__(
284 self,
285 file: h5py.File,
286 compression_options: Mapping[str, Any] | None = None,
287 opaque_metadata: FitsOpaqueMetadata | None = None,
288 root: Ndf | NdfContainer | None = None,
289 lsst_path: str = "/MORE/LSST",
290 direct_ndf_array_paths: Mapping[str, str] | None = None,
291 wcs_ndf_paths: Sequence[str] = ("/",),
292 ) -> None:
293 super().__init__()
294 self._file = file
295 self._document = NdfDocument(root=root if root is not None else Ndf())
296 self._lsst_path = lsst_path.rstrip("/") or "/LSST"
297 self._direct_ndf_array_paths = dict(direct_ndf_array_paths) if direct_ndf_array_paths else {}
298 self._wcs_ndf_paths = tuple(wcs_ndf_paths)
299 self._bbox_array_struct_paths: set[str] = set()
300 self._compression_options = dict(compression_options) if compression_options else {}
301 self._opaque_metadata = opaque_metadata if opaque_metadata is not None else FitsOpaqueMetadata()
302 self._frame_sets: list[tuple[FrameSet, NdfPointerModel]] = []
303 self._pointers: dict[Hashable, NdfPointerModel] = {}
304 self._hdf5_path_owners: dict[str, str] = {}
305 self._name_shrinker = HdsNameShrinker()
306 # Keep the open file in sync so existing direct-archive tests can
307 # inspect it immediately, while all mutations go through the IR.
308 self._flush()
310 @classmethod
311 @contextmanager
312 def open(
313 cls,
314 filename: str | None,
315 *,
316 compression_options: Mapping[str, Any] | None = None,
317 opaque_metadata: FitsOpaqueMetadata | None = None,
318 root: Ndf | NdfContainer | None = None,
319 lsst_path: str = "/MORE/LSST",
320 direct_ndf_array_paths: Mapping[str, str] | None = None,
321 wcs_ndf_paths: Sequence[str] = ("/",),
322 ) -> Iterator[Self]:
323 """Open an NDF file for writing and yield an `NdfOutputArchive`.
325 ``filename=None`` uses an in-memory HDF5 file; the on-disk
326 artefact is discarded but the archive's writes still produce a
327 usable returned tree (handy for tests).
328 """
329 if filename is None: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 h5_file = h5py.File("inmem.sdf", "w", driver="core", backing_store=False)
331 else:
332 if os.path.exists(filename) and os.path.getsize(filename) > 0: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 raise OSError(f"File {filename!r} already exists.")
334 h5_file = h5py.File(filename, "w")
335 try:
336 yield cls(
337 h5_file,
338 compression_options=compression_options,
339 opaque_metadata=opaque_metadata,
340 root=root,
341 lsst_path=lsst_path,
342 direct_ndf_array_paths=direct_ndf_array_paths,
343 wcs_ndf_paths=wcs_ndf_paths,
344 )
345 finally:
346 h5_file.close()
348 def add_tree(
349 self,
350 tree: ArchiveTree,
351 *,
352 sky_projection: Any = None,
353 bbox: Any = None,
354 unit: astropy.units.UnitBase | None = None,
355 root_name: str | None = None,
356 ) -> None:
357 """Finalize the file: write WCS, units, JSON tree, and ORIGIN.
359 Writes the canonical NDF ``/WCS`` HDS structure (an AST channel
360 text dump that KAPPA / hdstrace expect) when ``sky_projection`` is
361 provided. A native mask sub-NDF at ``/MORE/LSST/MASK`` gets a 3D
362 WCS whose first two axes reuse the parent's sky projection and
363 whose third axis is the mask-byte coordinate. The JSON tree at
364 ``<lsst_path>/JSON`` remains the source of truth for symmetric
365 round-trips; ``/WCS`` is for Starlink tools. Auto-detect read of
366 ``/WCS/DATA`` into a typed ``SkyProjection`` is a follow-up.
368 Parameters
369 ----------
370 tree
371 Pydantic tree returned by the object's ``serialize`` method,
372 with ``metadata``/``butler_info`` already applied.
373 sky_projection, bbox, unit
374 Top-level object attributes that drive NDF-canonical writes.
375 root_name
376 Value to assign to the root group's ``HDS_ROOT_NAME``
377 attribute (fixed-length ASCII so KAPPA / hdstrace decode it).
378 """
379 if sky_projection is not None:
380 self._write_wcs(sky_projection, bbox)
381 if unit is not None and isinstance(self._document.root, Ndf):
382 self._document.ensure_ndf("/").set_units(_unit_to_ndf_string(unit))
383 json_text = tree.model_dump_json()
384 # Let ensure_structure derive types from path-component names so
385 # /MORE/LSST/... gets <MORE> stay as EXT and <LSST> typed LSST,
386 # mirroring HDS convention.
387 lsst = self._ensure_model_structure(self._lsst_path)
388 lsst.children["JSON"] = HdsPrimitive.char_array([json_text], width=max(80, len(json_text)))
389 lsst.children["DATA_MODEL"] = HdsPrimitive.char_scalar(
390 tree.schema_url, width=max(80, len(tree.schema_url))
391 )
392 lsst.children["FORMAT_VERSION"] = HdsPrimitive.array(np.array(_NDF_FORMAT_VERSION, dtype=np.int32))
393 primary = self._opaque_metadata.headers.get(ExtensionKey())
394 if primary is not None and len(primary):
395 cards = _fits_header_records(primary)
396 more = self._ensure_model_structure("/MORE")
397 more.children["FITS"] = HdsPrimitive.char_array(cards, width=80)
398 if bbox is not None: 398 ↛ 403line 398 didn't jump to line 403 because the condition on line 398 was always true
399 origin = _origin_from_bbox(bbox)
400 for struct_path in self._bbox_array_struct_paths:
401 if self._has_model_path(struct_path): 401 ↛ 400line 401 didn't jump to line 400 because the condition on line 401 was always true
402 self.set_array_origin(struct_path, origin)
403 if root_name is not None: 403 ↛ 405line 403 didn't jump to line 405 because the condition on line 403 was always true
404 self._document.root_name = root_name
405 self._flush()
407 def _write_wcs(self, sky_projection: Any, bbox: Any) -> None:
408 ast_frame_set = sky_projection._pixel_to_sky._get_ast_frame_set()
409 text = _show_ast_for_ndf(ast_frame_set, bbox)
410 lines = _hds.encode_ndf_ast_data(text)
411 for ndf_path in self._wcs_ndf_paths:
412 if self._has_model_path(ndf_path): 412 ↛ 411line 412 didn't jump to line 411 because the condition on line 412 was always true
413 self._document.ensure_ndf(ndf_path).set_wcs(NdfWcs(lines))
414 if self._has_model_path("/MORE/LSST/MASK"):
415 mask_origin = _origin_from_bbox(bbox) if bbox is not None else (0, 0)
416 mask_ndim = self._model_array_ndim("/MORE/LSST/MASK/DATA_ARRAY")
417 mask_origin = (*mask_origin, *((0,) * max(0, mask_ndim - len(mask_origin))))
418 mask_ast_frame_set = sky_projection._pixel_to_sky._get_ast_frame_set()
419 mask_text = _show_mask_ast_for_ndf(
420 mask_ast_frame_set,
421 mask_origin,
422 labels=("x", "y", "mask-byte"),
423 )
424 self._document.ensure_ndf("/MORE/LSST/MASK").set_wcs(NdfWcs(_hds.encode_ndf_ast_data(mask_text)))
426 def serialize_direct[T: pydantic.BaseModel | None](
427 self, name: str, serializer: Callable[[OutputArchive[NdfPointerModel]], T]
428 ) -> T:
429 nested = NestedOutputArchive[NdfPointerModel](name, self)
430 return serializer(nested)
432 def serialize_pointer[T: ArchiveTree](
433 self, name: str, serializer: Callable[[OutputArchive[NdfPointerModel]], T], key: Hashable
434 ) -> NdfPointerModel:
435 if (pointer := self._pointers.get(key)) is not None:
436 return pointer
437 archive_path = name if name.startswith("/") else f"/{name}"
438 target_path = self._archive_path_to_hdf5_path(archive_path)
439 self._register_hdf5_path(target_path, archive_path)
440 # Run the serializer first so any nested add_array / serialize_pointer
441 # calls write into the file before we dump this sub-tree to JSON.
442 model = self.serialize_direct(name, serializer)
443 json_text = model.model_dump_json()
444 # Store the JSON document as a "JSON" child of the target structure,
445 # mirroring the main /MORE/LSST/JSON tree. Writing it at the target
446 # path itself would clobber any nested arrays the serializer added
447 # under that path (IR.write_to_hdf5 deletes the existing node before
448 # writing a primitive).
449 # ensure_structure derives the HDS type from the leaf name, so
450 # /MORE/LSST/PSF is typed <PSF> rather than the generic <EXT>.
451 target = self._ensure_model_structure(target_path)
452 target.children["JSON"] = HdsPrimitive.char_array([json_text], width=max(80, len(json_text)))
453 self._flush()
454 pointer = NdfPointerModel(path=f"{target_path}/JSON")
455 self._pointers[key] = pointer
456 return pointer
458 def serialize_frame_set[T: ArchiveTree](
459 self, name: str, frame_set: FrameSet, serializer: Callable[[OutputArchive], T], key: Hashable
460 ) -> NdfPointerModel:
461 pointer = self.serialize_pointer(name, serializer, key)
462 self._frame_sets.append((frame_set, pointer))
463 return pointer
465 def iter_frame_sets(self) -> Iterator[tuple[FrameSet, NdfPointerModel]]:
466 return iter(self._frame_sets)
468 _COMPATIBLE_MASK_DTYPES = (np.dtype(np.uint8),)
469 _prefer_native_mask_arrays = True
471 def add_array(
472 self,
473 array: np.ndarray,
474 *,
475 name: str | None = None,
476 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
477 tile_shape: tuple[int, ...] | None = None,
478 options_name: str | None = None,
479 ) -> ArrayReferenceModel:
480 if name is None: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true
481 raise ValueError("Anonymous arrays are not supported in the NDF archive.")
482 name, version = self._register_name(name)
483 # Recognised top-level names go to standard NDF locations.
484 # Anything else hoists under /MORE/LSST.
485 if name == "image":
486 root = self._document.ensure_ndf("/")
487 root.set_array_component(
488 "DATA_ARRAY",
489 array,
490 origin=np.zeros(array.ndim, dtype=np.int64),
491 compression_options=self._compression_options,
492 )
493 path = "/DATA_ARRAY/DATA"
494 self._bbox_array_struct_paths.add("/DATA_ARRAY")
495 elif name == "variance":
496 root = self._document.ensure_ndf("/")
497 root.set_array_component(
498 "VARIANCE",
499 array,
500 origin=np.zeros(array.ndim, dtype=np.int64),
501 compression_options=self._compression_options,
502 )
503 path = "/VARIANCE/DATA"
504 self._bbox_array_struct_paths.add("/VARIANCE")
505 elif name == "mask":
506 if array.ndim == 2 and array.dtype in self._COMPATIBLE_MASK_DTYPES:
507 self._set_quality_array(array)
508 path = "/QUALITY/QUALITY/DATA"
509 self._bbox_array_struct_paths.add("/QUALITY/QUALITY")
510 else:
511 # Native Mask serialization passes HDF5 shape
512 # (mask-byte, y, x). HDS reports the reverse dimension order,
513 # so Starlink tools see (x, y, mask-byte).
514 if array.ndim == 3 and array.dtype in self._COMPATIBLE_MASK_DTYPES: 514 ↛ 517line 514 didn't jump to line 517 because the condition on line 514 was always true
515 self._set_quality_array(self._collapse_mask_to_quality(array))
516 self._bbox_array_struct_paths.add("/QUALITY/QUALITY")
517 mask_ndf = self._document.ensure_ndf("/MORE/LSST/MASK")
518 mask_ndf.set_array_component(
519 "DATA_ARRAY",
520 array,
521 origin=np.zeros(array.ndim, dtype=np.int64),
522 bad_pixel=False,
523 compression_options=self._compression_options,
524 )
525 path = "/MORE/LSST/MASK/DATA_ARRAY/DATA"
526 self._bbox_array_struct_paths.add("/MORE/LSST/MASK/DATA_ARRAY")
527 elif name in self._direct_ndf_array_paths:
528 sub_ndf_path = self._direct_ndf_array_paths[name]
529 sub_ndf = self._document.ensure_ndf(sub_ndf_path)
530 sub_ndf.set_array_component(
531 "DATA_ARRAY",
532 array,
533 origin=np.zeros(array.ndim, dtype=np.int64),
534 compression_options=self._compression_options,
535 )
536 path = f"{sub_ndf_path}/DATA_ARRAY/DATA"
537 self._bbox_array_struct_paths.add(f"{sub_ndf_path}/DATA_ARRAY")
538 else:
539 # Hoisted numeric arrays are wrapped as sub-NDFs under
540 # /MORE/LSST/<UPPER_PATH> so Starlink tools (KAPPA `display`,
541 # `hdstrace`, etc.) can inspect them just like the main image.
542 # The sub-NDF has the canonical layout: top-level group with
543 # CLASS="NDF" containing a DATA_ARRAY structure (CLASS="ARRAY")
544 # with DATA + ORIGIN primitives. Over-long components are shrunk
545 # to fit the HDS object-name limit (DAT__SZNAM); repeated names
546 # get a version suffix on their leaf so siblings stay distinct.
547 archive_path, logical_id = self._versioned_archive_path(name, version)
548 sub_ndf_path = self._archive_path_to_hdf5_path(archive_path)
549 self._register_hdf5_path(sub_ndf_path, logical_id)
550 sub_ndf = self._document.ensure_ndf(sub_ndf_path)
551 sub_ndf.set_array_component(
552 "DATA_ARRAY",
553 array,
554 origin=np.zeros(array.ndim, dtype=np.int64),
555 compression_options=self._compression_options,
556 )
557 path = f"{sub_ndf_path}/DATA_ARRAY/DATA"
558 self._flush()
559 # Shape is stored in the JSON tree (matching the FITS archive) because
560 # MaskSerializationModel.bbox needs it before any arrays are read.
561 # Future work: resolve shape from the HDF5 dataset on read instead.
562 return ArrayReferenceModel(
563 source=f"ndf:{path}",
564 shape=list(array.shape),
565 datatype=NumberType.from_numpy(array.dtype),
566 )
568 def _ensure_path(self, path: str) -> h5py.Group:
569 """Walk/create groups for an HDF5 absolute path.
571 Intermediate groups created on demand are tagged with
572 ``CLASS="EXT"``, the HDS type for general-purpose extension
573 containers (matches what hds-v5 writes for ``MORE``).
574 """
575 self._ensure_model_structure(path, "EXT")
576 self._flush()
577 return self._file["/" if path in ("", "/") else path]
579 def _ensure_struct(self, path: str, hds_type: str) -> h5py.Group:
580 """Ensure a structure exists at ``path`` with the given HDS type."""
581 self._ensure_model_structure(path, hds_type)
582 self._flush()
583 return self._file["/" if path in ("", "/") else path]
585 def _ensure_array_structure(self, path: str) -> h5py.Group:
586 """Ensure an HDS ``ARRAY`` structure exists at ``path``."""
587 return self._ensure_struct(path, "ARRAY")
589 def _ensure_quality_structure(self) -> h5py.Group:
590 """Ensure ``/QUALITY`` exists with ``CLASS="QUALITY"`` and BADBITS.
592 BADBITS is set to 255 so every bit of the collapsed single-byte
593 QUALITY plane is treated as bad by NDF applications.
594 """
595 root = self._document.ensure_ndf("/")
596 if "QUALITY" not in root.children:
597 root.children["QUALITY"] = HdsStructure("QUALITY")
598 quality = root.get_structure("QUALITY")
599 quality.hds_type = "QUALITY"
600 quality.children["BADBITS"] = HdsPrimitive.array(np.array(255, dtype=np.uint8))
601 self._flush()
602 return self._file["/QUALITY"]
604 def _ensure_quality_array_structure(self) -> h5py.Group:
605 """Ensure the nested ``/QUALITY/QUALITY`` ARRAY structure exists."""
606 root = self._document.ensure_ndf("/")
607 if "QUALITY" not in root.children:
608 root.children["QUALITY"] = HdsStructure("QUALITY")
609 quality = root.get_structure("QUALITY")
610 quality.hds_type = "QUALITY"
611 quality.children["BADBITS"] = HdsPrimitive.array(np.array(255, dtype=np.uint8))
612 if "QUALITY" not in quality.children or not isinstance(quality.children["QUALITY"], HdsStructure):
613 quality.children["QUALITY"] = HdsStructure("ARRAY")
614 quality_array = quality.get_structure("QUALITY")
615 quality_array.hds_type = "ARRAY"
616 quality_array.children.setdefault("ORIGIN", HdsPrimitive.array(np.zeros(2, dtype=np.int32)))
617 quality_array.children.setdefault("BAD_PIXEL", HdsPrimitive.array(np.array(False, dtype=np.bool_)))
618 self._flush()
619 return self._file["/QUALITY/QUALITY"]
621 def _write_quality_array(self, quality: np.ndarray) -> None:
622 """Write or replace the NDF QUALITY array."""
623 self._set_quality_array(quality)
624 self._flush()
626 def _set_quality_array(self, quality: np.ndarray) -> None:
627 """Set or replace the NDF QUALITY array in the model."""
628 root = self._document.ensure_ndf("/")
629 root.set_quality(
630 NdfQuality(
631 NdfArray(
632 quality,
633 origin=np.zeros(2, dtype=np.int32),
634 bad_pixel=False,
635 compression_options=self._compression_options,
636 )
637 )
638 )
640 def _collapse_mask_to_quality(self, array: np.ndarray) -> np.ndarray:
641 """Compress an NDF-native 3-D mask array into 2-D QUALITY.
643 The input array is in HDF5 storage order ``(mask-byte, y, x)``.
644 Single-byte masks copy directly to preserve bit values. Wider masks
645 collapse to 1 where any byte is non-zero and 0 otherwise.
646 """
647 if array.shape[0] == 1:
648 return array[0, :, :]
649 return np.any(array != 0, axis=0).astype(np.uint8)
651 def _write_origin_for_array(self, struct_path: str, array: np.ndarray) -> None:
652 """Write a placeholder ORIGIN of zeros (int64).
654 The top-level `write` function overwrites this
655 with bbox-derived values via :meth:`set_array_origin` once the
656 bbox is known.
657 """
658 struct = self._document.root.get_structure(struct_path)
659 if "ORIGIN" not in struct.children:
660 struct.children["ORIGIN"] = HdsPrimitive.array(np.zeros(array.ndim, dtype=np.int64))
662 def set_array_origin(self, struct_path: str, origin: tuple[int, ...]) -> None:
663 """Overwrite the ORIGIN of an ARRAY structure.
665 Parameters
666 ----------
667 struct_path
668 HDF5 path to the ARRAY structure (e.g. ``"/DATA_ARRAY"``).
669 origin
670 Origin in NDF/Fortran axis order (e.g. ``(x_min, y_min)``
671 for a 2D image with bbox lower bound ``(x_min, y_min)``).
672 """
673 struct = self._document.root.get_structure(struct_path)
674 origin_dtype = np.int32 if struct_path == "/QUALITY/QUALITY" else np.int64
675 origin_array = np.asarray(origin, dtype=origin_dtype)
676 data_node = struct.children.get("DATA")
677 if isinstance(data_node, HdsPrimitive): 677 ↛ 681line 677 didn't jump to line 681 because the condition on line 677 was always true
678 data_ndim = data_node.read_array().ndim
679 if origin_array.size < data_ndim:
680 origin_array = np.pad(origin_array, (0, data_ndim - origin_array.size))
681 struct.children["ORIGIN"] = HdsPrimitive.array(origin_array)
682 self._flush()
684 def _ensure_model_structure(self, path: str, hds_type: str | None = None) -> HdsStructure:
685 """Return or create a structure in the NDF document model.
687 With ``hds_type=None`` the leaf type is derived from its name;
688 see `HdsStructure.ensure_structure`.
689 """
690 if path in ("", "/"): 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true
691 return self._document.root
692 return self._document.root.ensure_structure(path, hds_type)
694 def _archive_path_to_hdf5_path(self, archive_path: str) -> str:
695 """Translate an archive path to this layout's HDF5 path."""
696 if self._lsst_path == "/MORE/LSST": 696 ↛ 698line 696 didn't jump to line 698 because the condition on line 696 was always true
697 return archive_path_to_hdf5_path(archive_path, self._name_shrinker)
698 if not archive_path:
699 return f"{self._lsst_path}/JSON"
700 components = archive_path_to_hdf5_path_components(archive_path, self._name_shrinker)
701 return f"{self._lsst_path}/{'/'.join(components)}"
703 def _register_hdf5_path(self, hdf5_path: str, logical_id: str) -> None:
704 """Record that ``logical_id`` owns ``hdf5_path``; raise on collision.
706 ``logical_id`` is the un-shrunk archive path (with any version suffix
707 applied), which is unique per logical write.
708 Two different archive entries shrinking to the same HDS path would
709 silently clobber one another, so this fails loudly instead.
710 """
711 previous = self._hdf5_path_owners.get(hdf5_path)
712 if previous is not None and previous != logical_id:
713 raise ValueError(
714 f"NDF/HDS name collision: archive entries {previous!r} and {logical_id!r} "
715 f"both map to {hdf5_path!r} after shrinking to the {_hds.DAT__SZNAM}-character "
716 f"HDS name limit; rename one of them."
717 )
718 self._hdf5_path_owners[hdf5_path] = logical_id
720 def _versioned_archive_path(self, name: str, version: int) -> tuple[str, str]:
721 """Return ``(archive_path, logical_id)`` for a hoisted name.
723 ``archive_path`` has any version suffix applied to its leaf through the
724 version-aware shrinker (so the suffix survives the later per-component
725 shrink); ``logical_id`` is the un-shrunk version-applied path used for
726 collision detection.
727 """
728 archive_path = name if name.startswith("/") else f"/{name}"
729 if version <= 1:
730 return archive_path, archive_path
731 head, sep, leaf = archive_path.rpartition("/")
732 logical_id = f"{archive_path}_{version}"
733 versioned = f"{head}{sep}{self._name_shrinker.shrink_versioned(leaf, version)}"
734 return versioned, logical_id
736 def _has_model_path(self, path: str) -> bool:
737 """Return `True` if a path exists in the NDF document model."""
738 try:
739 self._document.get(path)
740 except KeyError:
741 return False
742 return True
744 def _model_array_ndim(self, struct_path: str) -> int:
745 """Return the dimensionality of an ARRAY structure's DATA primitive."""
746 struct = self._document.root.get_structure(struct_path)
747 data_node = struct.children["DATA"]
748 if not isinstance(data_node, HdsPrimitive): 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true
749 raise TypeError(f"{struct_path}/DATA is not an HDS primitive.")
750 return data_node.read_array().ndim
752 def _flush(self) -> None:
753 """Synchronize the Python NDF document model to the open HDF5 file."""
754 self._document.write_to_hdf5(self._file)
756 def add_table(
757 self,
758 table: astropy.table.Table,
759 *,
760 name: str | None = None,
761 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
762 ) -> TableModel:
763 columns = TableColumnModel.from_table(table, inline=True)
764 return TableModel(columns=columns, meta=table.meta)
766 def add_structured_array(
767 self,
768 array: np.ndarray,
769 *,
770 name: str | None = None,
771 units: Mapping[str, astropy.units.Unit] | None = None,
772 descriptions: Mapping[str, str] | None = None,
773 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
774 ) -> TableModel:
775 if name is None: 775 ↛ 776line 775 didn't jump to line 776 because the condition on line 775 was never true
776 columns = TableColumnModel.from_record_array(array, inline=True)
777 for c in columns:
778 if units and (unit := units.get(c.name)):
779 c.unit = unit
780 if descriptions and (description := descriptions.get(c.name)):
781 c.description = description
782 return TableModel(columns=columns)
783 name, version = self._register_name(name)
784 base_path, base_logical = self._versioned_archive_path(name, version)
785 columns = TableColumnModel.from_record_dtype(array.dtype)
786 for c in columns:
787 if len(columns) == 1:
788 archive_path = base_path
789 logical_id = base_logical
790 else:
791 archive_path = f"{base_path}/{c.name}"
792 logical_id = f"{base_logical}/{c.name}"
793 sub_ndf_path = self._archive_path_to_hdf5_path(archive_path)
794 self._register_hdf5_path(sub_ndf_path, logical_id)
795 column_array = np.asarray(array[c.name])
796 sub_ndf = self._document.ensure_ndf(sub_ndf_path)
797 sub_ndf.set_array_component(
798 "DATA_ARRAY",
799 column_array,
800 origin=np.zeros(column_array.ndim, dtype=np.int64),
801 compression_options=self._compression_options,
802 )
803 assert isinstance(c.data, ArrayReferenceModel)
804 c.data.source = f"ndf:{sub_ndf_path}/DATA_ARRAY/DATA"
805 for c in columns:
806 if units and (unit := units.get(c.name)):
807 c.unit = unit
808 if descriptions and (description := descriptions.get(c.name)):
809 c.description = description
810 self._flush()
811 return TableModel(columns=columns)