Coverage for core / src / sensorkit / data / fits.py: 97%

290 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2import array 

3import asyncio 

4import io 

5from collections.abc import Buffer, Iterable 

6from typing import Any, Literal, NamedTuple, Protocol, Self, overload, runtime_checkable 

7 

8import numpy as np 

9from astropy.io import fits 

10from astropy.io.fits.card import UNDEFINED 

11from loguru import logger 

12from pydantic import BaseModel, Field 

13 

14from sensorkit.common.keyword import declare_keyword, is_keyword 

15from sensorkit.data.context import Context 

16from sensorkit.data.graph import DataFlow, DataOp 

17 

18# `array.array` typecodes to numpy dtype names, for cameras whose SDK hands back a 

19# flat `array.array` rather than an ndarray. (Platform note: `l`/`L` are at least 

20# 32 bits and platform-dependent in width; the mapping assumes the 64-bit form.) 

21_ARRAY_TYPECODE_TO_DTYPE = { 

22 "b": "int8", 

23 "B": "uint8", 

24 "h": "int16", 

25 "H": "uint16", 

26 "i": "int32", 

27 "I": "uint32", 

28 "l": "int64", 

29 "L": "uint64", 

30 "f": "float32", 

31 "d": "float64", 

32} 

33 

34 

35@declare_keyword 

36class ArrayInfo(BaseModel): 

37 """Metadata describing the shape, dtype, and memory order of a raw array buffer.""" 

38 shape: tuple[int, ...] 

39 dtype: str 

40 order: Literal["C", "F"] = "C" 

41 

42 @overload 

43 @classmethod 

44 def from_array(cls, source: np.ndarray) -> Self: ... 

45 

46 @overload 

47 @classmethod 

48 def from_array( 

49 cls, 

50 source: array.array, 

51 *, 

52 shape: tuple[int, ...], 

53 order: Literal["C", "F"] = "C", 

54 ) -> Self: ... 

55 

56 @classmethod 

57 def from_array( 

58 cls, 

59 source: np.ndarray | array.array, 

60 *, 

61 shape: tuple[int, ...] | None = None, 

62 order: Literal["C", "F"] | None = None, 

63 ) -> Self: 

64 """Build an `ArrayInfo` describing *source*. 

65 

66 For an ndarray, `shape`, `dtype`, and memory `order` are all read from the array 

67 itself, because it already knows its own geometry. 

68 

69 For an `array.array`, the dtype is derived from its typecode and *shape* is required, 

70 because the buffer carries no 2-D geometry. 

71 

72 Args: 

73 source: The source ndarray, or a flat `array.array` buffer. 

74 shape: Image shape as `(rows, cols)`. Required for an `array.array`; rejected for 

75 an ndarray. 

76 order: Memory order the resulting metadata describes. Applies to an `array.array` 

77 (default `"C"`); rejected for an ndarray. 

78 

79 Raises: 

80 TypeError: The source is neither an ndarray nor an `array.array` of a recognized 

81 typecode. 

82 ValueError: A `shape` or `order` was given for an ndarray, or `shape` was omitted 

83 for an `array.array`. 

84 """ 

85 match source: 

86 case np.ndarray(): 

87 if shape is not None or order is not None: 

88 raise ValueError( 

89 "shape and order do not apply to an ndarray; it already carries its " 

90 "own shape and memory order" 

91 ) 

92 

93 derived_order = ( 

94 "F" if source.flags.f_contiguous and not source.flags.c_contiguous else "C" 

95 ) 

96 

97 return cls(shape=source.shape, dtype=str(source.dtype), order=derived_order) 

98 

99 case array.array(): 

100 dtype = _ARRAY_TYPECODE_TO_DTYPE.get(source.typecode) 

101 

102 if dtype is None: 

103 raise TypeError( 

104 f"Cannot infer a dtype from an array.array of typecode {source.typecode!r}" 

105 ) 

106 

107 if shape is None: 

108 raise ValueError( 

109 "shape is required to build ArrayInfo from a flat array.array buffer" 

110 ) 

111 

112 return cls(shape=shape, dtype=dtype, order=order or "C") 

113 

114 case _: 

