Coverage for python / lsst / summit / utils / guiders / plotting.py: 12%

309 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-22 09:17 +0000

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 

24import os 

25from collections.abc import Sequence 

26from dataclasses import dataclass, field 

27from typing import TYPE_CHECKING, Any, cast 

28 

29import matplotlib.pyplot as plt 

30import numpy as np 

31import pandas as pd 

32import seaborn as sns 

33from astropy.stats import mad_std 

34from matplotlib import animation 

35from matplotlib.artist import Artist 

36from matplotlib.lines import Line2D 

37from matplotlib.patches import Circle 

38 

39from lsst.summit.utils.utils import RobustFitter 

40from lsst.utils.plotting.figures import make_figure 

41 

42if TYPE_CHECKING: 

43 from .reading import GuiderData 

44 

45__all__ = ["GuiderPlotter"] 

46 

47LIGHT_BLUE = "#6495ED" 

48 

49STRIP_PLOT_KWARGS: dict[str, dict] = { 

50 "centroidAltAz": { 

51 "ylabel": "Centroid Offset [arcsec]", 

52 "unit": "arcsec", 

53 "col": ["dalt", "daz"], 

54 "title": "Alt/Az Centroid Offsets", 

55 }, 

56 "centroidPixel": { 

57 "ylabel": "Centroid Offset [pixels]", 

58 "col": ["dx", "dy"], 

59 "unit": "pixels", 

60 "title": "CCD Pixel Centroid Offsets", 

61 }, 

62 "flux": { 

63 "ylabel": "Magnitude Offset [mmag]", 

64 "col": ["magoffset"], 

65 "unit": "mmag", 

66 "scale": 1, # keep scale to mmag 

67 "title": "Flux Magnitude Offsets", 

68 }, 

69 "rotator": { 

70 "ylabel": "Rotation Angle [arcsec]", 

71 "col": ["dtheta"], 

72 "unit": "arcsec", 

73 "scale": 1.0, 

74 "title": "Rotation Angle", 

75 }, 

76 "ellip": { 

77 "ylabel": "Ellipticity", 

78 "col": ["e1_altaz", "e2_altaz"], 

79 "unit": "", 

80 "title": "", 

81 }, 

82 "psf": { 

83 "ylabel": "PSF FWHM [arcsec]", 

84 "col": ["fwhm"], 

85 "scale": 1.0, 

86 "unit": "arcsec", 

87 "title": "PSF FWHM", 

88 }, 

89} 

90 

91 

92def _getWriter(filename: str) -> str: 

93 """ 

94 Get the appropriate writer for saving animations based on file extension. 

95 

96 Parameters 

97 ---------- 

98 extension : `str` 

99 Filename to determine the writer type. 

100 

101 Returns 

102 ------- 

103 writer : `str` 

104 The name of the writer to use. 

105 

106 Raises 

107 ------ 

108 ValueError 

109 If the file extension is not supported. 

110 """ 

111 _, extension = os.path.splitext(filename) 

112 match extension.lower(): 

113 case ".gif": 

114 return "pillow" 

115 case ".mp4": 

116 return "ffmpeg" 

117 case _: 

118 raise ValueError(f"Unsupported file extension for {filename}: {extension}") 

119 

120 

121@dataclass(frozen=True) 

122class MosaicLayout: 

123 grid: list[tuple[str, ...]] = field( 

124 default_factory=lambda: [ 

125 (".", "R40_SG1", "R44_SG0", "."), 

126 ("R40_SG0", "center", ".", "R44_SG1"), 

127 ("R00_SG1", ".", ".", "R04_SG0"), 

128 ("arrow", "R00_SG0", "R04_SG1", "."), 

129 ] 

130 ) 

131 

132 def build( 

133 self, 

134 *, 

135 figsize: tuple[float, float] = (12, 12), 

136 hspace: float = 0.0, 

137 wspace: float = 0.0, 

138 constrained_layout: bool = False, 

139 ) -> tuple[plt.Figure, dict[str, plt.Axes]]: 

140 """ 

141 Build the figure and axes dictionary for the predefined mosaic layout 

142 using LSST's Agg-backed figure (no pyplot). 

143 

144 Parameters 

145 ---------- 

146 figsize : `tuple[float, float]`, optional 

147 Figure size in inches (width, height). 

148 hspace : `float`, optional 

149 Height space between subplots (gridspec hspace). 

150 wspace : `float`, optional 

151 Width space between subplots (gridspec wspace). 

152 constrained_layout : `bool`, optional 

153 Whether to enable Matplotlib constrained layout. 

154 

155 Returns 

156 ------- 

157 fig : `matplotlib.figure.Figure` 

158 The created figure. 

159 axs : `dict[str, matplotlib.axes.Axes]` 

160 Mapping from mosaic panel labels to axes. 

161 """ 

162 # 1) Create Agg-backed figure (no pyplot, no caching) 

163 fig = make_figure(figsize=figsize, constrained_layout=constrained_layout) 

164 

165 # 2) Use Figure.subplot_mosaic (same signature as plt.subplot_mosaic) 

166 axs: dict[str, plt.Axes] = fig.subplot_mosaic( 

167 cast(Any, self.grid), 

168 gridspec_kw=dict(hspace=hspace, wspace=wspace), 

169 sharex=False, # keep the original intent 

170 sharey=False, 

171 ) 

172 return fig, axs 

173 

174 

175class GuiderPlotter: 

176 # The MARKERS lists are used for plotting different detector 

