Coverage for python/lsst/images/ndf/_input_archive.py: 81%

314 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 02:03 -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. 

11 

12from __future__ import annotations 

13 

14__all__ = ("NdfInputArchive", "read_starlink") 

15 

16import logging 

17from collections.abc import Callable, Iterator 

18from contextlib import contextmanager 

19from types import EllipsisType 

20from typing import IO, Any, Self 

21 

22import astropy.io.fits 

23import astropy.table 

24import astropy.units as u 

25import h5py 

26import numpy as np 

27 

28from lsst.resources import ResourcePath, ResourcePathExpression 

29 

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._backends import _is_binary_stream 

51from ..serialization._common import _check_format_version 

52from . import _hds 

53from ._common import NdfPointerModel 

54from ._model import HdsPrimitive, NdfDocument 

55 

56_LOG = logging.getLogger(__name__) 

57 

58_NDF_FORMAT_VERSION = 1 

59"""Container layout version this release of `NdfInputArchive` understands.""" 

60 

61_LSST_EXTENSION_PREFIXES = ("/MORE/LSST", "/LSST") 

62"""Fixed locations of the LSST extension structure within an NDF. 

63 

64Only these top-level locations are searched; nested pointer trees can 

65therefore never be mistaken for the main one. 

66""" 

67 

68 

69def _get_lsst_primitive( 

70 get_primitive: Callable[[str], HdsPrimitive | None], name: str 

71) -> HdsPrimitive | None: 

72 """Find a named primitive in the LSST extension structure. 

73 

74 Parameters 

75 ---------- 

76 get_primitive 

77 Callable returning the primitive at an absolute path, or `None` if 

78 there is no primitive there. 

79 name 

80 Component name within the LSST extension, e.g. ``DATA_MODEL``. 

81 """ 

82 for prefix in _LSST_EXTENSION_PREFIXES: 

83 if (primitive := get_primitive(f"{prefix}/{name}")) is not None: 

84 return primitive 

85 return None 

86 

87 

88def _read_format_version(get_primitive: Callable[[str], HdsPrimitive | None]) -> int: 

89 """Read the container-layout ``FORMAT_VERSION`` from the LSST extension. 

90 

91 Absence is treated as ``1`` (legacy default). 

92 """ 

93 primitive = _get_lsst_primitive(get_primitive, "FORMAT_VERSION") 

94 if primitive is None: 

95 return 1 

96 # The writer emits the version as a 0-d int32 numpy array; .item() 

97 # unwraps to a Python int. 

98 return int(primitive.read_array().item()) 

99 

100 

101def _read_archive_info(get_primitive: Callable[[str], HdsPrimitive | None], source: str) -> ArchiveInfo: 

102 """Read the schema URL and format version from the LSST extension. 

103 

104 Parameters 

105 ---------- 

106 get_primitive 

107 Callable returning the primitive at an absolute path, or `None` if 

108 there is no primitive there. 

109 source 

110 Description of the file being read, used in error messages. 

111 """ 

112 schema_url: str | None = None 

113 if (data_model := _get_lsst_primitive(get_primitive, "DATA_MODEL")) is not None: 113 ↛ 116line 113 didn't jump to line 116 because the condition on line 113 was always true

114 lines = data_model.read_char_array() 

115 schema_url = lines[0].strip() if lines else None 

116 if not schema_url: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 raise ArchiveReadError( 

118 f"Could not read the schema of {source} from /MORE/LSST/DATA_MODEL or /LSST/DATA_MODEL." 

119 ) 

120 return ArchiveInfo.from_schema_url(schema_url, format_version=_read_format_version(get_primitive)) 

121 

122 

123class NdfInputArchive(InputArchive[NdfPointerModel]): 

124 """Reads HDS-on-HDF5 NDF files written by `NdfOutputArchive`. 

125 

126 Instances should only be constructed via the :meth:`open` context 

127 manager. 

128 

129 Parameters 

130 ---------- 

131 file 

132 Open `h5py.File` handle. Owned by the caller of :meth:`open`; 

133 the archive does not close it. 

134 """ 

135 

136 def __init__(self, file: h5py.File) -> None: 

137 self._file = file 

