Coverage for python/lsst/images/ndf/_output_archive.py: 81%
355 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__ = (
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 root
282 Root NDF or NDF container to populate; a new empty ``Ndf`` is created
283 when `None`.
284 lsst_path
285 HDS path under which the LSST extension is written.
286 direct_ndf_array_paths
287 Mapping from archive path to NDF array path for arrays written
288 directly as NDF components rather than into the LSST extension.
289 wcs_ndf_paths
290 NDF paths for which WCS information is written.
291 """
293 def __init__(
294 self,
295 file: h5py.File,
296 compression_options: Mapping[str, Any] | None = None,
297 opaque_metadata: FitsOpaqueMetadata | None = None,
298 root: Ndf | NdfContainer | None = None,
299 lsst_path: str = "/MORE/LSST",
300 direct_ndf_array_paths: Mapping[str, str] | None = None,
301 wcs_ndf_paths: Sequence[str] = ("/",),
302 ) -> None:
303 super().__init__()
304 self._file = file
305 self._document = NdfDocument(root=root if root is not None else Ndf())
306 self._lsst_path = lsst_path.rstrip("/") or "/LSST"
307 self._direct_ndf_array_paths = dict(direct_ndf_array_paths) if direct_ndf_array_paths else {}
308 self._wcs_ndf_paths = tuple(wcs_ndf_paths)
309 self._bbox_array_struct_paths: set[str] = set()
310 self._compression_options = dict(compression_options) if compression_options else {}
311 self._opaque_metadata = opaque_metadata if opaque_metadata is not None else FitsOpaqueMetadata()
312 self._frame_sets: list[tuple[FrameSet, NdfPointerModel]] = []
313 self._pointers: dict[Hashable, NdfPointerModel] = {}
314 self._hdf5_path_owners: dict[str, str] = {}
315 self._name_shrinker = HdsNameShrinker()
316 # Keep the open file in sync so existing direct-archive tests can
317 # inspect it immediately, while all mutations go through the IR.
318 self._flush()
320 @classmethod
321 @contextmanager
322 def open(
323 cls,
324 filename: str | None,
325 *,
326 compression_options: Mapping[str, Any] | None = None,
327 opaque_metadata: FitsOpaqueMetadata | None = None,
328 root: Ndf | NdfContainer | None = None,
329 lsst_path: str = "/MORE/LSST",
330 direct_ndf_array_paths: Mapping[str, str] | None = None,
331 wcs_ndf_paths: Sequence[str] = ("/",),
332 ) -> Iterator[Self]:
333 """Open an NDF file for writing and yield an `NdfOutputArchive`.
335 ``filename=None`` uses an in-memory HDF5 file; the on-disk
336 artefact is discarded but the archive's writes still produce a
337 usable returned tree (handy for tests).
339 Parameters
340 ----------
341 filename
342 Name of the file to write to, or `None` to use an in-memory
343 HDF5 file whose on-disk artefact is discarded.
344 compression_options
345 Optional dict passed through to `h5py.Group.create_dataset`
346 for image arrays.
347 opaque_metadata
348 Optional `~lsst.images.fits.FitsOpaqueMetadata` whose cards are
349 written to ``/MORE/FITS`` by the top-level `write` function.
350 root
351 Root NDF or NDF container to populate; a new empty ``Ndf`` is
352 created when `None`.
353 lsst_path
354 HDS path under which the LSST extension is written.
355 direct_ndf_array_paths
356 Mapping from archive path to NDF array path for arrays written
357 directly as NDF components rather than into the LSST extension.
358 wcs_ndf_paths
359 NDF paths for which WCS information is written.
360 """
361 if filename is None: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true
362 h5_file = h5py.File("inmem.sdf", "w", driver="core", backing_store=False)
363 else:
364 if os.path.exists(filename) and os.path.getsize(filename) > 0: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true
365 raise OSError(f"File {filename!r} already exists.")
366 h5_file = h5py.File(filename, "w")
367 try:
368 yield cls(
369 h5_file,
370 compression_options=compression_options,
371 opaque_metadata=opaque_metadata,
372 root=root,
373 lsst_path=lsst_path,
374 direct_ndf_array_paths=direct_ndf_array_paths,
375 wcs_ndf_paths=wcs_ndf_paths,
376 )
377 finally:
378 h5_file.close()
380 def add_tree(
381 self,
382 tree: ArchiveTree,
383 *,
384 sky_projection: Any = None,
385 bbox: Any = None,
386 unit: astropy.units.UnitBase | None = None,
387 root_name: str | None = None,
388 ) -> None:
389 """Finalize the file: write WCS, units, JSON tree, and ORIGIN.
391 Writes the canonical NDF ``/WCS`` HDS structure (an AST channel
392 text dump that KAPPA / hdstrace expect) when ``sky_projection`` is
393 provided. A native mask sub-NDF at ``/MORE/LSST/MASK`` gets a 3D
394 WCS whose first two axes reuse the parent's sky projection and
395 whose third axis is the mask-byte coordinate. The JSON tree at
396 ``<lsst_path>/JSON`` remains the source of truth for symmetric
397 round-trips; ``/WCS`` is for Starlink tools. Auto-detect read of
398 ``/WCS/DATA`` into a typed ``SkyProjection`` is a follow-up.
400 Parameters
401 ----------
402 tree
403 Pydantic tree returned by the object's ``serialize`` method,
404 with ``metadata``/``butler_info`` already applied.
405 sky_projection, bbox, unit
406 Top-level object attributes that drive NDF-canonical writes.
407 root_name
408 Value to assign to the root group's ``HDS_ROOT_NAME``
409 attribute (fixed-length ASCII so KAPPA / hdstrace decode it).
410 """
411 if sky_projection is not None:
412 self._write_wcs(sky_projection, bbox)
413 if unit is not None and isinstance(self._document.root, Ndf):
414 self._document.ensure_ndf("/").set_units(_unit_to_ndf_string(unit))
415 json_text = tree.model_dump_json()
416 # Let ensure_structure derive types from path-component names so
417 # /MORE/LSST/... gets <MORE> stay as EXT and <LSST> typed LSST,
418 # mirroring HDS convention.
419 lsst = self._ensure_model_structure(self._lsst_path)
420 lsst.children["JSON"] = HdsPrimitive.char_array([json_text], width=max(80, len(json_text)))
421 lsst.children["DATA_MODEL"] = HdsPrimitive.char_scalar(
422 tree.schema_url, width=max(80, len(tree.schema_url))
423 )
424 lsst.children["FORMAT_VERSION"] = HdsPrimitive.array(np.array(_NDF_FORMAT_VERSION, dtype=np.int32))
425 primary = self._opaque_metadata.headers.get(ExtensionKey())
426 if primary is not None and len(primary):
427 cards = _fits_header_records(primary)
428 more = self._ensure_model_structure("/MORE")
429 more.children["FITS"] = HdsPrimitive.char_array(cards, width=80)
430 if bbox is not None: 430 ↛ 435line 430 didn't jump to line 435 because the condition on line 430 was always true
431 origin = _origin_from_bbox(bbox)
432 for struct_path in self._bbox_array_struct_paths:
433 if self._has_model_path(struct_path): 433 ↛ 432line 433 didn't jump to line 432 because the condition on line 433 was always true
434 self.set_array_origin(struct_path, origin)
435 if root_name is not None: 435 ↛ 437line 435 didn't jump to line 437 because the condition on line 435 was always true
436 self._document.root_name = root_name
437 self._flush()
439 def _write_wcs(self, sky_projection: Any, bbox: Any) -> None:
440 ast_frame_set = sky_projection._pixel_to_sky._get_ast_frame_set()
441 text = _show_ast_for_ndf(ast_frame_set, bbox)
442 lines = _hds.encode_ndf_ast_data(text)
443 for ndf_path in self._wcs_ndf_paths:
444 if self._has_model_path(ndf_path): 444 ↛ 443line 444 didn't jump to line 443 because the condition on line 444 was always true
445 self._document.ensure_ndf(ndf_path).set_wcs(NdfWcs(lines))
446 if self._has_model_path("/MORE/LSST/MASK"):
447 mask_origin = _origin_from_bbox(bbox) if bbox is not None else (0, 0)
448 mask_ndim = self._model_array_ndim("/MORE/LSST/MASK/DATA_ARRAY")
449 mask_origin = (*mask_origin, *((0,) * max(0, mask_ndim - len(mask_origin))))
450 mask_ast_frame_set = sky_projection._pixel_to_sky._get_ast_frame_set()
451 mask_text = _show_mask_ast_for_ndf(
452 mask_ast_frame_set,
453 mask_origin,
454 labels=("x", "y", "mask-byte"),
455 )
456 self._document.ensure_ndf("/MORE/LSST/MASK").set_wcs(NdfWcs(_hds.encode_ndf_ast_data(mask_text)))
458 def serialize_direct[T: pydantic.BaseModel | None](
459 self, name: str, serializer: Callable[[OutputArchive[NdfPointerModel]], T]
460 ) -> T:
461 nested = NestedOutputArchive[NdfPointerModel](name, self)
462 return serializer(nested)
464 def serialize_pointer[T: ArchiveTree](
465 self, name: str, serializer: Callable[[OutputArchive[NdfPointerModel]], T], key: Hashable
466 ) -> NdfPointerModel:
467 if (pointer := self._pointers.get(key)) is not None:
468 return pointer
469 archive_path = name if name.startswith("/") else f"/{name}"
470 target_path = self._archive_path_to_hdf5_path(archive_path)
471 self._register_hdf5_path(target_path, archive_path)
472 # Run the serializer first so any nested add_array / serialize_pointer
473 # calls write into the file before we dump this sub-tree to JSON.
474 model = self.serialize_direct(name, serializer)
475 json_text = model.model_dump_json()
476 # Store the JSON document as a "JSON" child of the target structure,
477 # mirroring the main /MORE/LSST/JSON tree. Writing it at the target
478 # path itself would clobber any nested arrays the serializer added
479 # under that path (IR.write_to_hdf5 deletes the existing node before
480 # writing a primitive).
481 # ensure_structure derives the HDS type from the leaf name, so
482 # /MORE/LSST/PSF is typed <PSF> rather than the generic <EXT>.
483 target = self._ensure_model_structure(target_path)
484 target.children["JSON"] = HdsPrimitive.char_array([json_text], width=max(80, len(json_text)))
485 self._flush()
486 pointer = NdfPointerModel(path=f"{target_path}/JSON")
487 self._pointers[key] = pointer
488 return pointer
490 def serialize_frame_set[T: ArchiveTree](
491 self, name: str, frame_set: FrameSet, serializer: Callable[[OutputArchive], T], key: Hashable
492 ) -> NdfPointerModel:
493 pointer = self.serialize_pointer(name, serializer, key)
494 self._frame_sets.append((frame_set, pointer))
495 return pointer
497 def iter_frame_sets(self) -> Iterator[tuple[FrameSet, NdfPointerModel]]:
498 return iter(self._frame_sets)
500 _COMPATIBLE_MASK_DTYPES = (np.dtype(np.uint8),)
501 _prefer_native_mask_arrays = True
503 def add_array(
504 self,
505 array: np.ndarray,
506 *,
507 name: str | None = None,
508 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
509 tile_shape: tuple[int, ...] | None = None,
510 options_name: str | None = None,
511 ) -> ArrayReferenceModel:
512 if name is None: 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true
513 raise ValueError("Anonymous arrays are not supported in the NDF archive.")
514 name, version = self._register_name(name)
515 # Recognised top-level names go to standard NDF locations.
516 # Anything else hoists under /MORE/LSST.
517 if name == "image":
518 root = self._document.ensure_ndf("/")
519 root.set_array_component(
520 "DATA_ARRAY",
521 array,
522 origin=np.zeros(array.ndim, dtype=np.int64),
523 compression_options=self._compression_options,
524 )
525 path = "/DATA_ARRAY/DATA"
526 self._bbox_array_struct_paths.add("/DATA_ARRAY")
527 elif name == "variance":
528 root = self._document.ensure_ndf("/")
529 root.set_array_component(
530 "VARIANCE",
531 array,
532 origin=np.zeros(array.ndim, dtype=np.int64),
533 compression_options=self._compression_options,
534 )
535 path = "/VARIANCE/DATA"
536 self._bbox_array_struct_paths.add("/VARIANCE")
537 elif name == "mask":
538 if array.ndim == 2 and array.dtype in self._COMPATIBLE_MASK_DTYPES:
539 self._set_quality_array(array)
540 path = "/QUALITY/QUALITY/DATA"
541 self._bbox_array_struct_paths.add("/QUALITY/QUALITY")
542 else:
543 # Native Mask serialization passes HDF5 shape
544 # (mask-byte, y, x). HDS reports the reverse dimension order,
545 # so Starlink tools see (x, y, mask-byte).
546 if array.ndim == 3 and array.dtype in self._COMPATIBLE_MASK_DTYPES: 546 ↛ 549line 546 didn't jump to line 549 because the condition on line 546 was always true
547 self._set_quality_array(self._collapse_mask_to_quality(array))
548 self._bbox_array_struct_paths.add("/QUALITY/QUALITY")
549 mask_ndf = self._document.ensure_ndf("/MORE/LSST/MASK")
550 mask_ndf.set_array_component(
551 "DATA_ARRAY",
552 array,
553 origin=np.zeros(array.ndim, dtype=np.int64),
554 bad_pixel=False,
555 compression_options=self._compression_options,
556 )
557 path = "/MORE/LSST/MASK/DATA_ARRAY/DATA"
558 self._bbox_array_struct_paths.add("/MORE/LSST/MASK/DATA_ARRAY")
559 elif name in self._direct_ndf_array_paths:
560 sub_ndf_path = self._direct_ndf_array_paths[name]
561 sub_ndf = self._document.ensure_ndf(sub_ndf_path)
562 sub_ndf.set_array_component(
563 "DATA_ARRAY",
564 array,
565 origin=np.zeros(array.ndim, dtype=np.int64),
566 compression_options=self._compression_options,
567 )
568 path = f"{sub_ndf_path}/DATA_ARRAY/DATA"
569 self._bbox_array_struct_paths.add(f"{sub_ndf_path}/DATA_ARRAY")
570 else:
571 # Hoisted numeric arrays are wrapped as sub-NDFs under
572 # /MORE/LSST/<UPPER_PATH> so Starlink tools (KAPPA `display`,
573 # `hdstrace`, etc.) can inspect them just like the main image.
574 # The sub-NDF has the canonical layout: top-level group with
575 # CLASS="NDF" containing a DATA_ARRAY structure (CLASS="ARRAY")
576 # with DATA + ORIGIN primitives. Over-long components are shrunk
577 # to fit the HDS object-name limit (DAT__SZNAM); repeated names
578 # get a version suffix on their leaf so siblings stay distinct.
579 archive_path, logical_id = self._versioned_archive_path(name, version)
580 sub_ndf_path = self._archive_path_to_hdf5_path(archive_path)
581 self._register_hdf5_path(sub_ndf_path, logical_id)
582 sub_ndf = self._document.ensure_ndf(sub_ndf_path)
583 sub_ndf.set_array_component(
584 "DATA_ARRAY",
585 array,
586 origin=np.zeros(array.ndim, dtype=np.int64),
587 compression_options=self._compression_options,
588 )
589 path = f"{sub_ndf_path}/DATA_ARRAY/DATA"
590 self._flush()
591 # Shape is stored in the JSON tree (matching the FITS archive) because
592 # MaskSerializationModel.bbox needs it before any arrays are read.
593 # Future work: resolve shape from the HDF5 dataset on read instead.
594 return ArrayReferenceModel(
595 source=f"ndf:{path}",
596 shape=list(array.shape),
597 datatype=NumberType.from_numpy(array.dtype),
598 )
600 def _ensure_path(self, path: str) -> h5py.Group:
601 """Walk/create groups for an HDF5 absolute path.
603 Intermediate groups created on demand are tagged with
604 ``CLASS="EXT"``, the HDS type for general-purpose extension
605 containers (matches what hds-v5 writes for ``MORE``).
606 """
607 self._ensure_model_structure(path, "EXT")
608 self._flush()
609 return self._file["/" if path in ("", "/") else path]
611 def _ensure_struct(self, path: str, hds_type: str) -> h5py.Group:
612 """Ensure a structure exists at ``path`` with the given HDS type."""
613 self._ensure_model_structure(path, hds_type)
614 self._flush()
615 return self._file["/" if path in ("", "/") else path]
617 def _ensure_array_structure(self, path: str) -> h5py.Group:
618 """Ensure an HDS ``ARRAY`` structure exists at ``path``."""
619 return self._ensure_struct(path, "ARRAY")
621 def _ensure_quality_structure(self) -> h5py.Group:
622 """Ensure ``/QUALITY`` exists with ``CLASS="QUALITY"`` and BADBITS.
624 BADBITS is set to 255 so every bit of the collapsed single-byte
625 QUALITY plane is treated as bad by NDF applications.
626 """
627 root = self._document.ensure_ndf("/")
628 if "QUALITY" not in root.children:
629 root.children["QUALITY"] = HdsStructure("QUALITY")
630 quality = root.get_structure("QUALITY")
631 quality.hds_type = "QUALITY"
632 quality.children["BADBITS"] = HdsPrimitive.array(np.array(255, dtype=np.uint8))
633 self._flush()
634 return self._file["/QUALITY"]
636 def _ensure_quality_array_structure(self) -> h5py.Group:
637 """Ensure the nested ``/QUALITY/QUALITY`` ARRAY structure exists."""
638 root = self._document.ensure_ndf("/")
639 if "QUALITY" not in root.children:
640 root.children["QUALITY"] = HdsStructure("QUALITY")
641 quality = root.get_structure("QUALITY")
642 quality.hds_type = "QUALITY"
643 quality.children["BADBITS"] = HdsPrimitive.array(np.array(255, dtype=np.uint8))
644 if "QUALITY" not in quality.children or not isinstance(quality.children["QUALITY"], HdsStructure):
645 quality.children["QUALITY"] = HdsStructure("ARRAY")
646 quality_array = quality.get_structure("QUALITY")
647 quality_array.hds_type = "ARRAY"
648 quality_array.children.setdefault("ORIGIN", HdsPrimitive.array(np.zeros(2, dtype=np.int32)))
649 quality_array.children.setdefault("BAD_PIXEL", HdsPrimitive.array(np.array(False, dtype=np.bool_)))
650 self._flush()
651 return self._file["/QUALITY/QUALITY"]
653 def _write_quality_array(self, quality: np.ndarray) -> None:
654 """Write or replace the NDF QUALITY array."""
655 self._set_quality_array(quality)
656 self._flush()
658 def _set_quality_array(self, quality: np.ndarray) -> None:
659 """Set or replace the NDF QUALITY array in the model."""
660 root = self._document.ensure_ndf("/")
661 root.set_quality(
662 NdfQuality(
663 NdfArray(
664 quality,
665 origin=np.zeros(2, dtype=np.int32),
666 bad_pixel=False,
667 compression_options=self._compression_options,
668 )
669 )
670 )
672 def _collapse_mask_to_quality(self, array: np.ndarray) -> np.ndarray:
673 """Compress an NDF-native 3-D mask array into 2-D QUALITY.
675 The input array is in HDF5 storage order ``(mask-byte, y, x)``.
676 Single-byte masks copy directly to preserve bit values. Wider masks
677 collapse to 1 where any byte is non-zero and 0 otherwise.
678 """
679 if array.shape[0] == 1:
680 return array[0, :, :]
681 return np.any(array != 0, axis=0).astype(np.uint8)
683 def _write_origin_for_array(self, struct_path: str, array: np.ndarray) -> None:
684 """Write a placeholder ORIGIN of zeros (int64).
686 The top-level `write` function overwrites this
687 with bbox-derived values via :meth:`set_array_origin` once the
688 bbox is known.
689 """
690 struct = self._document.root.get_structure(struct_path)
691 if "ORIGIN" not in struct.children:
692 struct.children["ORIGIN"] = HdsPrimitive.array(np.zeros(array.ndim, dtype=np.int64))
694 def set_array_origin(self, struct_path: str, origin: tuple[int, ...]) -> None:
695 """Overwrite the ORIGIN of an ARRAY structure.
697 Parameters
698 ----------
699 struct_path
700 HDF5 path to the ARRAY structure (e.g. ``"/DATA_ARRAY"``).
701 origin
702 Origin in NDF/Fortran axis order (e.g. ``(x_min, y_min)``
703 for a 2D image with bbox lower bound ``(x_min, y_min)``).
704 """
705 struct = self._document.root.get_structure(struct_path)
706 origin_dtype = np.int32 if struct_path == "/QUALITY/QUALITY" else np.int64
707 origin_array = np.asarray(origin, dtype=origin_dtype)
708 data_node = struct.children.get("DATA")
709 if isinstance(data_node, HdsPrimitive): 709 ↛ 713line 709 didn't jump to line 713 because the condition on line 709 was always true
710 data_ndim = data_node.read_array().ndim
711 if origin_array.size < data_ndim:
712 origin_array = np.pad(origin_array, (0, data_ndim - origin_array.size))
713 struct.children["ORIGIN"] = HdsPrimitive.array(origin_array)
714 self._flush()
716 def _ensure_model_structure(self, path: str, hds_type: str | None = None) -> HdsStructure:
717 """Return or create a structure in the NDF document model.
719 With ``hds_type=None`` the leaf type is derived from its name;
720 see `HdsStructure.ensure_structure`.
721 """
722 if path in ("", "/"): 722 ↛ 723line 722 didn't jump to line 723 because the condition on line 722 was never true
723 return self._document.root
724 return self._document.root.ensure_structure(path, hds_type)
726 def _archive_path_to_hdf5_path(self, archive_path: str) -> str:
727 """Translate an archive path to this layout's HDF5 path.
729 Parameters
730 ----------
731 archive_path
732 Serialization archive path to translate.
733 """
734 if self._lsst_path == "/MORE/LSST": 734 ↛ 736line 734 didn't jump to line 736 because the condition on line 734 was always true
735 return archive_path_to_hdf5_path(archive_path, self._name_shrinker)
736 if not archive_path:
737 return f"{self._lsst_path}/JSON"
738 components = archive_path_to_hdf5_path_components(archive_path, self._name_shrinker)
739 return f"{self._lsst_path}/{'/'.join(components)}"
741 def _register_hdf5_path(self, hdf5_path: str, logical_id: str) -> None:
742 """Record that ``logical_id`` owns ``hdf5_path``; raise on collision.
744 ``logical_id`` is the un-shrunk archive path (with any version suffix
745 applied), which is unique per logical write.
746 Two different archive entries shrinking to the same HDS path would
747 silently clobber one another, so this fails loudly instead.
749 Parameters
750 ----------
751 hdf5_path
752 HDF5 path being claimed.
753 logical_id
754 Un-shrunk archive path that owns ``hdf5_path``.
755 """
756 previous = self._hdf5_path_owners.get(hdf5_path)
757 if previous is not None and previous != logical_id:
758 raise ValueError(
759 f"NDF/HDS name collision: archive entries {previous!r} and {logical_id!r} "
760 f"both map to {hdf5_path!r} after shrinking to the {_hds.DAT__SZNAM}-character "
761 f"HDS name limit; rename one of them."
762 )
763 self._hdf5_path_owners[hdf5_path] = logical_id
765 def _versioned_archive_path(self, name: str, version: int) -> tuple[str, str]:
766 """Return ``(archive_path, logical_id)`` for a hoisted name.
768 ``archive_path`` has any version suffix applied to its leaf through the
769 version-aware shrinker (so the suffix survives the later per-component
770 shrink); ``logical_id`` is the un-shrunk version-applied path used for
771 collision detection.
772 """
773 archive_path = name if name.startswith("/") else f"/{name}"
774 if version <= 1:
775 return archive_path, archive_path
776 head, sep, leaf = archive_path.rpartition("/")
777 logical_id = f"{archive_path}_{version}"
778 versioned = f"{head}{sep}{self._name_shrinker.shrink_versioned(leaf, version)}"
779 return versioned, logical_id
781 def _has_model_path(self, path: str) -> bool:
782 """Return `True` if a path exists in the NDF document model."""
783 try:
784 self._document.get(path)
785 except KeyError:
786 return False
787 return True
789 def _model_array_ndim(self, struct_path: str) -> int:
790 """Return the dimensionality of an ARRAY structure's DATA primitive."""
791 struct = self._document.root.get_structure(struct_path)
792 data_node = struct.children["DATA"]
793 if not isinstance(data_node, HdsPrimitive): 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true
794 raise TypeError(f"{struct_path}/DATA is not an HDS primitive.")
795 return data_node.read_array().ndim
797 def _flush(self) -> None:
798 """Synchronize the Python NDF document model to the open HDF5 file."""
799 self._document.write_to_hdf5(self._file)
801 def add_table(
802 self,
803 table: astropy.table.Table,
804 *,
805 name: str | None = None,
806 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
807 ) -> TableModel:
808 columns = TableColumnModel.from_table(table, inline=True)
809 return TableModel(columns=columns, meta=table.meta)
811 def add_structured_array(
812 self,
813 array: np.ndarray,
814 *,
815 name: str | None = None,
816 units: Mapping[str, astropy.units.Unit] | None = None,
817 descriptions: Mapping[str, str] | None = None,
818 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
819 ) -> TableModel:
820 if name is None: 820 ↛ 821line 820 didn't jump to line 821 because the condition on line 820 was never true
821 columns = TableColumnModel.from_record_array(array, inline=True)
822 for c in columns:
823 if units and (unit := units.get(c.name)):
824 c.unit = unit
825 if descriptions and (description := descriptions.get(c.name)):
826 c.description = description
827 return TableModel(columns=columns)
828 name, version = self._register_name(name)
829 base_path, base_logical = self._versioned_archive_path(name, version)
830 columns = TableColumnModel.from_record_dtype(array.dtype)
831 for c in columns:
832 if len(columns) == 1:
833 archive_path = base_path
834 logical_id = base_logical
835 else:
836 archive_path = f"{base_path}/{c.name}"
837 logical_id = f"{base_logical}/{c.name}"
838 sub_ndf_path = self._archive_path_to_hdf5_path(archive_path)
839 self._register_hdf5_path(sub_ndf_path, logical_id)
840 column_array = np.asarray(array[c.name])
841 sub_ndf = self._document.ensure_ndf(sub_ndf_path)
842 sub_ndf.set_array_component(
843 "DATA_ARRAY",
844 column_array,
845 origin=np.zeros(column_array.ndim, dtype=np.int64),
846 compression_options=self._compression_options,
847 )
848 assert isinstance(c.data, ArrayReferenceModel)
849 c.data.source = f"ndf:{sub_ndf_path}/DATA_ARRAY/DATA"
850 for c in columns:
851 if units and (unit := units.get(c.name)):
852 c.unit = unit
853 if descriptions and (description := descriptions.get(c.name)):
854 c.description = description
855 self._flush()
856 return TableModel(columns=columns)