115 raise TypeError(f"Cannot infer a dtype from a {type(source).__name__} buffer") 

116 

117 @property 

118 def bit_length(self): 

119 """Return the number of bits per element in the array.""" 

120 return np.dtype(self.dtype).itemsize * 8 

121 

122 def ndarray_from_buffer(self, buffer: Buffer, allow_copy: bool = False) -> np.ndarray: 

123 """Interpret *buffer* as an ndarray with this object's shape and dtype.""" 

124 arr = np.frombuffer(buffer, dtype=np.dtype(self.dtype)) 

125 

126 if arr.size != np.prod(self.shape): 

127 raise ValueError(f"Array size {arr.size} does not match shape {self.shape}") 

128 

129 return arr.reshape(self.shape, order=self.order, copy=None if allow_copy else False) 

130 

131 

132class ReshapeArray(DataOp): 

133 """Convert the input buffer into an ndarray.""" 

134 op: Literal["reshape_array"] = "reshape_array" 

135 array: ArrayInfo 

136 

137 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

138 context, buffer = await incoming[0].receive("buffer") 

139 

140 if _info := context.get(ArrayInfo): 

141 # TODO: handle pre-existing ArrayInfo 

142 pass 

143 

144 arr = await asyncio.to_thread(self.array.ndarray_from_buffer, buffer) 

145 context.set(self.array) 

146 await outgoing[0].send(context, arr) 

147 

148 

149# FITS scalar types and 2-tuple (value, comment) form for header cards. 

150type FITSCardScalar = str | int | float | bool | complex | None 

151type FITSCardValue = FITSCardScalar | tuple[FITSCardScalar, str] 

152 

153 

154class FITSCardValueWithComment(NamedTuple): 

155 """Model for parsing a FITS card value with a comment. 

156 

157 This exists only to enable a dictionary input form in user configuration. Instances 

158 will match the bare tuple form `(value, comment)` thus are included in `FITSCardValue`. 

159 """ 

160 

161 value: FITSCardScalar 

162 comment: str 

163 

164 

165# Type for parsing FITS card input. 

166type FITSCardInput = FITSCardValue | FITSCardValueWithComment 

167 

168 

169@declare_keyword 

170class FITSHeader(dict[str, FITSCardValue]): 

171 """A FITS header as a dictionary of keyword-value pairs. 

172 

173 Each value is either a bare scalar card value or a `(value, comment)` tuple carrying 

174 an associated comment. 

175 """ 

176 

177 @classmethod 

178 def from_astropy_header(cls, header: fits.Header) -> Self: 

179 """Build a header from an astropy `Header`, keyed by card keyword. 

180 

181 This is the foreign-data path, taking values as they appear in the file: a valueless 

182 card becomes `None` and a complex-valued card stays a Python `complex`. The result may 

183 therefore hold values outside `FITSCardValue`, so it is served as-is rather than 

184 validated as a keyword. 

185 

186 TODO: Repeated cards (e.g. COMMENT, HISTORY) are not supported here; the last card of 

187 a given keyword wins. Supporting multi-valued cards likely means backing this type with 

188 an ordered list plus an index rather than a plain dict, which would also open the door 

189 to user-facing card reordering. 

190 """ 

191 result = cls() 

192 

193 for card in header.cards: 

194 if not card.keyword: 

195 continue 

196 

197 value: Any = None if card.value is UNDEFINED else card.value 

198 result[card.keyword] = value 

199 

200 return result 

201 

202 def write_to(self, header: fits.Header) -> None: 

203 """Write these cards into an astropy *header*, preserving comments.""" 

204 for keyword, card in self.items(): 

205 header[keyword] = card 

206 

207 def resolve_from_context( 

208 self, 

209 context: Context, 

210 keyword: str, 

211 card: FITSCardValue, 

212 *, 

213 suppress_missing: bool = False, 

214 ) -> None: 

215 """Resolve *card* and set it on this header under *keyword* unless it resolves to None.""" 

216 resolved = resolve_fits_card(card, context, suppress_missing=suppress_missing) 

217 

218 if resolved is not None: 

219 self[keyword] = resolved 

220 

