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

321 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-07 09:02 +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" 

48DEFAULT_CUTOUT_SIZE = 30 

49 

50 

51@dataclass 

52class StarInfo: 

53 """Container for star position information in a detector panel. 

54 

55 Attributes 

56 ---------- 

57 hasData : bool 

58 Whether valid star measurement data exists. 

59 refCenter : tuple[float, float] 

60 Reference center (median xroi_ref, yroi_ref) for the fixed frame. 

61 starCenter : tuple[float, float] 

62 Actual star centroid (xroi, yroi) for the current stamp. 

63 """ 

64 

65 hasData: bool 

66 refCenter: tuple[float, float] 

67 starCenter: tuple[float, float] 

68 

69 @classmethod 

70 def from_stars_df(cls, starsDf: pd.DataFrame, detName: str, stampNum: int) -> StarInfo: 

71 """Create StarInfo from stars DataFrame. 

72 

73 Parameters 

74 ---------- 

75 starsDf : pandas.DataFrame 

76 Star measurements table. 

77 detName : str 

78 Detector name. 

79 stampNum : int 

80 Stamp index; negative implies stacked image. 

81 

82 Returns 

83 ------- 

84 StarInfo 

85 Star position information. 

86 

87 Raises 

88 ------ 

89 ValueError 

90 If no rows exist for the detector. 

91 """ 

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

93 if not mask1.any(): 

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

95 

96 # Reference center (fixed frame) 

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

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

99 refCenter = (refX, refY) 

100 

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

102 mask = mask1 & mask2 

103 

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

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

106 return cls(hasData=True, refCenter=refCenter, starCenter=refCenter) 

107 

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

109 starCenter = (float(row["xroi"]), float(row["yroi"])) 

110 return cls(hasData=True, refCenter=refCenter, starCenter=starCenter) 

111 

112 @classmethod 

113 def from_image_center(cls, shape: tuple[int, int]) -> StarInfo: 

114 """Create StarInfo centered on image. 

115 

116 Parameters 

117 ---------- 

118 shape : tuple[int, int] 

119 Image shape (height, width). 

120 

121 Returns 

122 ------- 

123 StarInfo 

124 Star position centered on image. 

125 """ 

126 center = (shape[1] / 2.0, shape[0] / 2.0) 

127 return cls(hasData=False, refCenter=center, starCenter=center) 

128 

129 

130STRIP_PLOT_KWARGS: dict[str, dict] = { 

131 "centroidAltAz": { 

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

133 "unit": "arcsec", 

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

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

136 }, 

137 "centroidPixel": { 

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

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

140 "unit": "pixels", 

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

142 }, 

143 "flux": { 

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

145 "col": ["magoffset"], 

146 "unit": "mmag", 

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

148 "title": "Flux Magnitude Offsets", 

149 }, 

150 "rotator": { 

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

152 "col": ["dtheta"], 

153 "unit": "arcsec", 

154 "scale": 1.0, 

155 "title": "Rotation Angle", 

156 }, 

157 "ellip": { 

158 "ylabel": "Ellipticity", 

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

160 "unit": "", 

161 "title": "", 

162 }, 

163 "psf": { 

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

165 "col": ["fwhm"], 

166 "scale": 1.0, 

167 "unit": "arcsec", 

168 "title": "PSF FWHM", 

169 }, 

170} 

171 

172 

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

174 """ 

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

176 

177 Parameters 

178 ---------- 

179 extension : `str` 

180 Filename to determine the writer type. 

181 

182 Returns 

183 ------- 

184 writer : `str` 

185 The name of the writer to use. 

186 

187 Raises 

188 ------ 

189 ValueError 

190 If the file extension is not supported. 

191 """ 

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

193 match extension.lower(): 

194 case ".gif": 

195 return "pillow" 

196 case ".mp4": 

197 return "ffmpeg" 

198 case _: 

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

200 

201 

202@dataclass(frozen=True) 

203class MosaicLayout: 

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

205 default_factory=lambda: [ 

206 (".", "R40_SG1", "R44_SG0", "."), 

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

208 ("R00_SG1", ".", ".", "R04_SG0"), 

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

210 ] 

211 ) 

212 

