Coverage for python/lsst/summit/utils/guiders/reading.py: 23%

362 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-25 01:54 -0800

1# This file is part of summit_utils. 

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# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21from __future__ import annotations 

22 

23import logging 

24from typing import TYPE_CHECKING, Any 

25 

26__all__ = [ 

27 "GuiderReader", 

28 "convertRawStampsToView", 

29 "GuiderData", 

30] 

31 

32from dataclasses import dataclass 

33from functools import cached_property 

34 

35import matplotlib.pyplot as plt 

36import numpy as np 

37from astropy.time import Time 

38from numpy.typing import NDArray 

39 

40from lsst.afw import cameraGeom 

41from lsst.afw.image import ExposureF, ImageF, MaskedImageF, VisitInfo 

42from lsst.daf.butler import Butler, DatasetNotFoundError 

43from lsst.meas.algorithms.stamps import Stamp, Stamps 

44from lsst.obs.lsst import LsstCam 

45from lsst.utils.plotting.figures import make_figure 

46 

47from .plotting import GuiderPlotter, labelDetector 

48from .transformation import ( 

49 getCamRotAngle, 

50 makeCcdToDvcsTransform, 

51 makeInitGuiderWcs, 

52 makeRoiBbox, 

53 roiImageToDvcs, 

54) 

55 

56if TYPE_CHECKING: 

57 from lsst.afw.cameraGeom import Camera, Detector 

58 from lsst.geom import SkyWcs 

59 

60 

61def make_subplot(nrows=1, ncols=1, **fig_kwargs): 

62 """Return (fig, axs) using LSST's make_figure.""" 

63 fig = make_figure(**fig_kwargs) 

64 axs = fig.subplots(nrows=nrows, ncols=ncols, squeeze=True) 

65 return fig, axs 

66 

67 

68@dataclass 

69class GuiderData: 

70 """ 

71 LSST guider data container. 

72 

73 Holds raw guider Stamps, WCS objects, and exposure metadata. Provides 

74 cached properties for timestamps, header information, and convenience 

75 (dict-like) access. 

76 

77 Parameters 

78 ---------- 

79 seqNum : `int` 

80 Sequence number. 

81 dayObs : `int` 

82 Day of observation (YYYYMMDD). 

83 guiderNameMap : `dict[str, int]` 

84 Detector name to detector number map. 

85 rawStampsMap : `dict[str, Stamps]` 

86 Detector name to raw Stamps map. 

87 wcsMap : `dict[str, SkyWcs]` 

88 Detector name to SkyWcs map. 

89 camRotAngle : `float` 

90 Camera rotation angle in degrees. 

91 isMedianSubtracted : `bool`, optional 

92 If True, the raw stamps have been subtracted. 

93 view : `str`, optional 

94 Output view: 'dvcs', 'ccd', or 'roi'. 

95 

96 Cached properties 

97 ----------------- 

98 guiderNames : sorted detector names. 

99 detNameMax : detector with most stamps. 

100 metadata : metadata dict for detNameMax. 

101 header : exposure-level header summary. 

102 expid : DAYOBS * 100000 + SEQNUM. 

103 timestamps : masked astropy Time array (TAI scale). 

104 stampsMap : Stamps for all detectors in requested view. 

105 roiAmpNames : detector → amplifier name map. 

106 guiderFrequency : stamp cadence in Hz. 

107 

108 Public methods 

109 -------------- 

110 printHeaderInfo() : print header summary. 

111 getWcs(det) : return SkyWcs for detector. 

112 getStampArrayCoadd(det) : median stack of stamps. 

113 getGuiderAmpName(det) : ROI amplifier name. 

114 getGuiderDetNum(det) : detector number. 

115 plotMosaic(...) : plot all detectors as a mosaic. 

116 plotStamp(...) : plot one stamp from a single detector. 

117 makeGif(...) : create a GIF animation of guider frames. 

118 

119 Iteration and indexing 

120 ---------------------- 

121 for det in guiderData # iterates detector names 

122 guiderData.items() # yields (det, Stamps) 

123 guiderData['R44_SG0'] # full Stamps object 

124 guiderData['R44_SG0', 3] # stamp ndarray 

125 guiderData[3] # stamp from detNameMax 

126 """ 

127 

128 seqNum: int 

129 dayObs: int 

130 guiderNameMap: dict[str, int] 

131 rawStampsMap: dict[str, Stamps] 

132 wcsMap: dict[str, "SkyWcs"] 

133 camRotAngle: float 

134 isMedianSubtracted: bool = False 

135 view: str = "dvcs" # view type, either 'dvcs' or 'ccd' or 'roi' 

136 

137 def __repr__(self) -> str: 

138 """ 

139 Return a compact one-line representation. 

140 

141 Returns 

142 ------- 

143 reprStr : `str` 

144 String summary with exposure id, number of stamps, view, and guider 

145 names. 

146 """ 

147 return ( 

148 f"GuiderData(seqNum={self.seqNum}, " 

149 f"dayObs={self.dayObs}, " 

150 f"nStamps={len(self)}, view='{self.view}', " 

151 f"guiders={self.guiderNames})" 

152 ) 

153 

154 def __len__(self) -> int: 

155 """ 

156 Return number of stamps for the detector with the maximum count. 

157 

158 Returns 

159 ------- 

160 nStamps : `int` 

161 Number of stamps (time samples) in the exposure for the primary 

162 detector. 

163 """ 