138 self._document = NdfDocument.from_hdf5(file) 

139 self._opaque_metadata = FitsOpaqueMetadata() 

140 self._deserialized_pointer_cache: dict[str, Any] = {} 

141 self._frame_set_cache: dict[str, FrameSet] = {} 

142 self._read_opaque_fits_metadata() 

143 self._check_format_version() 

144 

145 @classmethod 

146 def get_basic_info(cls, path: ResourcePathExpression) -> ArchiveInfo: 

147 """Read the schema URL from the ``DATA_MODEL`` scalar and the 

148 ``FORMAT_VERSION`` primitive without deserializing pixel data. 

149 

150 Reading the datasets directly from the HDF5 file avoids building 

151 the full internal model, which would eagerly read the 

152 (potentially large) JSON tree. 

153 

154 Parameters 

155 ---------- 

156 path 

157 Path to the archive to read. 

158 """ 

159 ospath = ResourcePath(path).ospath 

160 

161 with h5py.File(ospath, "r") as handle: 

162 

163 def get_primitive(component_path: str) -> HdsPrimitive | None: 

164 node = handle.get(component_path) 

165 return HdsPrimitive.from_hdf5(node) if isinstance(node, h5py.Dataset) else None 

166 

167 return _read_archive_info(get_primitive, repr(path)) 

168 

169 @classmethod 

170 @contextmanager 

171 def open_tree( 

172 cls, 

173 path: ResourcePathExpression | IO[bytes], 

174 *, 

175 partial: bool = True, 

176 **backend_kwargs: Any, 

177 ) -> Iterator[tuple[Self, ArchiveTree, ArchiveInfo]]: 

178 """Open the NDF file and yield ``(archive, tree, info)``. 

179 

180 The schema is read from the open document's ``DATA_MODEL`` rather than 

181 a separate `get_basic_info` open. Requires the symmetric LSST JSON 

182 tree; ``partial`` is accepted but not meaningful, since h5py reads 

183 lazily regardless. 

184 

185 Parameters 

186 ---------- 

187 path 

188 The file resource to open, or a seekable binary stream 

189 containing the file's content. 

190 partial 

191 Accepted for interface compatibility but not meaningful; h5py 

192 reads lazily regardless. 

193 **backend_kwargs 

194 Backend-specific options; none are currently used. 

195 """ 

196 with cls.open(path) as archive: 

197 if archive._get_main_json_path() is None: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 raise ArchiveReadError( 

199 f"{path!r} has no LSST JSON tree; only the symmetric read path is supported." 

200 ) 

201 info = archive.info 

202 tree_cls = tree_class_for_info(info, path) 

203 parameterized = parameterize_tree(tree_cls, NdfPointerModel) 

204 tree = archive.get_tree(parameterized) 

205 yield archive, tree, info 

206 

207 @classmethod 

208 @contextmanager 

209 def open(cls, path: ResourcePathExpression | IO[bytes]) -> Iterator[Self]: 

210 """Open an NDF file for reading and yield an `NdfInputArchive`. 

211 

212 Remote ResourcePaths are materialised locally first; fsspec-direct 

213 h5py reads are a deferred follow-up. 

214 

215 Parameters 

216 ---------- 

217 path 

218 Path to the NDF file to open, or a seekable binary stream 

219 containing the file's content. 

220 """ 

221 if _is_binary_stream(path): 

222 with h5py.File(path, "r") as f: 

223 yield cls(f) 

224 return 

225 rp = ResourcePath(path) 

226 with rp.as_local() as local: 

227 with h5py.File(local.ospath, "r") as f: 

228 yield cls(f) 

229 

230 def get_tree[T: ArchiveTree](self, model_type: type[T]) -> T: 

231 """Read and validate the main Pydantic tree at ``/MORE/LSST/JSON``. 

232 

233 Parameters 

234 ---------- 

235 model_type 

236 Archive tree model type to validate the JSON tree against. 

237 """ 

238 json_path = self._get_main_json_path() 

239 if json_path is None: 

240 raise ArchiveReadError( 

241 "File has no /MORE/LSST/JSON tree; this is either a " 

242 "Starlink-only NDF (use ndf.read_starlink() for auto-detect) or " 

243 "the file was written by an unrelated tool." 

244 ) 