213 def build( 

214 self, 

215 *, 

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

217 hspace: float = 0.0, 

218 wspace: float = 0.0, 

219 constrained_layout: bool = False, 

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

221 """ 

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

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

224 

225 Parameters 

226 ---------- 

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

228 Figure size in inches (width, height). 

229 hspace : `float`, optional 

230 Height space between subplots (gridspec hspace). 

231 wspace : `float`, optional 

232 Width space between subplots (gridspec wspace). 

233 constrained_layout : `bool`, optional 

234 Whether to enable Matplotlib constrained layout. 

235 

236 Returns 

237 ------- 

238 fig : `matplotlib.figure.Figure` 

239 The created figure. 

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

241 Mapping from mosaic panel labels to axes. 

242 """ 

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

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

245 

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

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

248 cast(Any, self.grid), 

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

250 sharex=False, # keep the original intent 

251 sharey=False, 

252 ) 

253 return fig, axs 

254 

255 

256class GuiderPlotter: 

257 # The MARKERS lists are used for plotting different detector 

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

259 

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

261 self.log = logging.getLogger(__name__) 

262 self.expId = guiderData.expid 

263 self.layout: MosaicLayout = MosaicLayout() 

264 

265 self.guiderData = guiderData 

266 

267 # Some metadata information 

268 self.expTime = self.guiderData.guiderDurationSec 

269 self.camRotAngle = self.guiderData.camRotAngle 

270 

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

272 if starsDf is not None: 

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

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

275 

276 # set seaborn style 

277 sns.set_style("white") 

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

279 

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

281 """ 

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

283 

284 Parameters 

285 ---------- 

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

287 Figure size in inches (width, height). 

288 

289 Returns 

290 ------- 

291 fig : `matplotlib.figure.Figure` 

292 The created figure. 

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

294 Mapping of panel name to axes. 

295 """ 

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

297 return fig, axs 

298 

299 def stripPlot( 

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

301 ) -> plt.Figure: 

302 """ 

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

304 

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

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

307 lines or median PSF as needed. 

308 

309 Parameters 

310 ---------- 

311 plotType : `str`, optional 

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

313 saveAs : `str`, optional 

314 If not None, path to save the figure. 

315 coveragePct : `int`, optional 

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

317 

318 Returns 

319 ------- 

320 stripFig : `matplotlib.figure.Figure` 

321 Figure containing the strip plot panels. 

322 """ 

323 if self.starsDf.empty: 

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

325 

326 cfg = STRIP_PLOT_KWARGS.get(plotType) 

327 if cfg is None: 

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

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

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

331 

332 # get alt/az 

333 alt = self.guiderData.alt 

334 az = self.guiderData.az 

335 

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

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

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

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

340 axes = np.atleast_1d(axes) 

341 

342 cols = cfg["col"] 

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

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

345 expTime = float(self.expTime) 

346 

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

348 

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

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

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

352 sigma = mad_std(yvals, ignore_nan=True) 

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

354 

355 def _zero(ax, c): 

356 label = { 

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

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

359 "dx": "CCD X", 

360 "dy": "CCD Y", 

361 "e1_altaz": "e1", 

362 "e2_altaz": "e2", 

363 "magoffset": "Magnitude Offset", 

364 "dtheta": "Rotation Offset", 

365 }.get(c, "") 

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

367 

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

369 _zero(ax, c) 

370 fitter = RobustFitter() 

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

372 txt = ( 

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

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

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

376 ) 

377 ax.text( 

378 0.02, 

379 0.98, 

380 txt, 

381 transform=ax.transAxes, 

382 ha="left", 

383 va="top", 

384 fontsize=11, 

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

386 ) 

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

388 

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

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

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

392 ax.scatter( 

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

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

395 color="lightgrey", 

396 alpha=0.6, 

397 marker=m, 

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

399 ) 

400 out = msk & fitter.outlierMask 

401 ax.scatter( 

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

403 ) 

404 

405 if i == 0: 

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

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

408 ax.set_ylim(*ylims) 

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

410 

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

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

413 if saveAs: 

414 fig.savefig(saveAs, dpi=120) 

415 return fig 

416 

417 def _starMosaic( 

418 self, 

419 stampNum: int = 2, 

420 fig: plt.Figure | None = None, 

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

422 plo: float = 90.0, 

423 phi: float = 99.0, 

424 cutoutSize: int = -1, 

425 isAnimated: bool = False, 

426 saveAs: str | None = None, 

427 ) -> list[Artist]: 