177 MARKERS: list[str] = ["o", "x", "+", "*", "^", "v", "s", "p"] 

178 

179 def __init__(self, guiderData: GuiderData, starsDf: pd.DataFrame | None = None) -> None: 

180 self.log = logging.getLogger(__name__) 

181 self.expId = guiderData.expid 

182 self.layout: MosaicLayout = MosaicLayout() 

183 

184 self.guiderData = guiderData 

185 

186 # Some metadata information 

187 self.expTime = self.guiderData.guiderDurationSec 

188 self.camRotAngle = self.guiderData.camRotAngle 

189 

190 self.starsDf: pd.DataFrame = pd.DataFrame() 

191 if starsDf is not None: 

192 self.starsDf = starsDf.loc[starsDf["expid"] == self.expId].reset_index(drop=True) 

193 self.withStars: bool = not self.starsDf.empty 

194 

195 # set seaborn style 

196 sns.set_style("white") 

197 sns.set_context("talk", font_scale=0.8) 

198 

199 def setupFigure(self, figsize: tuple[float, float] = (12, 12)) -> tuple[plt.Figure, dict[str, plt.Axes]]: 

200 """ 

201 Create a figure and axes using the guider mosaic layout. 

202 

203 Parameters 

204 ---------- 

205 figsize : `tuple[float, float]`, optional 

206 Figure size in inches (width, height). 

207 

208 Returns 

209 ------- 

210 fig : `matplotlib.figure.Figure` 

211 The created figure. 

212 axs : `dict[str, matplotlib.axes.Axes]` 

213 Mapping of panel name to axes. 

214 """ 

215 fig, axs = self.layout.build(figsize=figsize) 

216 return fig, axs 

217 

218 def stripPlot( 

219 self, plotType: str = "centroidAltAz", saveAs: str | None = None, coveragePct: int = 68 

220 ) -> plt.Figure: 

221 """ 

222 Plot time-series strip plot for a chosen metric. 

223 

224 This renders one or more panels vs elapsed time, fits a robust linear 

225 trend, annotates slope/significance/scatter, and draws reference zero 

226 lines or median PSF as needed. 

227 

228 Parameters 

229 ---------- 

230 plotType : `str`, optional 

231 Metric key in `STRIP_PLOT_KWARGS` (e.g., 'centroidAltAz'). 

232 saveAs : `str`, optional 

233 If not None, path to save the figure. 

234 coveragePct : `int`, optional 

235 Central percentile span used to derive y-limits (e.g. 68 -> 16–84). 

236 

237 Returns 

238 ------- 

239 stripFig : `matplotlib.figure.Figure` 

240 Figure containing the strip plot panels. 

241 """ 

242 if self.starsDf.empty: 

243 raise ValueError("starsDf is empty. No data to make a stripPlot.") 

244 

245 cfg = STRIP_PLOT_KWARGS.get(plotType) 

246 if cfg is None: 

247 raise ValueError(f"Unknown plotType: {plotType}") 

248 # from here, tell mypy it’s a dict[str, Any] 

249 cfg = cast(dict[str, Any], cfg) 

250 

251 # get alt/az 

252 alt = self.guiderData.alt 

253 az = self.guiderData.az 

254 

255 n = len(cfg["col"]) 

256 fig = make_figure(figsize=(8 * n, 6)) 

257 fig.subplots_adjust(hspace=0.0, wspace=0.0) 

258 axes = fig.subplots(nrows=1, ncols=n, sharex=True, sharey=True) 

259 axes = np.atleast_1d(axes) 

260 

261 cols = cfg["col"] 

262 scale = float(cfg.get("scale", 1.0)) 

263 unit = cfg.get("unit", "") 

264 expTime = float(self.expTime) 

265 

266 df = self.starsDf.loc[self.starsDf["stamp"] > 0, ["elapsed_time", "detector", *cols]].copy() 

267 

268 q1, q3 = (100 - coveragePct) / 2, 100 - (100 - coveragePct) / 2 

269 yvals = (df[cols].to_numpy(dtype=float) * scale).ravel() 

270 p16, p84 = np.nanpercentile(yvals, [q1, q3]) 

271 sigma = mad_std(yvals, ignore_nan=True) 

272 ylims = (p16 - 2.5 * sigma, p84 + 2.5 * sigma) 

273 

274 def _zero(ax, c): 

275 label = { 

276 "daz": f"Az: {az:0.5f} deg", 

277 "dalt": f"Alt: {alt:0.5f} deg", 

278 "dx": "CCD X", 

279 "dy": "CCD Y", 

280 "e1_altaz": "e1", 

281 "e2_altaz": "e2", 

282 "magoffset": "Magnitude Offset", 

283 "dtheta": "Rotation Offset", 

284 }.get(c, "") 

285 ax.axhline(0 if c != "fwhm" else np.nanmedian(df[c] * scale), color="grey", ls="--", label=label) 

286 

287 for i, (ax, c) in enumerate(zip(axes, cols)): 

288 _zero(ax, c) 

289 fitter = RobustFitter() 

290 res = fitter.fit(x=np.asarray(df["elapsed_time"].values), y=(df[c].values * scale)) 

291 txt = ( 

292 f"Slope: {expTime * res.slope:.2f} {unit}/exposure\n" 

293 f"Significance: {abs(res.slopeTValue):.1f} σ\n" 

294 f"scatter: {res.scatter:.3f} {unit}\n" 

295 ) 