245 json_text = _read_json_record(self._get_primitive(json_path), json_path) 

246 return model_type.model_validate_json(json_text) 

247 

248 def deserialize_pointer[U: ArchiveTree, V]( 

249 self, 

250 pointer: NdfPointerModel, 

251 model_type: type[U], 

252 deserializer: Callable[[U, InputArchive[NdfPointerModel]], V], 

253 ) -> V: 

254 # Cache by pointer.path so repeated dereferences reuse the same 

255 # deserialised result and don't re-run the deserializer. 

256 if (cached := self._deserialized_pointer_cache.get(pointer.path)) is not None: 

257 return cached 

258 if not self._has_model_path(pointer.path): 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 raise ArchiveReadError(f"Pointer reference {pointer.path!r} not found in NDF file.") 

260 primitive = self._get_primitive(pointer.path) 

261 json_text = _read_json_record(primitive, pointer.path) 

262 model = model_type.model_validate_json(json_text) 

263 result = deserializer(model, self) 

264 self._deserialized_pointer_cache[pointer.path] = result 

265 if isinstance(result, FrameSet): 

266 self._frame_set_cache[pointer.path] = result 

267 return result 

268 

269 def get_frame_set(self, pointer: NdfPointerModel) -> FrameSet: 

270 try: 

271 return self._frame_set_cache[pointer.path] 

272 except KeyError: 

273 raise AssertionError( 

274 f"Frame set at {pointer.path!r} must be deserialised via " 

275 f"deserialize_pointer before any dependent transform can be." 

276 ) from None 

277 

278 def get_array( 

279 self, 

280 model: ArrayReferenceModel | InlineArrayModel, 

281 *, 

282 slices: tuple[slice, ...] | EllipsisType = ..., 

283 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

284 ) -> np.ndarray: 

285 if isinstance(model, InlineArrayModel): 

286 data: np.ndarray = np.array(model.data, dtype=model.datatype.to_numpy()) 

287 return data if slices is ... else data[slices] 

288 if not isinstance(model.source, str) or not model.source.startswith("ndf:"): 

289 raise ArchiveReadError( 

290 f"NdfInputArchive cannot resolve array source {model.source!r}; " 

291 f"expected an 'ndf:<HDF5-path>' reference." 

292 ) 

293 path = model.source[len("ndf:") :] 

294 if not self._has_model_path(path): 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true

295 raise ArchiveReadError(f"Array reference {path!r} not in file.") 

296 primitive = self._get_primitive(path) 

297 # h5py supports lazy slicing via dataset[slices]. 

298 if isinstance(primitive.data, h5py.Dataset): 298 ↛ 300line 298 didn't jump to line 300 because the condition on line 298 was always true

299 return primitive.data[()] if slices is ... else primitive.data[slices] 

300 data = primitive.read_array() 

301 return data if slices is ... else data[slices] 

302 

303 def get_table( 

304 self, 

305 model: TableModel, 

306 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

307 ) -> astropy.table.Table: 

308 result = astropy.table.Table(meta=model.meta) 

309 for column_model in model.columns: 

310 if isinstance(column_model.data, InlineArrayModel): 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 data: Any = column_model.data.data 

312 else: 

313 data = self.get_array(column_model.data, strip_header=strip_header) 

314 result[column_model.name] = astropy.table.Column( 

315 data, 

316 name=column_model.name, 

317 dtype=column_model.data.datatype.to_numpy(), 

318 unit=column_model.unit, 

319 description=column_model.description, 

320 meta=column_model.meta, 

321 ) 

322 return result 

323 

324 def get_structured_array( 

325 self, 

326 model: TableModel, 

327 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

328 ) -> np.ndarray: 

329 return self.get_table(model, strip_header).as_array() 

330 

331 def _read_opaque_fits_metadata(self) -> None: 

332 if not self._has_model_path("/MORE/FITS"): 

333 return 

334 cards = self._get_primitive("/MORE/FITS").read_char_array() 

335 # FITS Header.fromstring expects fixed-width 80-char cards 

336 # concatenated; pad each card defensively so readers tolerate 