428 """ 

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

430 Wraps the plotting logic for static and animated frames. 

431 """ 

432 if fig is None or axs is None: 

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

434 

435 if not self.withStars and cutoutSize > 0: 

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

437 cutoutSize = -1 

438 

439 nStamps = len(self.guiderData) 

440 view = self.guiderData.view 

441 camAngle = self.guiderData.camRotAngle 

442 

443 jitter: float | None = None 

444 artists: list[Artist] = [] 

445 

446 for detName in self.guiderData.guiderNames: 

447 ax = axs[detName] 

448 

449 # Get star info for this detector 

450 starInfo = self._getStarInfo(detName, stampNum) 

451 

452 # Render the stamp 

453 imObj = renderStampPanel( 

454 ax, 

455 self.guiderData, 

456 detName, 

457 stampNum, 

458 viewCenter=starInfo.refCenter, 

459 cutoutSize=cutoutSize, 

460 plo=plo, 

461 phi=phi, 

462 ) 

463 artists.append(imObj) 

464 

465 # Static overlays (reference frame elements) 

466 if not isAnimated: 

467 addReferenceOverlays(ax, detName, starInfo.refCenter, cutoutSize) 

468 

469 # Animated overlays (star position elements) 

470 if starInfo.hasData and cutoutSize > 0: 

471 # Rotated crosshair follows the star 

472 crosshairArtists = plotCrosshairRotated( 

473 starInfo.starCenter, 

474 90 + camAngle, 

475 ax, 

476 color="grey", 

477 size=cutoutSize, 

478 ) 

479 artists.extend(crosshairArtists) 

480 

481 # Star centroid marker 

482 starCross = plotStarCentroid( 

483 ax, 

484 starInfo.starCenter, 

485 markerSize=8, 

486 ) 

487 artists.extend(starCross) 

488 

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

490 

491 # Center panel annotation 

492 stampInfo = annotateStampInfo( 

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

494 ) 

495 artists.append(stampInfo) 

496 

497 # Arrow panel 

498 if not isAnimated: 

499 drawArrows( 

500 axs["arrow"], 

501 cutoutSize if cutoutSize > 0 else DEFAULT_CUTOUT_SIZE, 

502 90.0 + self.camRotAngle, 

503 ) 

504 

505 # Clear ticks - detector panels keep spines for full frame, 

506 # center/arrow never have spines 

507 for name, ax in axs.items(): 

508 isDetector = name not in ("center", "arrow") 

509 clearAxisTicks(ax, isSpine=isDetector and cutoutSize < 0) 

510 

511 if saveAs: 

512 fig.savefig(saveAs, dpi=120) 

513 

514 return artists 

515 

516 def _getStarInfo(self, detName: str, stampNum: int) -> StarInfo: 

517 """Get star info for a detector, falling back to image center 

518 if unavailable. 

519 """ 

520 if not self.withStars: 

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

522 return StarInfo.from_image_center(shape) 

523 

524 detMask = self.starsDf["detector"] == detName 

525 if not detMask.any(): 

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

527 return StarInfo.from_image_center(shape) 

528 

529 return StarInfo.from_stars_df(self.starsDf, detName, stampNum) 

530 

531 def plotMosaic( 

532 self, 

533 stampNum: int = 2, 

534 plo: float = 90.0, 

535 phi: float = 99.0, 

536 cutoutSize: int = -1, 

537 saveAs: str | None = None, 

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

539 ) -> plt.Figure: 

540 """ 

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

542 

543 Parameters 

544 ---------- 

545 stampNum : `int`, optional 

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

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

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

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

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

551 plo : `float`, optional 

552 Lower percentile for intensity scaling. 

553 phi : `float`, optional 

554 Upper percentile for intensity scaling. 

555 cutoutSize : `int`, optional 

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

557 isAnimated : `bool`, optional 

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

559 saveAs : `str`, optional 

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

561 

562 Returns 

563 ------- 

564 fig : `matplotlib.figure.Figure` 

565 The resulting figure. 

566 """ 

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

568 self._starMosaic( 

569 stampNum=stampNum, 

570 fig=fig, 

571 axs=axs, 

572 plo=plo, 

573 phi=phi, 

574 cutoutSize=cutoutSize, 

575 isAnimated=False, 

576 saveAs=saveAs, 

577 ) 