221 

222@runtime_checkable 

223class FITSCardProvider(Protocol): 

224 """Protocol for objects that can provide FITS header cards.""" 

225 

226 def get_fits_cards(self) -> Iterable[tuple[str, FITSCardValue]]: ... 

227 

228 

229# Color filter array pattern, named by the 2x2 filter tile at the image origin. A monochrome 

230# sensor, or one whose driver has already debayered into color planes, has no pattern. 

231type BayerPattern = Literal["RGGB", "BGGR", "GRBG", "GBRG"] 

232 

233 

234@declare_keyword 

235class ImageInfo(BaseModel): 

236 """Structure and pixel encoding of a captured image. 

237 

238 Describes what is needed to interpret the pixels: the buffer they arrive in, how color 

239 is encoded, and where on the sensor they were read from. 

240 

241 Composes: `ArrayInfo` 

242 

243 Attributes: 

244 array: The raw buffer this image arrives in. 

245 bayer: Color filter array pattern, or None for monochrome or already-debayered data. 

246 bayer_offset: `(x, y)` shift of the bayer pattern, which a subframe read from an odd 

247 origin displaces. Meaningless without `bayer`. 

248 binning: `(x, y)` on-sensor binning factors. 

249 origin: `(x, y)` start position of the subframe on the sensor. 

250 pixel_size: `(x, y)` pixel pitch in microns, after binning. 

251 top_down: Whether row 0 is the top of the image. Defaults to False (bottom-up). 

252 """ 

253 

254 array: ArrayInfo 

255 bayer: BayerPattern | None = None 

256 bayer_offset: tuple[int, int] = (0, 0) 

257 binning: tuple[int, int] = (1, 1) 

258 origin: tuple[int, int] = (0, 0) 

259 pixel_size: tuple[float, float] | None = None 

260 top_down: bool = False 

261 

262 @property 

263 def width(self) -> int: 

264 """Return the number of columns in the image.""" 

265 return self.array.shape[-1] 

266 

267 @property 

268 def height(self) -> int: 

269 """Return the number of rows in the image.""" 

270 return self.array.shape[-2] 

271 

272 def get_fits_cards(self) -> Iterable[tuple[str, FITSCardValue]]: 

273 """Yield the FITS cards describing this image. 

274 

275 The structural and scaling keywords (SIMPLE, BITPIX, NAXIS, NAXISn, BSCALE, BZERO) are 

276 owned by the writer: astropy derives them from the physical array itself — including the 

277 half-range BZERO for unsigned data — so they are not yielded here. 

278 """ 

279 yield "XBINNING", (self.binning[0], "Binning factor in X") 

280 yield "YBINNING", (self.binning[1], "Binning factor in Y") 

281 yield "XORGSUBF", (self.origin[0], "Subframe origin in X") 

282 yield "YORGSUBF", (self.origin[1], "Subframe origin in Y") 

283 yield "ROWORDER", ("TOP-DOWN" if self.top_down else "BOTTOM-UP", "Row order of the data") 

284 

285 if self.bayer is not None: 

286 yield "BAYERPAT", (self.bayer, "Color filter array pattern") 

287 yield "XBAYROFF", (self.bayer_offset[0], "Bayer pattern offset in X") 

288 yield "YBAYROFF", (self.bayer_offset[1], "Bayer pattern offset in Y") 

289 

290 if self.pixel_size is not None: 

291 yield "XPIXSZ", (self.pixel_size[0], "Pixel pitch in X [micron]") 

292 yield "YPIXSZ", (self.pixel_size[1], "Pixel pitch in Y [micron]") 

293 

294 def composed_keywords(self) -> Iterable[object]: 

295 yield self.array 

296 

297 

298def resolve_fits_card( 

299 card: FITSCardValue, 

300 context: Context, 

301 *, 

302 suppress_missing: bool = False, 

303) -> FITSCardValue | None: 