337 # files written with shorter widths. 

338 header = astropy.io.fits.Header.fromstring("".join(c.ljust(80) for c in cards)) 

339 self._opaque_metadata.add_header(header, name="", ver=1) 

340 

341 def get_opaque_metadata(self) -> FitsOpaqueMetadata: 

342 return self._opaque_metadata 

343 

344 @property 

345 def info(self) -> ArchiveInfo: 

346 """Schema/format info read from the open document's ``DATA_MODEL`` 

347 (`.serialization.ArchiveInfo`). 

348 """ 

349 return _read_archive_info(self._get_optional_primitive, repr(self._file.filename)) 

350 

351 def _get_main_json_path(self) -> str | None: 

352 """Return the path of the main LSST JSON tree, if present.""" 

353 for prefix in _LSST_EXTENSION_PREFIXES: 

354 path = f"{prefix}/JSON" 

355 if self._has_model_path(path): 

356 return path 

357 return None 

358 

359 def _check_format_version(self) -> None: 

360 """Read FORMAT_VERSION from the NDF top-level structure and check it. 

361 

362 DATA_MODEL is informational only on read; the JSON tree's 

363 ``schema_version`` / ``min_read_version`` drive data-model 

364 compatibility. 

365 """ 

366 _check_format_version("ndf", _read_format_version(self._get_optional_primitive), _NDF_FORMAT_VERSION) 

367 

368 def _has_model_path(self, path: str) -> bool: 

369 """Return `True` if a path exists in the NDF document model.""" 

370 try: 

371 self._document.get(path) 

372 except KeyError: 

373 return False 

374 return True 

375 

376 def _get_primitive(self, path: str) -> HdsPrimitive: 

377 """Return a primitive component from the NDF document model.""" 

378 node = self._document.get(path) 

379 if not isinstance(node, HdsPrimitive): 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 raise ArchiveReadError(f"NDF reference {path!r} is not a primitive dataset.") 

381 return node 

382 

383 def _get_optional_primitive(self, path: str) -> HdsPrimitive | None: 

384 """Return a primitive from the document model, or `None` if there is 

385 no primitive at ``path``. 

386 """ 

387 try: 

388 node = self._document.get(path) 

389 except KeyError: 

390 return None 

391 return node if isinstance(node, HdsPrimitive) else None 

392 

393 

394def read_starlink[T: Any](cls_: type[T], path: ResourcePathExpression) -> T: 

395 """Reconstruct an `~lsst.images.Image` or `~lsst.images.MaskedImage` 

396 from a schema-less Starlink NDF. 

397 

398 Files written by this package carry a ``/MORE/LSST/JSON`` tree and are 

399 read through the generic `lsst.images.serialization.read` / 

400 `lsst.images.serialization.open`. A Starlink-produced NDF has no such 

401 tree and therefore no schema, so it cannot go through that path; this 

402 function auto-detects a minimal recognised-component set 

403 (``DATA_ARRAY``, ``VARIANCE``, ``QUALITY``, ``MORE.FITS``) instead. 

404 ``WCS`` is reconstructed when possible; other components are 

405 logged-and-dropped. 

406 

407 Parameters 

408 ---------- 

409 cls_ 

410 Expected return type; `~lsst.images.Image` and 

411 `~lsst.images.MaskedImage` are the only types the auto-detect path 

412 can produce. 

413 path 

414 File path or `lsst.resources.ResourcePathExpression`. 

415 

416 Returns 

417 ------- 

418 object 

419 The deserialized ``cls`` instance. 

420 

421 Raises 

422 ------ 

423 ArchiveReadError 

424 If the file has an LSST JSON tree (use the generic ``read`` instead) 

425 or no recognised ``DATA_ARRAY`` component. 

426 """ 

427 with NdfInputArchive.open(path) as archive: 

428 if archive._get_main_json_path() is not None: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true

429 raise ArchiveReadError( 

430 f"{path!r} has an LSST JSON tree; read it with serialization.read()/open()." 

431 ) 

432 return _read_auto_detect(cls_, archive) 

433 

434 

435def _read_auto_detect[T: Any](cls: type[T], archive: NdfInputArchive) -> T: 