578 return fig 

579 

580 def makeAnimation( 

581 self, 

582 cutoutSize: int, 

583 saveAs: str = "", 

584 fps: int = 5, 

585 dpi: int = 80, 

586 plo: float = 50, 

587 phi: float = 99, 

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

589 holdFrames: int = 2, 

590 ) -> animation.ArtistAnimation: 

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

592 

593 Parameters 

594 ---------- 

595 cutoutSize : `int`, optional 

596 Size scale used for arrow lengths and framing. 

597 saveAs : `str`, optional 

598 Output filepath for the GIF. 

599 fps : `int`, optional 

600 Frames per second. 

601 dpi : `int`, optional 

602 Output resolution in dots per inch. 

603 plo : `float`, optional 

604 Lower percentile for image scaling. 

605 phi : `float`, optional 

606 Upper percentile for image scaling. 

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

608 Figure size in inches. 

609 holdFrames : `int`, optional 

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

611 

612 Returns 

613 ------- 

614 ani : `matplotlib.animation.ArtistAnimation` 

615 The created animation object. 

616 """ 

617 # build canvas 

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

619 

620 # number of frames 

621 total = len(self.guiderData) 

622 

623 # initial (stacked) frame 

624 artists0 = self._starMosaic( 

625 stampNum=-1, 

626 fig=fig, 

627 axs=axs, 

628 plo=plo, 

629 phi=phi, 

630 cutoutSize=cutoutSize, 

631 isAnimated=False, 

632 ) 

633 

634 frames = holdFrames * [artists0] 

635 

636 # sequential stamps 

637 for i in range(1, total): 

638 artists = self._starMosaic( 

639 stampNum=i, 

640 fig=fig, 

641 axs=axs, 

642 plo=plo, 

643 phi=phi, 

644 cutoutSize=cutoutSize, 

645 isAnimated=True, 

646 ) 

647 frames.append(artists) 

648 frames += holdFrames * [artists0] 

649 

650 # create animation 

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

652 

653 if saveAs: 

654 writer = _getWriter(saveAs) 

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

656 return ani 

657 

658 

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

660 """ 

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

662 

663 Parameters 

664 ---------- 

665 statsDf : `pandas.DataFrame` 

666 Statistics DataFrame containing centroid scatter columns. 

667 expId : `int` 

668 Exposure identifier. 

669 

670 Returns 

671 ------- 

672 jitter : `float` 

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

674 """ 

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

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

677 return np.hypot(stdAz, stdAlt) 

678 

679 

680def drawArrows( 

681 ax: plt.Axes, 

682 cutoutSize: int, 

683 rotAngle: float = 0.0, 

684 *, 

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

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

687 baseColor: str = LIGHT_BLUE, 

688 overlayColor: str = "lightgrey", 

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

690) -> None: 

691 """ 

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

693 

694 Parameters 

695 ---------- 

696 ax : `matplotlib.axes.Axes` 

697 Target axes. 

698 cutoutSize : `int` 

699 Size scale used for arrow lengths and framing. 

700 rotAngle : `float`, optional 

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

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

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

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

705 Labels for the rotated Alt/Az axes. 

706 baseColor : `str`, optional 

707 Color for base axes arrows. 

708 overlayColor : `str`, optional 

709 Color for rotated axes arrows. 

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

711 Arrow origin; defaults to cutout center when None. 

712 """ 

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

714 L = cutoutSize / 3.0 

715 

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

717 t = np.radians(theta_deg) 

718 # unit vectors rotated by t 

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

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

721 

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

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

724 ax.arrow( 

725 *coords, 

726 color=color, 

727 width=cutoutSize / 120, 

728 head_width=cutoutSize / 30, 

729 head_length=cutoutSize / 20, 

730 length_includes_head=True, 

731 zorder=10, 

732 ) 

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

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

735 

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

737 _ = _draw(0.0, baseColor, baseLabels) 

738 deltas = _draw(rotAngle, overlayColor, overlayLabels) 

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

740 

741 # Framing 

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

743 border = cutoutSize * 0.15 

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

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

746 

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

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

749 clearAxisTicks(ax, isSpine=True) 

750 ax.set_axis_off() 

751 

752 

753def renderStampPanel( 

754 ax: plt.Axes, 

755 guiderData: GuiderData, 

756 detName: str, 

757 stampNum: int, 

758 *, 

759 viewCenter: tuple[float, float], 

760 cutoutSize: int = -1, 

761 plo: float = 50.0, 

762 phi: float = 99.0, 

763) -> plt.AxesImage: 

764 """ 

