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

309 statements  

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

51from . import _hds 

52from ._common import NdfPointerModel 

53from ._model import HdsPrimitive, NdfDocument 

54 

55_LOG = logging.getLogger(__name__) 

56 

57_NDF_FORMAT_VERSION = 1 

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

59 

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

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

62 

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

64therefore never be mistaken for the main one. 

65""" 

66 

67 

68def _get_lsst_primitive( 

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

70) -> HdsPrimitive | None: 

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

72 

73 Parameters 

74 ---------- 

75 get_primitive 

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

77 there is no primitive there. 

78 name 

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

80 """ 

81 for prefix in _LSST_EXTENSION_PREFIXES: 

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

83 return primitive 

84 return None 

85 

86 

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

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

89 

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

91 """ 

92 primitive = _get_lsst_primitive(get_primitive, "FORMAT_VERSION") 

93 if primitive is None: 

94 return 1 

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

96 # unwraps to a Python int. 

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

98 

99 

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

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

102 

103 Parameters 

104 ---------- 

105 get_primitive 

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

107 there is no primitive there. 

108 source 

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

110 """ 

111 schema_url: str | None = None 

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

113 lines = data_model.read_char_array() 

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

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

116 raise ArchiveReadError( 

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

118 ) 

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

120 

121 

122class NdfInputArchive(InputArchive[NdfPointerModel]): 

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

124 

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

126 manager. 

127 

128 Parameters 

129 ---------- 

130 file 

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

132 the archive does not close it. 

133 """ 

134 

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

136 self._file = file 

137 self._document = NdfDocument.from_hdf5(file) 

138 self._opaque_metadata = FitsOpaqueMetadata() 

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

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

141 self._read_opaque_fits_metadata() 

142 self._check_format_version() 

143 

144 @classmethod 

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

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

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

148 

149 Reading the datasets directly from the HDF5 file avoids building 

150 the full internal model, which would eagerly read the 

151 (potentially large) JSON tree. 

152 

153 Parameters 

154 ---------- 

155 path 

156 Path to the archive to read. 

157 """ 

158 ospath = ResourcePath(path).ospath 

159 

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

161 

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

163 node = handle.get(component_path) 

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

165 

166 return _read_archive_info(get_primitive, repr(path)) 

167 

168 @classmethod 

169 @contextmanager 