436 """Reconstruct an `Image` (or `MaskedImage`) from a Starlink NDF. 

437 

438 Recognised components: ``DATA_ARRAY`` (in either simple or complex 

439 form), ``VARIANCE``, ``QUALITY``, ``MORE.FITS``. Other components 

440 (``WCS``, ``HISTORY``, ``AXIS``, ``LABEL``, custom ``MORE.*``, 

441 ``_LOGICAL`` primitives) are warned-and-dropped. 

442 """ 

443 f = archive._file 

444 ndf_group = _locate_ndf_root(f) 

445 

446 # DATA_ARRAY is required. 

447 if "DATA_ARRAY" not in ndf_group: 

448 raise ArchiveReadError(f"Auto-detect read of {f.filename!r}: no DATA_ARRAY component.") 

449 data_arr, bbox = _read_data_array_with_bbox(ndf_group["DATA_ARRAY"]) 

450 

451 # VARIANCE / QUALITY are optional. 

452 variance_arr: np.ndarray | None = None 

453 variance_bbox: Any | None = None 

454 if "VARIANCE" in ndf_group: 

455 variance_arr, variance_bbox = _read_data_array_with_bbox(ndf_group["VARIANCE"]) 

456 quality_arr: np.ndarray | None = None 

457 quality_bbox: Any | None = None 

458 quality_badbits = 255 

459 if "QUALITY" in ndf_group and isinstance(ndf_group["QUALITY"], h5py.Group): 

460 q = ndf_group["QUALITY"] 

461 quality_badbits = _read_quality_badbits(q) 

462 if "QUALITY" in q and isinstance(q["QUALITY"], h5py.Dataset): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 quality_arr = _validate_quality_array(_hds.read_array(q["QUALITY"])) 

464 quality_bbox = _make_bbox(x_min=0, y_min=0, array=quality_arr) 

465 elif "QUALITY" in q and isinstance(q["QUALITY"], h5py.Group): 465 ↛ 469line 465 didn't jump to line 469 because the condition on line 465 was always true

466 quality_arr, quality_bbox = _read_data_array_with_bbox(q["QUALITY"]) 

467 quality_arr = _validate_quality_array(quality_arr) 

468 

469 sky_projection: SkyProjection | None = None 

470 if "WCS" in ndf_group: 

471 try: 

472 wcs_group = ndf_group["WCS"] 

473 if isinstance(wcs_group, h5py.Group) and "DATA" in wcs_group: 473 ↛ 491line 473 didn't jump to line 491 because the condition on line 473 was always true

474 wcs_lines = _hds.read_char_array(wcs_group["DATA"]) 

475 wcs_text = _hds.decode_ndf_ast_data(wcs_lines) 

476 ast_obj = astshim.Object.fromString(wcs_text) 

477 if isinstance(ast_obj, astshim.FrameSet): 477 ↛ 491line 477 didn't jump to line 491 because the condition on line 477 was always true

478 pixel_frame = GeneralFrame(unit=u.pix) 

479 sky_projection = SkyProjection.from_ast_frame_set( 

480 ast_obj, 

481 pixel_frame, 

482 pixel_bounds=bbox, 

483 ) 

484 except Exception: 

485 _LOG.warning( 

486 "Could not reconstruct Projection from WCS in %s; dropping.", 

487 f.filename, 

488 exc_info=True, 

489 ) 

490 

491 unit = _read_ndf_units(ndf_group) 

492 

493 # Anything unrecognised: warn-and-drop. 

494 recognised = { 

495 "DATA_ARRAY", 

496 "VARIANCE", 

497 "QUALITY", 

498 "WCS", 

499 "MORE", 

500 "TITLE", 

501 "LABEL", 

502 "UNITS", 

503 "HISTORY", 

504 "AXIS", 

505 } 

506 for name in ndf_group: 

507 if name not in recognised: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 _LOG.warning( 

509 "Ignoring unrecognised NDF component %s/%s during auto-detect read.", 

510 ndf_group.name, 

511 name, 

512 ) 

513 

514 # Build the requested in-memory object. Any NDF can be read as an Image; 

