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

309 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 03:15 -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 

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 n = len(cfg["col"]) 

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

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

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

255 axes = np.atleast_1d(axes) 

256 

257 cols = cfg["col"] 

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

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

260 expTime = float(self.expTime) 

261 

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

263 

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

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

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

267 sigma = mad_std(yvals, ignore_nan=True) 

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

269 

270 def _zero(ax, c): 

271 label = { 

272 "daz": "Az", 

273 "dalt": "Alt", 

274 "dx": "CCD X", 

275 "dy": "CCD Y", 

276 "e1_altaz": "e1", 

277 "e2_altaz": "e2", 

278 "magoffset": "Magnitude Offset", 

279 "dtheta": "Rotation Offset", 

280 }.get(c, "") 

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

282 

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

284 _zero(ax, c) 

285 fitter = RobustFitter() 

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

287 txt = ( 

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

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

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

291 ) 

292 ax.text( 

293 0.02, 

294 0.98, 

295 txt, 

296 transform=ax.transAxes, 

297 ha="left", 

298 va="top", 

299 fontsize=11, 

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

301 ) 

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

303 

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

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

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

307 ax.scatter( 

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

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

310 color="lightgrey", 

311 alpha=0.6, 

312 marker=m, 

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

314 ) 

315 out = msk & fitter.outlierMask 

316 ax.scatter( 

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

318 ) 

319 

320 if i == 0: 

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

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

323 ax.set_ylim(*ylims) 

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

325 

326 fig.suptitle(cfg["title"] or "", fontsize=14, fontweight="bold") 

327 if saveAs: 

328 fig.savefig(saveAs, dpi=120) 

329 return fig 

330 

331 def _starMosaic( 

332 self, 

333 stampNum: int = 2, 

334 fig: plt.Figure | None = None, 

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

336 plo: float = 90.0, 

337 phi: float = 99.0, 

338 cutoutSize: int = -1, 

339 isAnimated: bool = False, 

340 saveAs: str | None = None, 

341 ) -> list[Artist]: 

342 """ 

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

344 Wraps the plotting logic for static and animated frames. 

345 """ 

346 if fig is None or axs is None: 

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

348 

349 if not self.withStars: 

350 if cutoutSize > 0: 

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

352 cutoutSize = -1 

353 

354 nStamps = len(self.guiderData) 

355 view = self.guiderData.view 

356 camAngle = self.guiderData.camRotAngle 

357 

358 jitter: float | None = None 

359 artists: list[Artist] = [] 

360 cutoutShapeList = [] 

361 for detName in self.guiderData.guiderNames: 

362 if self.withStars: 

363 # check if detector has data 

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

365 else: 

366 withStars = False 

367 

368 if withStars: 

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

370 else: 

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

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

373 centroidOffset = (0.0, 0.0) 

374 

375 # Render the stamp panel 

376 imObj, centerCutout, shape, _ = renderStampPanel( 

377 axs[detName], 

378 self.guiderData, 

379 detName, 

380 stampNum, 

381 center=refCenter, 

382 cutoutSize=cutoutSize, 

383 plo=plo, 

384 phi=phi, 

385 annotate=False, 

386 ) 

387 # Static overlays (when not animating) 

388 if not isAnimated: 

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

390 

391 artists.append(imObj) 

392 

393 if withStars: 

394 starCross = plotStarCentroid( 

395 axs[detName], 

396 centerCutout, 

397 deltaXY=centroidOffset, 

398 markerSize=8 if cutoutSize > 0 else 0, 

399 ) 

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

401 

402 artists.extend(starCross) 

403 

404 cutoutShapeList.append(shape) 

405 

406 stampInfo = annotateStampInfo( 

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

408 ) 

409 artists.append(stampInfo) 

410 

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

412 

413 if not isAnimated: 

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

415 

416 for ax in axs.values(): 

417 clearAxisTicks(ax, isSpine=cutoutSize < 0) 

418 

419 if saveAs: 

420 fig.savefig(saveAs, dpi=120) 

421 

422 return artists 

423 

424 def plotMosaic( 

425 self, 

426 stampNum: int = 2, 

427 plo: float = 90.0, 

428 phi: float = 99.0, 

429 cutoutSize: int = -1, 

430 saveAs: str | None = None, 

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

432 ) -> plt.Figure: 