765 Render a single detector stamp with zoom around viewCenter. 

766 

767 Parameters 

768 ---------- 

769 ax : `matplotlib.axes.Axes` 

770 Target axes. 

771 guiderData : `GuiderData` 

772 Guider data container. 

773 detName : `str` 

774 Detector name. 

775 stampNum : `int` 

776 Stamp index; negative for stacked/coadd. 

777 viewCenter : `tuple[float, float]` 

778 Center for the view (xlim/ylim) and intensity scaling. 

779 cutoutSize : `int`, optional 

780 Zoom size; -1 for full frame. 

781 plo : `float`, optional 

782 Lower percentile for intensity scaling. 

783 phi : `float`, optional 

784 Upper percentile for intensity scaling. 

785 

786 Returns 

787 ------- 

788 image : `matplotlib.image.AxesImage` 

789 The image artist (for animation). 

790 """ 

791 # Get image 

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

793 h, w = img.shape 

794 

795 # Crop and calculate scaling 

796 if cutoutSize > 0: 

797 cx, cy = int(viewCenter[0]), int(viewCenter[1]) 

798 half = cutoutSize // 2 

799 # Crop bounds (clamped to image) 

800 y0, y1 = max(0, cy - half), min(h, cy + half) 

801 x0, x1 = max(0, cx - half), min(w, cx + half) 

802 else: 

803 y0, y1 = 0, h 

804 x0, x1 = 0, w 

805 

806 region = img[y0:y1, x0:x1] 

807 vmin, vmax = np.nanpercentile(region, [plo, phi]) 

808 extent: tuple[float, float, float, float] = (float(x0), float(x1), float(y0), float(y1)) 

809 

810 # Render only the visible region 

811 im = ax.imshow( 

812 region, 

813 origin="lower", 

814 cmap="Greys", 

815 vmin=vmin, 

816 vmax=vmax, 

817 interpolation="nearest", 

818 extent=extent, 

819 animated=True, 

820 ) 

821 

822 # Set zoom limits 

823 if cutoutSize > 0: 

824 halfF = cutoutSize / 2.0 

825 ax.set_xlim(viewCenter[0] - halfF, viewCenter[0] + halfF) 

826 ax.set_ylim(viewCenter[1] - halfF, viewCenter[1] + halfF) 

827 

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

829 return im 

830 

831 

832def labelDetector( 

833 ax: plt.Axes, 

834 name: str, 

835 *, 

836 corner: str = "tl", 

837 color: str = LIGHT_BLUE, 

838 fontsize: int = 9, 

839 weight: str = "bold", 

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

841) -> plt.Text: 

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

843 

844 Parameters 

845 ---------- 

846 ax : `matplotlib.axes.Axes` 

847 Target axes. 

848 name : `str` 

849 Detector name to display. 

850 corner : `str`, optional 

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

852 color : `str`, optional 

853 Text color. 

854 fontsize : `int`, optional 

855 Font size. 

856 weight : `str`, optional 

857 Font weight. 

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

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

860 

861 Returns 

862 ------- 

863 text : `matplotlib.text.Text` 

864 Created text artist. 

865 """ 

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

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

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

869 

870 xpad, ypad = pad 

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

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

873 

874 txt = ax.text( 

875 xpos, 

876 ypos, 

877 name, 

878 transform=ax.transAxes, 

879 ha=ha, 

880 va=va, 

881 fontsize=fontsize, 

882 weight=weight, 

883 color=color, 

884 ) 

885 return txt 

886 

887 

888def annotateStampInfo( 

889 ax: plt.Axes, 

890 *, 

891 expid: int, 

892 stampNum: int, 

893 nStamps: int, 

894 view: str | None = None, 

895 jitter: float | None = None, 

896 extra: str | None = None, 

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

898) -> plt.Text: 