164 return len(self.rawStampsMap[self.detNameMax]) 

165 

166 @cached_property 

167 def expid(self) -> int: 

168 """ 

169 Exposure identifier. 

170 

171 Returns 

172 ------- 

173 expid : `int` 

174 The exposure id. 

175 """ 

176 return self.dayObs * 100000 + self.seqNum 

177 

178 @cached_property 

179 def guiderNames(self) -> list[str]: 

180 """ 

181 Names of the guider detectors. 

182 

183 Returns 

184 ------- 

185 guiderNames : `list[str]` 

186 Sorted list of guider detector names. 

187 """ 

188 return sorted(list(self.guiderNameMap.keys())) 

189 

190 @cached_property 

191 def guiderIds(self) -> list[int]: 

192 """ 

193 IDs of the guider detectors. 

194 

195 Returns 

196 ------- 

197 guiderIds : `list[int]` 

198 Sorted list of guider detector numeric IDs. 

199 """ 

200 return sorted(list(self.guiderNameMap.values())) 

201 

202 @cached_property 

203 def detNameMax(self) -> str: 

204 """ 

205 Detector name with the maximum number of stamps. 

206 

207 Returns 

208 ------- 

209 detName : `str` 

210 Detector name possessing the most stamps. 

211 """ 

212 return max(self.rawStampsMap, key=lambda k: len(self.rawStampsMap[k])) 

213 

214 @cached_property 

215 def metadata(self) -> dict: 

216 """ 

217 Metadata for the detector with the most stamps. 

218 

219 Returns 

220 ------- 

221 metadata : `dict` 

222 Copy of the detector-level metadata dictionary. 

223 """ 

224 return self.rawStampsMap[self.detNameMax].metadata.toDict() 

225 

226 @cached_property 

227 def header(self) -> dict[str, str | float | None]: 

228 """ 

229 Dictionary of header metadata for this GuiderData. 

230 Fields: n_stamps, freq, expid, filter, cam_rot_angle, start_time, 

231 roi_cols, roi_rows, shuttime, az_start, el_start, az_end, el_end, 

232 seeing. 

233 

234 Returns 

235 ------- 

236 header : `dict[str, str | float | None]` 

237 The headers. 

238 """ 

239 md = self.metadata 

240 info: dict[str, str | float | None] = {} 

241 info["n_stamps"] = len(self) 

242 info["freq"] = self.guiderFrequency 

243 # Ensure DAYOBS and SEQNUM are ints before math, keep ≤79 chars 

244 info["expid"] = self.expid 

245 info["filter"] = md.get("FILTBAND", "Unknown") 

246 info["cam_rot_angle"] = self.camRotAngle 

247 info["start_time"] = md.get("GDSSTART", None) 

248 info["roi_cols"] = int(md.get("ROICOLS", 0)) 

249 info["roi_rows"] = int(md.get("ROIROWS", 0)) 

250 info["shuttime"] = metadata_to_float(md, "SHUTTIME", np.nan) 

251 info["guider_duration"] = self.guiderDurationSec 

252 info["az_start"] = metadata_to_float(md, "AZSTART", np.nan) 

253 info["el_start"] = metadata_to_float(md, "ELSTART", np.nan) 

254 info["az_end"] = metadata_to_float(md, "AZEND", np.nan) 

255 info["el_end"] = metadata_to_float(md, "ELEND", np.nan) 

256 return info 

257 

258 def printHeaderInfo(self) -> None: 

259 """Print a concise summary of key header fields.""" 

260 print(f"Data Id: {self.expid}, filter-band: {self.header['filter']}") 

261 print(f"ROI Shape (row, col): {self.header['roi_rows']}, " f"{self.header['roi_cols']}") 

262 print(f"With nStamps {len(self)}" f" at {self.guiderFrequency} Hz") 

263 print( 

264 f"Acq. Start Time: {self.header['start_time']} \n" 

265 f"with readout duration: {self.header['guider_duration']:.2f} sec" 

266 ) 

267 

268 @cached_property 

269 def weather(self) -> WeatherInfo: 

270 """ 

271 Weather information from metadata. 

272 

273 Returns 

274 ------- 

275 weather : `WeatherInfo` 

276 Parsed weather information. 

277 """ 

278 return WeatherInfo.fromMetadata(self.metadata) 

279 

280 @cached_property 

281 def timestampMap(self) -> dict[str, Time]: 

282 """ 

283 Aligned timestamp arrays for all detectors. 

284 

285 Returns 

286 ------- 

287 timestampMap : `dict[str, Time]` 

288 Mapping from detector name to masked Time array (TAI scale). 

289 """ 

290 nStamps = len(self.rawStampsMap[self.detNameMax]) 

291 timestampMap: dict[str, Time] = {} 

292 for detName, stamps in self.rawStampsMap.items(): 

293 timestampMap[detName] = standardizeGuiderTimestamps(stamps, nStamps) 

294 return timestampMap 

295 

296 @cached_property 

297 def guiderFrequency(self) -> float: 

298 """ 

299 Guider stamp cadence. 

300 

301 Returns 

302 ------- 

303 frequency : `float` 

304 Median stamp frequency in Hz. 

305 """ 

306 timestamps = self.timestampMap[self.detNameMax] 

307 # extract only the unmasked MJD values 

308 jd = timestamps[~timestamps.mask].jd 

309 period_sec = np.median(np.diff(jd)) * 86400.0 

310 return float(1.0 / period_sec) 

311 

312 @cached_property 