515 # MaskedImage construction uses whatever VARIANCE/QUALITY are present and 

516 # lets the MaskedImage constructor provide defaults for missing planes. 

517 image = Image(data_arr, bbox=bbox, unit=unit, sky_projection=sky_projection) 

518 obj: Any 

519 if cls is Image: 

520 obj = image 

521 elif issubclass(cls, MaskedImage): 

522 if quality_arr is not None: 

523 schema = _make_quality_mask_schema(quality_badbits) 

524 mask = Mask(quality_arr[:, :, np.newaxis], schema=schema, bbox=quality_bbox) 

525 else: 

526 schema = MaskSchema([MaskPlane(name="BAD", description="Bad pixel.")]) 

527 mask = None 

528 variance = Image(variance_arr, bbox=variance_bbox) if variance_arr is not None else None 

529 obj = cls( 

530 image=image, 

531 mask=mask, 

532 mask_schema=schema if mask is None else None, 

533 variance=variance, 

534 ) 

535 else: 

536 raise ArchiveReadError( 

537 f"Auto-detect can produce Image or MaskedImage, but caller asked for {cls.__name__}." 

538 ) 

539 obj._opaque_metadata = archive.get_opaque_metadata() 

540 return obj 

541 

542 

543def _read_ndf_units(ndf_group: h5py.Group) -> u.UnitBase | None: 

544 """Read the NDF UNITS component, if present.""" 

545 if "UNITS" not in ndf_group or not isinstance(ndf_group["UNITS"], h5py.Dataset): 

546 return None 

547 dataset = ndf_group["UNITS"] 

548 if dataset.dtype.kind != "S": 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 _LOG.warning("Ignoring non-character NDF UNITS component in %s.", ndf_group.name) 

550 return None 

551 if dataset.ndim == 0: 551 ↛ 559line 551 didn't jump to line 559 because the condition on line 551 was always true

552 raw = dataset[()] 

553 if isinstance(raw, np.bytes_): 553 ↛ 555line 553 didn't jump to line 555 because the condition on line 553 was always true

554 raw = bytes(raw) 

555 if not isinstance(raw, bytes): 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true

556 return None 

557 units_text = raw.decode("ascii").rstrip(" ") 

558 else: 

559 records = _hds.read_char_array(dataset) 

560 units_text = records[0] if records else "" 

561 if not units_text: 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true

562 return None 

563 for kwargs in ({"format": "fits"}, {}): 563 ↛ 568line 563 didn't jump to line 568 because the loop on line 563 didn't complete

564 try: 

565 return u.Unit(units_text, **kwargs) 

566 except ValueError: 

567 continue 

568 _LOG.warning("Could not parse NDF UNITS value %r in %s.", units_text, ndf_group.name) 

569 return None 

570 

571 

572def _read_quality_badbits(quality_group: h5py.Group) -> int: 

573 """Read the scalar NDF QUALITY.BADBITS value.""" 

574 badbits = quality_group.get("BADBITS") 

575 if not isinstance(badbits, h5py.Dataset): 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true

576 return 255 

577 value = np.asarray(_hds.read_array(badbits)).reshape(-1) 

578 if value.size == 0: 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true

579 return 255 

580 return int(value[0]) 

581 

582 

583def _validate_quality_array(quality: np.ndarray) -> np.ndarray: 

584 """Return an NDF QUALITY array as a `numpy.uint8` mask plane.""" 

585 if quality.dtype != np.dtype(np.uint8): 585 ↛ 586line 585 didn't jump to line 586 because the condition on line 585 was never true

586 raise ArchiveReadError(f"NDF QUALITY array has dtype {quality.dtype}; expected uint8.") 

587 return quality 

588 

589 

590def _make_quality_mask_schema(badbits: int) -> MaskSchema: 

591 """Create a fallback `MaskSchema` for an unnamed 8-bit QUALITY array.""" 

592 planes = [] 

593 for bit in range(8): 

594 mask = 1 << bit 

595 description = f"NDF QUALITY bit {bit}." 

596 if badbits & mask: 

597 description += " Selected by BADBITS." 

598 planes.append(MaskPlane(name=f"MASK{bit}", description=description)) 