433 """ 

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

435 

436 Parameters 

437 ---------- 

438 stampNum : `int`, optional 

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

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

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

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

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

444 plo : `float`, optional 

445 Lower percentile for intensity scaling. 

446 phi : `float`, optional 

447 Upper percentile for intensity scaling. 

448 cutoutSize : `int`, optional 

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

450 isAnimated : `bool`, optional 

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

452 saveAs : `str`, optional 

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

454 

455 Returns 

456 ------- 

457 fig : `matplotlib.figure.Figure` 

458 The resulting figure. 

459 """ 

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

461 self._starMosaic( 

462 stampNum=stampNum, 

463 fig=fig, 

464 axs=axs, 

465 plo=plo, 

466 phi=phi, 

467 cutoutSize=cutoutSize, 

468 isAnimated=False, 

469 saveAs=saveAs, 

470 ) 

471 return fig 

472 

473 def makeAnimation( 

474 self, 

475 cutoutSize: int, 

476 saveAs: str = "", 

477 fps: int = 5, 

478 dpi: int = 80, 

479 plo: float = 50, 

480 phi: float = 99, 

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

482 holdFrames: int = 2, 

483 ) -> animation.ArtistAnimation: 

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

485 

486 Parameters 

487 ---------- 

488 cutoutSize : `int`, optional 

489 Size scale used for arrow lengths and framing. 

490 saveAs : `str`, optional 

491 Output filepath for the GIF. 

492 fps : `int`, optional 

493 Frames per second. 

494 dpi : `int`, optional 

495 Output resolution in dots per inch. 

496 plo : `float`, optional 

497 Lower percentile for image scaling. 

498 phi : `float`, optional 

499 Upper percentile for image scaling. 

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

501 Figure size in inches. 

502 holdFrames : `int`, optional 

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

504 

505 Returns 

506 ------- 

507 ani : `matplotlib.animation.ArtistAnimation` 

508 The created animation object. 

509 """ 

510 # build canvas 

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

512 

513 if self.starsDf.empty: 

514 self.log.warning("stars_df is empty. No data to plot.") 

515 return animation.ArtistAnimation(fig, [], interval=1000 / fps, blit=True, repeat_delay=1000) 

516 

517 # number of frames 

518 total = len(self.guiderData) 

519 

520 # initial (stacked) frame 

521 artists0 = self._starMosaic( 

522 stampNum=-1, 

523 fig=fig, 

524 axs=axs, 

525 plo=plo, 

526 phi=phi, 

527 cutoutSize=cutoutSize, 

528 isAnimated=False, 

529 ) 

530 

531 frames = holdFrames * [artists0] 

532 

533 # sequential stamps 

534 for i in range(1, total): 

535 artists = self._starMosaic( 

536 stampNum=i, 

537 fig=fig, 

538 axs=axs, 

539 plo=plo, 

540 phi=phi, 

541 cutoutSize=cutoutSize, 

542 isAnimated=True, 

543 ) 

544 frames.append(artists) 

545 frames += holdFrames * [artists0] 

546 

547 # create animation 

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

549 

550 if saveAs: 

551 writer = _getWriter(saveAs) 

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

553 return ani 

554 

555 

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

557 """ 

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

559 

560 Parameters 

561 ---------- 

562 statsDf : `pandas.DataFrame` 

563 Statistics DataFrame containing centroid scatter columns. 

564 expId : `int` 

565 Exposure identifier. 

566 

567 Returns 

568 ------- 

569 jitter : `float` 

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

571 """ 

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

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

574 return np.hypot(stdAz, stdAlt) 

575 

576 

577def drawArrows( 

578 ax: plt.Axes, 

579 cutoutSize: int, 

580 rotAngle: float = 0.0, 

581 *, 

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

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

584 baseColor: str = LIGHT_BLUE, 

585 overlayColor: str = "lightgrey", 

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

587) -> None: 

588 """ 

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

590 

591 Parameters 

592 ---------- 

593 ax : `matplotlib.axes.Axes` 

594 Target axes. 

595 cutoutSize : `int` 

596 Size scale used for arrow lengths and framing. 

597 rotAngle : `float`, optional 

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

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

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

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

602 Labels for the rotated Alt/Az axes. 

603 baseColor : `str`, optional 

604 Color for base axes arrows. 

605 overlayColor : `str`, optional 

606 Color for rotated axes arrows. 

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

608 Arrow origin; defaults to cutout center when None. 

