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

309 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-18 10:45 -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 ospath = ResourcePath(path).ospath 

154 

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

156 

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

158 node = handle.get(component_path) 

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

160 

161 return _read_archive_info(get_primitive, repr(path)) 

162 

163 @classmethod 

164 @contextmanager 

165 def open_tree( 

166 cls, 

167 path: ResourcePathExpression, 

168 *, 

169 partial: bool = True, 

170 **backend_kwargs: Any, 

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

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

173 

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

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

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

177 lazily regardless. 

178 """ 

179 with cls.open(path) as archive: 

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

181 raise ArchiveReadError( 

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

183 ) 

184 info = archive.info 

185 tree_cls = tree_class_for_info(info, path) 

186 parameterized = parameterize_tree(tree_cls, NdfPointerModel) 

187 tree = archive.get_tree(parameterized) 

188 yield archive, tree, info 

189 

190 @classmethod 

191 @contextmanager 

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

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

194 

195 Remote ResourcePaths are materialised locally first; fsspec-direct 

196 h5py reads are a deferred follow-up. 

197 """ 

198 rp = ResourcePath(path) 

199 with rp.as_local() as local: 

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

201 yield cls(f) 

202 

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

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

205 json_path = self._get_main_json_path() 

206 if json_path is None: 

207 raise ArchiveReadError( 

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

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

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

211 ) 

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

213 return model_type.model_validate_json(json_text) 

214 

215 def deserialize_pointer[U: ArchiveTree, V]( 

216 self, 

217 pointer: NdfPointerModel, 

218 model_type: type[U], 

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

220 ) -> V: 

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

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

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

224 return cached 

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

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

227 primitive = self._get_primitive(pointer.path) 

228 json_text = _read_json_record(primitive, pointer.path) 

229 model = model_type.model_validate_json(json_text) 

230 result = deserializer(model, self) 

231 self._deserialized_pointer_cache[pointer.path] = result 

232 if isinstance(result, FrameSet): 

233 self._frame_set_cache[pointer.path] = result 

234 return result 

235 

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

237 try: 

238 return self._frame_set_cache[pointer.path] 

239 except KeyError: 

240 raise AssertionError( 

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

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

243 ) from None 

244 

245 def get_array( 

246 self, 

247 model: ArrayReferenceModel | InlineArrayModel, 

248 *, 

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

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

251 ) -> np.ndarray: 

252 if isinstance(model, InlineArrayModel): 

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

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

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

256 raise ArchiveReadError( 

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

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

259 ) 

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

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

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

263 primitive = self._get_primitive(path) 

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

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

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

267 data = primitive.read_array() 

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

269 

270 def get_table( 

271 self, 

272 model: TableModel, 

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

274 ) -> astropy.table.Table: 

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

276 for column_model in model.columns: 

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

278 data: Any = column_model.data.data 

279 else: 

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

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

282 data, 

283 name=column_model.name, 

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

285 unit=column_model.unit, 

286 description=column_model.description, 

287 meta=column_model.meta, 

288 ) 

289 return result 

290 

291 def get_structured_array( 

292 self, 

293 model: TableModel, 

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

295 ) -> np.ndarray: 

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

297 

298 def _read_opaque_fits_metadata(self) -> None: 

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

300 return 

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

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

303 # concatenated; pad each card defensively so readers tolerate 

304 # files written with shorter widths. 

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

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

307 

308 def get_opaque_metadata(self) -> FitsOpaqueMetadata: 

309 return self._opaque_metadata 

310 

311 @property 

312 def info(self) -> ArchiveInfo: 

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

314 (`.serialization.ArchiveInfo`) 

315 """ 

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

317 

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

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

320 for prefix in _LSST_EXTENSION_PREFIXES: 

321 path = f"{prefix}/JSON" 

322 if self._has_model_path(path): 

323 return path 

324 return None 

325 

326 def _check_format_version(self) -> None: 

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

328 

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

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

331 compatibility. 

332 """ 

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

334 

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

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

337 try: 

338 self._document.get(path) 

339 except KeyError: 

340 return False 

341 return True 

342 

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

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

345 node = self._document.get(path) 

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

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

348 return node 

349 

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

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

352 no primitive at ``path``. 

353 """ 

354 try: 

355 node = self._document.get(path) 

356 except KeyError: 

357 return None 

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

359 

360 

361def read_starlink[T: Any](cls: type[T], path: ResourcePathExpression) -> T: 

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

363 from a schema-less Starlink NDF. 

364 

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

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

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

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

369 function auto-detects a minimal recognised-component set 

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

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

372 logged-and-dropped. 

373 

374 Parameters 

375 ---------- 

376 cls 

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

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

379 can produce. 

380 path 

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

382 

383 Returns 

384 ------- 

385 object 

386 The deserialized ``cls`` instance. 

387 

388 Raises 

389 ------ 

390 ArchiveReadError 

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

392 or no recognised ``DATA_ARRAY`` component. 

393 """ 

394 with NdfInputArchive.open(path) as archive: 

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