313 def guiderDurationSec(self) -> float: 

314 """ 

315 Total guider duration. 

316 

317 Returns 

318 ------- 

319 durationSec : `float` 

320 Total elapsed guider duration in seconds (including final period). 

321 """ 

322 timestamps = self.timestampMap[self.detNameMax] 

323 jd = timestamps[~timestamps.mask].jd 

324 duration_sec = (jd[-1] - jd[0]) * 86400.0 + 1 / self.guiderFrequency 

325 return float(duration_sec) 

326 

327 @cached_property 

328 def stampsMap(self) -> dict[str, Stamps]: 

329 """ 

330 Stamps converted to requested view. 

331 

332 Returns 

333 ------- 

334 stampsMap : `dict[str, Stamps]` 

335 Mapping of detector name to converted Stamps object. 

336 """ 

337 result: dict[str, Stamps] = {} 

338 if self.view == "roi": 

339 return self.rawStampsMap 

340 else: 

341 for detName, rawStamps in self.rawStampsMap.items(): 

342 result[detName] = convertRawStampsToView( 

343 rawStamps, 

344 detName, 

345 len(self), 

346 view=self.view, 

347 ) 

348 return result 

349 

350 @cached_property 

351 def roiAmpNames(self) -> dict[str, str]: 

352 """ 

353 Amplifier names used in the ROI. 

354 

355 Returns 

356 ------- 

357 ampNames : `dict[str, str]` 

358 Mapping from detector name to amplifier name active in ROI. 

359 """ 

360 ampNames: dict[str, str] = {} 

361 for detName in self.rawStampsMap.keys(): 

362 md = self.rawStampsMap[detName].metadata.toDict() 

363 segment = md["ROISEG"] 

364 ampName = "C" + segment[7:] 

365 ampNames[detName] = ampName 

366 return ampNames 

367 

368 def getStampArrayCoadd(self, detName: str) -> np.ndarray: 

369 """ 

370 Get the median-stacked stamp across time for the specified detector. 

371 

372 The stack is computed across the time axis after optional median-row 

373 bias removal (controlled by ``self.isMedianSubtracted``). 

374 

375 Parameters 

376 ---------- 

377 detName : `str` 

378 Guider detector name. 

379 

380 Returns 

381 ------- 

382 coadd : `np.ndarray` 

383 Median of all stamp arrays (time axis collapsed). 

384 """ 

385 if detName not in self.stampsMap: 

386 raise KeyError(f"{detName!r} not present in stampsMap") 

387 

388 stamps = self.stampsMap[detName] 

389 if len(stamps) == 0: 

390 raise ValueError(f"No stamps found for detector {detName!r}") 

391 

392 # Collect arrays, with optional bias subtraction 

393 arrList = [self[detName, idx] for idx in range(len(stamps))] 

394 stack = np.nanmedian(arrList, axis=0) 

395 return stack 

396 

397 def getGuiderAmpName(self, detName: str) -> str: 

398 """ 

399 Return amplifier name for a guider detector. 

400 

401 Parameters 

402 ---------- 

403 detName : `str` 

404 Guider detector name. 

405 

406 Returns 

407 ------- 

408 ampName : `str` 

409 Amplifier name string. 

410 """ 

411 if detName not in self.roiAmpNames: 

412 raise ValueError(f"Detector {detName} not found in roiAmpNames.") 

413 return self.roiAmpNames[detName] 

414 

415 def getGuiderDetNum(self, detName: str) -> int: 

416 """ 

417 Return detector number for a guider detector name. 

418 

419 Parameters 

420 ---------- 

421 detName : `str` 

422 Guider detector name. 

423 

424 Returns 

425 ------- 

426 detNum : `int` 

427 The numeric detector id. 

428 """ 

429 if detName not in self.guiderNameMap: 

430 raise ValueError(f"Detector {detName} not found in guiderNameMap.") 

431 return self.guiderNameMap[detName] 

432 

433 def getWcs(self, detName: str) -> SkyWcs: 

434 """ 

435 Return the wcs for a guider detector. 

436 

437 Parameters 

438 ---------- 

439 detName : `str` 

440 Guider detector name. 

441 

442 Returns 

443 ------- 

444 wcs : `SkyWcs` 

445 SkyWcs for the detector. 

446 """ 

447 try: 

448 return self.wcsMap[detName] 

449 except KeyError as e: 

450 raise KeyError(f"{detName!r} not found in wcsMap") from e 

451 

452 @cached_property 

453 def obsTime(self) -> Time: 

454 """ 

455 Observation start time in TAI. 

456 

457 Returns 

458 ------- 

459 obsTime : `Time` 

460 Start time parsed from metadata (TAI scale). 

461 """ 

462 gdstart = self.header["start_time"] 

463 return Time(gdstart, format="isot", scale="tai") 

464 

465 @cached_property 

466 def missingStampsMap(self) -> dict[str, int]: 

467 """ 

468 Number of missing stamps in the primary detector. 

469 

470 Returns 

471 ------- 

472 nMissing : dict[str, int] 

473 Count of missing stamps (NaN timestamps) in detNameMax. 

474 """ 

475 nMissing: dict[str, int] = {} 

476 for detName, timestamps in self.timestampMap.items(): 

477 nMissing[detName] = np.sum(timestamps.mask) 

478 return nMissing 

479 

480 @cached_property 

481 def nMissingStamps(self) -> int: 