609 """ 

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

611 L = cutoutSize / 3.0 

612 

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

614 t = np.radians(theta_deg) 

615 # unit vectors rotated by t 

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

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

618 

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

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

621 ax.arrow( 

622 *coords, 

623 color=color, 

624 width=cutoutSize / 120, 

625 head_width=cutoutSize / 30, 

626 head_length=cutoutSize / 20, 

627 length_includes_head=True, 

628 zorder=10, 

629 ) 

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

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

632 

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

634 _ = _draw(0.0, baseColor, baseLabels) 

635 deltas = _draw(rotAngle, overlayColor, overlayLabels) 

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

637 

638 # Framing 

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

640 border = cutoutSize * 0.15 

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

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

643 

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

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

646 clearAxisTicks(ax, isSpine=True) 

647 ax.set_axis_off() 

648 

649 

650def getReferenceCenter( 

651 starsDf: pd.DataFrame, 

652 detName: str, 

653 stampNum: int, 

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

655 """ 

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

657 

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

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

660 

661 Parameters 

662 ---------- 

663 starsDf : `pandas.DataFrame` 

664 Star measurements table. 

665 detName : `str` 

666 Detector name. 

667 stampNum : `int` 

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

669 

670 Returns 

671 ------- 

672 center_ref : `tuple[float, float]` 

673 Reference center coordinates. 

674 delta : `tuple[float, float]` 

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

676 """ 

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

678 if not mask1.any(): 

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

680 

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

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

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

684 

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

686 mask = mask1 & mask2 

687 

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

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

690 return (refX, refY), (0.0, 0.0) 

691 

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

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

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

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

696 

697 

698def renderStampPanel( 

699 ax: plt.Axes, 

700 guiderData: GuiderData, 

701 detName: str, 

702 stampNum: int, 

703 *, 

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

705 cutoutSize: int = -1, 

706 plo: float = 50.0, 

707 phi: float = 99.0, 

708 annotate: bool = True, 

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

710 """ 

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

712 and annotation. 

713 

714 Steps: 

715 1. Select stamp or coadd. 

716 2. Optionally crop around center. 

717 3. Apply percentile scaling. 

718 4. Display with imshow. 

719 5. Optionally annotate detector label. 

720 

721 Parameters 

722 ---------- 

723 ax : `matplotlib.axes.Axes` 

724 Target axes. 

725 guiderData : `GuiderData` 

726 Guider data container. 

727 detName : `str` 

728 Detector name. 

729 stampNum : `int` 

730 Stamp index; negative for stacked/coadd. 

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

732 Center for cropping; image center if None. 

733 cutoutSize : `int`, optional 

734 Square cutout size; -1 for full frame. 

735 plo : `float`, optional 

736 Lower percentile for scaling. 

737 phi : `float`, optional 

738 Upper percentile for scaling. 

739 annotate : `bool`, optional 

740 If True, add detector label text. 

741 

742 Returns 

743 ------- 

744 image : `matplotlib.image.AxesImage` 

745 Image artist. 

746 centerCutout : `tuple[float, float]` 

747 Center coordinates within the (possibly cropped) image. 

748 shape : `tuple[int, int]` 

749 Shape of the displayed (possibly cropped) image. 

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

751 Detector label artist if added. 

752 """ 

753 # 1) image 

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

755 

756 # 2) center + crop 

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

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

759 if center is None: 

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

761 else: 

762 cx_raw, cy_raw = center 

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

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

765 

766 if cutoutSize > 0: 

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

768 else: 

769 cutout = img # ndarray 

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

771 

772 # 3) scaling 

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

774 

775 # 4) render 

776 im = ax.imshow( 

777 cutout, 

778 origin="lower", 

779 cmap="Greys", 

780 vmin=vmin, 

781 vmax=vmax, 

782 interpolation="nearest", 

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

784 animated=True, 

785 ) 

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

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

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

789 

790 # 5) optional label 

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

792 

793 return im, centerCutout, cutout.shape, label 

794 

795 

796def labelDetector( 

797 ax: plt.Axes, 

798 name: str, 

799 *, 

800 corner: str = "tl", 

801 color: str = LIGHT_BLUE, 

802 fontsize: int = 9, 

803 weight: str = "bold", 

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

805) -> plt.Text: 

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

807 

808 Parameters 

809 ---------- 

810 ax : `matplotlib.axes.Axes` 

811 Target axes. 

812 name : `str` 

813 Detector name to display. 

814 corner : `str`, optional 

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

816 color : `str`, optional 

817 Text color. 

818 fontsize : `int`, optional 

819 Font size. 

820 weight : `str`, optional 

821 Font weight. 

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

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

824 

825 Returns 

826 ------- 

827 text : `matplotlib.text.Text` 

828 Created text artist. 

829 """ 

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

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

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

833 

834 xpad, ypad = pad 

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

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

837 