296 ax.text( 

297 0.02, 

298 0.98, 

299 txt, 

300 transform=ax.transAxes, 

301 ha="left", 

302 va="top", 

303 fontsize=11, 

304 bbox=dict(facecolor="white", alpha=0.75, edgecolor="none", boxstyle="round,pad=0.3"), 

305 ) 

306 fitter.plotBestFit(ax=ax, color=LIGHT_BLUE, label="", lw=2) 

307 

308 for j, det in enumerate(df["detector"].unique()): 

309 m = self.MARKERS[j % len(self.MARKERS)] 

310 msk = df["detector"].eq(det) 

311 ax.scatter( 

312 df.loc[msk, "elapsed_time"], 

313 df.loc[msk, c] * scale, 

314 color="lightgrey", 

315 alpha=0.6, 

316 marker=m, 

317 label=f"{det}" if i == 0 else "", 

318 ) 

319 out = msk & fitter.outlierMask 

320 ax.scatter( 

321 df.loc[out, "elapsed_time"], df.loc[out, c] * scale, color=LIGHT_BLUE, alpha=0.2, marker=m 

322 ) 

323 

324 if i == 0: 

325 ax.set_ylabel(cfg["ylabel"]) 

326 ax.set_xlabel("Elapsed time [sec]") 

327 ax.set_ylim(*ylims) 

328 ax.legend(fontsize=10, ncol=4, loc="lower left") 

329 

330 title = cfg["title"] + f"\n Expid: {self.expId}" 

331 fig.suptitle(title, fontsize=14, fontweight="bold") 

332 if saveAs: 

333 fig.savefig(saveAs, dpi=120) 

334 return fig 

335 

336 def _starMosaic( 

337 self, 

338 stampNum: int = 2, 

339 fig: plt.Figure | None = None, 

340 axs: dict[str, plt.Axes] | None = None, 

341 plo: float = 90.0, 

342 phi: float = 99.0, 

343 cutoutSize: int = -1, 

344 isAnimated: bool = False, 

345 saveAs: str | None = None, 

346 ) -> list[Artist]: 

347 """ 

348 Internal: plot a mosaic of guider stamps for a given stamp index. 

349 Wraps the plotting logic for static and animated frames. 

350 """ 

351 if fig is None or axs is None: 

352 fig, axs = self.setupFigure(figsize=(9, 9)) 

353 

354 if not self.withStars: 

355 if cutoutSize > 0: 

356 self.log.warning("No stars data available. Using full frame cutout.") 

357 cutoutSize = -1 

358 

359 nStamps = len(self.guiderData) 

360 view = self.guiderData.view 

361 camAngle = self.guiderData.camRotAngle 

362 

363 jitter: float | None = None 

364 artists: list[Artist] = [] 

365 cutoutShapeList = [] 

366 for detName in self.guiderData.guiderNames: 

367 if self.withStars: 

368 # check if detector has data 

369 withStars: bool = bool(np.any(self.starsDf["detector"] == detName)) 

370 else: 

371 withStars = False 

372 

373 if withStars: 

374 refCenter, centroidOffset = getReferenceCenter(self.starsDf, detName, stampNum) 

375 else: 

376 shape = self.guiderData[detName, 0].shape 