899 """ 

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

901 

902 Parameters 

903 ---------- 

904 ax : `matplotlib.axes.Axes` 

905 Target axes. 

906 expid : `int` 

907 Exposure identifier. 

908 stampNum : `int` 

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

910 nStamps : `int` 

911 Total number of stamps available. 

912 view : `str`, optional 

913 Orientation descriptor. 

914 jitter : `float`, optional 

915 Combined centroid scatter (arcsec). 

916 extra : `str`, optional 

917 Additional free-form text. 

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

919 Annotation position in axes coordinates. 

920 

921 Returns 

922 ------- 

923 text : `matplotlib.text.Text` 

924 Created text artist. 

925 """ 

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

927 

928 text = "" 

929 if jitter is not None: 

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

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

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

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

934 if extra is not None: 

935 text += "\n" + extra 

936 

937 txt = ax.text( 

938 xy[0], 

939 xy[1], 

940 text, 

941 transform=ax.transAxes, 

942 ha="center", 

943 va="center", 

944 fontsize=14, 

945 color="grey", 

946 ) 

947 clearAxisTicks(ax, isSpine=True) 

948 return txt 

949 

950 

951def addReferenceOverlays( 

952 ax: plt.Axes, 

953 detName: str, 

954 refCenter: tuple[float, float], 

955 cutoutSize: int, 

956) -> None: 

957 """Add static reference overlays: detector label, crosshairs, 

958 and guide circles. 

959 

960 These are static elements that don't change during animation. 

961 

962 Parameters 

963 ---------- 

964 ax : `matplotlib.axes.Axes` 

965 Target axes. 

966 detName : `str` 

967 Detector name. 

968 refCenter : `tuple[float, float]` 

969 Reference center coordinates (fixed frame). 

970 cutoutSize : `int` 

971 Cutout size; influences overlay scaling. 

972 """ 

973 labelDetector(ax, detName) 

974 if cutoutSize > 0: 

975 ax.axvline(refCenter[0], color=LIGHT_BLUE, lw=1.25, linestyle="--", alpha=0.75) 

976 ax.axhline(refCenter[1], color=LIGHT_BLUE, lw=1.25, linestyle="--", alpha=0.75) 

977 radii = [10, 5] 

978 drawGuideCircles( 

979 ax, 

980 refCenter, 

981 radii=radii, 

982 colors=[LIGHT_BLUE, LIGHT_BLUE], 

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

984 linewidth=2.0, 

985 ) 

986 

987 

988def drawGuideCircles( 

989 ax: plt.Axes, 

990 center: tuple[float, float], 

991 radii: Sequence[float], 

992 colors: Sequence[str], 

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

994 text_offset: float = 1.0, 

995 **circle_kwargs: Any, 

996) -> list[plt.Text]: 

997 """ 

998 Draw concentric guide circles with optional labels. 

999 

1000 Parameters 

1001 ---------- 

1002 ax : `matplotlib.axes.Axes` 

1003 Target axes. 

1004 center : `tuple[float, float]` 

1005 Center (x, y) of circles. 

1006 radii : `Sequence[float]` 

1007 Radii of circles. 

1008 colors : `Sequence[str]` 

1009 Edge colors per circle. 

1010 labels : `Sequence[str]`, optional 

1011 Labels per circle; None for no labels. 

1012 text_offset : `float`, optional 

1013 Offset for label placement along +x. 

1014 **circle_kwargs : `dict` 

1015 Extra keyword args passed to Circle. 

1016 

1017 Returns 

1018 ------- 

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

1020 List of label text artists. 

1021 """ 

1022 x0, y0 = center 

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

1024 for i, r in enumerate(radii): 

1025 c = Circle( 

1026 (x0, y0), 

1027 r, 

1028 edgecolor=colors[i], 

1029 facecolor="none", 

1030 linestyle="--", 

1031 **circle_kwargs, 

1032 ) 

1033 ax.add_patch(c) 

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

1035 txt = ax.text( 

1036 x0 + r + text_offset, 

1037 y0 - r / 4.0, 

1038 label_txt, 

1039 color=colors[i], 

1040 va="center", 

1041 fontsize=8, 

1042 ) 

1043 txt_list.append(txt) 

1044 return txt_list 

1045 

1046 

1047def plotCrosshairRotated( 

1048 center: tuple[float, float], 

1049 angle: float, 

1050 ax: plt.Axes, 

1051 color: str = "grey", 

1052 size: int = 30, 

1053) -> list[Line2D]: 

