Coverage for python / lsst / summit / utils / guiders / plotting.py: 14%
321 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 09:02 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 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
23import logging
24import os
25from collections.abc import Sequence
26from dataclasses import dataclass, field
27from typing import TYPE_CHECKING, Any, cast
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
39from lsst.summit.utils.utils import RobustFitter
40from lsst.utils.plotting.figures import make_figure
42if TYPE_CHECKING:
43 from .reading import GuiderData
45__all__ = ["GuiderPlotter"]
47LIGHT_BLUE = "#6495ED"
48DEFAULT_CUTOUT_SIZE = 30
51@dataclass
52class StarInfo:
53 """Container for star position information in a detector panel.
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 """
65 hasData: bool
66 refCenter: tuple[float, float]
67 starCenter: tuple[float, float]
69 @classmethod
70 def from_stars_df(cls, starsDf: pd.DataFrame, detName: str, stampNum: int) -> StarInfo:
71 """Create StarInfo from stars DataFrame.
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.
82 Returns
83 -------
84 StarInfo
85 Star position information.
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}")
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)
101 mask2 = starsDf["stamp"] == stampNum
102 mask = mask1 & mask2
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)
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)
112 @classmethod
113 def from_image_center(cls, shape: tuple[int, int]) -> StarInfo:
114 """Create StarInfo centered on image.
116 Parameters
117 ----------
118 shape : tuple[int, int]
119 Image shape (height, width).
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)
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}
173def _getWriter(filename: str) -> str:
174 """
175 Get the appropriate writer for saving animations based on file extension.
177 Parameters
178 ----------
179 extension : `str`
180 Filename to determine the writer type.
182 Returns
183 -------
184 writer : `str`
185 The name of the writer to use.
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}")
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 )
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).
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.
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)
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
256class GuiderPlotter:
257 # The MARKERS lists are used for plotting different detector
258 MARKERS: list[str] = ["o", "x", "+", "*", "^", "v", "s", "p"]
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()
265 self.guiderData = guiderData
267 # Some metadata information
268 self.expTime = self.guiderData.guiderDurationSec
269 self.camRotAngle = self.guiderData.camRotAngle
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
276 # set seaborn style
277 sns.set_style("white")
278 sns.set_context("talk", font_scale=0.8)
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.
284 Parameters
285 ----------
286 figsize : `tuple[float, float]`, optional
287 Figure size in inches (width, height).
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
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.
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.
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).
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.")
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)
332 # get alt/az
333 alt = self.guiderData.alt
334 az = self.guiderData.az
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)
342 cols = cfg["col"]
343 scale = float(cfg.get("scale", 1.0))
344 unit = cfg.get("unit", "")
345 expTime = float(self.expTime)
347 df = self.starsDf.loc[self.starsDf["stamp"] > 0, ["elapsed_time", "detector", *cols]].copy()
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)
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)
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)
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 )
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")
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
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))
435 if not self.withStars and cutoutSize > 0:
436 self.log.warning("No stars data available. Using full frame.")
437 cutoutSize = -1
439 nStamps = len(self.guiderData)
440 view = self.guiderData.view
441 camAngle = self.guiderData.camRotAngle
443 jitter: float | None = None
444 artists: list[Artist] = []
446 for detName in self.guiderData.guiderNames:
447 ax = axs[detName]
449 # Get star info for this detector
450 starInfo = self._getStarInfo(detName, stampNum)
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)
465 # Static overlays (reference frame elements)
466 if not isAnimated:
467 addReferenceOverlays(ax, detName, starInfo.refCenter, cutoutSize)
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)
481 # Star centroid marker
482 starCross = plotStarCentroid(
483 ax,
484 starInfo.starCenter,
485 markerSize=8,
486 )
487 artists.extend(starCross)
489 jitter = getStdCentroid(self.starsDf, self.expId)
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)
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 )
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)
511 if saveAs:
512 fig.savefig(saveAs, dpi=120)
514 return artists
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)
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)
529 return StarInfo.from_stars_df(self.starsDf, detName, stampNum)
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).
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.
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
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.
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.
612 Returns
613 -------
614 ani : `matplotlib.animation.ArtistAnimation`
615 The created animation object.
616 """
617 # build canvas
618 fig, axs = self.setupFigure(figsize=figsize)
620 # number of frames
621 total = len(self.guiderData)
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 )
634 frames = holdFrames * [artists0]
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]
650 # create animation
651 ani = animation.ArtistAnimation(fig, frames, interval=1000 / fps, blit=True, repeat_delay=1000)
653 if saveAs:
654 writer = _getWriter(saveAs)
655 ani.save(saveAs, fps=fps, dpi=dpi, writer=writer)
656 return ani
659def getStdCentroid(statsDf: pd.DataFrame, expId: int) -> float:
660 """
661 Compute combined (quadrature) corrected centroid scatter for an exposure.
663 Parameters
664 ----------
665 statsDf : `pandas.DataFrame`
666 Statistics DataFrame containing centroid scatter columns.
667 expId : `int`
668 Exposure identifier.
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)
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.
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
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)
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)])
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
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)
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()
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.
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.
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
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
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))
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 )
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)
828 ax.set_aspect("equal", "box")
829 return im
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.
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.
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
870 xpad, ypad = pad
871 xpos = xpad if ha == "left" else 1 - xpad
872 ypos = 1 - ypad if va == "top" else ypad
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
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.
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.
921 Returns
922 -------
923 text : `matplotlib.text.Text`
924 Created text artist.
925 """
926 dayObs, seqNum = str(expid)[:8], int(str(expid)[-5:])
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
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
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.
960 These are static elements that don't change during animation.
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 )
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.
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.
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
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.
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.
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)
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]
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.
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.
1128 Returns
1129 -------
1130 artists : `list[matplotlib.lines.Line2D]`
1131 Marker and error bar line artists.
1132 """
1133 if markerSize <= 0:
1134 return []
1136 x_star, y_star = centerCutout
1137 xerr, yerr = errXY if errXY is not None else (0.0, 0.0)
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]
1145def clearAxisTicks(ax: plt.Axes, isSpine: bool = False) -> None:
1146 """
1147 Remove all ticks/labels; optionally keep spines.
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([])
1161 if not isSpine:
1162 for spine in ax.spines.values():
1163 spine.set_visible(False)
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.
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.
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
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)
1197 cropped = image[y0:y1, x0:x1]
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))
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 )
1213 # New center in cropped image
1214 newCenter = (center[0] - x0 + padLeft, center[1] - y0 + padTop)
1215 return cropped, newCenter