838 txt = ax.text( 

839 xpos, 

840 ypos, 

841 name, 

842 transform=ax.transAxes, 

843 ha=ha, 

844 va=va, 

845 fontsize=fontsize, 

846 weight=weight, 

847 color=color, 

848 ) 

849 return txt 

850 

851 

852def annotateStampInfo( 

853 ax: plt.Axes, 

854 *, 

855 expid: int, 

856 stampNum: int, 

857 nStamps: int, 

858 view: str | None = None, 

859 jitter: float | None = None, 

860 extra: str | None = None, 

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

862) -> plt.Text: 

863 """ 

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

865 

866 Parameters 

867 ---------- 

868 ax : `matplotlib.axes.Axes` 

869 Target axes. 

870 expid : `int` 

871 Exposure identifier. 

872 stampNum : `int` 

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

874 nStamps : `int` 

875 Total number of stamps available. 

876 view : `str`, optional 

877 Orientation descriptor. 

878 jitter : `float`, optional 

879 Combined centroid scatter (arcsec). 

880 extra : `str`, optional 

881 Additional free-form text. 

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

883 Annotation position in axes coordinates. 

884 

885 Returns 

886 ------- 

887 text : `matplotlib.text.Text` 

888 Created text artist. 

889 """ 

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

891 

892 text = "" 

893 if jitter is not None: 

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

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

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

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

898 if extra is not None: 

899 text += "\n" + extra 

900 

901 txt = ax.text( 

902 xy[0], 

903 xy[1], 

904 text, 

905 transform=ax.transAxes, 

906 ha="center", 

907 va="center", 

908 fontsize=14, 

909 color="grey", 

910 ) 

911 clearAxisTicks(ax, isSpine=True) 

912 return txt 

913 

914 

915def addStaticOverlays( 

916 axsImg: plt.Axes, 

917 detName: str, 

918 centerCutout: tuple[float, float], 

919 cutoutSize: int, 

920 camRotAngle: float, 

921) -> None: 

922 """ 

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

924 

925 Parameters 

926 ---------- 

927 axsImg : `matplotlib.axes.Axes` 

928 Target axes. 

929 detName : `str` 

930 Detector name. 

931 centerCutout : `tuple[float, float]` 

932 Center coordinates in the displayed cutout. 

933 cutoutSize : `int` 

934 Cutout size; influences overlay scaling. 

935 camRotAngle : `float` 

936 Camera rotation angle (degrees). 

937 """ 

938 _ = labelDetector(axsImg, detName) 

939 if cutoutSize > 0: 

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

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

942 plotCrosshairRotated( 

943 centerCutout, 

944 90 + camRotAngle, 

945 axs=axsImg, 

946 color="grey", 

947 size=cutoutSize, 

948 ) 

949 radii = [10, 5] 

950 _ = drawGuideCircles( 

951 axsImg, 

952 centerCutout, 

953 radii=radii, 

954 colors=[LIGHT_BLUE, LIGHT_BLUE], 

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

956 linewidth=2.0, 

957 ) 

958 

959 

960def drawGuideCircles( 

961 ax: plt.Axes, 

962 center: tuple[float, float], 

963 radii: Sequence[float], 

964 colors: Sequence[str], 

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

966 text_offset: float = 1.0, 

967 **circle_kwargs: Any, 

968) -> list[plt.Text]: 

969 """ 

970 Draw concentric guide circles with optional labels. 

971 

972 Parameters 

973 ---------- 

974 ax : `matplotlib.axes.Axes` 

975 Target axes. 

976 center : `tuple[float, float]` 

977 Center (x, y) of circles. 

978 radii : `Sequence[float]` 

979 Radii of circles. 

980 colors : `Sequence[str]` 

981 Edge colors per circle. 

982 labels : `Sequence[str]`, optional 

983 Labels per circle; None for no labels. 

984 text_offset : `float`, optional 

985 Offset for label placement along +x. 

986 **circle_kwargs : `dict` 

987 Extra keyword args passed to Circle. 

988 

989 Returns 

990 ------- 

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

992 List of label text artists. 

993 """ 

994 x0, y0 = center 

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

996 for i, r in enumerate(radii): 

997 c = Circle( 

998 (x0, y0), 

999 r, 

1000 edgecolor=colors[i], 

1001 facecolor="none", 

1002 linestyle="--", 

1003 **circle_kwargs, 

1004 ) 

1005 ax.add_patch(c) 

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

1007 txt = ax.text( 

1008 x0 + r + text_offset, 

1009 y0 - r / 4.0, 

1010 label_txt, 

1011 color=colors[i], 

1012 va="center", 

1013 fontsize=8, 

1014 ) 

