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