482 """ 

483 Total number of missing stamps across all detectors. 

484 

485 Returns 

486 ------- 

487 nMissing : int 

488 Total count of missing stamps across all detectors. 

489 """ 

490 return sum(self.missingStampsMap.values()) 

491 

492 # Iterable / dict-like helpers 

493 def __iter__(self): 

494 """Iterate over detector names in guiderNames order.""" 

495 return iter(self.guiderNames) 

496 

497 def items(self): 

498 """Yield (detName, stamps) pairs like dict.items().""" 

499 for det in self.guiderNames: 

500 yield det, self.stampsMap[det] 

501 

502 def keys(self): 

503 """Iterate over detector names (dict-like .keys()).""" 

504 return iter(self.guiderNames) 

505 

506 def values(self): 

507 """Iterate over Stamps objects in guiderNames order.""" 

508 for det in self.guiderNames: 

509 yield self.stampsMap[det] 

510 

511 def __getitem__(self, key): 

512 """ 

513 Direct stamp access helper. 

514 

515 Example usage: 

516 gd["R44_SG0"] -> full `Stamps` object 

517 gd["R44_SG0", 3] -> stamp #3 ndarray 

518 gd[3] -> stamp #3 from `detNameMax` 

519 

520 Parameters 

521 ---------- 

522 key : `str | int | slice | tuple` 

523 Access pattern: 

524 - 'DET' -> Stamps 

525 - ('DET', i) -> single stamp ndarray 

526 - i (int) -> stamp i from primary detector 

527 - ('DET', slice) -> list of ndarrays 

528 

529 Returns 

530 ------- 

531 result : `Stamps | np.ndarray | list[np.ndarray]` 

532 Retrieved object according to key specification. 

533 """ 

534 # Single detector name -> full Stamps 

535 if isinstance(key, str): 

536 return self.stampsMap[key] 

537 

538 # idx only -> assume detNameMax 

539 if isinstance(key, (int, slice)): 

540 key = (self.detNameMax, key) 

541 

542 if isinstance(key, tuple): 

543 if len(key) != 2: 

544 raise TypeError("Key must be (detName, idx) or (idx,)") 

545 detName, idx = key[0], key[1] 

546 

547 if detName not in self.stampsMap: 

548 raise KeyError(f"{detName!r} not found in stampsMap") 

549 

550 stamps = self.stampsMap[detName] 

551 # slice returns list of ndarrays 

552 if isinstance(idx, slice): 

553 arrays = [self._processStampArray(stamp, detName) for stamp in stamps[idx]] 

554 return arrays 

555 # int -> single ndarray 

556 return self._processStampArray(stamps[idx], detName) 

557 

558 raise TypeError("Invalid key type for GuiderData indexing.") 

559 

560 def _processStampArray(self, stamp: Stamp, detName: str) -> np.ndarray: 

561 """ 

562 Convert a Stamp to an ndarray with optional median-row subtraction. 

563 

564 Parameters 

565 ---------- 

566 stamp : `Stamp` 

567 Input stamp object. 

568 detName : `str` 

569 Detector name (for row-axis logic). 

570 

571 Returns 

572 ------- 

573 array : `np.ndarray` 

574 2D image array (bias-corrected if configured). 

575 """ 

576 arr = stamp.stamp_im.image.array 

577 return arr 

578 

579 @cached_property 

580 def plotter(self): 

581 return GuiderPlotter(self) 

582 

583 def plotStamp( 

584 self, detName: str, stampNum: int, plo: float = 90, phi: float = 99.5, figsize=(10, 8) 

585 ) -> plt.Figure: 

586 """ 

587 Plot a single guider stamp. 

588 

589 Parameters 

590 ---------- 

591 detName : `str` 

592 Detector name. 

593 stampNum : `int` 

594 Stamp index. 

595 plo : `float`, optional 

596 Lower percentile stretch. 

597 phi : `float`, optional 

598 Upper percentile stretch. 

599 figsize : `tuple`, optional 

600 Figure size. 

601 

602 Returns 

603 ------- 

604 fig : `plt.Figure` 

605 The resulting figure. 

606 """ 

607 fig, axs = make_subplot(nrows=1, ncols=1, figsize=figsize) 

608 img = self[detName, stampNum] if stampNum > 0 else self.getStampArrayCoadd(detName) 

609 _ = axs.imshow( 

610 img, 

611 origin="lower", 

612 vmin=np.nanpercentile(img, plo), 

613 vmax=np.nanpercentile(img, phi), 

614 cmap="Greys", 

615 ) 

616 _ = labelDetector(axs, detName) 

617 axs.set_xlabel("X (pixels)", fontsize=11) 

618 axs.set_ylabel("Y (pixels)", fontsize=11) 

619 axs.set_title(f"{self.expid}") 

620 return fig 

621 

622 

623class GuiderReader: 

624 """ 

625 Utility to fetch LSST guider data via Butler. 

626 

627 Example: 

628 from lsst.summit.utils.guiders.reading import GuiderReader 

629 from lsst.daf.butler import Butler 

630 butler = Butler("embargo", collections="LSSTCam/raw/guider") 

631 

632 seqNum, dayObs = 461, 20250425 

633 reader = GuiderReader(butler, view="dvcs") 

634 guiderData = reader.get(dayObs=dayObs, seqNum=seqNum) 

635 """ 

636 

637 def __init__(self, butler: Butler, view: str = "dvcs"): 

638 self.butler = butler 

639 self.view = view 