170 def open_tree( 

171 cls, 

172 path: ResourcePathExpression, 

173 *, 

174 partial: bool = True, 

175 **backend_kwargs: Any, 

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

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

178 

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

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

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

182 lazily regardless. 

183 

184 Parameters 

185 ---------- 

186 path 

187 The file resource to open. 

188 partial 

189 Accepted for interface compatibility but not meaningful; h5py 

190 reads lazily regardless. 

191 **backend_kwargs 

192 Backend-specific options; none are currently used. 

193 """ 

194 with cls.open(path) as archive: 

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

196 raise ArchiveReadError( 

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

198 ) 

199 info = archive.info 

200 tree_cls = tree_class_for_info(info, path) 

201 parameterized = parameterize_tree(tree_cls, NdfPointerModel) 

202 tree = archive.get_tree(parameterized) 

203 yield archive, tree, info 

204 

205 @classmethod 

206 @contextmanager 

207 def open(cls, path: ResourcePathExpression) -> Iterator[Self]: 

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

209 

210 Remote ResourcePaths are materialised locally first; fsspec-direct 

211 h5py reads are a deferred follow-up. 

212 

213 Parameters 

214 ---------- 

215 path 

216 Path to the NDF file to open. 

217 """ 

218 rp = ResourcePath(path) 

219 with rp.as_local() as local: 

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

221 yield cls(f) 

222 

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

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

225 

226 Parameters 

227 ---------- 

228 model_type 

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

230 """ 

231 json_path = self._get_main_json_path() 

232 if json_path is None: 

233 raise ArchiveReadError( 

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

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

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

237 ) 

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

239 return model_type.model_validate_json(json_text) 

240 

241 def deserialize_pointer[U: ArchiveTree, V]( 

242 self, 

243 pointer: NdfPointerModel, 

244 model_type: type[U], 

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

246 ) -> V: 

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

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

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

250 return cached 

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

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

253 primitive = self._get_primitive(pointer.path) 

254 json_text = _read_json_record(primitive, pointer.path) 

255 model = model_type.model_validate_json(json_text) 

256 result = deserializer(model, self) 

257 self._deserialized_pointer_cache[pointer.path] = result 

258 if isinstance(result, FrameSet): 

259 self._frame_set_cache[pointer.path] = result 

260 return result 

261 

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

263 try: 

264 return self._frame_set_cache[pointer.path] 

265 except KeyError: 

266 raise AssertionError( 

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

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

269 ) from None 

270 

271 def get_array( 

272 self, 

273 model: ArrayReferenceModel | InlineArrayModel, 

274 *, 

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

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

277 ) -> np.ndarray: 

278 if isinstance(model, InlineArrayModel): 

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

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

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

282 raise ArchiveReadError( 

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

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

285 ) 

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

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

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

289 primitive = self._get_primitive(path) 

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

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

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

293 data = primitive.read_array() 

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

295 

296 def get_table( 

297 self, 

298 model: TableModel, 

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

300 ) -> astropy.table.Table: 

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

302 for column_model in model.columns: 

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

304 data: Any = column_model.data.data 

305 else: 

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

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

308 data, 

309 name=column_model.name, 

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

311 unit=column_model.unit, 

312 description=column_model.description, 

313 meta=column_model.meta, 

314 ) 

315 return result 

316 

317 def get_structured_array( 

318 self, 

319 model: TableModel, 

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

321 ) -> np.ndarray: 

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

323 

324 def _read_opaque_fits_metadata(self) -> None: 

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

326 return 

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

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

329 # concatenated; pad each card defensively so readers tolerate 

330 # files written with shorter widths. 

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

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

333 

334 def get_opaque_metadata(self) -> FitsOpaqueMetadata: 

335 return self._opaque_metadata 

336 

337 @property 

338 def info(self) -> ArchiveInfo: 

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

340 (`.serialization.ArchiveInfo`). 

341 """ 

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

343 

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

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

346 for prefix in _LSST_EXTENSION_PREFIXES: 

347 path = f"{prefix}/JSON" 

348 if self._has_model_path(path): 

349 return path 

350 return None 

351 

352 def _check_format_version(self) -> None: 

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

354 

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

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

357 compatibility. 

358 """ 

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

360 

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

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

363 try: 

364 self._document.get(path) 

365 except KeyError: 

366 return False 

367 return True 

368 

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

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

371 node = self._document.get(path) 

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

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

374 return node 

375 

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

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

378 no primitive at ``path``. 

379 """ 

380 try: 

381 node = self._document.get(path) 

382 except KeyError: 

383 return None 

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

385 

386 

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

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

389 from a schema-less Starlink NDF. 

390 

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

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

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

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

395 function auto-detects a minimal recognised-component set 

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

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

398 logged-and-dropped. 

399 

400 Parameters 

401 ---------- 

402 cls_ 

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

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

405 can produce. 

406 path 

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

408 

409 Returns 

410 ------- 

411 object 

412 The deserialized ``cls`` instance. 

413 

414 Raises 

415 ------ 

416 ArchiveReadError 

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

418 or no recognised ``DATA_ARRAY`` component. 

419 """ 

420 with NdfInputArchive.open(path) as archive: 

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

422 raise ArchiveReadError( 

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

424 ) 

425 return _read_auto_detect(cls_, archive) 

426 

427 

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

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

430 

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

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

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

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

435 """ 

436 f = archive._file 

437 ndf_group = _locate_ndf_root(f) 

438 

439 # DATA_ARRAY is required. 

440 if "DATA_ARRAY" not in ndf_group: 

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

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

443 

444 # VARIANCE / QUALITY are optional. 

445 variance_arr: np.ndarray | None = None 

446 variance_bbox: Any | None = None 

447 if "VARIANCE" in ndf_group: 

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

449 quality_arr: np.ndarray | None = None 

450 quality_bbox: Any | None = None 

451 quality_badbits = 255 

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

453 q = ndf_group["QUALITY"] 

454 quality_badbits = _read_quality_badbits(q) 

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

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

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

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

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

460 quality_arr = _validate_quality_array(quality_arr) 

461 

462 sky_projection: SkyProjection | None = None 

463 if "WCS" in ndf_group: 

464 try: 

465 wcs_group = ndf_group["WCS"] 

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

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

468 wcs_text = _hds.decode_ndf_ast_data(wcs_lines) 

469 ast_obj = astshim.Object.fromString(wcs_text) 

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

471 pixel_frame = GeneralFrame(unit=u.pix) 

472 sky_projection = SkyProjection.from_ast_frame_set( 

473 ast_obj, 

474 pixel_frame, 

475 pixel_bounds=bbox, 

476 ) 

477 except Exception: 

478 _LOG.warning( 

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

480 f.filename, 

481 exc_info=True, 

482 ) 

483 

484 unit = _read_ndf_units(ndf_group) 

485 

486 # Anything unrecognised: warn-and-drop. 

487 recognised = { 

488 "DATA_ARRAY", 

489 "VARIANCE", 

490 "QUALITY", 

491 "WCS", 

492 "MORE", 

493 "TITLE", 

494 "LABEL", 

495 "UNITS", 

496 "HISTORY", 

497 "AXIS", 

498 } 

499 for name in ndf_group: 

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

501 _LOG.warning( 

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

503 ndf_group.name, 

504 name, 

505 ) 

506 

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

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

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

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

511 obj: Any 

512 if cls is Image: 

513 obj = image 

514 elif issubclass(cls, MaskedImage): 

515 if quality_arr is not None: 

516 schema = _make_quality_mask_schema(quality_badbits) 

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

518 else: 

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

520 mask = None 

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

522 obj = cls( 

523 image=image, 

524 mask=mask, 

525 mask_schema=schema if mask is None else None, 

526 variance=variance, 

527 ) 

528 else: 

529 raise ArchiveReadError( 

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

531 ) 

532 obj._opaque_metadata = archive.get_opaque_metadata() 

533 return obj 

534 

535 

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

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

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

539 return None 

540 dataset = ndf_group["UNITS"] 

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

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

543 return None 

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

545 raw = dataset[()] 

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

547 raw = bytes(raw) 

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

549 return None 

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

551 else: 

552 records = _hds.read_char_array(dataset) 

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

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

555 return None 

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

557 try: 

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

559 except ValueError: 

560 continue 

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

562 return None 

563 

564 

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

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

567 badbits = quality_group.get("BADBITS") 

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

569 return 255 

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

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

572 return 255 

573 return int(value[0]) 

574 

575 

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

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

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

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

580 return quality 

581 

582 

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

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

585 planes = [] 

586 for bit in range(8): 

587 mask = 1 << bit 

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

589 if badbits & mask: 

590 description += " Selected by BADBITS." 

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

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

593 

594 

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

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

597 

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

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

600 too. Anything more elaborate raises. 

601 """ 

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

603 if isinstance(root_class, bytes): 

604 root_class = root_class.decode("ascii") 

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

606 return f["/"] 

607 # Maybe a one-level container. 

608 candidates = [] 

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

610 if isinstance(child, h5py.Group): 

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

612 if isinstance(cls_attr, bytes): 

613 cls_attr = cls_attr.decode("ascii") 

614 if cls_attr == "NDF": 

615 candidates.append(name) 

616 if len(candidates) == 1: 

617 return f[candidates[0]] 

618 raise ArchiveReadError( 

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

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

621 ) 

622 

623 

624def _read_data_array_with_bbox( 

625 obj: h5py.Group | h5py.Dataset, 

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

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

628 

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

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

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

632 primitive dataset. 

633 

634 Returns 

635 ------- 

636 array, bbox : tuple 

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

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

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

640 """ 

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

642 # Simple form. 

643 array = _hds.read_array(obj) 

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

645 return array, bbox 

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

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

648 if "ORIGIN" in obj: 

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

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

651 else: 

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

653 return data, bbox 

654 

655 

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

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

658 

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

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

661 trailing whitespace inside JSON string values, since 

662 `read_char_array` strips trailing spaces per record. 

663 """ 

664 records = primitive.read_char_array() 

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

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

667 return records[0] 

668 

669 

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

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

672 

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

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

675 """ 

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

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

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

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