Coverage for python/lsst/display/matplotlib/wcsAxes.py: 88%
119 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:07 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:07 -0700
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._callbackIds = [
150 axes.callbacks.connect("xlim_changed", self._onLimitsChanged),
151 axes.callbacks.connect("ylim_changed", self._onLimitsChanged),
152 ]
154 try:
155 self.draw()
156 except Exception:
157 # Restore the matplotlib furniture so the axes remain
158 # usable as ordinary pixel axes.
159 self.remove()
160 raise
162 def _plotOptions(self):
163 """Return the AST Plot options string for the current state."""
164 options = ["DrawTitle=0"]
165 options.append("Grid=1" if self._grid else "Grid=0")
166 if not self._useSexagesimal:
167 # Decimal degrees; ".*" lets the Plot choose the precision
168 # needed to keep adjacent labels distinct.
169 options.append("Format(1)=d.*")
170 options.append("Format(2)=d.*")
171 if self._extraOptions:
172 options.append(self._extraOptions)
173 return ", ".join(options)
175 def draw(self):
176 """Draw (or redraw) the AST axes for the current view limits."""
177 self._removeArtists()
179 xlo, xhi = self._axes.get_xlim()
180 ylo, yhi = self._axes.get_ylim()
181 box = (xlo, ylo, xhi, yhi)
183 before = set(self._axes.lines) | set(self._axes.texts)
184 try:
185 plot = starlink.Ast.Plot(self._frameSet, box, box,
186 grf_matplotlib(self._axes),
187 self._plotOptions())
188 plot.grid()
189 except Exception:
190 # grid() may have drawn some artists before failing.
191 for artist in list(self._axes.lines) + list(self._axes.texts): 191 ↛ 192line 191 didn't jump to line 192 because the loop on line 191 never started
192 if artist not in before:
193 artist.remove()
194 raise
195 self.artists = [a for a in list(self._axes.lines) + list(self._axes.texts)
196 if a not in before]
198 # Axes.add_artist clips to the axes patch but the exterior
199 # labels sit just outside it.
200 for artist in self.artists:
201 if isinstance(artist, matplotlib.text.Text):
202 artist.set_clip_on(False)
204 def _removeArtists(self):
205 """Remove the AST artists from the axes."""
206 for artist in self.artists:
207 try:
208 artist.remove()
209 except (ValueError, NotImplementedError):
210 # Already gone, e.g. the axes were cleared.
211 pass
212 self.artists = []
214 # Idle time before redrawing after a view change. Interactive
215 # panning and zooming change the limits on every mouse-motion
216 # event, and a full AST rebuild per event is far too slow.
217 _redrawDelayMs = 200
219 def _onLimitsChanged(self, axes):
220 """Schedule a redraw for new view limits; matplotlib callback.
222 The redraw is debounced with a one-shot timer so that a stream
223 of limit changes (interactive panning or zooming, or the x/y
224 pair from a single zoom) produces a single rebuild once the
225 view settles. Backends without a running event loop cannot
226 fire timers, so there the redraw happens immediately.
227 """
228 if self._redrawing: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 return
230 if self._redrawTimer is None:
231 canvas = self._axes.get_figure().canvas
232 timer = canvas.new_timer(interval=self._redrawDelayMs)
233 if type(timer) is TimerBase:
234 # The base class is a no-op that never fires.
235 self._redrawNow()
236 return
237 timer.single_shot = True
238 timer.add_callback(self._redrawNow)
239 self._redrawTimer = timer
240 self._redrawTimer.stop()
241 self._redrawTimer.start()
243 def _redrawNow(self):
244 """Rebuild the AST axes for the current view and repaint."""
245 if not self._callbackIds: 245 ↛ 247line 245 didn't jump to line 247 because the condition on line 245 was never true
246 # The manager was removed while a redraw was pending.
247 return
248 if self._redrawing: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 return
250 self._redrawing = True
251 try:
252 self.draw()
253 finally:
254 self._redrawing = False
255 self._axes.get_figure().canvas.draw_idle()
257 def setSexagesimal(self, useSexagesimal):
258 """Switch between sexagesimal and decimal degree labels.
260 Parameters
261 ----------
262 useSexagesimal : `bool`
263 Format labels as e.g. HH:MM:SS.s iff True.
264 """
265 useSexagesimal = bool(useSexagesimal)
266 if useSexagesimal != self._useSexagesimal: 266 ↛ exitline 266 didn't return from function 'setSexagesimal' because the condition on line 266 was always true
267 self._useSexagesimal = useSexagesimal
268 self.draw()
270 def remove(self):
271 """Remove the AST axes and restore matplotlib's axis furniture."""
272 if self._redrawTimer is not None: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 self._redrawTimer.stop()
274 self._redrawTimer = None
275 for callbackId in self._callbackIds:
276 self._axes.callbacks.disconnect(callbackId)
277 self._callbackIds = []
279 self._removeArtists()
281 xVisible, yVisible, spines = self._savedVisibility
282 self._axes.xaxis.set_visible(xVisible)
283 self._axes.yaxis.set_visible(yVisible)
284 for name, visible in spines.items():
285 self._axes.spines[name].set_visible(visible)