640 self.log = logging.getLogger(__name__) 

641 # Define camera objects 

642 self.camera = LsstCam.getCamera() 

643 

644 # Build guiderNameMap 

645 self.guiderNameMap: dict[str, int] = {} 

646 for detector in self.camera: 

647 if detector.getType() == cameraGeom.DetectorType.GUIDER: 

648 detName = detector.getName() 

649 self.guiderNameMap[detName] = detector.getId() 

650 

651 self.guiderDetNames = list(self.guiderNameMap.keys()) 

652 self.nGuiders = len(self.guiderNameMap) 

653 

654 def get( 

655 self, dayObs: int, seqNum: int, doSubtractMedian: bool = True, scienceDetNum: int = 94 

656 ) -> GuiderData: 

657 """ 

658 Retrieve guider data for a given dayObs / seqNum. 

659 

660 Parameters 

661 ---------- 

662 dayObs : `int` 

663 Day of observation in YYYYMMDD format. 

664 seqNum : `int` 

665 Sequence number. 

666 doSubtractMedian : `bool`, optional 

667 If True, subtract median row bias from each stamp upon access. 

668 scienceDetNum : `int`, optional 

669 Science detector number for WCS reference. 

670 

671 Returns 

672 ------- 

673 guiderData : `GuiderData` 

674 Assembled guider data object. 

675 """ 

676 # check if the guider name is swaped (dayObs < 20250509) 

677 # modifies self.guiderNameMap in place if necessary 

678 self.applyGuiderNameSwapIfNeeded(dayObs) 

679 

680 # 1. Get guider_raw stamps all guiders 

681 rawStampsDict = self.getGuiderRawStamps(dayObs, seqNum) 

682 

683 # set the number of stamps 

684 # determine the maximum number of stamps among all guiders 

685 nStampsList = [len(stamps) for stamps in rawStampsDict.values()] 

686 self.nStamps = max(nStampsList) 

687 

688 if self.nStamps <= 1: 

689 raise RuntimeError( 

690 f"Only {self.nStamps} stamps found for dayObs {dayObs}, seqNum {seqNum}. " 

691 "At least 2 stamps are required to create GuiderData." 

692 ) 

693 

694 # Create a visitinfo dataId to get the WCS and camera rotation angle 

695 visitInfo = getVisitInfo(self.butler, dayObs, seqNum, scienceDetNum) 

696 

697 # 2. Get WCS map for all guiders 

698 wcsMapDict = makeInitGuiderWcs(self.camera, visitInfo) 

699 

700 # 3. Get camera rotation angle 

701 camRotAngle = getCamRotAngle(visitInfo) 

702 

703 # 4. subtract median row bias if requested 

704 if doSubtractMedian: 

705 rawStampsDict = self.subtractMedianRowBias(rawStampsDict) 

706 

707 # 4. Pack everything into a GuiderData object 

708 guiderData = GuiderData( 

709 seqNum=seqNum, 

710 dayObs=dayObs, 

711 view=self.view, 

712 rawStampsMap=rawStampsDict, 

713 guiderNameMap=self.guiderNameMap, 

714 wcsMap=wcsMapDict, 

715 camRotAngle=camRotAngle, 

716 isMedianSubtracted=doSubtractMedian, 

717 ) 

718 return guiderData 

719 

720 def getGuiderRawStamps(self, dayObs: int, seqNum: int) -> dict[str, Stamps]: 

721 """ 

722 Fetch raw guider Stamps for all detectors. 

723 

724 Parameters 

725 ---------- 

726 dayObs : `int` 

727 Observation day (YYYYMMDD). 

728 seqNum : `int` 

729 Sequence number. 

730 

731 Returns 

732 ------- 

733 rawStamps : `dict[str, Stamps]` 

734 Mapping from detector name to raw Stamps object. 

735 """ 

736 rawStamps: dict[str, Stamps] = {} 

737 for detName, detNum in self.guiderNameMap.items(): 

738 try: 

739 rawStamps[detName] = self.butler.get( 

740 "guider_raw", 

741 day_obs=dayObs, 

742 seq_num=seqNum, 

743 detector=detNum, 

744 instrument="LSSTCam", 

745 ) 

746 except DatasetNotFoundError as e: 

747 raise DatasetNotFoundError(f"No data for {detName} on {dayObs=} {seqNum=}") from e 

748 return rawStamps 

749 

750 def subtractMedianRowBias(self, rawStampsDict: dict[str, Stamps]) -> dict[str, Stamps]: 

751 """ 

752 Subtract median row bias from all raw stamps in-place. 

753 

754 This modifies the rawStampsMap of the GuiderReader instance. 

755 

756 Parameters 

757 ---------- 

758 rawStampsDict : `dict[str, Stamps]` 

759 Mapping from detector name to raw Stamps object. 

760 """ 

761 axisRowMap = self.getAxisRowMap() 

762 for detName, stamps in rawStampsDict.items(): 

763 # Subtract median along the row axis 

764 rowAxis = axisRowMap[detName] 

765 for i in range(len(stamps)): 

766 # Get the stamp image array 

767 data = stamps[i].stamp_im.image.array 

768 # Subtract median along the specified row axis 

769 medianRow = np.nanmedian(data, axis=rowAxis, keepdims=True) 

770 # Update the stamp with the new data 

771 stamps[i].stamp_im.image.array = data - medianRow 

772 # Update the rawStampsMap in-place 

773 rawStampsDict[detName] = stamps 