599 return MaskSchema(planes, dtype=np.uint8) 

600 

601 

602def _locate_ndf_root(f: h5py.File) -> h5py.Group: 

603 """Return the group representing the top-level NDF. 

604 

605 Most files have the NDF at the root group itself. A few wrap it 

606 in a single-child container at the root; we accept that shape 

607 too. Anything more elaborate raises. 

608 """ 

609 root_class = f["/"].attrs.get(_hds.ATTR_CLASS) 

610 if isinstance(root_class, bytes): 

611 root_class = root_class.decode("ascii") 

612 if root_class == "NDF": 612 ↛ 615line 612 didn't jump to line 615 because the condition on line 612 was always true

613 return f["/"] 

614 # Maybe a one-level container. 

615 candidates = [] 

616 for name, child in f["/"].items(): 

617 if isinstance(child, h5py.Group): 

618 cls_attr = child.attrs.get(_hds.ATTR_CLASS) 

619 if isinstance(cls_attr, bytes): 

620 cls_attr = cls_attr.decode("ascii") 

621 if cls_attr == "NDF": 

622 candidates.append(name) 

623 if len(candidates) == 1: 

624 return f[candidates[0]] 

625 raise ArchiveReadError( 

626 f"Could not locate top-level NDF in {f.filename!r}; " 

627 f"expected the root group or a single NDF-typed child." 

628 ) 

629 

630 

631def _read_data_array_with_bbox( 

632 obj: h5py.Group | h5py.Dataset, 

633) -> tuple[np.ndarray, Any]: 

634 """Read a DATA_ARRAY component in either simple or complex form. 

635 

636 The complex form (what our writer always produces) is an HDS 

637 ARRAY structure (h5py group with CLASS="ARRAY") containing 

638 ``DATA`` and ``ORIGIN`` primitives. The simple form is a bare 

639 primitive dataset. 

640 

641 Returns 

642 ------- 

643 array, bbox : tuple 

644 ``array`` is the C-order numpy data (shape ``(height, width)`` 

645 for 2D images). ``bbox`` is constructed from the ORIGIN if 

646 present, else from a default origin of (0, 0). 

647 """ 

648 if isinstance(obj, h5py.Dataset): 648 ↛ 650line 648 didn't jump to line 650 because the condition on line 648 was never true

649 # Simple form. 

650 array = _hds.read_array(obj) 

651 bbox = _make_bbox(x_min=0, y_min=0, array=array) 

652 return array, bbox 

653 # Complex form: an HDS structure with DATA + ORIGIN. 

654 data = _hds.read_array(obj["DATA"]) 

655 if "ORIGIN" in obj: 

656 origin = _hds.read_array(obj["ORIGIN"]) 

657 bbox = _make_bbox(x_min=int(origin[0]), y_min=int(origin[1]), array=data) 

658 else: 

659 bbox = _make_bbox(x_min=0, y_min=0, array=data) 

660 return data, bbox 

661 

662 

663def _read_json_record(primitive: HdsPrimitive, path: str) -> str: 

664 """Read a JSON document stored as a single _CHAR*N record. 

665 

666 Our writer always emits JSON trees as a single-element character 

667 array sized to the document. Joining multiple records would lose 

668 trailing whitespace inside JSON string values, since 

669 `read_char_array` strips trailing spaces per record. 

670 """ 

671 records = primitive.read_char_array() 

672 if len(records) != 1: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true

673 raise ArchiveReadError(f"Expected a single _CHAR*N record at {path!r}, got {len(records)}.") 

674 return records[0] 

675 

676 

677def _make_bbox(*, x_min: int, y_min: int, array: np.ndarray) -> Any: 

678 """Build an lsst.images.Box for a 2D image array. 

679 

680 The array is C-order ``(height, width)``. NDF stores ``ORIGIN`` 

681 in Fortran axis order ``(x_min, y_min)``. 

682 """ 

683 if array.ndim != 2: 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true

684 raise ArchiveReadError(f"Auto-detect read only supports 2D arrays, got ndim={array.ndim}.") 

685 # Box.from_shape takes (height, width) and start=(y_start, x_start). 

686 return Box.from_shape(array.shape, start=(y_min, x_min))