304 """Resolve a FITS card's value against *context*. 

305 

306 The card is either a bare value or a `(value, comment)` tuple whose value is resolved 

307 and whose comment is a literal kept verbatim. A string value is resolved with 

308 `Context.resolve`: `=expr` evaluates a Python expression, `f"..."` and text containing 

309 `{...}` interpolate, and anything else is literal text. A non-string scalar (int, float, 

310 or bool, e.g. a YAML number or boolean) is a literal carried through unresolved. 

311 

312 Args: 

313 card: The card to resolve, as a bare scalar value or a `(value, comment)` tuple. 

314 context: The context the value is resolved against. 

315 suppress_missing: When true, a reference to a name absent from the context resolves 

316 to `None` instead of raising `NameError`. 

317 

318 Returns: 

319 The resolved card (a bare value, or a `(value, comment)` tuple when a comment is 

320 present), or `None` when the value resolves to `None`. 

321 """ 

322 if isinstance(card, tuple): 

323 source, comment = card[0], card[1] 

324 else: 

325 source, comment = card, "" 

326 

327 if isinstance(source, str): 

328 value = context.resolve(source, default=None) if suppress_missing else context.resolve(source) 

329 else: 

330 # A non-string scalar (int/float/bool) is a literal value; pass it through unresolved. 

331 value = source 

332 

333 if value is None: 

334 return None 

335 

336 return FITSCardValueWithComment(value, comment) if comment else value 

337 

338 

339class BuildFITSHeader(DataOp): 

340 """Build a FITS header from a context. 

341 

342 This DataOp constructs FITS header cards by resolving card values against the context 

343 and applying various transformations. Card values are resolved with `resolve_fits_card` 

344 (via `Context.resolve`): `=expr` evaluates an expression, `f"..."` and `{...}` forms 

345 interpolate, and anything else is literal text. A card carrying a comment is written 

346 either as `[value, comment]` or as `{value: ..., comment: ...}`. It supports multiple 

347 ways to populate header keywords, applied *in order* as shown below: 

348 

349 - **Include** — `FITSCardProvider` objects looked up from the context by keyword, either 

350 a given set of keywords or every such provider in the context. 

351 - **Rename** — change keyword names. 

352 - **Remove** — delete specific keywords. 

353 - **Define** — set keywords that are not already set. 

354 - **Option** — set keywords only if values resolve to non-`None` (suppresses `NameError`). 

355 - **Mutate** — replace the value of keywords that already exist. 

356 

357 Attributes: 

358 op: Operation type identifier, fixed as `"fits_header"`. 

359 include: Set of keyword keys, or `"all"`. Keywords that implement the `FITSCardProvider` 

360 protocol are queried for FITS cards, and they are included in the header. Invalid 

361 keywords or keywords that do not implement the protocol result in warnings. If "all" 

362 is specified, the inclusion order is defined by the configured import order. 

363 rename: Dictionary mapping old FITS keyword names to new names. 

364 remove: Set of FITS keyword names to remove from the header. 

365 define: Dictionary mapping FITS keyword names to card values resolved against the 

366 context; only keywords not already present are set. 

367 option: Like `define`, but the keyword is added only if the value resolves to a 

368 non-`None` value, and a reference to an absent context name is suppressed. 

369 mutate: Dictionary mapping existing FITS keyword names to card values that replace 

370 their current values (keywords absent from the header are ignored). 

371 """ 

372 

373 op: Literal["fits_header"] = "fits_header" 

374 include: set[str] | Literal["all"] = Field(default_factory=set) 

375 rename: dict[str, str] = Field(default_factory=dict) 

376 remove: set[str] = Field(default_factory=set) 

377 define: dict[str, FITSCardInput] = Field(default_factory=dict) 

378 option: dict[str, FITSCardInput] = Field(default_factory=dict) 

379 mutate: dict[str, FITSCardInput] = Field(default_factory=dict) 

380 

381 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

382 context, buffer = await incoming[0].receive("buffer") 

383 

384 # Respect a pre-existing FITSHeader keyword, otherwise start a fresh one. 

385 header = context.get(FITSHeader) 

386 

387 if header is None: 

388 header = FITSHeader() 

389 

390 self._populate(header, context) 

391 context.set(header) 

392 

393 # Pass the buffer through unchanged; this op only builds the header. 

394 await outgoing[0].send(context, buffer) 

395 