377 refCenter = (float(shape[1] // 2), float(shape[0] // 2)) 

378 centroidOffset = (0.0, 0.0) 

379 

380 # Render the stamp panel 

381 imObj, centerCutout, shape, _ = renderStampPanel( 

382 axs[detName], 

383 self.guiderData, 

384 detName, 

385 stampNum, 

386 center=refCenter, 

387 cutoutSize=cutoutSize, 

388 plo=plo, 

389 phi=phi, 

390 annotate=False, 

391 ) 

392 # Static overlays (when not animating) 

393 if not isAnimated: 

394 addStaticOverlays(axs[detName], detName, centerCutout, cutoutSize, camRotAngle=camAngle) 

395 

396 artists.append(imObj) 

397 

398 if withStars: 

399 starCross = plotStarCentroid( 

400 axs[detName], 

401 centerCutout, 

402 deltaXY=centroidOffset, 

403 markerSize=8 if cutoutSize > 0 else 0, 

404 ) 

405 jitter = getStdCentroid(self.starsDf, self.expId) 

406 

407 artists.extend(starCross) 

408 

409 cutoutShapeList.append(shape) 

410 

411 stampInfo = annotateStampInfo( 

412 axs["center"], expid=self.expId, stampNum=stampNum, nStamps=nStamps, view=view, jitter=jitter 

413 ) 

414 artists.append(stampInfo) 

415 

416 cutoutSize = np.max(cutoutShapeList) if cutoutShapeList else 30 

417 

418 if not isAnimated: 

419 drawArrows(axs["arrow"], cutoutSize, 90.0 + self.camRotAngle) 

420 

421 for ax in axs.values(): 

422 clearAxisTicks(ax, isSpine=cutoutSize < 0) 

423 

424 if saveAs: 

425 fig.savefig(saveAs, dpi=120) 

426 

427 return artists 

428 

429 def plotMosaic( 

430 self, 

431 stampNum: int = 2, 

432 plo: float = 90.0, 

433 phi: float = 99.0, 

434 cutoutSize: int = -1, 

435 saveAs: str | None = None, 

436 figsize: tuple[float, float] = (9, 9), 

437 ) -> plt.Figure: 

438 """ 

439 Plot a mosaic of guider stamps (a single stamp or a stacked image). 

440 

441 Parameters 

442 ---------- 

443 stampNum : `int`, optional 

444 Stamp index; values < 0 select the stacked (coadd) image. 

445 fig : `matplotlib.figure.Figure`, optional 

446 Existing figure to draw on; created if ``None``. 

447 axs : `dict[str, matplotlib.axes.Axes]`, optional 

448 Axes dictionary for the mosaic; created if ``None``. 

449 plo : `float`, optional 

450 Lower percentile for intensity scaling. 

451 phi : `float`, optional 

452 Upper percentile for intensity scaling. 

453 cutoutSize : `int`, optional 

454 Square cutout size around star center; -1 uses full frame. 

455 isAnimated : `bool`, optional 

456 If True, skip static overlays intended only for static displays. 

457 saveAs : `str`, optional 

458 If provided, path and filename to which the figure is saved. 

459 

460 Returns 

461 ------- 

462 fig : `matplotlib.figure.Figure` 

463 The resulting figure. 

464 """ 

465 fig, axs = self.setupFigure(figsize=figsize) 

466 self._starMosaic( 

467 stampNum=stampNum, 

468 fig=fig, 

469 axs=axs, 

470 plo=plo, 

471 phi=phi, 

472 cutoutSize=cutoutSize, 

473 isAnimated=False, 

474 saveAs=saveAs, 

475 ) 

476 return fig 

477 

478 def makeAnimation( 

479 self, 

480 cutoutSize: int, 

481 saveAs: str = "", 

482 fps: int = 5, 

483 dpi: int = 80, 

484 plo: float = 50, 

485 phi: float = 99, 

486 figsize: tuple[float, float] = (9, 9), 

487 holdFrames: int = 2, 

488 ) -> animation.ArtistAnimation: 

489 """Create a gif or mp4 of the guider mosaic across sequential stamps. 

490 

491 Parameters 

492 ---------- 

493 cutoutSize : `int`, optional 

494 Size scale used for arrow lengths and framing. 

495 saveAs : `str`, optional 

496 Output filepath for the GIF. 

497 fps : `int`, optional 

498 Frames per second. 

499 dpi : `int`, optional 

500 Output resolution in dots per inch. 

501 plo : `float`, optional 

502 Lower percentile for image scaling. 

503 phi : `float`, optional 

504 Upper percentile for image scaling. 

505 figsize : `tuple[float, float]`, optional 

506 Figure size in inches. 

507 holdFrames : `int`, optional 

508 Number of frames to hold the first and last frames. 

509 

510 Returns 

511 ------- 

512 ani : `matplotlib.animation.ArtistAnimation` 

513 The created animation object. 

514 """ 

515 # build canvas 

516 fig, axs = self.setupFigure(figsize=figsize) 

517 

518 # number of frames 

519 total = len(self.guiderData) 

520 

521 # initial (stacked) frame 

522 artists0 = self._starMosaic( 

523 stampNum=-1, 

524 fig=fig, 

525 axs=axs, 

526 plo=plo, 

527 phi=phi, 

528 cutoutSize=cutoutSize, 

529 isAnimated=False, 

530 ) 

531 

532 frames = holdFrames * [artists0] 

533 

534 # sequential stamps 

535 for i in range(1, total): 

536 artists = self._starMosaic( 

537 stampNum=i, 

538 fig=fig, 

539 axs=axs, 

540 plo=plo, 

541 phi=phi, 

542 cutoutSize=cutoutSize, 

543 isAnimated=True, 

544 ) 

545 frames.append(artists) 

546 frames += holdFrames * [artists0] 

547 

548 # create animation 

549 ani = animation.ArtistAnimation(fig, frames, interval=1000 / fps, blit=True, repeat_delay=1000) 

550 

551 if saveAs: 

552 writer = _getWriter(saveAs) 

553 ani.save(saveAs, fps=fps, dpi=dpi, writer=writer) 

554 return ani 

555 

556 

557def getStdCentroid(statsDf: pd.DataFrame, expId: int) -> float: 

558 """ 

559 Compute combined (quadrature) corrected centroid scatter for an exposure. 

560 

561 Parameters 

562 ---------- 

563 statsDf : `pandas.DataFrame` 

564 Statistics DataFrame containing centroid scatter columns. 

565 expId : `int` 

566 Exposure identifier. 

567 

568 Returns 

569 ------- 

570 jitter : `float` 

571 Quadrature sum of corrected AZ and ALT centroid scatter (arcsec). 

572 """ 

573 stdAz = mad_std(statsDf.loc[statsDf["expid"] == expId, "dalt"].to_numpy()) 

574 stdAlt = mad_std(statsDf.loc[statsDf["expid"] == expId, "daz"].to_numpy()) 

575 return np.hypot(stdAz, stdAlt) 

576 

577 

578def drawArrows( 

579 ax: plt.Axes, 

580 cutoutSize: int, 

581 rotAngle: float = 0.0, 

582 *, 

583 baseLabels: tuple[str, str] = ("Y DVCS", "X DVCS"), 

584 overlayLabels: tuple[str, str] = ("Alt", "Az"), 

585 baseColor: str = LIGHT_BLUE, 

586 overlayColor: str = "lightgrey", 

587 center: tuple[float, float] | None = None, 

588) -> None: 

589 """ 

590 Draw reference arrows for instrument (DVCS) and rotated Alt/Az axes. 

591 

592 Parameters 

593 ---------- 

594 ax : `matplotlib.axes.Axes` 

595 Target axes. 

596 cutoutSize : `int` 

597 Size scale used for arrow lengths and framing. 

598 rotAngle : `float`, optional 

599 Rotation angle (degrees) for overlay (Alt/Az) axes. 

600 baseLabels : `tuple[str, str]`, optional 

601 Labels for the base (unrotated) Y/X axes. 

602 overlayLabels : `tuple[str, str]`, optional 

603 Labels for the rotated Alt/Az axes. 

604 baseColor : `str`, optional 

605 Color for base axes arrows. 

606 overlayColor : `str`, optional 

607 Color for rotated axes arrows. 

608 center : `tuple[float, float]`, optional 

609 Arrow origin; defaults to cutout center when None. 

610 """ 

611 x0, y0 = (cutoutSize // 2, cutoutSize // 2) if center is None else center 

612 L = cutoutSize / 3.0 

613 

614 def _draw(theta_deg: float, color: str, labels: tuple[str, str]) -> np.ndarray: 

615 t = np.radians(theta_deg) 

616 # unit vectors rotated by t 

617 dx_az, dy_az = L * np.cos(t), L * np.sin(t) # +X (Az) 

618 dx_alt, dy_alt = -L * np.sin(t), L * np.cos(t) # +Y (Alt) 

619 

620 for dx, dy, label in ((dx_az, dy_az, labels[1]), (dx_alt, dy_alt, labels[0])): 

621 coords = (x0, y0, dx, dy) 

622 ax.arrow( 

623 *coords, 

624 color=color, 

625 width=cutoutSize / 120, 

626 head_width=cutoutSize / 30, 

627 head_length=cutoutSize / 20, 

628 length_includes_head=True, 

629 zorder=10, 

630 ) 

631 ax.text(x0 + dx + 0.5, y0 + dy, label, color=color, fontsize=6, fontweight="bold") 

632 return np.array([min(dx_az, dx_alt), min(dy_az, dy_alt)]) 

633 

634 # Base DVCS axes (θ=0), then rotated Alt/Az overlay 

635 _ = _draw(0.0, baseColor, baseLabels) 

636 deltas = _draw(rotAngle, overlayColor, overlayLabels) 

637 deltas = np.where(deltas > 0, 0, deltas) # only negative shifts 

638 

639 # Framing 

640 ax.set_aspect("equal", adjustable="box") 

641 border = cutoutSize * 0.15 

642 xmin = min(x0 - border, x0 + deltas[0] - border) 

643 ymin = min(y0 - border, y0 + deltas[1] - border) 

644 

645 ax.set_xlim(xmin - border, xmin + cutoutSize + border) 

646 ax.set_ylim(ymin - border, ymin + cutoutSize + border) 

647 clearAxisTicks(ax, isSpine=True) 

648 ax.set_axis_off() 

649 

650 

651def getReferenceCenter( 

652 starsDf: pd.DataFrame, 

653 detName: str, 

654 stampNum: int, 

655) -> tuple[tuple[float, float], tuple[float, float]]: 

656 """ 

657 Determine reference (x,y) center and centroid offset for a detector/stamp. 

658 

659 If there is no measurement for that stamp, or stampNum < 0 (a stacked 

660 image), the offset is (0, 0). 

661 

662 Parameters 

663 ---------- 

664 starsDf : `pandas.DataFrame` 

665 Star measurements table. 

666 detName : `str` 

667 Detector name. 

668 stampNum : `int` 

669 Stamp index; negative implies stacked image (zero offset). 

670 

671 Returns 

672 ------- 

673 center_ref : `tuple[float, float]` 

674 Reference center coordinates. 

675 delta : `tuple[float, float]` 

676 Centroid offset (dX, dY) relative to the reference center. 

677 """ 

678 mask1 = starsDf["detector"] == detName 

679 if not mask1.any(): 

680 raise ValueError(f"No rows for detector {detName!r}") 

681 

682 # Reference center (use .iloc[0] to avoid IndexError) 

683 refX = float(starsDf.loc[mask1, "xroi_ref"].median()) 

684 refY = float(starsDf.loc[mask1, "yroi_ref"].median()) 

685 

686 mask2 = starsDf["stamp"] == stampNum 

687 mask = mask1 & mask2 

688 

689 # Fallback: no row for that stamp or stacked request 

690 if (not mask.any()) or (stampNum < 0): 

691 return (refX, refY), (0.0, 0.0) 

692 

693 row = starsDf.loc[mask, ["xroi", "yroi"]].iloc[0] 

694 dX = float(row["xroi"]) - refX 

695 dY = float(row["yroi"]) - refY 

696 return (refX, refY), (dX, dY) 

697 

698 

699def renderStampPanel( 

700 ax: plt.Axes, 

701 guiderData: GuiderData, 

702 detName: str, 

703 stampNum: int, 

704 *, 

705 center: tuple[float, float] | None = None, 

706 cutoutSize: int = -1, 

707 plo: float = 50.0, 

708 phi: float = 99.0, 

709 annotate: bool = True, 

710) -> tuple[plt.AxesImage, tuple[float, float], tuple[int, int], plt.Text | None]: 

711 """ 

712 Render a single detector stamp (or stacked image) with optional cropping 

713 and annotation. 

714 

715 Steps: 

716 1. Select stamp or coadd. 

717 2. Optionally crop around center. 

718 3. Apply percentile scaling. 

719 4. Display with imshow. 

720 5. Optionally annotate detector label. 

721 

722 Parameters 

723 ---------- 

724 ax : `matplotlib.axes.Axes` 

725 Target axes. 

726 guiderData : `GuiderData` 

727 Guider data container. 

728 detName : `str` 

729 Detector name. 

730 stampNum : `int` 

731 Stamp index; negative for stacked/coadd. 

732 center : `tuple[float, float]`, optional 

733 Center for cropping; image center if None. 

734 cutoutSize : `int`, optional 

735 Square cutout size; -1 for full frame. 

736 plo : `float`, optional 

737 Lower percentile for scaling. 

738 phi : `float`, optional 

739 Upper percentile for scaling. 

740 annotate : `bool`, optional 

741 If True, add detector label text. 

742 

743 Returns 

744 ------- 

745 image : `matplotlib.image.AxesImage` 

746 Image artist. 

747 centerCutout : `tuple[float, float]` 

748 Center coordinates within the (possibly cropped) image. 

749 shape : `tuple[int, int]` 

750 Shape of the displayed (possibly cropped) image. 

751 label : `matplotlib.text.Text` or `None` 

752 Detector label artist if added. 

753 """ 

754 # 1) image 

755 img = guiderData.getStampArrayCoadd(detName) if stampNum < 0 else guiderData[detName, stampNum] 

756 

757 # 2) center + crop 

758 # Always produce concrete floats (fallback = image center) 

759 mx, my = img.shape[1] // 2, img.shape[0] // 2 # x=cols, y=rows 

760 if center is None: 

761 cx, cy = float(mx), float(my) 

762 else: 

763 cx_raw, cy_raw = center 

764 cx = float(cx_raw) if cx_raw is not None else float(mx) 

765 cy = float(cy_raw) if cy_raw is not None else float(my) 

766 

767 if cutoutSize > 0: 

768 cutout, centerCutout = cropAroundCenter(img, (cx, cy), cutoutSize) 

769 else: 

770 cutout = img # ndarray 

771 centerCutout = (cx, cy) # tuple[float, float] 

772 

773 # 3) scaling 

774 vmin, vmax = np.nanpercentile(cutout, [plo, phi]) 

775 

776 # 4) render 

777 im = ax.imshow( 

778 cutout, 

779 origin="lower", 

780 cmap="Greys", 

781 vmin=vmin, 

782 vmax=vmax, 

783 interpolation="nearest", 

784 extent=(0, cutout.shape[1], 0, cutout.shape[0]), 

785 animated=True, 

786 ) 

787 ax.set_ylim(0, cutout.shape[0]) 

788 ax.set_xlim(0, cutout.shape[1]) 

789 ax.set_aspect("equal", "box") 

790 

791 # 5) optional label 

792 label = labelDetector(ax, detName) if annotate else None 

793 

794 return im, centerCutout, cutout.shape, label 

795 

796 

797def labelDetector( 

798 ax: plt.Axes, 

799 name: str, 

800 *, 

801 corner: str = "tl", 

802 color: str = LIGHT_BLUE, 

803 fontsize: int = 9, 

804 weight: str = "bold", 

805 pad: tuple[float, float] = (0.025, 0.025), 

806) -> plt.Text: 

807 """Place the detector name text inside a panel. 

808 

809 Parameters 

810 ---------- 

811 ax : `matplotlib.axes.Axes` 

812 Target axes. 

813 name : `str` 

814 Detector name to display. 

815 corner : `str`, optional 

816 Two-letter code: t/b (top/bottom) + l/r (left/right). 

817 color : `str`, optional 

818 Text color. 

819 fontsize : `int`, optional 

820 Font size. 

821 weight : `str`, optional 

822 Font weight. 

823 pad : `tuple[float, float]`, optional 

824 Relative (x,y) padding in axes fraction. 

825 

826 Returns 

827 ------- 

828 text : `matplotlib.text.Text` 

829 Created text artist. 

830 """ 

831 ha, va = ("left", "top") if corner[0] == "t" else ("left", "bottom") 

832 ha = "right" if corner[1] == "r" else ha 

833 va = "top" if corner[0] == "t" else va 

834 

835 xpad, ypad = pad 

836 xpos = xpad if ha == "left" else 1 - xpad 

837 ypos = 1 - ypad if va == "top" else ypad 

838 

839 txt = ax.text( 

840 xpos, 

841 ypos, 

842 name, 

843 transform=ax.transAxes, 

844 ha=ha, 

845 va=va, 

846 fontsize=fontsize, 

847 weight=weight, 

848 color=color, 

849 ) 

850 return txt 

851 

852 

853def annotateStampInfo( 

854 ax: plt.Axes, 

855 *, 

856 expid: int, 

857 stampNum: int, 

858 nStamps: int, 

859 view: str | None = None, 

860 jitter: float | None = None, 

861 extra: str | None = None, 

862 xy: tuple[float, float] = (1.085, -0.10), 

863) -> plt.Text: 

864 """ 

865 Annotate exposure metadata and stamp index on the center panel. 

866 

867 Parameters 

868 ---------- 

869 ax : `matplotlib.axes.Axes` 

870 Target axes. 

871 expid : `int` 

872 Exposure identifier. 

873 stampNum : `int` 

874 Current stamp index (0-based). Negative implies stacked. 

875 nStamps : `int` 

876 Total number of stamps available. 

877 view : `str`, optional 

878 Orientation descriptor. 

879 jitter : `float`, optional 

880 Combined centroid scatter (arcsec). 

881 extra : `str`, optional 

882 Additional free-form text. 

883 xy : `tuple[float, float]`, optional 

884 Annotation position in axes coordinates. 

885 

886 Returns 

887 ------- 

888 text : `matplotlib.text.Text` 

889 Created text artist. 

890 """ 

891 dayObs, seqNum = str(expid)[:8], int(str(expid)[-5:]) 

892 

893 text = "" 

894 if jitter is not None: 

895 text += f"Center Stdev.: {jitter:.2f} arcsec\n" 

896 if dayObs is not None and seqNum is not None and view is not None: 

897 text += f"day_obs: {dayObs}\nseq_num: {seqNum}\norientation: {view}\n" 

898 text += f"Stamp #: {stampNum + 1:02d}" if stampNum >= 0 else f"Stacked w/ {nStamps} stamps" 

899 if extra is not None: 

900 text += "\n" + extra 

901 

902 txt = ax.text( 

903 xy[0], 

904 xy[1], 

905 text, 

906 transform=ax.transAxes, 

907 ha="center", 

908 va="center", 

909 fontsize=14, 

910 color="grey", 

911 ) 

912 clearAxisTicks(ax, isSpine=True) 

913 return txt 

914 

915 

916def addStaticOverlays( 

917 axsImg: plt.Axes, 

918 detName: str, 

919 centerCutout: tuple[float, float], 

920 cutoutSize: int, 

921 camRotAngle: float, 

922) -> None: 

923 """ 

924 Add detector label, crosshairs, and guide circles to a guider panel. 

925 

926 Parameters 

927 ---------- 

928 axsImg : `matplotlib.axes.Axes` 

929 Target axes. 

930 detName : `str` 

931 Detector name. 

932 centerCutout : `tuple[float, float]` 

933 Center coordinates in the displayed cutout. 

934 cutoutSize : `int` 

935 Cutout size; influences overlay scaling. 

936 camRotAngle : `float` 

937 Camera rotation angle (degrees). 

938 """ 

939 _ = labelDetector(axsImg, detName) 

940 if cutoutSize > 0: 

941 axsImg.axvline(centerCutout[0], color=LIGHT_BLUE, lw=1.25, linestyle="--", alpha=0.75) 

942 axsImg.axhline(centerCutout[1], color=LIGHT_BLUE, lw=1.25, linestyle="--", alpha=0.75) 

943 plotCrosshairRotated( 

944 centerCutout, 

945 90 + camRotAngle, 

946 axs=axsImg, 

947 color="grey", 

948 size=cutoutSize, 

949 ) 

950 radii = [10, 5] 

951 _ = drawGuideCircles( 

952 axsImg, 

953 centerCutout, 

954 radii=radii, 

955 colors=[LIGHT_BLUE, LIGHT_BLUE], 

956 labels=["2″", "1″"], 

957 linewidth=2.0, 

958 ) 

959 

960 

961def drawGuideCircles( 

962 ax: plt.Axes, 

963 center: tuple[float, float], 

964 radii: Sequence[float], 

965 colors: Sequence[str], 

966 labels: Sequence[str] | None = None, 

967 text_offset: float = 1.0, 

968 **circle_kwargs: Any, 

969) -> list[plt.Text]: 

970 """ 

971 Draw concentric guide circles with optional labels. 

972 

973 Parameters 

974 ---------- 

975 ax : `matplotlib.axes.Axes` 

976 Target axes. 

977 center : `tuple[float, float]` 

978 Center (x, y) of circles. 

979 radii : `Sequence[float]` 

980 Radii of circles. 

981 colors : `Sequence[str]` 

982 Edge colors per circle. 

983 labels : `Sequence[str]`, optional 

984 Labels per circle; None for no labels. 

985 text_offset : `float`, optional 

986 Offset for label placement along +x. 

987 **circle_kwargs : `dict` 

988 Extra keyword args passed to Circle. 

989 

990 Returns 

991 ------- 

992 texts : `list[matplotlib.text.Text]` 

993 List of label text artists. 

994 """ 

995 x0, y0 = center 

996 txt_list: list[plt.Text] = [] 

997 for i, r in enumerate(radii): 

998 c = Circle( 

999 (x0, y0), 

1000 r, 

1001 edgecolor=colors[i], 

1002 facecolor="none", 

1003 linestyle="--", 

1004 **circle_kwargs, 

1005 ) 

1006 ax.add_patch(c) 

1007 label_txt = labels[i] if labels is not None else "" 

1008 txt = ax.text( 

1009 x0 + r + text_offset, 

1010 y0 - r / 4.0, 

1011 label_txt, 

1012 color=colors[i], 

1013 va="center", 

1014 fontsize=8, 

1015 ) 

1016 txt_list.append(txt) 

1017 return txt_list 

1018 

1019 

1020def plotCrosshairRotated( 

1021 center: tuple[float, float], 

1022 angle: float, 

1023 axs: plt.Axes, 

1024 color: str = "grey", 

1025 size: int = 30, 

1026) -> None: 

1027 """ 

1028 Plot a rotated crosshair centered on given coordinates. 

1029 

1030 Parameters 

1031 ---------- 

1032 center : `tuple[float, float]` 

1033 Center (x, y) in image coordinates. 

1034 angle : `float` 

1035 Rotation angle in degrees. 

1036 axs : `matplotlib.axes.Axes`, optional 

1037 Target axes or None to use current. 

1038 color : `str`, optional 

1039 Line color. 

1040 size : `int`, optional 

1041 Size scaling factor. 

1042 """ 

1043 # make a cross rotated by the camera rotation angle 

1044 cross_length = 1.5 * size if size > 0 else 30 

1045 theta = np.radians(angle) 

1046 

1047 # Cross center 

1048 cx, cy = center 

1049 # Horizontal line (rotated) 

1050 dx = cross_length * np.cos(theta) / 2 

1051 dy = cross_length * np.sin(theta) / 2 

1052 axs.plot( 

1053 [cx - dx, cx + dx], 

1054 [cy - dy, cy + dy], 

1055 color=color, 

1056 ls="--", 

1057 lw=1.0, 

1058 alpha=0.5, 

1059 ) 

1060 # Vertical line (rotated) 

1061 dx_v = cross_length * np.cos(theta + np.pi / 2) / 2 

1062 dy_v = cross_length * np.sin(theta + np.pi / 2) / 2 

1063 axs.plot( 

1064 [cx - dx_v, cx + dx_v], 

1065 [cy - dy_v, cy + dy_v], 

1066 color=color, 

1067 ls="--", 

1068 lw=1.0, 

1069 alpha=0.5, 

1070 ) 

1071 

1072 

1073def plotStarCentroid( 

1074 ax: plt.Axes, 

1075 centerCutout: tuple[float, float], 

1076 *, 

1077 deltaXY: tuple[float, float], 

1078 markerSize: int = 8, 

1079 errXY: tuple[float, float] | None = None, 

1080 color: str = "firebrick", 

1081) -> list[Line2D]: 

1082 """ 

1083 Plot the measured star centroid (with optional error bars) on a cutout. 

1084 

1085 Parameters 

1086 ---------- 

1087 ax : `matplotlib.axes.Axes` 

1088 Target axes. 

1089 centerCutout : `tuple[float, float]` 

1090 Reference center in the cutout. 

1091 deltaXY : `tuple[float, float]` 

1092 Offset (dx, dy) from center to centroid. 

1093 markerSize : `int`, optional 

1094 Marker size; <=0 disables plotting. 

1095 errXY : `tuple[float, float]`, optional 

1096 (xerr, yerr) half-lengths for error bars. 

1097 color : `str`, optional 

1098 Marker and line color. 

1099 

1100 Returns 

1101 ------- 

1102 artists : `list[matplotlib.lines.Line2D]` 

1103 Marker and error bar line artists. 

1104 """ 

1105 if markerSize <= 0: 

1106 return [] 

1107 

1108 x_c, y_c = centerCutout 

1109 dx, dy = deltaXY 

1110 x_star, y_star = x_c + dx, y_c + dy 

1111 xerr, yerr = errXY if errXY is not None else (0.0, 0.0) 

1112 

1113 (marker,) = ax.plot(x_star, y_star, "o", color=color, markersize=markerSize) 

1114 (hline,) = ax.plot([x_star - xerr, x_star + xerr], [y_star, y_star], color=color, lw=2.5) 

1115 (vline,) = ax.plot([x_star, x_star], [y_star - yerr, y_star + yerr], color=color, lw=2.5) 

1116 return [marker, hline, vline] 

1117 

1118 

1119def clearAxisTicks(ax: plt.Axes, isSpine: bool = False) -> None: 

1120 """ 

1121 Remove all ticks/labels; optionally keep spines. 

1122 

1123 Parameters 

1124 ---------- 

1125 ax : `matplotlib.axes.Axes` 

1126 Target axes. 

1127 isSpine : `bool`, optional 

1128 If True, retain spines; otherwise hide them. 

1129 """ 

1130 ax.set_xticks([]) 

1131 ax.set_yticks([]) 

1132 ax.set_xticklabels([]) 

1133 ax.set_yticklabels([]) 

1134 

1135 if not isSpine: 

1136 for spine in ax.spines.values(): 

1137 spine.set_visible(False) 

1138 

1139 

1140def cropAroundCenter( 

1141 image: np.ndarray, center: tuple[float, float], size: int 

1142) -> tuple[np.ndarray, tuple[float, float]]: 

1143 """ 

1144 Extract a square crop centered on the specified coordinates. 

1145 

1146 Parameters 

1147 ---------- 

1148 image : `numpy.ndarray` 

1149 Source 2D image. 

1150 center : `tuple[float, float]` 

1151 Center (x, y) in original image coordinates. 

1152 size : `int` 

1153 Desired square size in pixels. 

1154 

1155 Returns 

1156 ------- 

1157 cropped : `numpy.ndarray` 

1158 Cropped (and possibly padded) image of shape (size, size). 

1159 new_center : `tuple[float, float]` 

1160 Center coordinates within the cropped image. 

1161 """ 

1162 x, y = center 

1163 x, y = int(round(x)), int(round(y)) 

1164 half = size // 2 

1165 

1166 y0 = max(0, y - half) 

1167 y1 = min(image.shape[0], y + half) 

1168 x0 = max(0, x - half) 

1169 x1 = min(image.shape[1], x + half) 

1170 

1171 cropped = image[y0:y1, x0:x1] 

1172 

1173 # Calculate offsets for padding 

1174 padTop = max(0, (half - y)) 

1175 padLeft = max(0, (half - x)) 

1176 padBottom = size - (padTop + (y1 - y0)) 

1177 padRight = size - (padLeft + (x1 - x0)) 

1178 

1179 if padTop > 0 or padBottom > 0 or padLeft > 0 or padRight > 0: 

1180 cropped = np.pad( 

1181 cropped, 

1182 ((padTop, padBottom), (padLeft, padRight)), 

1183 mode="constant", 

1184 constant_values=np.nan, 

1185 ) 

1186 

1187 # New center in cropped image 

1188 newCenter = (x - x0 + padLeft, y - y0 + padTop) 

1189 return cropped, newCenter