774 return rawStampsDict 

775 

776 def getAxisRowMap(self) -> dict[str, int]: 

777 """ 

778 Axis mapping (row axis) for guider detectors. 

779 

780 Returns 

781 ------- 

782 axisMap : `dict[str, int]` 

783 Dictionary with detector names as keys and axis mapping as values. 

784 The axis mapping is 1 for X and 0 for Y. 

785 """ 

786 camera = LsstCam.getCamera() 

787 axisMap: dict[str, int] = {} 

788 for detName, detNum in self.guiderNameMap.items(): 

789 # Get the detector object from the camera 

790 detector = camera[detNum] 

791 # Determine the row axis based on the orientation 

792 nq = detector.getOrientation().getNQuarter() 

793 axisMap[detName] = 0 if nq % 2 == 0 else 1 

794 return axisMap 

795 

796 def applyGuiderNameSwapIfNeeded(self, dayObs: int) -> None: 

797 """ 

798 Apply guider name swap (SG0/SG1) for early data if required. 

799 

800 Parameters 

801 ---------- 

802 dayObs : `int` 

803 Observation day (YYYYMMDD) used to decide whether to swap. 

804 """ 

805 if getattr(self, "_guiderNameMapSwapped", False): 

806 return # Already swapped; do nothing 

807 if dayObs < 20250509: 

808 newMap = {} 

809 for detName, detNum in self.guiderNameMap.items(): 

810 if detName.endswith("SG0"): 

811 swapped = detName.replace("SG0", "SG1") 

812 elif detName.endswith("SG1"): 

813 swapped = detName.replace("SG1", "SG0") 

814 else: 

815 swapped = detName 

816 swappedDetNum = self.camera[swapped].getId() 

817 newMap[swapped] = swappedDetNum 

818 self.guiderNameMap = newMap 

819 self._guiderNameMapSwapped = True 

820 

821 

822def getVisitInfo(butler: Butler, dayObs: int, seqNum: int, scienceDetNum: int) -> VisitInfo: 

823 """ 

824 Retrieve VisitInfo for a given dayObs / seqNum. 

825 

826 Parameters 

827 ---------- 

828 butler : `Butler` 

829 Active Butler instance. 

830 dayObs : `int` 

831 Observation day (YYYYMMDD). 

832 seqNum : `int` 

833 Sequence number. 

834 scienceDetNum : `int` 

835 Science detector number. 

836 

837 Returns 

838 ------- 

839 visitInfo : `VisitInfo` 

840 VisitInfo object retrieved from butler. 

841 """ 

842 dataId = { 

843 "instrument": "LSSTCam", 

844 "day_obs": dayObs, 

845 "seq_num": seqNum, 

846 "detector": scienceDetNum, 

847 } 

848 visitInfo = butler.get("raw.visitInfo", dataId) 

849 return visitInfo 

850 

851 

852# Helper functions for stamp conversion 

853def _makeRoiTransforms(metadata: dict, detector: Detector, camera: Camera) -> tuple[tuple, str]: 

854 """ 

855 Construct ROI transforms and derive amplifier name. 

856 

857 Parameters 

858 ---------- 

859 metadata : `dict` 

860 Exposure-level metadata. 

861 detector : `Detector` 

862 The detector. 

863 camera : `Camera` 

864 The camera object. 

865 

866 Returns 

867 ------- 

868 result : `tuple[tuple, str]` 

869 ((ccdViewBbox, fwd, back), ampName) 

870 """ 

871 ampName = "C" + metadata["ROISEG"][7:] 

872 ccdViewBbox = makeRoiBbox(metadata, camera) 

873 fwd, back = makeCcdToDvcsTransform(ccdViewBbox, detector.getOrientation().getNQuarter()) 

874 return (ccdViewBbox, fwd, back), ampName 

875 

876 

877def _convertMaskedImage( 

878 maskedImage: MaskedImageF, 

879 stampMetadata: dict[str, Any], 

880 metadata: dict[str, Any], 

881 transforms: tuple, 

882 detector: Detector, 

883 ampName: str, 

884 camera: Camera, 

885 view: str, 

886 mask: None | np.ndarray = None, 

887) -> Stamp: 

888 """ 

889 Convert one masked image ROI and build a Stamp. 

890 

891 Parameters 

892 ---------- 

893 maskedImage : `MaskedImageF` 

894 Input masked image for the ROI. 

895 stampMetadata : `dict` 

896 Stamp-level metadata (modified in-place). 

897 metadata : `dict` 

898 Exposure-level metadata. 

899 transforms : `tuple` 

900 Tuple containing (ccdViewBbox, forwardTransform, inverseTransform). 

901 detector : `Detector` 

902 Camera detector. 

903 ampName : `str` 

904 Amplifier name. 

905 camera : `Camera` 

906 Camera object. 

907 view : `str` 

908 Output view ('dvcs', 'ccd', or 'roi'). 

909 mask: `ndarray or None` 

910 Masked bad columns. 

911 

912 Returns 

913 ------- 

914 stamp : `Stamp` 

915 Converted stamp object. 

916 """ 

917 ccdViewBbox, fwd, back = transforms 

918 rawArray = maskedImage.getImage().getArray() 

919 if mask is not None: 

920 median = np.nanmedian(rawArray[~mask].flatten()) 

921 rawArray[mask] = median 

922 dvcsArray = roiImageToDvcs(rawArray, metadata, detector, ampName, camera, view=view) 