396 def _populate(self, header: FITSHeader, context: Context): 

397 """Apply each population step against *header* in documented order.""" 

398 # Include: add cards from each context-resolved `FITSCardProvider`. 

399 self._apply_includes(header, context) 

400 

401 # Rename: move a keyword's card to a new name. 

402 for old, new in self.rename.items(): 

403 if old in header: 

404 header[new] = header.pop(old) 

405 

406 # Remove: drop keywords entirely. 

407 for keyword in self.remove: 

408 header.pop(keyword, None) 

409 

410 # Define: set keywords that are not already present. 

411 for keyword, card in self.define.items(): 

412 if keyword not in header: 

413 header.resolve_from_context(context, keyword, card) 

414 

415 # Option: set only when the value resolves to non-None; missing names are ignored. 

416 for keyword, card in self.option.items(): 

417 header.resolve_from_context(context, keyword, card, suppress_missing=True) 

418 

419 # Mutate: replace the value of keywords that already exist. 

420 for keyword, card in self.mutate.items(): 

421 if keyword in header: 

422 header.resolve_from_context(context, keyword, card) 

423 

424 def _apply_includes(self, header: FITSHeader, context: Context): 

425 """Add cards from each context-resolved `FITSCardProvider`.""" 

426 match self.include: 

427 case "all": 

428 include = ( 

429 key for key, value in context.items() if isinstance(value, FITSCardProvider) 

430 ) 

431 case _: 

432 include = self.include 

433 

434 for key in include: 

435 if not is_keyword(key): 

436 logger.warning(f"fits_header include '{key}' is not a declared keyword") 

437 continue 

438 

439 provider = context.get(key) 

440 

441 # Includes are optional and will not warn if a valid keyword is not present. 

442 if provider is None: 

443 continue 

444 

445 if not isinstance(provider, FITSCardProvider): 

446 logger.warning(f"fits_header include '{key}' is not a FITSCardProvider") 

447 continue 

448 

449 for kw, card in provider.get_fits_cards(): 

450 header[kw] = card 

451 

452 

453class ArrayToFITS(DataOp): 

454 """Converts an input array to FITS format.""" 

455 op: Literal["array_to_fits"] = "array_to_fits" 

456 header: dict[str, FITSCardInput] = Field(default_factory=dict) 

457 

458 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

459 context, buffer = await incoming[0].receive("buffer") 

460 

461 # This op requires the ArrayInfo keyword to be present. 

462 array = context[ArrayInfo] 

463 

464 # Reshape the input buffer. This should always be zero-copy in cases where ReshapeArray 

465 # preceded this op, because if memory reallocation was required, it will have already been 

466 # done. Note in that case the buffer is actually already an ndarray, but we cannot assume 

467 # that. 

468 image_ndarray: np.ndarray = await asyncio.to_thread(array.ndarray_from_buffer, buffer) 

469 

470 # Here we stipulate that this is a 2D image. FITS data cube support could be added here. 

471 if image_ndarray.ndim != 2: 

472 raise RuntimeError("array_to_fits only supports 2D arrays") 

473 

474 # Set the ImageInfo context if it is not already present. 

475 if context.get(ImageInfo) is None: 

476 context.set(ImageInfo(array=array)) 

477 

478 # Respect a pre-existing FITSHeader keyword, otherwise start a fresh one, then add this 

479 # op's own keywords by evaluating the input patterns against the context. 

480 fits_header = context.get(FITSHeader) 

481 

482 if fits_header is None: 

483 fits_header = FITSHeader() 

484 

485 for kw, card in self.header.items(): 

486 fits_header.resolve_from_context(context, kw, card) 

487 

488 context.set(fits_header) 

489 

490 # Build primary HDU. astropy populates SIMPLE, NAXIS, and NAXIS{n} from the 

491 # array shape (NAXIS1 = fastest-varying axis = number of columns). 

492 primary_hdu = fits.PrimaryHDU(image_ndarray) 

493 

494 # The accumulated FITSHeader is the source of truth for the written header. 

495 fits_header.write_to(primary_hdu.header) 

496 

497 # Build the output FITS bytes and send it along the graph. 

498 hdul = fits.HDUList([primary_hdu]) 

