Coverage for python / lsst / summit / utils / guiders / plotting.py: 12%
309 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:32 +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"
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}
92def _getWriter(filename: str) -> str:
93 """
94 Get the appropriate writer for saving animations based on file extension.
96 Parameters
97 ----------
98 extension : `str`
99 Filename to determine the writer type.
101 Returns
102 -------
103 writer : `str`
104 The name of the writer to use.
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}")
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 )
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).
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.
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)
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
175class GuiderPlotter:
176 # The MARKERS lists are used for plotting different detector
177 MARKERS: list[str] = ["o", "x", "+", "*", "^", "v", "s", "p"]
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()
184 self.guiderData = guiderData
186 # Some metadata information
187 self.expTime = self.guiderData.guiderDurationSec
188 self.camRotAngle = self.guiderData.camRotAngle
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
195 # set seaborn style
196 sns.set_style("white")
197 sns.set_context("talk", font_scale=0.8)
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.
203 Parameters
204 ----------
205 figsize : `tuple[float, float]`, optional
206 Figure size in inches (width, height).
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
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.
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.
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).
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.")
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)
251 # get alt/az
252 alt = self.guiderData.alt
253 az = self.guiderData.az
255 n = len(cfg["col"])
256 fig = make_figure(figsize=(8 * n, 6))
257 fig.subplots_adjust(hspace=0.0, wspace=0.0)
258 axes = fig.subplots(nrows=1, ncols=n, sharex=True, sharey=True)
259 axes = np.atleast_1d(axes)
261 cols = cfg["col"]
262 scale = float(cfg.get("scale", 1.0))
263 unit = cfg.get("unit", "")
264 expTime = float(self.expTime)
266 df = self.starsDf.loc[self.starsDf["stamp"] > 0, ["elapsed_time", "detector", *cols]].copy()
268 q1, q3 = (100 - coveragePct) / 2, 100 - (100 - coveragePct) / 2
269 yvals = (df[cols].to_numpy(dtype=float) * scale).ravel()
270 p16, p84 = np.nanpercentile(yvals, [q1, q3])
271 sigma = mad_std(yvals, ignore_nan=True)
272 ylims = (p16 - 2.5 * sigma, p84 + 2.5 * sigma)
274 def _zero(ax, c):
275 label = {
276 "daz": f"Az: {az:0.5f} deg",
277 "dalt": f"Alt: {alt:0.5f} deg",
278 "dx": "CCD X",
279 "dy": "CCD Y",
280 "e1_altaz": "e1",
281 "e2_altaz": "e2",
282 "magoffset": "Magnitude Offset",
283 "dtheta": "Rotation Offset",
284 }.get(c, "")
285 ax.axhline(0 if c != "fwhm" else np.nanmedian(df[c] * scale), color="grey", ls="--", label=label)
287 for i, (ax, c) in enumerate(zip(axes, cols)):
288 _zero(ax, c)
289 fitter = RobustFitter()
290 res = fitter.fit(x=np.asarray(df["elapsed_time"].values), y=(df[c].values * scale))
291 txt = (
292 f"Slope: {expTime * res.slope:.2f} {unit}/exposure\n"
293 f"Significance: {abs(res.slopeTValue):.1f} σ\n"
294 f"scatter: {res.scatter:.3f} {unit}\n"
295 )
296 ax.text(
297 0.02,
298 0.98,
299 txt,
300 transform=ax.transAxes,
301 ha="left",
302 va="top",
303 fontsize=11,
304 bbox=dict(facecolor="white", alpha=0.75, edgecolor="none", boxstyle="round,pad=0.3"),
305 )
306 fitter.plotBestFit(ax=ax, color=LIGHT_BLUE, label="", lw=2)
308 for j, det in enumerate(df["detector"].unique()):
309 m = self.MARKERS[j % len(self.MARKERS)]
310 msk = df["detector"].eq(det)
311 ax.scatter(
312 df.loc[msk, "elapsed_time"],
313 df.loc[msk, c] * scale,
314 color="lightgrey",
315 alpha=0.6,
316 marker=m,
317 label=f"{det}" if i == 0 else "",
318 )
319 out = msk & fitter.outlierMask
320 ax.scatter(
321 df.loc[out, "elapsed_time"], df.loc[out, c] * scale, color=LIGHT_BLUE, alpha=0.2, marker=m
322 )
324 if i == 0:
325 ax.set_ylabel(cfg["ylabel"])
326 ax.set_xlabel("Elapsed time [sec]")
327 ax.set_ylim(*ylims)
328 ax.legend(fontsize=10, ncol=4, loc="lower left")
330 title = cfg["title"] + f"\n Expid: {self.expId}"
331 fig.suptitle(title, fontsize=14, fontweight="bold")
332 if saveAs:
333 fig.savefig(saveAs, dpi=120)
334 return fig
336 def _starMosaic(
337 self,
338 stampNum: int = 2,
339 fig: plt.Figure | None = None,
340 axs: dict[str, plt.Axes] | None = None,
341 plo: float = 90.0,
342 phi: float = 99.0,
343 cutoutSize: int = -1,
344 isAnimated: bool = False,
345 saveAs: str | None = None,
346 ) -> list[Artist]:
347 """
348 Internal: plot a mosaic of guider stamps for a given stamp index.
349 Wraps the plotting logic for static and animated frames.
350 """
351 if fig is None or axs is None:
352 fig, axs = self.setupFigure(figsize=(9, 9))
354 if not self.withStars:
355 if cutoutSize > 0:
356 self.log.warning("No stars data available. Using full frame cutout.")
357 cutoutSize = -1
359 nStamps = len(self.guiderData)
360 view = self.guiderData.view
361 camAngle = self.guiderData.camRotAngle
363 jitter: float | None = None
364 artists: list[Artist] = []
365 cutoutShapeList = []
366 for detName in self.guiderData.guiderNames:
367 if self.withStars:
368 # check if detector has data
369 withStars: bool = bool(np.any(self.starsDf["detector"] == detName))
370 else:
371 withStars = False
373 if withStars:
374 refCenter, centroidOffset = getReferenceCenter(self.starsDf, detName, stampNum)
375 else:
376 shape = self.guiderData[detName, 0].shape
377 refCenter = (float(shape[1] // 2), float(shape[0] // 2))
378 centroidOffset = (0.0, 0.0)
380 # Render the stamp panel
381 imObj, centerCutout, shape, _ = renderStampPanel(
382 axs[detName],
383 self.guiderData,
384 detName,
385 stampNum,
386 center=refCenter,
387 cutoutSize=cutoutSize,
388 plo=plo,
389 phi=phi,
390 annotate=False,
391 )
392 # Static overlays (when not animating)
393 if not isAnimated:
394 addStaticOverlays(axs[detName], detName, centerCutout, cutoutSize, camRotAngle=camAngle)
396 artists.append(imObj)
398 if withStars:
399 starCross = plotStarCentroid(
400 axs[detName],
401 centerCutout,
402 deltaXY=centroidOffset,
403 markerSize=8 if cutoutSize > 0 else 0,
404 )
405 jitter = getStdCentroid(self.starsDf, self.expId)
407 artists.extend(starCross)
409 cutoutShapeList.append(shape)
411 stampInfo = annotateStampInfo(
412 axs["center"], expid=self.expId, stampNum=stampNum, nStamps=nStamps, view=view, jitter=jitter
413 )
414 artists.append(stampInfo)
416 cutoutSize = np.max(cutoutShapeList) if cutoutShapeList else 30
418 if not isAnimated:
419 drawArrows(axs["arrow"], cutoutSize, 90.0 + self.camRotAngle)
421 for ax in axs.values():
422 clearAxisTicks(ax, isSpine=cutoutSize < 0)
424 if saveAs:
425 fig.savefig(saveAs, dpi=120)
427 return artists
429 def plotMosaic(
430 self,
431 stampNum: int = 2,
432 plo: float = 90.0,
433 phi: float = 99.0,
434 cutoutSize: int = -1,
435 saveAs: str | None = None,
436 figsize: tuple[float, float] = (9, 9),
437 ) -> plt.Figure:
438 """
439 Plot a mosaic of guider stamps (a single stamp or a stacked image).
441 Parameters
442 ----------
443 stampNum : `int`, optional
444 Stamp index; values < 0 select the stacked (coadd) image.
445 fig : `matplotlib.figure.Figure`, optional
446 Existing figure to draw on; created if ``None``.
447 axs : `dict[str, matplotlib.axes.Axes]`, optional
448 Axes dictionary for the mosaic; created if ``None``.
449 plo : `float`, optional
450 Lower percentile for intensity scaling.
451 phi : `float`, optional
452 Upper percentile for intensity scaling.
453 cutoutSize : `int`, optional
454 Square cutout size around star center; -1 uses full frame.
455 isAnimated : `bool`, optional
456 If True, skip static overlays intended only for static displays.
457 saveAs : `str`, optional
458 If provided, path and filename to which the figure is saved.
460 Returns
461 -------
462 fig : `matplotlib.figure.Figure`
463 The resulting figure.
464 """
465 fig, axs = self.setupFigure(figsize=figsize)
466 self._starMosaic(
467 stampNum=stampNum,
468 fig=fig,
469 axs=axs,
470 plo=plo,
471 phi=phi,
472 cutoutSize=cutoutSize,
473 isAnimated=False,
474 saveAs=saveAs,
475 )
476 return fig
478 def makeAnimation(
479 self,
480 cutoutSize: int,
481 saveAs: str = "",
482 fps: int = 5,
483 dpi: int = 80,
484 plo: float = 50,
485 phi: float = 99,
486 figsize: tuple[float, float] = (9, 9),
487 holdFrames: int = 2,
488 ) -> animation.ArtistAnimation:
489 """Create a gif or mp4 of the guider mosaic across sequential stamps.
491 Parameters
492 ----------
493 cutoutSize : `int`, optional
494 Size scale used for arrow lengths and framing.
495 saveAs : `str`, optional
496 Output filepath for the GIF.
497 fps : `int`, optional
498 Frames per second.
499 dpi : `int`, optional
500 Output resolution in dots per inch.
501 plo : `float`, optional
502 Lower percentile for image scaling.
503 phi : `float`, optional
504 Upper percentile for image scaling.
505 figsize : `tuple[float, float]`, optional
506 Figure size in inches.
507 holdFrames : `int`, optional
508 Number of frames to hold the first and last frames.
510 Returns
511 -------
512 ani : `matplotlib.animation.ArtistAnimation`
513 The created animation object.
514 """
515 # build canvas
516 fig, axs = self.setupFigure(figsize=figsize)
518 # number of frames
519 total = len(self.guiderData)
521 # initial (stacked) frame
522 artists0 = self._starMosaic(
523 stampNum=-1,
524 fig=fig,
525 axs=axs,
526 plo=plo,
527 phi=phi,
528 cutoutSize=cutoutSize,
529 isAnimated=False,
530 )
532 frames = holdFrames * [artists0]
534 # sequential stamps
535 for i in range(1, total):
536 artists = self._starMosaic(
537 stampNum=i,
538 fig=fig,
539 axs=axs,
540 plo=plo,
541 phi=phi,
542 cutoutSize=cutoutSize,
543 isAnimated=True,
544 )
545 frames.append(artists)
546 frames += holdFrames * [artists0]
548 # create animation
549 ani = animation.ArtistAnimation(fig, frames, interval=1000 / fps, blit=True, repeat_delay=1000)
551 if saveAs:
552 writer = _getWriter(saveAs)
553 ani.save(saveAs, fps=fps, dpi=dpi, writer=writer)
554 return ani
557def getStdCentroid(statsDf: pd.DataFrame, expId: int) -> float:
558 """
559 Compute combined (quadrature) corrected centroid scatter for an exposure.
561 Parameters
562 ----------
563 statsDf : `pandas.DataFrame`
564 Statistics DataFrame containing centroid scatter columns.
565 expId : `int`
566 Exposure identifier.
568 Returns
569 -------
570 jitter : `float`
571 Quadrature sum of corrected AZ and ALT centroid scatter (arcsec).
572 """
573 stdAz = mad_std(statsDf.loc[statsDf["expid"] == expId, "dalt"].to_numpy())
574 stdAlt = mad_std(statsDf.loc[statsDf["expid"] == expId, "daz"].to_numpy())
575 return np.hypot(stdAz, stdAlt)
578def drawArrows(
579 ax: plt.Axes,
580 cutoutSize: int,
581 rotAngle: float = 0.0,
582 *,
583 baseLabels: tuple[str, str] = ("Y DVCS", "X DVCS"),
584 overlayLabels: tuple[str, str] = ("Alt", "Az"),
585 baseColor: str = LIGHT_BLUE,
586 overlayColor: str = "lightgrey",
587 center: tuple[float, float] | None = None,
588) -> None:
589 """
590 Draw reference arrows for instrument (DVCS) and rotated Alt/Az axes.
592 Parameters
593 ----------
594 ax : `matplotlib.axes.Axes`
595 Target axes.
596 cutoutSize : `int`
597 Size scale used for arrow lengths and framing.
598 rotAngle : `float`, optional
599 Rotation angle (degrees) for overlay (Alt/Az) axes.
600 baseLabels : `tuple[str, str]`, optional
601 Labels for the base (unrotated) Y/X axes.
602 overlayLabels : `tuple[str, str]`, optional
603 Labels for the rotated Alt/Az axes.
604 baseColor : `str`, optional
605 Color for base axes arrows.
606 overlayColor : `str`, optional
607 Color for rotated axes arrows.
608 center : `tuple[float, float]`, optional
609 Arrow origin; defaults to cutout center when None.
610 """
611 x0, y0 = (cutoutSize // 2, cutoutSize // 2) if center is None else center
612 L = cutoutSize / 3.0
614 def _draw(theta_deg: float, color: str, labels: tuple[str, str]) -> np.ndarray:
615 t = np.radians(theta_deg)
616 # unit vectors rotated by t
617 dx_az, dy_az = L * np.cos(t), L * np.sin(t) # +X (Az)
618 dx_alt, dy_alt = -L * np.sin(t), L * np.cos(t) # +Y (Alt)
620 for dx, dy, label in ((dx_az, dy_az, labels[1]), (dx_alt, dy_alt, labels[0])):
621 coords = (x0, y0, dx, dy)
622 ax.arrow(
623 *coords,
624 color=color,
625 width=cutoutSize / 120,
626 head_width=cutoutSize / 30,
627 head_length=cutoutSize / 20,
628 length_includes_head=True,
629 zorder=10,
630 )
631 ax.text(x0 + dx + 0.5, y0 + dy, label, color=color, fontsize=6, fontweight="bold")
632 return np.array([min(dx_az, dx_alt), min(dy_az, dy_alt)])
634 # Base DVCS axes (θ=0), then rotated Alt/Az overlay
635 _ = _draw(0.0, baseColor, baseLabels)
636 deltas = _draw(rotAngle, overlayColor, overlayLabels)
637 deltas = np.where(deltas > 0, 0, deltas) # only negative shifts
639 # Framing
640 ax.set_aspect("equal", adjustable="box")
641 border = cutoutSize * 0.15
642 xmin = min(x0 - border, x0 + deltas[0] - border)
643 ymin = min(y0 - border, y0 + deltas[1] - border)
645 ax.set_xlim(xmin - border, xmin + cutoutSize + border)
646 ax.set_ylim(ymin - border, ymin + cutoutSize + border)
647 clearAxisTicks(ax, isSpine=True)
648 ax.set_axis_off()
651def getReferenceCenter(
652 starsDf: pd.DataFrame,
653 detName: str,
654 stampNum: int,
655) -> tuple[tuple[float, float], tuple[float, float]]:
656 """
657 Determine reference (x,y) center and centroid offset for a detector/stamp.
659 If there is no measurement for that stamp, or stampNum < 0 (a stacked
660 image), the offset is (0, 0).
662 Parameters
663 ----------
664 starsDf : `pandas.DataFrame`
665 Star measurements table.
666 detName : `str`
667 Detector name.
668 stampNum : `int`
669 Stamp index; negative implies stacked image (zero offset).
671 Returns
672 -------
673 center_ref : `tuple[float, float]`
674 Reference center coordinates.
675 delta : `tuple[float, float]`
676 Centroid offset (dX, dY) relative to the reference center.
677 """
678 mask1 = starsDf["detector"] == detName
679 if not mask1.any():
680 raise ValueError(f"No rows for detector {detName!r}")
682 # Reference center (use .iloc[0] to avoid IndexError)
683 refX = float(starsDf.loc[mask1, "xroi_ref"].median())
684 refY = float(starsDf.loc[mask1, "yroi_ref"].median())
686 mask2 = starsDf["stamp"] == stampNum
687 mask = mask1 & mask2
689 # Fallback: no row for that stamp or stacked request
690 if (not mask.any()) or (stampNum < 0):
691 return (refX, refY), (0.0, 0.0)
693 row = starsDf.loc[mask, ["xroi", "yroi"]].iloc[0]
694 dX = float(row["xroi"]) - refX
695 dY = float(row["yroi"]) - refY
696 return (refX, refY), (dX, dY)
699def renderStampPanel(
700 ax: plt.Axes,
701 guiderData: GuiderData,
702 detName: str,
703 stampNum: int,
704 *,
705 center: tuple[float, float] | None = None,
706 cutoutSize: int = -1,
707 plo: float = 50.0,
708 phi: float = 99.0,
709 annotate: bool = True,
710) -> tuple[plt.AxesImage, tuple[float, float], tuple[int, int], plt.Text | None]:
711 """
712 Render a single detector stamp (or stacked image) with optional cropping
713 and annotation.
715 Steps:
716 1. Select stamp or coadd.
717 2. Optionally crop around center.
718 3. Apply percentile scaling.
719 4. Display with imshow.
720 5. Optionally annotate detector label.
722 Parameters
723 ----------
724 ax : `matplotlib.axes.Axes`
725 Target axes.
726 guiderData : `GuiderData`
727 Guider data container.
728 detName : `str`
729 Detector name.
730 stampNum : `int`
731 Stamp index; negative for stacked/coadd.
732 center : `tuple[float, float]`, optional
733 Center for cropping; image center if None.
734 cutoutSize : `int`, optional
735 Square cutout size; -1 for full frame.
736 plo : `float`, optional
737 Lower percentile for scaling.
738 phi : `float`, optional
739 Upper percentile for scaling.
740 annotate : `bool`, optional
741 If True, add detector label text.
743 Returns
744 -------
745 image : `matplotlib.image.AxesImage`
746 Image artist.
747 centerCutout : `tuple[float, float]`
748 Center coordinates within the (possibly cropped) image.
749 shape : `tuple[int, int]`
750 Shape of the displayed (possibly cropped) image.
751 label : `matplotlib.text.Text` or `None`
752 Detector label artist if added.
753 """
754 # 1) image
755 img = guiderData.getStampArrayCoadd(detName) if stampNum < 0 else guiderData[detName, stampNum]
757 # 2) center + crop
758 # Always produce concrete floats (fallback = image center)
759 mx, my = img.shape[1] // 2, img.shape[0] // 2 # x=cols, y=rows
760 if center is None:
761 cx, cy = float(mx), float(my)
762 else:
763 cx_raw, cy_raw = center
764 cx = float(cx_raw) if cx_raw is not None else float(mx)
765 cy = float(cy_raw) if cy_raw is not None else float(my)
767 if cutoutSize > 0:
768 cutout, centerCutout = cropAroundCenter(img, (cx, cy), cutoutSize)
769 else:
770 cutout = img # ndarray
771 centerCutout = (cx, cy) # tuple[float, float]
773 # 3) scaling
774 vmin, vmax = np.nanpercentile(cutout, [plo, phi])
776 # 4) render
777 im = ax.imshow(
778 cutout,
779 origin="lower",
780 cmap="Greys",
781 vmin=vmin,
782 vmax=vmax,
783 interpolation="nearest",
784 extent=(0, cutout.shape[1], 0, cutout.shape[0]),
785 animated=True,
786 )
787 ax.set_ylim(0, cutout.shape[0])
788 ax.set_xlim(0, cutout.shape[1])
789 ax.set_aspect("equal", "box")
791 # 5) optional label
792 label = labelDetector(ax, detName) if annotate else None
794 return im, centerCutout, cutout.shape, label
797def labelDetector(
798 ax: plt.Axes,
799 name: str,
800 *,
801 corner: str = "tl",
802 color: str = LIGHT_BLUE,
803 fontsize: int = 9,
804 weight: str = "bold",
805 pad: tuple[float, float] = (0.025, 0.025),
806) -> plt.Text:
807 """Place the detector name text inside a panel.
809 Parameters
810 ----------
811 ax : `matplotlib.axes.Axes`
812 Target axes.
813 name : `str`
814 Detector name to display.
815 corner : `str`, optional
816 Two-letter code: t/b (top/bottom) + l/r (left/right).
817 color : `str`, optional
818 Text color.
819 fontsize : `int`, optional
820 Font size.
821 weight : `str`, optional
822 Font weight.
823 pad : `tuple[float, float]`, optional
824 Relative (x,y) padding in axes fraction.
826 Returns
827 -------
828 text : `matplotlib.text.Text`
829 Created text artist.
830 """
831 ha, va = ("left", "top") if corner[0] == "t" else ("left", "bottom")
832 ha = "right" if corner[1] == "r" else ha
833 va = "top" if corner[0] == "t" else va
835 xpad, ypad = pad
836 xpos = xpad if ha == "left" else 1 - xpad
837 ypos = 1 - ypad if va == "top" else ypad
839 txt = ax.text(
840 xpos,
841 ypos,
842 name,
843 transform=ax.transAxes,
844 ha=ha,
845 va=va,
846 fontsize=fontsize,
847 weight=weight,
848 color=color,
849 )
850 return txt
853def annotateStampInfo(
854 ax: plt.Axes,
855 *,
856 expid: int,
857 stampNum: int,
858 nStamps: int,
859 view: str | None = None,
860 jitter: float | None = None,
861 extra: str | None = None,
862 xy: tuple[float, float] = (1.085, -0.10),
863) -> plt.Text:
864 """
865 Annotate exposure metadata and stamp index on the center panel.
867 Parameters
868 ----------
869 ax : `matplotlib.axes.Axes`
870 Target axes.
871 expid : `int`
872 Exposure identifier.
873 stampNum : `int`
874 Current stamp index (0-based). Negative implies stacked.
875 nStamps : `int`
876 Total number of stamps available.
877 view : `str`, optional
878 Orientation descriptor.
879 jitter : `float`, optional
880 Combined centroid scatter (arcsec).
881 extra : `str`, optional
882 Additional free-form text.
883 xy : `tuple[float, float]`, optional
884 Annotation position in axes coordinates.
886 Returns
887 -------
888 text : `matplotlib.text.Text`
889 Created text artist.
890 """
891 dayObs, seqNum = str(expid)[:8], int(str(expid)[-5:])
893 text = ""
894 if jitter is not None:
895 text += f"Center Stdev.: {jitter:.2f} arcsec\n"
896 if dayObs is not None and seqNum is not None and view is not None:
897 text += f"day_obs: {dayObs}\nseq_num: {seqNum}\norientation: {view}\n"
898 text += f"Stamp #: {stampNum + 1:02d}" if stampNum >= 0 else f"Stacked w/ {nStamps} stamps"
899 if extra is not None:
900 text += "\n" + extra
902 txt = ax.text(
903 xy[0],
904 xy[1],
905 text,
906 transform=ax.transAxes,
907 ha="center",
908 va="center",
909 fontsize=14,
910 color="grey",
911 )
912 clearAxisTicks(ax, isSpine=True)
913 return txt
916def addStaticOverlays(
917 axsImg: plt.Axes,
918 detName: str,
919 centerCutout: tuple[float, float],
920 cutoutSize: int,
921 camRotAngle: float,
922) -> None:
923 """
924 Add detector label, crosshairs, and guide circles to a guider panel.
926 Parameters
927 ----------
928 axsImg : `matplotlib.axes.Axes`
929 Target axes.
930 detName : `str`
931 Detector name.
932 centerCutout : `tuple[float, float]`
933 Center coordinates in the displayed cutout.
934 cutoutSize : `int`
935 Cutout size; influences overlay scaling.
936 camRotAngle : `float`
937 Camera rotation angle (degrees).
938 """
939 _ = labelDetector(axsImg, detName)
940 if cutoutSize > 0:
941 axsImg.axvline(centerCutout[0], color=LIGHT_BLUE, lw=1.25, linestyle="--", alpha=0.75)
942 axsImg.axhline(centerCutout[1], color=LIGHT_BLUE, lw=1.25, linestyle="--", alpha=0.75)
943 plotCrosshairRotated(
944 centerCutout,
945 90 + camRotAngle,
946 axs=axsImg,
947 color="grey",
948 size=cutoutSize,
949 )
950 radii = [10, 5]
951 _ = drawGuideCircles(
952 axsImg,
953 centerCutout,
954 radii=radii,
955 colors=[LIGHT_BLUE, LIGHT_BLUE],
956 labels=["2″", "1″"],
957 linewidth=2.0,
958 )
961def drawGuideCircles(
962 ax: plt.Axes,
963 center: tuple[float, float],
964 radii: Sequence[float],
965 colors: Sequence[str],
966 labels: Sequence[str] | None = None,
967 text_offset: float = 1.0,
968 **circle_kwargs: Any,
969) -> list[plt.Text]:
970 """
971 Draw concentric guide circles with optional labels.
973 Parameters
974 ----------
975 ax : `matplotlib.axes.Axes`
976 Target axes.
977 center : `tuple[float, float]`
978 Center (x, y) of circles.
979 radii : `Sequence[float]`
980 Radii of circles.
981 colors : `Sequence[str]`
982 Edge colors per circle.
983 labels : `Sequence[str]`, optional
984 Labels per circle; None for no labels.
985 text_offset : `float`, optional
986 Offset for label placement along +x.
987 **circle_kwargs : `dict`
988 Extra keyword args passed to Circle.
990 Returns
991 -------
992 texts : `list[matplotlib.text.Text]`
993 List of label text artists.
994 """
995 x0, y0 = center
996 txt_list: list[plt.Text] = []
997 for i, r in enumerate(radii):
998 c = Circle(
999 (x0, y0),
1000 r,
1001 edgecolor=colors[i],
1002 facecolor="none",
1003 linestyle="--",
1004 **circle_kwargs,
1005 )
1006 ax.add_patch(c)
1007 label_txt = labels[i] if labels is not None else ""
1008 txt = ax.text(
1009 x0 + r + text_offset,
1010 y0 - r / 4.0,
1011 label_txt,
1012 color=colors[i],
1013 va="center",
1014 fontsize=8,
1015 )
1016 txt_list.append(txt)
1017 return txt_list
1020def plotCrosshairRotated(
1021 center: tuple[float, float],
1022 angle: float,
1023 axs: plt.Axes,
1024 color: str = "grey",
1025 size: int = 30,
1026) -> None:
1027 """
1028 Plot a rotated crosshair centered on given coordinates.
1030 Parameters
1031 ----------
1032 center : `tuple[float, float]`
1033 Center (x, y) in image coordinates.
1034 angle : `float`
1035 Rotation angle in degrees.
1036 axs : `matplotlib.axes.Axes`, optional
1037 Target axes or None to use current.
1038 color : `str`, optional
1039 Line color.
1040 size : `int`, optional
1041 Size scaling factor.
1042 """
1043 # make a cross rotated by the camera rotation angle
1044 cross_length = 1.5 * size if size > 0 else 30
1045 theta = np.radians(angle)
1047 # Cross center
1048 cx, cy = center
1049 # Horizontal line (rotated)
1050 dx = cross_length * np.cos(theta) / 2
1051 dy = cross_length * np.sin(theta) / 2
1052 axs.plot(
1053 [cx - dx, cx + dx],
1054 [cy - dy, cy + dy],
1055 color=color,
1056 ls="--",
1057 lw=1.0,
1058 alpha=0.5,
1059 )
1060 # Vertical line (rotated)
1061 dx_v = cross_length * np.cos(theta + np.pi / 2) / 2
1062 dy_v = cross_length * np.sin(theta + np.pi / 2) / 2
1063 axs.plot(
1064 [cx - dx_v, cx + dx_v],
1065 [cy - dy_v, cy + dy_v],
1066 color=color,
1067 ls="--",
1068 lw=1.0,
1069 alpha=0.5,
1070 )
1073def plotStarCentroid(
1074 ax: plt.Axes,
1075 centerCutout: tuple[float, float],
1076 *,
1077 deltaXY: tuple[float, float],
1078 markerSize: int = 8,
1079 errXY: tuple[float, float] | None = None,
1080 color: str = "firebrick",
1081) -> list[Line2D]:
1082 """
1083 Plot the measured star centroid (with optional error bars) on a cutout.
1085 Parameters
1086 ----------
1087 ax : `matplotlib.axes.Axes`
1088 Target axes.
1089 centerCutout : `tuple[float, float]`
1090 Reference center in the cutout.
1091 deltaXY : `tuple[float, float]`
1092 Offset (dx, dy) from center to centroid.
1093 markerSize : `int`, optional
1094 Marker size; <=0 disables plotting.
1095 errXY : `tuple[float, float]`, optional
1096 (xerr, yerr) half-lengths for error bars.
1097 color : `str`, optional
1098 Marker and line color.
1100 Returns
1101 -------
1102 artists : `list[matplotlib.lines.Line2D]`
1103 Marker and error bar line artists.
1104 """
1105 if markerSize <= 0:
1106 return []
1108 x_c, y_c = centerCutout
1109 dx, dy = deltaXY
1110 x_star, y_star = x_c + dx, y_c + dy
1111 xerr, yerr = errXY if errXY is not None else (0.0, 0.0)
1113 (marker,) = ax.plot(x_star, y_star, "o", color=color, markersize=markerSize)
1114 (hline,) = ax.plot([x_star - xerr, x_star + xerr], [y_star, y_star], color=color, lw=2.5)
1115 (vline,) = ax.plot([x_star, x_star], [y_star - yerr, y_star + yerr], color=color, lw=2.5)
1116 return [marker, hline, vline]
1119def clearAxisTicks(ax: plt.Axes, isSpine: bool = False) -> None:
1120 """
1121 Remove all ticks/labels; optionally keep spines.
1123 Parameters
1124 ----------
1125 ax : `matplotlib.axes.Axes`
1126 Target axes.
1127 isSpine : `bool`, optional
1128 If True, retain spines; otherwise hide them.
1129 """
1130 ax.set_xticks([])
1131 ax.set_yticks([])
1132 ax.set_xticklabels([])
1133 ax.set_yticklabels([])
1135 if not isSpine:
1136 for spine in ax.spines.values():
1137 spine.set_visible(False)
1140def cropAroundCenter(
1141 image: np.ndarray, center: tuple[float, float], size: int
1142) -> tuple[np.ndarray, tuple[float, float]]:
1143 """
1144 Extract a square crop centered on the specified coordinates.
1146 Parameters
1147 ----------
1148 image : `numpy.ndarray`
1149 Source 2D image.
1150 center : `tuple[float, float]`
1151 Center (x, y) in original image coordinates.
1152 size : `int`
1153 Desired square size in pixels.
1155 Returns
1156 -------
1157 cropped : `numpy.ndarray`
1158 Cropped (and possibly padded) image of shape (size, size).
1159 new_center : `tuple[float, float]`
1160 Center coordinates within the cropped image.
1161 """
1162 x, y = center
1163 x, y = int(round(x)), int(round(y))
1164 half = size // 2
1166 y0 = max(0, y - half)
1167 y1 = min(image.shape[0], y + half)
1168 x0 = max(0, x - half)
1169 x1 = min(image.shape[1], x + half)
1171 cropped = image[y0:y1, x0:x1]
1173 # Calculate offsets for padding
1174 padTop = max(0, (half - y))
1175 padLeft = max(0, (half - x))
1176 padBottom = size - (padTop + (y1 - y0))
1177 padRight = size - (padLeft + (x1 - x0))
1179 if padTop > 0 or padBottom > 0 or padLeft > 0 or padRight > 0:
1180 cropped = np.pad(
1181 cropped,
1182 ((padTop, padBottom), (padLeft, padRight)),
1183 mode="constant",
1184 constant_values=np.nan,
1185 )
1187 # New center in cropped image
1188 newCenter = (x - x0 + padLeft, y - y0 + padTop)
1189 return cropped, newCenter