923 outImg = MaskedImageF(dvcsArray) 

924 archiveElement = [ccdViewBbox, fwd, back] 

925 return Stamp(outImg, archiveElement, metadata=stampMetadata) 

926 

927 

928def _blankStamp( 

929 stampIdx: int, 

930 metadata: dict[str, Any], 

931 transforms: tuple, 

932) -> Stamp: 

933 """ 

934 Create a blank (zero) stamp for a missing index. 

935 

936 Parameters 

937 ---------- 

938 stampIdx : `int` 

939 Missing stamp index. 

940 metadata : `dict` 

941 Original metadata container. 

942 transforms : `tuple` 

943 (ccdViewBbox, forwardTransform, inverseTransform). 

944 

945 Returns 

946 ------- 

947 stamp : `Stamp` 

948 Placeholder blank stamp with NaN timestamp. 

949 """ 

950 ccdViewBbox, fwd, back = transforms 

951 nRows, nCols = int(metadata["ROIROWS"]), int(metadata["ROICOLS"]) 

952 blankArray = np.zeros((nRows, nCols), dtype=np.float32) 

953 blankImg = ExposureF(MaskedImageF(ImageF(array=blankArray))) 

954 missingMetadata = metadata.copy() 

955 missingMetadata["DAQSTAMP"] = stampIdx 

956 missingMetadata["STMPTMJD"] = np.nan 

957 archiveElement = [ccdViewBbox, fwd, back] 

958 return Stamp(blankImg, archiveElement, metadata=missingMetadata) 

959 

960 

961def convertRawStampsToView( 

962 rawStamps: Stamps, 

963 detName: str, 

964 nStamps: int, 

965 view: str = "dvcs", 

966) -> Stamps: 

967 """ 

968 Convert guider stamps from raw ROI to a requested view ('dvcs', 'ccd', or 

969 'roi'). Handles missing stamps and preserves metadata and order. 

970 

971 Parameters 

972 ---------- 

973 rawStamps : `Stamps` 

974 Input raw ROI stamps. 

975 detName : `str` 

976 Detector name. 

977 nStamps : `int` 

978 Target total number of stamps (after filling gaps). 

979 view : `str`, optional 

980 Output view: 'dvcs', 'ccd', or 'roi'. 

981 

982 Returns 

983 ------- 

984 stampsOut : `Stamps` 

985 Converted stamps with gaps filled by blank stamps. 

986 """ 

987 camera = LsstCam.getCamera() 

988 detector = camera[detName] 

989 metadata = rawStamps.metadata 

990 metadataDict = metadata.toDict() 

991 

992 # Ensure CCDSLOT matches swapped name if applicable 

993 metadata["CCDSLOT"] = detName[4:7] 

994 

995 # Align timestamps and find valid/missing indices 

996 timestamps = standardizeGuiderTimestamps(rawStamps, nStamps) 

997 validIndices = np.where(~timestamps.mask)[0].tolist() 

998 missingIndices = np.where(timestamps.mask)[0].tolist() 

999 

1000 # Pre‑compute transforms once 

1001 transforms, ampName = _makeRoiTransforms(metadataDict, detector, camera) 

1002 

1003 stampsDict: dict[int, Stamp] = {} 

1004 mIdx = 0 # index into masked images list 

1005 

1006 # get bad columns 

1007 img0 = rawStamps.getMaskedImages()[mIdx].getImage().getArray() 

1008 mask = getColumnMask(img0, k=6) 

1009 

1010 for idx in validIndices: 

1011 maskedImage = rawStamps.getMaskedImages()[mIdx] 

1012 stampMeta = rawStamps[mIdx].metadata 

1013 stampMeta["DAQSTAMP"] = stampMeta.get("DAQSTAMP", idx) 

1014 stampsDict[idx] = _convertMaskedImage( 

1015 maskedImage, stampMeta, metadataDict, transforms, detector, ampName, camera, view, mask 

1016 ) 

1017 mIdx += 1 

1018 

1019 # Fill gaps with blanks 

1020 for idx in missingIndices: 

1021 stampsDict[idx] = _blankStamp(idx, metadataDict, transforms) 

1022 

1023 # Assemble in order 

1024 stampList = [stampsDict[i] for i in range(nStamps)] 

1025 return Stamps(stampList, metadata, use_archive=True) 

1026 

1027 

1028def standardizeGuiderTimestamps(rawStamps: Stamps, nStamps: int) -> Time: 

1029 """ 

1030 Return a masked `Time` array of length `nStamps` for one guider. 

1031 

1032 Missing stamps are filled with `NaN` and masked so every detector shares a 

1033 uniform, sortable timestamp vector. Result is in MJD, `scale="tai"`. 

1034 

1035 Parameters 

1036 ---------- 

1037 rawStamps : `Stamps` 

1038 Input stamps (may have missing indices). 

1039 nStamps : `int` 

1040 Desired total length (maximum among guiders). 

1041 

1042 Returns 

1043 ------- 

1044 fullTimestamps : `Time` 

1045 Masked Time array (scale='tai') with inferred cadence and gaps masked. 

1046 """ 

1047 timestampsList = [stamp.metadata.get("STMPTMJD", np.nan) for stamp in rawStamps] 

1048 mjdArray = np.ma.masked_invalid(timestampsList) 

1049 timestamps = Time(mjdArray, format="mjd", scale="tai") 

1050 

1051 # infer frequency from valid timestamps 

1052 jdDiff = np.diff(timestamps.jd) 