1054 """ 

1055 Plot a rotated crosshair centered on given coordinates. 

1056 

1057 Parameters 

1058 ---------- 

1059 center : `tuple[float, float]` 

1060 Center (x, y) in image coordinates. 

1061 angle : `float` 

1062 Rotation angle in degrees. 

1063 ax : `matplotlib.axes.Axes` 

1064 Target axes. 

1065 color : `str`, optional 

1066 Line color. 

1067 size : `int`, optional 

1068 Size scaling factor. 

1069 

1070 Returns 

1071 ------- 

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

1073 Line artists for animation. 

1074 """ 

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

1076 theta = np.radians(angle) 

1077 

1078 cx, cy = center 

1079 # Horizontal line (rotated) 

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

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

1082 (hline,) = ax.plot( 

1083 [cx - dx, cx + dx], 

1084 [cy - dy, cy + dy], 

1085 color=color, 

1086 ls="--", 

1087 lw=1.0, 

1088 alpha=0.5, 

1089 ) 

1090 # Vertical line (rotated) 

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

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

1093 (vline,) = ax.plot( 

1094 [cx - dx_v, cx + dx_v], 

1095 [cy - dy_v, cy + dy_v], 

1096 color=color, 

1097 ls="--", 

1098 lw=1.0, 

1099 alpha=0.5, 

1100 ) 

1101 return [hline, vline] 

1102 

1103 

1104def plotStarCentroid( 

1105 ax: plt.Axes, 

1106 centerCutout: tuple[float, float], 

1107 *, 

1108 markerSize: int = 8, 

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

1110 color: str = "firebrick", 

1111) -> list[Line2D]: 

1112 """ 

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

1114 

1115 Parameters 

1116 ---------- 

1117 ax : `matplotlib.axes.Axes` 

1118 Target axes. 

1119 centerCutout : `tuple[float, float]` 

1120 Reference center in the cutout. 

1121 markerSize : `int`, optional 

1122 Marker size; <=0 disables plotting. 

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

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

1125 color : `str`, optional 

1126 Marker and line color. 

1127 

1128 Returns 

1129 ------- 

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

1131 Marker and error bar line artists. 

1132 """ 

1133 if markerSize <= 0: 

1134 return [] 

1135 

1136 x_star, y_star = centerCutout 

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

1138 

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

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

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

1142 return [marker, hline, vline] 

1143 

1144 

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

1146 """ 

1147 Remove all ticks/labels; optionally keep spines. 

1148 

1149 Parameters 

1150 ---------- 

1151 ax : `matplotlib.axes.Axes` 

1152 Target axes. 

1153 isSpine : `bool`, optional 

1154 If True, retain spines; otherwise hide them. 

1155 """ 

1156 ax.set_xticks([]) 

1157 ax.set_yticks([]) 

1158 ax.set_xticklabels([]) 

1159 ax.set_yticklabels([]) 

1160 

1161 if not isSpine: 

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

1163 spine.set_visible(False) 

1164 

1165 

1166def cropAroundCenter( 

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

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

1169 """ 

1170 Extract a square crop centered on the specified coordinates. 

1171 

1172 Parameters 

1173 ---------- 

1174 image : `numpy.ndarray` 

1175 Source 2D image. 

1176 center : `tuple[float, float]` 

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

1178 size : `int` 

1179 Desired square size in pixels. 

1180 

1181 Returns 

1182 ------- 

1183 cropped : `numpy.ndarray` 

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

1185 new_center : `tuple[float, float]` 

1186 Center coordinates within the cropped image. 

1187 """ 

1188 x, y = center 

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

1190 half = size // 2 

1191 

1192 y0 = max(0, y - half) 

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

1194 x0 = max(0, x - half) 

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

1196 

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

1198 

1199 # Calculate offsets for padding 

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

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

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

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

1204 

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

1206 cropped = np.pad( 

1207 cropped, 

1208 ((padTop, padBottom), (padLeft, padRight)), 

1209 mode="constant", 

1210 constant_values=np.nan, 

1211 ) 

1212 

1213 # New center in cropped image 

1214 newCenter = (center[0] - x0 + padLeft, center[1] - y0 + padTop) 

1215 return cropped, newCenter