499 bio = io.BytesIO() 

500 hdul.writeto(bio) 

501 

502 await outgoing[0].send(context, bio.getvalue()) 

503 

504 

505class CompressFITS(DataOp): 

506 """Tile-compress a FITS image buffer using the FITS tiled-image convention. 

507 

508 Wraps the primary image HDU in a CompImageHDU. If the image data exceeds 

509 32 bits per pixel (e.g. int64, float64), compression falls back to GZIP_1 

510 (lossless for all dtypes) and logs a warning, since RICE_1 only supports 

511 ≤32-bit values. 

512 """ 

513 op: Literal["compress_fits"] = "compress_fits" 

514 algorithm: Literal["RICE_1", "GZIP_1", "GZIP_2", "HCOMPRESS_1"] = Field( 

515 "RICE_1", 

516 description="Tile compression algorithm. RICE_1 is lossless for ≤32-bit integer data.", 

517 ) 

518 quantize_level: float = Field( 

519 0.0, 

520 description=( 

521 "Floating-point quantization level. 0 disables quantization (lossless). " 

522 "Values > 0 enable lossy quantization for floating-point data." 

523 ), 

524 ) 

525 

526 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

527 context, buffer = await incoming[0].receive("buffer") 

528 compressed = await asyncio.to_thread(self._compress, buffer) 

529 await outgoing[0].send(context, compressed) 

530 

531 def _compress(self, buffer: bytes) -> bytes: 

532 with fits.open(io.BytesIO(buffer)) as hdul: 

533 data = hdul[0].data 

534 

535 # CompImageHDU derives the compressed HDU's structure from data, so the 

536 # source header must contribute observational cards only. Without strip(), 

537 # astropy carries the primary array's SIMPLE over as ZSIMPLE and the 

538 # reconstructed image header declares itself a primary array rather than 

539 # an IMAGE extension. 

540 header = hdul[0].header.copy() 

541 header.strip() 

542 

543 algorithm = self.algorithm 

544 

545 # Safety check: RICE_1 and HCOMPRESS_1 only support ≤32-bit data. 

546 if data.dtype.itemsize > 4 and algorithm in ("RICE_1", "HCOMPRESS_1"): 

547 logger.warning( 

548 f"compress_fits: image dtype {data.dtype} exceeds 32 bits; " 

549 f"falling back to GZIP_1 for lossless compression." 

550 ) 

551 algorithm = "GZIP_1" 

552 

553 compressed_hdu = fits.CompImageHDU( 

554 data=data, 

555 header=header, 

556 compression_type=algorithm, 

557 quantize_level=self.quantize_level, 

558 ) 

559 new_hdul = fits.HDUList([fits.PrimaryHDU(), compressed_hdu]) 

560 

561 bio = io.BytesIO() 

562 new_hdul.writeto(bio) 

563 return bio.getvalue() 

564 

565 

566class ContextFromFITS(DataOp): 

567 """DataGraph node that populates the context with values from a FITS header.""" 

568 op: Literal["context_from_fits"] = "context_from_fits" 

569 keyword_map: dict = Field(default_factory=dict) 

570 

571 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

572 from astropy.io import fits 

573 

574 # Receive the FITS data as a buffer 

575 context, buffer = await incoming[0].receive("buffer") 

576 

577 # Read FITS header from buffer 

578 with io.BytesIO(buffer) as bio: 

579 hdul = fits.open(bio) 

580 header = hdul[0].header 

581 

582 # Map FITS keywords to context based on keyword map 

583 for meta_key, fits_key in self.keyword_map.items(): 

584 if fits_key in header: 

585 context.set_value(meta_key, header[fits_key]) 

586 

587 # Pass through the original buffer 

588 await outgoing[0].send(context, buffer) 

589 

590 

591 

592 

593@declare_keyword 

594class DarkInfo(BaseModel): 

595 """Information about dark frame subtraction.""" 

596 applied: bool = False 

597 dark_path: str | None = None 

598 dark_exposure: float | None = None 

599 image_exposure: float | None = None 

600 

601 

602class ApplyDark(DataOp): 