1053 freqDays = np.nanmedian(jdDiff) 

1054 startJd = timestamps[0].jd 

1055 

1056 if np.isnan(freqDays) or freqDays <= 0: 

1057 raise ValueError( 

1058 f"Invalid frequency {freqDays} derived from timestamps. " 

1059 "Ensure that the timestamps are valid and evenly spaced." 

1060 ) 

1061 

1062 timestampsIdeal = Time(startJd + np.arange(nStamps) * freqDays, format="jd", scale="tai") 

1063 

1064 tolerance = 0.5 * freqDays 

1065 actualJd = timestamps.jd 

1066 

1067 # Build aligned timestamp list with np.nan where missing 

1068 mjdList = [] 

1069 for idealJd in timestampsIdeal.jd: 

1070 # check if any actual timestamp is close to this ideal timestamp 

1071 if np.any(np.abs(actualJd - idealJd) < tolerance): 

1072 # match found — find the closest 

1073 idx = np.argmin(np.abs(actualJd - idealJd)) 

1074 mjdList.append(actualJd[idx]) 

1075 else: 

1076 # no match — this is a missing timestamp 

1077 mjdList.append(np.nan) 

1078 

1079 mjdArray = np.ma.masked_invalid(mjdList) 

1080 fullTimestamps = Time(mjdArray, format="mjd", scale="tai") 

1081 fullTimestamps.freq = freqDays 

1082 return fullTimestamps 

1083 

1084 

1085@dataclass 

1086class WeatherInfo: 

1087 """ 

1088 Container for weather information. 

1089 

1090 Parameters 

1091 ---------- 

1092 temperature : `float` 

1093 Temperature in degrees Celsius. 

1094 humidity : `float` 

1095 Relative humidity in percent. 

1096 pressure : `float` 

1097 Atmospheric pressure in hPa. 

1098 windSpeed : `float` 

1099 Wind speed in m/s. 

1100 windDir : `float` 

1101 Wind direction in degrees from North. 

1102 """ 

1103 

1104 temperature: float 

1105 humidity: float 

1106 pressure: float 

1107 windSpeed: float 

1108 windDir: float 

1109 seeing: float 

1110 

1111 @staticmethod 

1112 def fromMetadata(metadata: dict) -> WeatherInfo: 

1113 """ 

1114 Create a WeatherInfo object from metadata dictionary. 

1115 

1116 Parameters 

1117 ---------- 

1118 metadata : `dict` 

1119 Metadata dictionary containing weather keys. 

1120 

1121 Returns 

1122 ------- 

1123 weather : `WeatherInfo` 

1124 Parsed weather information. 

1125 """ 

1126 return WeatherInfo( 

1127 temperature=metadata_to_float(metadata, "AIRTEMP", np.nan), 

1128 humidity=metadata_to_float(metadata, "HUMIDITY", np.nan), 

1129 pressure=metadata_to_float(metadata, "PRESSURE", np.nan), 

1130 windSpeed=metadata_to_float(metadata, "WINDSPD", np.nan), 

1131 windDir=metadata_to_float(metadata, "WINDDIR", np.nan), 

1132 seeing=metadata_to_float(metadata, "SEEING", np.nan), 

1133 ) 

1134 

1135 

1136def metadata_to_float(metadata: dict, key: str, default: float = np.nan) -> float: 

1137 """ 

1138 Safely convert a metadata value to float. 

1139 

1140 Parameters 

1141 ---------- 

1142 metadata : `dict` 

1143 Metadata dictionary. 

1144 key : `str` 

1145 Key to look up in the metadata. 

1146 default : `float`, optional 

1147 Default value if key is missing or conversion fails. 

1148 

1149 Returns 

1150 ------- 

1151 value : `float` 

1152 Converted float value or default. 

1153 """ 

1154 value = metadata[key] 

1155 if value is None: 

1156 return default 

1157 else: 

1158 return float(value) 

1159 

1160 

1161def mad(x: np.ndarray) -> float: 

1162 """ 

1163 Median absolute deviation metric for std. 

1164 

1165 Parameters 

1166 ---------- 

1167 x : `ndarray` 

1168 Input array. 

1169 

1170 Returns 

1171 ------- 

1172 value : `float` 

1173 Robust standard deviation 

1174 """ 

1175 med = np.nanmedian(x) 

1176 return 1.4826 * np.nanmedian(np.abs(x - med)) 

1177 

1178 

1179def getColumnMask(img: NDArray[np.floating], k: int = 6) -> NDArray[np.bool_]: 

1180 """Return a boolean mask for bad columns based on robust median statistics. 

1181 

1182 Parameters 

1183 ---------- 

1184 img : `ndarray` 

1185 2D image array. 

1186 k : `int`, optional 

1187 Threshold factor for MAD. 

1188 

1189 Returns 

1190 ------- 

1191 mask : `ndarray` 

1192 Boolean mask of same shape as img, ``True`` for bad columns. 

1193 """ 

1194 columnMedian = np.nanmedian(img, axis=0) 

1195 center = np.nanmedian(columnMedian) 

1196 

1197 # MAD of column medians 

1198 diffs = np.abs(columnMedian - center) 

1199 s = np.nanmedian(diffs) 

1200 

1201 if s == 0 or not np.isfinite(s): 

1202 return np.zeros(img.shape, dtype=bool) 

1203 

1204 badCols = diffs > k * s 

1205 return np.broadcast_to(badCols, img.shape).copy()