396 raise ArchiveReadError( 

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

398 ) 

399 return _read_auto_detect(cls, archive) 

400 

401 

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

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

404 

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

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

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

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

409 """ 

410 f = archive._file 

411 ndf_group = _locate_ndf_root(f) 

412 

413 # DATA_ARRAY is required. 

414 if "DATA_ARRAY" not in ndf_group: 

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

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

417 

418 # VARIANCE / QUALITY are optional. 

419 variance_arr: np.ndarray | None = None 

420 variance_bbox: Any | None = None 

421 if "VARIANCE" in ndf_group: 

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

423 quality_arr: np.ndarray | None = None 

424 quality_bbox: Any | None = None 

425 quality_badbits = 255 

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

427 q = ndf_group["QUALITY"] 

428 quality_badbits = _read_quality_badbits(q) 

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

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

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

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

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

434 quality_arr = _validate_quality_array(quality_arr) 

435 

436 sky_projection: SkyProjection | None = None 

437 if "WCS" in ndf_group: 

438 try: 

439 wcs_group = ndf_group["WCS"] 

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

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

442 wcs_text = _hds.decode_ndf_ast_data(wcs_lines) 

443 ast_obj = astshim.Object.fromString(wcs_text) 

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

445 pixel_frame = GeneralFrame(unit=u.pix) 

446 sky_projection = SkyProjection.from_ast_frame_set( 

447 ast_obj, 

448 pixel_frame, 

449 pixel_bounds=bbox, 

450 ) 

451 except Exception: 

452 _LOG.warning( 

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

454 f.filename, 

455 exc_info=True, 

456 ) 

457 

458 unit = _read_ndf_units(ndf_group) 

459 

460 # Anything unrecognised: warn-and-drop. 

461 recognised = { 

462 "DATA_ARRAY", 

463 "VARIANCE", 

464 "QUALITY", 

465 "WCS", 

466 "MORE", 

467 "TITLE", 

468 "LABEL", 

469 "UNITS", 

470 "HISTORY", 

471 "AXIS", 

472 } 

473 for name in ndf_group: 

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

475 _LOG.warning( 

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

477 ndf_group.name, 

478 name, 

479 ) 

480 

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

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

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

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

485 obj: Any 

486 if cls is Image: 

487 obj = image 

488 elif issubclass(cls, MaskedImage): 

489 if quality_arr is not None: 

490 schema = _make_quality_mask_schema(quality_badbits) 

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

492 else: 

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

494 mask = None 

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

496 obj = cls( 

497 image=image, 

498 mask=mask, 

499 mask_schema=schema if mask is None else None, 

500 variance=variance, 

501 ) 

502 else: 

503 raise ArchiveReadError( 

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

505 ) 

506 obj._opaque_metadata = archive.get_opaque_metadata() 

507 return obj 

508 

509 

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

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

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

513 return None 

514 dataset = ndf_group["UNITS"] 

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

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

517 return None 

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

519 raw = dataset[()] 

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

521 raw = bytes(raw) 

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

523 return None 

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

525 else: 

526 records = _hds.read_char_array(dataset) 

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

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

529 return None 

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

531 try: 

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

533 except ValueError: 

534 continue 

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

536 return None 

537 

538 

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

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

541 badbits = quality_group.get("BADBITS") 

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

543 return 255 

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

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

546 return 255 

547 return int(value[0]) 

548 

549 

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

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

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

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

554 return quality 

555 

556 

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

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

559 planes = [] 

560 for bit in range(8): 

561 mask = 1 << bit 

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

563 if badbits & mask: 

564 description += " Selected by BADBITS." 

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

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

567 

568 

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

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

571 

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

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

574 too. Anything more elaborate raises. 

575 """ 

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

577 if isinstance(root_class, bytes): 

578 root_class = root_class.decode("ascii") 

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

580 return f["/"] 

581 # Maybe a one-level container. 

582 candidates = [] 

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

584 if isinstance(child, h5py.Group): 

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

586 if isinstance(cls_attr, bytes): 

587 cls_attr = cls_attr.decode("ascii") 

588 if cls_attr == "NDF": 

589 candidates.append(name) 

590 if len(candidates) == 1: 

591 return f[candidates[0]] 

592 raise ArchiveReadError( 

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

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

595 ) 

596 

597 

598def _read_data_array_with_bbox( 

599 obj: h5py.Group | h5py.Dataset, 

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

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

602 

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

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

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

606 primitive dataset. 

607 

608 Returns 

609 ------- 

610 array, bbox : tuple 

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

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

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

614 """ 

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

616 # Simple form. 

617 array = _hds.read_array(obj) 

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

619 return array, bbox 

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

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

622 if "ORIGIN" in obj: 

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

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

625 else: 

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

627 return data, bbox 

628 

629 

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

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

632 

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

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

635 trailing whitespace inside JSON string values, since 

636 `read_char_array` strips trailing spaces per record. 

637 """ 

638 records = primitive.read_char_array() 

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

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

641 return records[0] 

642 

643 

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

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

646 

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

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

649 """ 

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

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

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

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