603 """Subtract a dark frame from the input image, matching by exposure time. 

604 

605 Scans a directory for master dark FITS files and selects the one with the 

606 closest exposure time to the input image. Dark frames should have EXPTIME 

607 or EXPOSURE in their headers. 

608 """ 

609 op: Literal["apply_dark"] = "apply_dark" 

610 dark_directory: str = Field(..., description="Directory containing master dark FITS files") 

611 pattern: str = Field("*.fits", description="Glob pattern for dark frame files") 

612 

613 _dark_library: dict[float, tuple[str, np.ndarray]] | None = None 

614 

615 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

616 context, buffer = await incoming[0].receive("buffer") 

617 

618 # Load dark library on first use 

619 if self._dark_library is None: 

620 self._dark_library = await asyncio.to_thread(self._load_dark_library) 

621 

622 # Apply dark subtraction 

623 result_buffer, dark_info = await asyncio.to_thread( 

624 self._apply_dark, buffer 

625 ) 

626 

627 context.set(dark_info) 

628 await outgoing[0].send(context, result_buffer) 

629 

630 def _load_dark_library(self) -> dict[float, tuple[str, np.ndarray]]: 

631 """Load all dark frames from directory, indexed by exposure time.""" 

632 import glob 

633 import os 

634 

635 library: dict[float, tuple[str, np.ndarray]] = {} 

636 pattern_path = os.path.join(self.dark_directory, self.pattern) 

637 

638 for filepath in glob.glob(pattern_path): 

639 try: 

640 with fits.open(filepath) as hdul: 

641 header = hdul[0].header 

642 exposure = header.get('EXPTIME') or header.get('EXPOSURE') 

643 if exposure is not None: 

644 data = hdul[0].data.astype(np.float64) 

645 library[float(exposure)] = (filepath, data) 

646 except Exception: 

647 continue 

648 

649 return library 

650 

651 def _find_closest_dark(self, target_exposure: float) -> tuple[float, str, np.ndarray] | None: 

652 """Find the dark frame with closest exposure time.""" 

653 if not self._dark_library: 

654 return None 

655 

656 exposures = list(self._dark_library.keys()) 

657 closest_exp = min(exposures, key=lambda x: abs(x - target_exposure)) 

658 filepath, data = self._dark_library[closest_exp] 

659 return closest_exp, filepath, data 

660 

661 def _apply_dark(self, buffer: bytes) -> tuple[bytes, DarkInfo]: 

662 """Subtract matching dark frame from image data.""" 

663 with io.BytesIO(buffer) as bio_in: 

664 with fits.open(bio_in) as hdul: 

665 image_data = hdul[0].data.astype(np.float64) 

666 header = hdul[0].header.copy() 

667 

668 # Get image exposure time 

669 img_exposure = header.get('EXPTIME') or header.get('EXPOSURE') 

670 

671 if img_exposure is None: 

672 # No exposure info, pass through unchanged 

673 return buffer, DarkInfo(applied=False, image_exposure=None) 

674 

675 # Find closest matching dark 

676 match = self._find_closest_dark(float(img_exposure)) 

677 if match is None: 

678 # No darks available, pass through unchanged 

679 return buffer, DarkInfo(applied=False, image_exposure=float(img_exposure)) 

680 

681 dark_exposure, dark_path, dark_data = match 

682 

683 # Subtract dark frame 

684 calibrated = image_data - dark_data 

685 

686 # Update header 

687 header['DARKFILE'] = (dark_path, 'Dark frame applied') 

688 header['DARKEXP'] = (dark_exposure, 'Dark frame exposure time') 

689 

690 # Create output FITS 

691 primary_hdu = fits.PrimaryHDU(calibrated.astype(image_data.dtype), header=header) 

692 hdul_out = fits.HDUList([primary_hdu]) 

693 

694 bio_out = io.BytesIO() 

695 hdul_out.writeto(bio_out) 

696 

697 dark_info = DarkInfo( 

698 applied=True, 

699 dark_path=dark_path, 

700 dark_exposure=dark_exposure, 

701 image_exposure=float(img_exposure), 

702 ) 

703 

704 return bio_out.getvalue(), dark_info