Coverage for python/lsst/display/matplotlib/wcsAxes.py: 89%
131 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 07:21 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 07:21 +0000
1#
2# LSST Data Management System
3# Copyright 2026 LSST Corporation.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <http://www.lsstcorp.org/LegalNotices/>.
21#
23"""Draw sky coordinate axes on a matplotlib Axes using AST.
25The axes are drawn by `starlink.Ast.Plot` using the matplotlib grf
26plugin, so arbitrary AST WCS transforms are supported with no FITS
27approximation.
28"""
30__all__ = ["astFrameSetFromWcs", "WcsAxesManager"]
32import astshim
33import matplotlib.text
34import starlink.Ast
35from matplotlib.backend_bases import TimerBase
36from starlink.Grf import grf_matplotlib
39class _AstStringSource:
40 """Present AST native serialization text to `starlink.Ast.Channel`.
42 `starlink.Ast.Channel` reads via an object with an ``astsource``
43 method returning one line per call, and `None` at end of input.
45 Parameters
46 ----------
47 text : `str`
48 AST native serialization of an AST object.
49 """
51 def __init__(self, text):
52 self._lines = text.splitlines()
53 self._pos = 0
55 def astsource(self):
56 if self._pos >= len(self._lines): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 return None
58 line = self._lines[self._pos]
59 self._pos += 1
60 return line
63def astFrameSetFromWcs(wcs):
64 """Convert an afw SkyWcs to a starlink-pyast FrameSet.
66 Parameters
67 ----------
68 wcs : `lsst.afw.geom.SkyWcs`
69 The WCS to convert.
71 Returns
72 -------
73 frameSet : `starlink.Ast.FrameSet`
74 FrameSet whose base frame is LSST 0-based pixel coordinates
75 (domain ``PIXELS``) and whose current frame is sky coordinates
76 in radians (domain ``SKY``).
78 Notes
79 -----
80 The conversion serializes the WCS's underlying `astshim.FrameDict`
81 to AST native text and reads it back with pyast, so the result is
82 exact; no FITS approximation is involved.
83 """
84 stream = astshim.StringStream()
85 astshim.Channel(stream).write(wcs.getFrameDict())
86 channel = starlink.Ast.Channel(_AstStringSource(stream.getSinkData()))
87 return channel.read()
90class WcsAxesManager:
91 """Manage AST-drawn sky coordinate axes on a matplotlib Axes.
93 All visible axis furniture (border, ticks, labels, optional grid)
94 is drawn by AST; matplotlib's own axis furniture is hidden while
95 this manager is active and restored by `remove`.
97 The axes' data coordinates must be pixel coordinates in the LSST
98 convention, matching the base frame of ``frameSet`` as produced by
99 `astFrameSetFromWcs`: the center of the first pixel of the parent
100 image is at (0, 0), and a subimage keeps its parent's coordinates
101 (its pixel coordinates start at the subimage's XY0, not at zero).
102 This differs from the FITS convention, in which the first pixel of
103 the image is centered at (1, 1).
105 Parameters
106 ----------
107 axes : `matplotlib.axes.Axes`
108 The axes to draw on.
109 frameSet : `starlink.Ast.FrameSet`
110 Pixel-to-sky transform, base frame in axes data coordinates.
111 grid : `bool`, optional
112 Draw the full curvilinear coordinate grid across the image?
113 useSexagesimal : `bool`, optional
114 Format labels as e.g. HH:MM:SS.s rather than decimal degrees.
115 extraOptions : `str`, optional
116 Comma-separated AST Plot attribute settings appended after the
117 defaults, so they take precedence (e.g.
118 ``"Colour(grid)=2, Width(border)=2"``).
120 Notes
121 -----
122 AST colour values are 1-based indices into the grf colour table
123 (see `starlink.Grf.grf_matplotlib`), not colour names:
124 1=default, 2=red, 3=green, 4=blue, 5=cyan, 6=magenta, 7=yellow,
125 8=black, 9=dark grey, 10=grey, 11=light grey, 12=white.
126 """
128 def __init__(self, axes, frameSet, *,
129 grid=False, useSexagesimal=False, extraOptions=""):
130 self._axes = axes
131 self._frameSet = frameSet
132 self._grid = grid
133 self._useSexagesimal = useSexagesimal
134 self._extraOptions = extraOptions
135 self.artists = []
137 self._savedVisibility = (
138 axes.xaxis.get_visible(),
139 axes.yaxis.get_visible(),
140 {name: spine.get_visible() for name, spine in axes.spines.items()},
141 )
142 axes.xaxis.set_visible(False)
143 axes.yaxis.set_visible(False)
144 for spine in axes.spines.values():
145 spine.set_visible(False)
147 self._redrawing = False
148 self._redrawTimer = None
149 self._renderedSize = None
150 self._callbackIds = [
151 axes.callbacks.connect("xlim_changed", self._onLimitsChanged),
152 axes.callbacks.connect("ylim_changed", self._onLimitsChanged),
153 ]
154 # A figure resize changes the axes size without changing the view
155 # limits, so the labels must be re-spaced for the new geometry too.
156 self._resizeCallbackId = axes.get_figure().canvas.mpl_connect(
157 "resize_event", self._onResize)
159 try:
160 self.draw()
161 except Exception:
162 # Restore the matplotlib furniture so the axes remain
163 # usable as ordinary pixel axes.
164 self.remove()
165 raise
167 def _plotOptions(self):
168 """Return the AST Plot options string for the current state."""
169 options = ["DrawTitle=0"]
170 options.append("Grid=1" if self._grid else "Grid=0")
171 if not self._useSexagesimal:
172 # Decimal degrees; ".*" lets the Plot choose the precision
173 # needed to keep adjacent labels distinct.
174 options.append("Format(1)=d.*")
175 options.append("Format(2)=d.*")
176 if self._extraOptions:
177 options.append(self._extraOptions)
178 return ", ".join(options)
180 def draw(self):
181 """Draw (or redraw) the AST axes for the current view limits."""
182 self._removeArtists()
184 xlo, xhi = self._axes.get_xlim()
185 ylo, yhi = self._axes.get_ylim()
186 box = (xlo, ylo, xhi, yhi)
188 before = set(self._axes.lines) | set(self._axes.texts)
189 try:
190 plot = starlink.Ast.Plot(self._frameSet, box, box,
191 grf_matplotlib(self._axes),
192 self._plotOptions())
193 plot.grid()
194 except Exception:
195 # grid() may have drawn some artists before failing.
196 for artist in list(self._axes.lines) + list(self._axes.texts): 196 ↛ 197line 196 didn't jump to line 197 because the loop on line 196 never started
197 if artist not in before:
198 artist.remove()
199 raise
200 self.artists = [a for a in list(self._axes.lines) + list(self._axes.texts)
201 if a not in before]
203 # Axes.add_artist clips to the axes patch but the exterior
204 # labels sit just outside it.
205 for artist in self.artists:
206 if isinstance(artist, matplotlib.text.Text):
207 artist.set_clip_on(False)
209 # Record the size the labels were spaced for, so a later resize
210 # event that leaves the size unchanged can be ignored.
211 self._renderedSize = self._canvasSize()
213 def _canvasSize(self):
214 """Return the current canvas size in device pixels."""
215 return tuple(self._axes.get_figure().canvas.get_width_height())
217 def _removeArtists(self):
218 """Remove the AST artists from the axes."""
219 for artist in self.artists:
220 try:
221 artist.remove()
222 except (ValueError, NotImplementedError):
223 # Already gone, e.g. the axes were cleared.
224 pass
225 self.artists = []
227 # Idle time before redrawing after a view change. Interactive
228 # panning and zooming change the limits on every mouse-motion
229 # event, and a full AST rebuild per event is far too slow.
230 _redrawDelayMs = 200
232 def _onLimitsChanged(self, axes):
233 """Schedule a redraw for new view limits; matplotlib callback.
235 The ``axes`` argument (the axes the callback fired for) is unused.
236 """
237 self._scheduleRedraw()
239 def _onResize(self, event):
240 """Schedule a redraw for a new figure size; matplotlib callback.
242 The ``event`` argument (the resize event) is unused. Interactive
243 web backends (e.g. ipympl) emit ``resize_event`` repeatedly at the
244 size the figure already has, and a redraw repaints the canvas,
245 which prompts yet another such event; acting on every one produces
246 an unbounded rebuild loop. Rebuild only when the canvas has
247 actually changed size since it was last drawn.
248 """
249 if self._canvasSize() == self._renderedSize:
250 return
251 self._scheduleRedraw()
253 def _scheduleRedraw(self):
254 """Debounce a rebuild of the AST axes after a view or size change.
256 The redraw is debounced with a one-shot timer so that a stream of
257 changes (interactive panning or zooming, the x/y pair from a single
258 zoom, or a drag-resize) produces a single rebuild once the view
259 settles. Backends without a running event loop cannot fire timers,
260 so there the redraw happens immediately.
261 """
262 if self._redrawing: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 return
264 if self._redrawTimer is None:
265 canvas = self._axes.get_figure().canvas
266 timer = canvas.new_timer(interval=self._redrawDelayMs)
267 if type(timer) is TimerBase:
268 # The base class is a no-op that never fires.
269 self._redrawNow()
270 return
271 timer.single_shot = True
272 timer.add_callback(self._redrawNow)
273 self._redrawTimer = timer
274 self._redrawTimer.stop()
275 self._redrawTimer.start()
277 def _redrawNow(self):
278 """Rebuild the AST axes for the current view and repaint."""
279 if not self._callbackIds: 279 ↛ 281line 279 didn't jump to line 281 because the condition on line 279 was never true
280 # The manager was removed while a redraw was pending.
281 return
282 if self._redrawing: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true
283 return
284 self._redrawing = True
285 try:
286 self.draw()
287 finally:
288 self._redrawing = False
289 self._axes.get_figure().canvas.draw_idle()
291 def setSexagesimal(self, useSexagesimal):
292 """Switch between sexagesimal and decimal degree labels.
294 Parameters
295 ----------
296 useSexagesimal : `bool`
297 Format labels as e.g. HH:MM:SS.s iff True.
298 """
299 useSexagesimal = bool(useSexagesimal)
300 if useSexagesimal != self._useSexagesimal: 300 ↛ exitline 300 didn't return from function 'setSexagesimal' because the condition on line 300 was always true
301 self._useSexagesimal = useSexagesimal
302 self.draw()
304 def remove(self):
305 """Remove the AST axes and restore matplotlib's axis furniture."""
306 if self._redrawTimer is not None: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 self._redrawTimer.stop()
308 self._redrawTimer = None
309 for callbackId in self._callbackIds:
310 self._axes.callbacks.disconnect(callbackId)
311 self._callbackIds = []
312 self._axes.get_figure().canvas.mpl_disconnect(self._resizeCallbackId)
314 self._removeArtists()
316 xVisible, yVisible, spines = self._savedVisibility
317 self._axes.xaxis.set_visible(xVisible)
318 self._axes.yaxis.set_visible(yVisible)
319 for name, visible in spines.items():
320 self._axes.spines[name].set_visible(visible)