1015 txt_list.append(txt) 

1016 return txt_list 

1017 

1018 

1019def plotCrosshairRotated( 

1020 center: tuple[float, float], 

1021 angle: float, 

1022 axs: plt.Axes, 

1023 color: str = "grey", 

1024 size: int = 30, 

1025) -> None: 

1026 """ 

1027 Plot a rotated crosshair centered on given coordinates. 

1028 

1029 Parameters 

1030 ---------- 

1031 center : `tuple[float, float]` 

1032 Center (x, y) in image coordinates. 

1033 angle : `float` 

1034 Rotation angle in degrees. 

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

1036 Target axes or None to use current. 

1037 color : `str`, optional 

1038 Line color. 

1039 size : `int`, optional 

1040 Size scaling factor. 

1041 """ 

1042 # make a cross rotated by the camera rotation angle 

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

1044 theta = np.radians(angle) 

1045 

1046 # Cross center 

1047 cx, cy = center 

1048 # Horizontal line (rotated) 

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

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

1051 axs.plot( 

1052 [cx - dx, cx + dx], 

1053 [cy - dy, cy + dy], 

1054 color=color, 

1055 ls="--", 

1056 lw=1.0, 

1057 alpha=0.5, 

1058 ) 

1059 # Vertical line (rotated) 

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

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

1062 axs.plot( 

1063 [cx - dx_v, cx + dx_v], 

1064 [cy - dy_v, cy + dy_v], 

1065 color=color, 

1066 ls="--", 

1067 lw=1.0, 

1068 alpha=0.5, 

1069 ) 

1070 

1071 

1072def plotStarCentroid( 

1073 ax: plt.Axes, 

1074 centerCutout: tuple[float, float], 

1075 *, 

1076 deltaXY: tuple[float, float], 

1077 markerSize: int = 8, 

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

1079 color: str = "firebrick", 

1080) -> list[Line2D]: 

1081 """ 

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

1083 

1084 Parameters 

1085 ---------- 

1086 ax : `matplotlib.axes.Axes` 

1087 Target axes. 

1088 centerCutout : `tuple[float, float]` 

1089 Reference center in the cutout. 

1090 deltaXY : `tuple[float, float]` 

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

1092 markerSize : `int`, optional 

1093 Marker size; <=0 disables plotting. 

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

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

1096 color : `str`, optional 

1097 Marker and line color. 

1098 

1099 Returns 

1100 ------- 

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

1102 Marker and error bar line artists. 

1103 """ 

1104 if markerSize <= 0: 

1105 return [] 

1106 

1107 x_c, y_c = centerCutout 

1108 dx, dy = deltaXY 

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

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

1111 

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

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

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

1115 return [marker, hline, vline] 

1116 

1117 

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

1119 """ 

1120 Remove all ticks/labels; optionally keep spines. 

1121 

1122 Parameters 

1123 ---------- 

1124 ax : `matplotlib.axes.Axes` 

1125 Target axes. 

1126 isSpine : `bool`, optional 

1127 If True, retain spines; otherwise hide them. 

1128 """ 

1129 ax.set_xticks([]) 

1130 ax.set_yticks([]) 

1131 ax.set_xticklabels([]) 

1132 ax.set_yticklabels([]) 

1133 

1134 if not isSpine: 

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

1136 spine.set_visible(False) 

1137 

1138 

1139def cropAroundCenter( 

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

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

1142 """ 

1143 Extract a square crop centered on the specified coordinates. 

1144 

1145 Parameters 

1146 ---------- 

1147 image : `numpy.ndarray` 

1148 Source 2D image. 

1149 center : `tuple[float, float]` 

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

1151 size : `int` 

1152 Desired square size in pixels. 

1153 

1154 Returns 

1155 ------- 

1156 cropped : `numpy.ndarray` 

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

1158 new_center : `tuple[float, float]` 

1159 Center coordinates within the cropped image. 

1160 """ 

1161 x, y = center 

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

1163 half = size // 2 

1164 

1165 y0 = max(0, y - half) 

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

1167 x0 = max(0, x - half) 

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

1169 

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

1171 

1172 # Calculate offsets for padding 

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

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

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

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

1177 

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

1179 cropped = np.pad( 

1180 cropped, 

1181 ((padTop, padBottom), (padLeft, padRight)), 

1182 mode="constant", 

1183 constant_values=np.nan, 

1184 ) 

1185 

1186 # New center in cropped image 

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

1188 return cropped, newCenter