pyredshift 1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyredshift/__init__.py +5 -0
- pyredshift/pyredshift-help.html +121 -0
- pyredshift/pyredshift.lines +79 -0
- pyredshift/redshift.py +1581 -0
- pyredshift-1.9.data/scripts/pyredshift +297 -0
- pyredshift-1.9.dist-info/METADATA +167 -0
- pyredshift-1.9.dist-info/RECORD +10 -0
- pyredshift-1.9.dist-info/WHEEL +5 -0
- pyredshift-1.9.dist-info/licenses/LICENSE +21 -0
- pyredshift-1.9.dist-info/top_level.txt +1 -0
pyredshift/redshift.py
ADDED
|
@@ -0,0 +1,1581 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pyredshift.redshift
|
|
3
|
+
|
|
4
|
+
Module containing the Python version of Karl's redshift browsing program.
|
|
5
|
+
Port of KGB::Redshift v2.0 (Perl/PDL/PGPLOT) to numpy/matplotlib.
|
|
6
|
+
|
|
7
|
+
Keeps the synchronous PGPLOT-style interaction: a blocking pgband() reads
|
|
8
|
+
the cursor + one key, and a single main loop dispatches on the key.
|
|
9
|
+
Style is deliberately plain and procedural, like the Perl original.
|
|
10
|
+
|
|
11
|
+
V1.0 - Initial port from Redshift.pm v2.0, Jul 2026.
|
|
12
|
+
- NaN is used for bad values throughout; matplotlib breaks the
|
|
13
|
+
plotted line at NaNs so the old hand-drawn pgbin() is not needed.
|
|
14
|
+
- 'p' prints to PDF (pyredshift.pdf) instead of EPS.
|
|
15
|
+
V1.1 - White background is now the default; dark=1 gives the PGPLOT look.
|
|
16
|
+
- New pyredshift.lines format: CSV with unicode labels and
|
|
17
|
+
matplotlib colour names (see the header of that file).
|
|
18
|
+
- Tightened the margins around the axes.
|
|
19
|
+
V1.2 - Toolbar integration: pan/zoom/Home/Back/Forward now update the view
|
|
20
|
+
state properly; Home returns to the startup view.
|
|
21
|
+
- 'h' = home, '?' = help.
|
|
22
|
+
- Left-button drag = rubber-band zoom (like 'e'); a purely
|
|
23
|
+
horizontal drag zooms X only, purely vertical Y only.
|
|
24
|
+
V1.3 - '?' opens the help in its own window.
|
|
25
|
+
- Window size is remembered between runs (~/.pyredshift.json) and
|
|
26
|
+
clamped to the screen if the display has changed.
|
|
27
|
+
V1.4 - Help is now pyredshift-help.html, opened themed in the browser
|
|
28
|
+
(falls back to a plain-text window); '?' button on the canvas
|
|
29
|
+
(the native Mac toolbar cannot take custom buttons).
|
|
30
|
+
V1.5 - Renamed: the module is now pyredshift.redshift (the kg namespace
|
|
31
|
+
is retired for distribution).
|
|
32
|
+
V1.6 - Continuous cursor readout (pixel, wavelength obs/rest, flux) at
|
|
33
|
+
the bottom right of the window; EW/flux measurements are also
|
|
34
|
+
shown in the window message area.
|
|
35
|
+
V1.7 - Works from a Jupyter notebook: redshift(wave, flux) pops up the
|
|
36
|
+
interactive window outside the notebook (inline backends cannot
|
|
37
|
+
deliver events), and on quit the final view is embedded in the
|
|
38
|
+
cell and the original backend restored. Cleanup is guaranteed
|
|
39
|
+
even on Kernel->Interrupt, and headless/remote kernels are
|
|
40
|
+
detected up front (clear error instead of a Qt kernel crash).
|
|
41
|
+
V1.8 - Right-click pops up the quick line list as a menu: pick a line
|
|
42
|
+
to set the redshift at the clicked position (same as ESC+key).
|
|
43
|
+
- pyredshift.lines colours may be 'light/dark' pairs; the strong
|
|
44
|
+
lines are now goldenrod on white, yellow on the retro background.
|
|
45
|
+
V1.9 - First PyPI release. --microns flag (keeps micron wavelengths so
|
|
46
|
+
micron-mode display is reachable from files); packaging metadata.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import ctypes
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
import warnings
|
|
55
|
+
from html.parser import HTMLParser
|
|
56
|
+
|
|
57
|
+
import numpy as np
|
|
58
|
+
import matplotlib
|
|
59
|
+
|
|
60
|
+
# Pick a GUI backend unless the user forced one via MPLBACKEND
|
|
61
|
+
if "MPLBACKEND" not in os.environ:
|
|
62
|
+
for _backend in ("QtAgg", "MacOSX", "TkAgg"):
|
|
63
|
+
try:
|
|
64
|
+
matplotlib.use(_backend)
|
|
65
|
+
break
|
|
66
|
+
except ImportError:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
import matplotlib.pyplot as plt
|
|
70
|
+
from matplotlib.patches import Rectangle
|
|
71
|
+
from matplotlib.transforms import Bbox
|
|
72
|
+
from matplotlib.widgets import Button, Cursor
|
|
73
|
+
|
|
74
|
+
# Take all the keys back from matplotlib's default bindings (k, l, s, g, o, q...)
|
|
75
|
+
for _p in [p for p in plt.rcParams if p.startswith("keymap.")]:
|
|
76
|
+
plt.rcParams[_p] = []
|
|
77
|
+
|
|
78
|
+
# Spectra are full of NaNs and zero continua - don't spam warnings about it
|
|
79
|
+
np.seterr(divide="ignore", invalid="ignore")
|
|
80
|
+
warnings.filterwarnings("ignore", message="Mean of empty slice")
|
|
81
|
+
try:
|
|
82
|
+
warnings.simplefilter("ignore", np.RankWarning)
|
|
83
|
+
except AttributeError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
__version__ = "1.9"
|
|
87
|
+
|
|
88
|
+
C_LIGHT = 2.99792458e8 # m/s
|
|
89
|
+
|
|
90
|
+
TEMPLATE_NAME = "GNIRS_N4608" # template for the 't' key
|
|
91
|
+
|
|
92
|
+
CONFIG_FILE = os.path.expanduser("~/.pyredshift.json") # remembers window size
|
|
93
|
+
DEFAULT_FIGSIZE = (13.0, 5.5) # inches
|
|
94
|
+
|
|
95
|
+
# Colours are named for the white (default) background; on a dark background
|
|
96
|
+
# some need brightening for legibility - this remaps them.
|
|
97
|
+
DARK_REMAP = {"gold": "yellow", "tab:blue": "dodgerblue", "green": "lime",
|
|
98
|
+
"darkblue": "dodgerblue", "seagreen": "greenyellow",
|
|
99
|
+
"black": "white", "darkorange": "orange"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def theme_col(c, light):
|
|
103
|
+
"""Resolve a colour spec for the current mode. A spec is a single
|
|
104
|
+
matplotlib colour (dark mode gets a brightness remap), or
|
|
105
|
+
'lightcolour/darkcolour' giving each mode its own colour."""
|
|
106
|
+
if "/" in c:
|
|
107
|
+
lc, dc = c.split("/", 1)
|
|
108
|
+
return lc.strip() if light else dc.strip()
|
|
109
|
+
return c if light else DARK_REMAP.get(c, c)
|
|
110
|
+
|
|
111
|
+
# Quick line guess shortcuts (rest wavelengths in Angstroms)
|
|
112
|
+
shortcuts = {"l": 1216, "c": 1549, "m": 2800, "o": 3727, "k": 3933, "h": 3969,
|
|
113
|
+
"g": 4304, "b": 4861, "O": 5007, "d": 5892, "a": 6563}
|
|
114
|
+
|
|
115
|
+
def load_help_html():
|
|
116
|
+
"""The command help lives in pyredshift-help.html next to the module.
|
|
117
|
+
Placeholders ({template}, {linelist}) are filled in by help_html()."""
|
|
118
|
+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
119
|
+
"pyredshift-help.html")
|
|
120
|
+
try:
|
|
121
|
+
with open(path) as fh:
|
|
122
|
+
return fh.read()
|
|
123
|
+
except OSError:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _HelpText(HTMLParser):
|
|
128
|
+
"""Crude HTML -> text, for the terminal echo and the no-browser
|
|
129
|
+
fallback window. Table rows become multi-space separated columns."""
|
|
130
|
+
|
|
131
|
+
def __init__(self):
|
|
132
|
+
super().__init__()
|
|
133
|
+
self.out = []
|
|
134
|
+
self.table = None # rows of the current table
|
|
135
|
+
self.row = None # cells of the current table row
|
|
136
|
+
self.cell = None # accumulating text of the current cell/heading
|
|
137
|
+
|
|
138
|
+
def handle_starttag(self, tag, attrs):
|
|
139
|
+
if tag == "table":
|
|
140
|
+
self.table = []
|
|
141
|
+
elif tag == "tr":
|
|
142
|
+
self.row = []
|
|
143
|
+
elif tag in ("td", "th", "h1", "h2", "p"):
|
|
144
|
+
self.cell = ""
|
|
145
|
+
|
|
146
|
+
def handle_endtag(self, tag):
|
|
147
|
+
if tag == "table" and self.table:
|
|
148
|
+
# Emit the whole table with aligned columns
|
|
149
|
+
ncol = max(len(r) for r in self.table)
|
|
150
|
+
rows = [r + [""] * (ncol - len(r)) for r in self.table]
|
|
151
|
+
widths = [max(len(r[i]) for r in rows) for i in range(ncol)]
|
|
152
|
+
self.out.append("")
|
|
153
|
+
for r in rows:
|
|
154
|
+
line = " " + " ".join(c.ljust(w) for c, w in zip(r, widths))
|
|
155
|
+
self.out.append(line.rstrip())
|
|
156
|
+
self.table = None
|
|
157
|
+
elif tag == "tr" and self.table is not None:
|
|
158
|
+
self.table.append(self.row)
|
|
159
|
+
self.row = None
|
|
160
|
+
elif self.cell is not None and tag in ("td", "th", "h1", "h2", "p"):
|
|
161
|
+
# (inline tags like <code> inside a cell just pass through)
|
|
162
|
+
text = " ".join(self.cell.split()) # normalise whitespace
|
|
163
|
+
if tag in ("td", "th") and self.row is not None:
|
|
164
|
+
self.row.append(text)
|
|
165
|
+
elif tag in ("h1", "h2"):
|
|
166
|
+
self.out.extend(["", " " + text.upper()])
|
|
167
|
+
elif tag == "p":
|
|
168
|
+
self.out.extend(["", " " + text])
|
|
169
|
+
self.cell = None
|
|
170
|
+
|
|
171
|
+
def handle_data(self, data):
|
|
172
|
+
if self.cell is not None:
|
|
173
|
+
self.cell += data
|
|
174
|
+
|
|
175
|
+
def text(self):
|
|
176
|
+
return "\n".join(self.out) + "\n"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def plain_help(raw):
|
|
180
|
+
if raw is None:
|
|
181
|
+
return "Help file (pyredshift-help.html) not found next to the module\n"
|
|
182
|
+
parser = _HelpText()
|
|
183
|
+
parser.feed(raw)
|
|
184
|
+
return parser.text()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
HELP_RAW = load_help_html()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def linelist_html():
|
|
191
|
+
"""Auto-generated table of the line list for the help page, two
|
|
192
|
+
column-pairs wide, each line coloured as plotted."""
|
|
193
|
+
if line_wav is None:
|
|
194
|
+
load_linelist()
|
|
195
|
+
light = not dark_mode
|
|
196
|
+
cells = []
|
|
197
|
+
for i in range(len(line_wav)):
|
|
198
|
+
if line_label[i] == "IGNORE" or line_wav[i] <= 0:
|
|
199
|
+
continue # killed with 'k' this session
|
|
200
|
+
wav = line_wav[i] * (10000.0 if micron_mode else 1.0)
|
|
201
|
+
col = theme_col(line_col[i], light)
|
|
202
|
+
cells.append("<td>%.2f</td><td><span style='color:%s'>■</span> "
|
|
203
|
+
"%s</td>" % (wav, col, line_label[i]))
|
|
204
|
+
half = (len(cells) + 1) // 2
|
|
205
|
+
rows = ["<tr><th>λ vac</th><th>Line</th>"
|
|
206
|
+
"<th>λ vac</th><th>Line</th></tr>"]
|
|
207
|
+
for i in range(half):
|
|
208
|
+
right = cells[i + half] if i + half < len(cells) else "<td></td><td></td>"
|
|
209
|
+
rows.append("<tr>%s%s</tr>" % (cells[i], right))
|
|
210
|
+
return "<table class='linelist'>\n%s\n</table>" % "\n".join(rows)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def help_html():
|
|
214
|
+
"""The help HTML body with the placeholders filled in."""
|
|
215
|
+
if HELP_RAW is None:
|
|
216
|
+
return None
|
|
217
|
+
return HELP_RAW.format(template=TEMPLATE_NAME, linelist=linelist_html())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Module state - plain globals, in the spirit of the Perl original
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
fig = None
|
|
224
|
+
ax = None
|
|
225
|
+
cursor = None
|
|
226
|
+
message_artist = None
|
|
227
|
+
|
|
228
|
+
w = None # wavelength array
|
|
229
|
+
f = None # flux array
|
|
230
|
+
specgood = None # boolean mask of good (finite) pixels
|
|
231
|
+
anybad = False
|
|
232
|
+
label = ""
|
|
233
|
+
dark_mode = 0 # 1 = PGPLOT-style black background
|
|
234
|
+
zshift = 0.0 # the redshift ($redshift in Perl; renamed to avoid the sub name clash)
|
|
235
|
+
found = 0
|
|
236
|
+
micron_mode = 0
|
|
237
|
+
unit = "Angstroms"
|
|
238
|
+
med = 0.0
|
|
239
|
+
xstart = xend = ylo = yhi = 0.0
|
|
240
|
+
|
|
241
|
+
line_wav = None
|
|
242
|
+
line_col = None
|
|
243
|
+
line_name = []
|
|
244
|
+
line_label = []
|
|
245
|
+
|
|
246
|
+
got_cuum = 0
|
|
247
|
+
f_cuum = None
|
|
248
|
+
RMS = 0.0
|
|
249
|
+
got_bin = 0
|
|
250
|
+
w_bin = f_bin = None
|
|
251
|
+
bin_off = 0.0
|
|
252
|
+
got_smooth = 0
|
|
253
|
+
f_smooth = None
|
|
254
|
+
smooth_off = 0.0
|
|
255
|
+
plot_template = 0
|
|
256
|
+
w_temp = f_temp = None
|
|
257
|
+
norm = 1.0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Cursor and blocking input - the pgband() replacement
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
class StickyCursor(Cursor):
|
|
264
|
+
"""Cursor that survives full canvas repaints and toolbar pan/zoom.
|
|
265
|
+
|
|
266
|
+
A blitted Cursor is erased by any full redraw and only reappears on the
|
|
267
|
+
next mouse move; remember the last motion event and re-draw the crosshair
|
|
268
|
+
after every repaint. Also reimplements onmove() without the base class's
|
|
269
|
+
widgetlock check, so the crosshair stays live while the toolbar pan/zoom
|
|
270
|
+
mode is switched on.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self, ax_, **kwargs):
|
|
274
|
+
self._last_event = None
|
|
275
|
+
super().__init__(ax_, **kwargs)
|
|
276
|
+
self.connect_event("draw_event", self._redraw)
|
|
277
|
+
|
|
278
|
+
def onmove(self, event):
|
|
279
|
+
# Copy of Cursor.onmove from matplotlib 3.9, minus the widgetlock
|
|
280
|
+
# check (version sensitive - revisit if matplotlib is upgraded)
|
|
281
|
+
if self.ignore(event):
|
|
282
|
+
return
|
|
283
|
+
if not self.ax.contains(event)[0]:
|
|
284
|
+
self._last_event = None # or _redraw() would resurrect it
|
|
285
|
+
self.linev.set_visible(False)
|
|
286
|
+
self.lineh.set_visible(False)
|
|
287
|
+
if self.needclear:
|
|
288
|
+
self.canvas.draw()
|
|
289
|
+
self.needclear = False
|
|
290
|
+
return
|
|
291
|
+
self._last_event = event
|
|
292
|
+
self.needclear = True
|
|
293
|
+
xdata, ydata = self._get_data_coords(event)
|
|
294
|
+
self.linev.set_xdata((xdata, xdata))
|
|
295
|
+
self.linev.set_visible(self.visible and self.vertOn)
|
|
296
|
+
self.lineh.set_ydata((ydata, ydata))
|
|
297
|
+
self.lineh.set_visible(self.visible and self.horizOn)
|
|
298
|
+
if not (self.visible and (self.vertOn or self.horizOn)):
|
|
299
|
+
return
|
|
300
|
+
# Redraw
|
|
301
|
+
if self.useblit:
|
|
302
|
+
if self.background is not None:
|
|
303
|
+
self.canvas.restore_region(self.background)
|
|
304
|
+
self.ax.draw_artist(self.linev)
|
|
305
|
+
self.ax.draw_artist(self.lineh)
|
|
306
|
+
self.canvas.blit(self.ax.bbox)
|
|
307
|
+
else:
|
|
308
|
+
self.canvas.draw_idle()
|
|
309
|
+
|
|
310
|
+
def _redraw(self, event):
|
|
311
|
+
if self._last_event is not None:
|
|
312
|
+
self.onmove(self._last_event)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def normkey(ch):
|
|
316
|
+
# Some backends report shifted letters as 'shift+b' - normalise to 'B'
|
|
317
|
+
if ch is not None and ch.startswith("shift+") and len(ch) == 7:
|
|
318
|
+
return ch[-1].upper()
|
|
319
|
+
return ch
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def pgband(allow_drag=False):
|
|
323
|
+
"""Block until a key press or mouse click; return (x, y, ch) in data coords.
|
|
324
|
+
|
|
325
|
+
Mouse button gives ch='A', as PGPLOT did. Returns ch='q' if the window
|
|
326
|
+
is closed.
|
|
327
|
+
|
|
328
|
+
With allow_drag=True a left-button drag rubber-band zooms (like 'e') and
|
|
329
|
+
returns ch='drag' after updating the view state; a purely horizontal drag
|
|
330
|
+
zooms X only, a purely vertical one Y only. Short drags (<5 pixels)
|
|
331
|
+
still count as a click.
|
|
332
|
+
"""
|
|
333
|
+
if fig is None or not plt.fignum_exists(fig.number):
|
|
334
|
+
return None, None, "q"
|
|
335
|
+
result = {}
|
|
336
|
+
drag = {}
|
|
337
|
+
|
|
338
|
+
def done(x, y, ch):
|
|
339
|
+
result["x"], result["y"], result["ch"] = x, y, ch
|
|
340
|
+
fig.canvas.stop_event_loop()
|
|
341
|
+
|
|
342
|
+
def toolbar_mode():
|
|
343
|
+
toolbar = getattr(fig.canvas.manager, "toolbar", None)
|
|
344
|
+
return getattr(toolbar, "mode", "") if toolbar is not None else ""
|
|
345
|
+
|
|
346
|
+
def on_key(ev):
|
|
347
|
+
done(ev.xdata, ev.ydata, normkey(ev.key))
|
|
348
|
+
|
|
349
|
+
def on_press(ev):
|
|
350
|
+
if ev.inaxes is not ax:
|
|
351
|
+
return # clicks elsewhere (margins, the ? button) aren't cursor reads
|
|
352
|
+
if allow_drag and ev.button == 3:
|
|
353
|
+
done(ev.xdata, ev.ydata, "menu") # right-click: quick line menu
|
|
354
|
+
return
|
|
355
|
+
if allow_drag and ev.button == 1 and not toolbar_mode():
|
|
356
|
+
drag["xpx"], drag["ypx"] = ev.x, ev.y # pixels, for threshold
|
|
357
|
+
drag["x0"], drag["y0"] = ev.xdata, ev.ydata
|
|
358
|
+
drag["x1"], drag["y1"] = ev.xdata, ev.ydata
|
|
359
|
+
drag["rect"] = ax.add_patch(Rectangle(
|
|
360
|
+
(ev.xdata, ev.ydata), 0, 0, fill=False,
|
|
361
|
+
edgecolor="red", lw=0.8, ls="--"))
|
|
362
|
+
else:
|
|
363
|
+
done(ev.xdata, ev.ydata, "A")
|
|
364
|
+
|
|
365
|
+
def on_motion(ev):
|
|
366
|
+
if "rect" not in drag or ev.inaxes is not ax:
|
|
367
|
+
return
|
|
368
|
+
drag["x1"], drag["y1"] = ev.xdata, ev.ydata
|
|
369
|
+
drag["rect"].set_bounds(drag["x0"], drag["y0"],
|
|
370
|
+
ev.xdata - drag["x0"], ev.ydata - drag["y0"])
|
|
371
|
+
fig.canvas.draw_idle()
|
|
372
|
+
|
|
373
|
+
def on_release(ev):
|
|
374
|
+
global xstart, xend, ylo, yhi
|
|
375
|
+
if "rect" not in drag:
|
|
376
|
+
return
|
|
377
|
+
drag.pop("rect").remove()
|
|
378
|
+
fig.canvas.draw_idle()
|
|
379
|
+
xmoved = abs(ev.x - drag["xpx"]) > 5
|
|
380
|
+
ymoved = abs(ev.y - drag["ypx"]) > 5
|
|
381
|
+
if not (xmoved or ymoved): # just a click
|
|
382
|
+
done(drag["x0"], drag["y0"], "A")
|
|
383
|
+
return
|
|
384
|
+
if xmoved:
|
|
385
|
+
xstart, xend = sorted((drag["x0"], drag["x1"]))
|
|
386
|
+
if ymoved:
|
|
387
|
+
ylo, yhi = sorted((drag["y0"], drag["y1"]))
|
|
388
|
+
done(drag["x1"], drag["y1"], "drag")
|
|
389
|
+
|
|
390
|
+
def on_close(ev):
|
|
391
|
+
done(None, None, "q")
|
|
392
|
+
|
|
393
|
+
cids = [fig.canvas.mpl_connect("key_press_event", on_key),
|
|
394
|
+
fig.canvas.mpl_connect("button_press_event", on_press),
|
|
395
|
+
fig.canvas.mpl_connect("motion_notify_event", on_motion),
|
|
396
|
+
fig.canvas.mpl_connect("button_release_event", on_release),
|
|
397
|
+
fig.canvas.mpl_connect("close_event", on_close)]
|
|
398
|
+
fig.canvas.start_event_loop()
|
|
399
|
+
for cid in cids:
|
|
400
|
+
fig.canvas.mpl_disconnect(cid)
|
|
401
|
+
rect = drag.get("rect")
|
|
402
|
+
if rect is not None and rect.axes is not None:
|
|
403
|
+
rect.remove() # a key press ended the loop mid-drag
|
|
404
|
+
return result.get("x"), result.get("y"), result.get("ch")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def refresh():
|
|
408
|
+
fig.canvas.draw_idle()
|
|
409
|
+
fig.canvas.flush_events()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Right-click quick-line menu - the ESC shortcut list as a popup, drawn
|
|
414
|
+
# with matplotlib artists so it works on any backend
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
def line_menu(xd, yd):
|
|
417
|
+
"""Pop up the quick line list at the cursor (right-click). Returns
|
|
418
|
+
the chosen rest wavelength (in current wavelength units), or None if
|
|
419
|
+
cancelled (click elsewhere, or any key)."""
|
|
420
|
+
# Entries from the ESC shortcut list, labelled from the line list,
|
|
421
|
+
# padded into aligned columns (monospace)
|
|
422
|
+
entries = []
|
|
423
|
+
for key, wav in sorted(shortcuts.items(), key=lambda kv: kv[1]):
|
|
424
|
+
wrest = wav / 10000.0 if micron_mode else float(wav)
|
|
425
|
+
i = int(np.argmin(np.abs(line_wav - wrest)))
|
|
426
|
+
lab = line_label[i] if line_label[i] != "IGNORE" else str(wav)
|
|
427
|
+
wav_A = line_wav[i] * (10000.0 if micron_mode else 1.0) # true vacuum
|
|
428
|
+
entries.append(("%-7s %4.0f" % (lab, wav_A), wrest))
|
|
429
|
+
n = len(entries)
|
|
430
|
+
|
|
431
|
+
# Geometry from real font metrics (hardcoded pixels break on HiDPI)
|
|
432
|
+
fs = 10
|
|
433
|
+
probe = fig.text(0.5, 0.5, max(e[0] for e in entries),
|
|
434
|
+
fontsize=fs, family="monospace")
|
|
435
|
+
try:
|
|
436
|
+
bb = probe.get_window_extent()
|
|
437
|
+
except Exception:
|
|
438
|
+
fig.canvas.draw()
|
|
439
|
+
bb = probe.get_window_extent()
|
|
440
|
+
probe.remove()
|
|
441
|
+
ih = bb.height * 1.55 # row height
|
|
442
|
+
pad = bb.height * 0.55 # box padding
|
|
443
|
+
tpad = bb.height * 0.7 # text indent
|
|
444
|
+
hh = ih * 0.85 # header row ("vacuum")
|
|
445
|
+
wpx = bb.width + 2 * tpad
|
|
446
|
+
hpx = n * ih + hh + 2 * pad
|
|
447
|
+
|
|
448
|
+
# Anchor at the click, kept on-canvas
|
|
449
|
+
x0, y0 = ax.transData.transform((xd, yd))
|
|
450
|
+
if x0 + wpx > fig.bbox.width:
|
|
451
|
+
x0 -= wpx
|
|
452
|
+
top = min(max(y0, hpx), fig.bbox.height)
|
|
453
|
+
|
|
454
|
+
inv = fig.transFigure.inverted()
|
|
455
|
+
fx0, fbot = inv.transform((x0, top - hpx))
|
|
456
|
+
fx1, ftop = inv.transform((x0 + wpx, top))
|
|
457
|
+
light = not dark_mode
|
|
458
|
+
box = Rectangle((fx0, fbot), fx1 - fx0, ftop - fbot,
|
|
459
|
+
transform=fig.transFigure, zorder=50, lw=1,
|
|
460
|
+
facecolor="#fffdf2" if light else "#222222",
|
|
461
|
+
edgecolor="black" if light else "#aaaaaa")
|
|
462
|
+
fig.add_artist(box)
|
|
463
|
+
hi = Rectangle((fx0, fbot), fx1 - fx0, 0, transform=fig.transFigure,
|
|
464
|
+
facecolor="#378ADD", alpha=0.35, zorder=51, visible=False)
|
|
465
|
+
fig.add_artist(hi)
|
|
466
|
+
texts = []
|
|
467
|
+
tx, ty = inv.transform((x0 + tpad, top - pad - 0.62 * hh))
|
|
468
|
+
texts.append(fig.text(tx, ty, "vacuum Å", fontsize=fs - 2, zorder=52,
|
|
469
|
+
style="italic", color="#666666" if light else "#aaaaaa"))
|
|
470
|
+
for k, (labtext, wrest) in enumerate(entries):
|
|
471
|
+
tx, ty = inv.transform((x0 + tpad, top - pad - hh - k * ih - 0.74 * ih))
|
|
472
|
+
texts.append(fig.text(tx, ty, labtext, fontsize=fs, zorder=52,
|
|
473
|
+
family="monospace",
|
|
474
|
+
color="black" if light else "white"))
|
|
475
|
+
if cursor is not None:
|
|
476
|
+
cursor.active = False # freeze the crosshair on the anchor point
|
|
477
|
+
refresh()
|
|
478
|
+
|
|
479
|
+
def index_at(ev):
|
|
480
|
+
if ev.x is None or not (x0 <= ev.x <= x0 + wpx):
|
|
481
|
+
return None
|
|
482
|
+
k = int((top - pad - hh - ev.y) // ih)
|
|
483
|
+
return k if (0 <= k < n
|
|
484
|
+
and top - pad - hh - n * ih <= ev.y <= top - pad - hh) \
|
|
485
|
+
else None
|
|
486
|
+
|
|
487
|
+
state = {"k": None, "picked": None}
|
|
488
|
+
|
|
489
|
+
def on_move(ev):
|
|
490
|
+
k = index_at(ev)
|
|
491
|
+
if k != state["k"]:
|
|
492
|
+
state["k"] = k
|
|
493
|
+
if k is None:
|
|
494
|
+
hi.set_visible(False)
|
|
495
|
+
else:
|
|
496
|
+
_, hy0 = inv.transform((0, top - pad - hh - (k + 1) * ih))
|
|
497
|
+
_, hy1 = inv.transform((0, top - pad - hh - k * ih))
|
|
498
|
+
hi.set_bounds(fx0, hy0, fx1 - fx0, hy1 - hy0)
|
|
499
|
+
hi.set_visible(True)
|
|
500
|
+
fig.canvas.draw_idle()
|
|
501
|
+
|
|
502
|
+
def on_click(ev):
|
|
503
|
+
state["picked"] = index_at(ev)
|
|
504
|
+
fig.canvas.stop_event_loop()
|
|
505
|
+
|
|
506
|
+
def on_key(ev):
|
|
507
|
+
state["picked"] = None
|
|
508
|
+
fig.canvas.stop_event_loop()
|
|
509
|
+
|
|
510
|
+
cids = [fig.canvas.mpl_connect("motion_notify_event", on_move),
|
|
511
|
+
fig.canvas.mpl_connect("button_press_event", on_click),
|
|
512
|
+
fig.canvas.mpl_connect("key_press_event", on_key)]
|
|
513
|
+
fig.canvas.start_event_loop()
|
|
514
|
+
for cid in cids:
|
|
515
|
+
fig.canvas.mpl_disconnect(cid)
|
|
516
|
+
box.remove()
|
|
517
|
+
hi.remove()
|
|
518
|
+
for t in texts:
|
|
519
|
+
t.remove()
|
|
520
|
+
if cursor is not None:
|
|
521
|
+
cursor.active = True
|
|
522
|
+
refresh()
|
|
523
|
+
return entries[state["picked"]][1] if state["picked"] is not None else None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
# Continuous cursor readout - bottom right corner of the window.
|
|
528
|
+
# The text is an animated artist blitted over a snapshot of its corner of
|
|
529
|
+
# the figure, so tracking the mouse costs two cheap blits per move, not a
|
|
530
|
+
# full redraw (same trick as the crosshair).
|
|
531
|
+
# ---------------------------------------------------------------------------
|
|
532
|
+
readout_artist = None
|
|
533
|
+
readout_bg = None # (background, bbox) pair for blitting
|
|
534
|
+
readout_lastev = None # last motion event, to survive full redraws
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def make_readout():
|
|
538
|
+
global readout_artist, readout_bg, readout_lastev
|
|
539
|
+
readout_artist = fig.text(0.995, 0.012, "", ha="right", va="bottom",
|
|
540
|
+
family="monospace", fontsize=8, animated=True,
|
|
541
|
+
color="white" if dark_mode else "black")
|
|
542
|
+
readout_bg = None
|
|
543
|
+
readout_lastev = None
|
|
544
|
+
fig.canvas.mpl_connect("draw_event", snapshot_readout)
|
|
545
|
+
fig.canvas.mpl_connect("motion_notify_event", update_readout)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def snapshot_readout(ev):
|
|
549
|
+
"""Cache the bottom-right corner after every full draw (the readout
|
|
550
|
+
artist is animated, so it is never part of the cached image), then
|
|
551
|
+
re-render the readout so redraws don't blank it - recomputed, so a
|
|
552
|
+
new redshift updates the rest wavelength immediately."""
|
|
553
|
+
global readout_bg
|
|
554
|
+
W, H = fig.bbox.width, fig.bbox.height
|
|
555
|
+
box = Bbox([[0.40 * W, 0.0], [W, 0.055 * H]])
|
|
556
|
+
readout_bg = (fig.canvas.copy_from_bbox(box), box)
|
|
557
|
+
if readout_lastev is not None:
|
|
558
|
+
update_readout(readout_lastev)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def update_readout(ev):
|
|
562
|
+
global readout_lastev
|
|
563
|
+
if readout_artist is None or readout_bg is None or f is None:
|
|
564
|
+
return
|
|
565
|
+
readout_lastev = ev
|
|
566
|
+
if ev.inaxes is ax and ev.xdata is not None:
|
|
567
|
+
i = int(np.argmin(np.abs(w - ev.xdata)))
|
|
568
|
+
wfmt = "%.5f" if micron_mode else "%.2f"
|
|
569
|
+
rest = wfmt % (ev.xdata / (1 + zshift)) if found else "-"
|
|
570
|
+
text = ("pix %d y %.4g λ %s rest %s flux %.4g"
|
|
571
|
+
% (i, ev.ydata, wfmt % ev.xdata, rest, f[i]))
|
|
572
|
+
else:
|
|
573
|
+
text = ""
|
|
574
|
+
readout_artist.set_text(text)
|
|
575
|
+
bg, box = readout_bg
|
|
576
|
+
fig.canvas.restore_region(bg)
|
|
577
|
+
fig.draw_artist(readout_artist)
|
|
578
|
+
fig.canvas.blit(box)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# Simulated terminal in the plot window - port of pgwin_prompt/pgwin_message
|
|
583
|
+
# ("Ghastly but works and is convenient")
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
def win_message(text):
|
|
586
|
+
global message_artist
|
|
587
|
+
if message_artist is not None:
|
|
588
|
+
try:
|
|
589
|
+
message_artist.remove()
|
|
590
|
+
except ValueError:
|
|
591
|
+
pass
|
|
592
|
+
message_artist = None
|
|
593
|
+
if text and text.strip():
|
|
594
|
+
message_artist = fig.text(0.06, 0.02, text, color="red", fontsize=9)
|
|
595
|
+
refresh()
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def get_input_win(prompt, default):
|
|
599
|
+
"""Prompt in the plot window; typed keys echo there. Enter accepts,
|
|
600
|
+
empty input returns the default (FIGARO style)."""
|
|
601
|
+
win_message("")
|
|
602
|
+
prompt_art = fig.text(0.06, 0.045, "%s [%s]: " % (prompt, default),
|
|
603
|
+
color="red", fontsize=9)
|
|
604
|
+
typed_art = fig.text(0.06, 0.012, "", fontsize=9,
|
|
605
|
+
color=theme_col("green", not dark_mode))
|
|
606
|
+
refresh()
|
|
607
|
+
s = ""
|
|
608
|
+
while True:
|
|
609
|
+
if not plt.fignum_exists(fig.number):
|
|
610
|
+
break
|
|
611
|
+
_, _, ch = pgband()
|
|
612
|
+
if ch is None or ch == "A":
|
|
613
|
+
continue
|
|
614
|
+
if ch == "enter":
|
|
615
|
+
break
|
|
616
|
+
if ch == "backspace":
|
|
617
|
+
s = s[:-1]
|
|
618
|
+
elif len(ch) == 1:
|
|
619
|
+
s += ch
|
|
620
|
+
else:
|
|
621
|
+
continue # ignore modifier keys etc
|
|
622
|
+
typed_art.set_text(s)
|
|
623
|
+
refresh()
|
|
624
|
+
try:
|
|
625
|
+
prompt_art.remove()
|
|
626
|
+
typed_art.remove()
|
|
627
|
+
except ValueError:
|
|
628
|
+
pass
|
|
629
|
+
refresh()
|
|
630
|
+
return str(default) if s.strip() == "" else s
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def get_number_win(prompt, default):
|
|
634
|
+
s = get_input_win(prompt, default)
|
|
635
|
+
try:
|
|
636
|
+
return float(s)
|
|
637
|
+
except ValueError:
|
|
638
|
+
print("Could not parse '%s', using %s" % (s, default))
|
|
639
|
+
win_message("Could not parse '%s', using %s" % (s, default))
|
|
640
|
+
return float(default)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def nint(x):
|
|
644
|
+
return int(np.floor(x + 0.5))
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# ---------------------------------------------------------------------------
|
|
648
|
+
# Jupyter / IPython support - the interactive window opens OUTSIDE the
|
|
649
|
+
# notebook (inline backends cannot deliver mouse/key events to a blocking
|
|
650
|
+
# loop), and the final view is embedded in the cell on quit.
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
GUI_BACKENDS = ("macosx", "qtagg", "qt5agg", "tkagg",
|
|
653
|
+
"gtk3agg", "gtk4agg", "wxagg")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def in_ipython():
|
|
657
|
+
try:
|
|
658
|
+
from IPython import get_ipython
|
|
659
|
+
return get_ipython() is not None
|
|
660
|
+
except ImportError:
|
|
661
|
+
return False
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def in_notebook():
|
|
665
|
+
"""True in a Jupyter kernel (as opposed to terminal IPython)."""
|
|
666
|
+
try:
|
|
667
|
+
from IPython import get_ipython
|
|
668
|
+
ip = get_ipython()
|
|
669
|
+
return ip is not None and type(ip).__name__ == "ZMQInteractiveShell"
|
|
670
|
+
except ImportError:
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def display_available():
|
|
675
|
+
"""Can a GUI window exist here? Remote/headless kernels (JupyterHub,
|
|
676
|
+
ssh without X forwarding) cannot - and on Linux, Qt may hard-abort
|
|
677
|
+
the kernel rather than fail politely, so check first."""
|
|
678
|
+
if sys.platform == "darwin" or sys.platform.startswith("win"):
|
|
679
|
+
return True
|
|
680
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def ensure_gui_backend():
|
|
684
|
+
"""Under IPython with a non-interactive backend (inline, ipympl, Agg)
|
|
685
|
+
switch to a native GUI backend so the window can pop up. Returns the
|
|
686
|
+
previous backend name if we switched (restored on quit), else None."""
|
|
687
|
+
if not in_ipython():
|
|
688
|
+
return None
|
|
689
|
+
# Respect an explicitly forced backend - but NOT the inline/ipympl one,
|
|
690
|
+
# because ipykernel itself sets MPLBACKEND to that in every notebook
|
|
691
|
+
forced = os.environ.get("MPLBACKEND", "")
|
|
692
|
+
if forced and "inline" not in forced and "ipympl" not in forced:
|
|
693
|
+
return None
|
|
694
|
+
current = matplotlib.get_backend()
|
|
695
|
+
if current.lower() in GUI_BACKENDS:
|
|
696
|
+
return None
|
|
697
|
+
if not display_available():
|
|
698
|
+
raise RuntimeError(
|
|
699
|
+
"pyredshift opens its interactive window on the machine where "
|
|
700
|
+
"the kernel runs, but this kernel appears to be headless or "
|
|
701
|
+
"remote (no DISPLAY). Run it with a local kernel, or with X "
|
|
702
|
+
"forwarding.")
|
|
703
|
+
for cand in ("MacOSX", "QtAgg", "TkAgg"):
|
|
704
|
+
try:
|
|
705
|
+
plt.switch_backend(cand)
|
|
706
|
+
except Exception:
|
|
707
|
+
continue
|
|
708
|
+
print("Opening the interactive window outside the notebook "
|
|
709
|
+
"(backend %s -> %s); press q there to return." % (current, cand))
|
|
710
|
+
return current
|
|
711
|
+
print("WARNING: backend %s is not interactive and no GUI backend could "
|
|
712
|
+
"be loaded - the window will not respond." % current)
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def final_view_png():
|
|
717
|
+
"""Render the current view to PNG bytes, independent of the live
|
|
718
|
+
backend (used to embed the final plot in the notebook cell)."""
|
|
719
|
+
import io
|
|
720
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
721
|
+
from matplotlib.figure import Figure
|
|
722
|
+
pfig = Figure(figsize=fig.get_size_inches())
|
|
723
|
+
FigureCanvasAgg(pfig)
|
|
724
|
+
pax = pfig.add_subplot()
|
|
725
|
+
set_margins(pfig)
|
|
726
|
+
render(pax)
|
|
727
|
+
buf = io.BytesIO()
|
|
728
|
+
pfig.savefig(buf, format="png", dpi=110, facecolor=pfig.get_facecolor())
|
|
729
|
+
return buf.getvalue()
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def show_in_cell(png):
|
|
733
|
+
try:
|
|
734
|
+
from IPython.display import display, Image
|
|
735
|
+
except ImportError:
|
|
736
|
+
return
|
|
737
|
+
display(Image(png))
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ---------------------------------------------------------------------------
|
|
741
|
+
# Config file - remembers the window size between runs
|
|
742
|
+
# ---------------------------------------------------------------------------
|
|
743
|
+
def load_config():
|
|
744
|
+
try:
|
|
745
|
+
with open(CONFIG_FILE) as fh:
|
|
746
|
+
return json.load(fh)
|
|
747
|
+
except (OSError, ValueError):
|
|
748
|
+
return {}
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def save_config(**kw):
|
|
752
|
+
cfg = load_config()
|
|
753
|
+
cfg.update(kw)
|
|
754
|
+
try:
|
|
755
|
+
with open(CONFIG_FILE, "w") as fh:
|
|
756
|
+
json.dump(cfg, fh, indent=1)
|
|
757
|
+
except OSError as err:
|
|
758
|
+
print("Could not save %s: %s" % (CONFIG_FILE, err))
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def screen_size_px():
|
|
762
|
+
"""Best-effort main screen size in pixels, or None if it cannot be found.
|
|
763
|
+
|
|
764
|
+
On the Mac ask CoreGraphics directly via ctypes - do NOT use tkinter
|
|
765
|
+
here: a Tk/macOS version mismatch aborts the whole process uncatchably.
|
|
766
|
+
"""
|
|
767
|
+
if sys.platform == "darwin":
|
|
768
|
+
try:
|
|
769
|
+
cg = ctypes.cdll.LoadLibrary(
|
|
770
|
+
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
|
|
771
|
+
cg.CGMainDisplayID.restype = ctypes.c_uint32
|
|
772
|
+
cg.CGDisplayPixelsWide.restype = ctypes.c_size_t
|
|
773
|
+
cg.CGDisplayPixelsWide.argtypes = [ctypes.c_uint32]
|
|
774
|
+
cg.CGDisplayPixelsHigh.restype = ctypes.c_size_t
|
|
775
|
+
cg.CGDisplayPixelsHigh.argtypes = [ctypes.c_uint32]
|
|
776
|
+
display = cg.CGMainDisplayID()
|
|
777
|
+
return (int(cg.CGDisplayPixelsWide(display)),
|
|
778
|
+
int(cg.CGDisplayPixelsHigh(display)))
|
|
779
|
+
except Exception:
|
|
780
|
+
return None
|
|
781
|
+
try: # elsewhere a throwaway Tk root works fine
|
|
782
|
+
import tkinter
|
|
783
|
+
root = tkinter.Tk()
|
|
784
|
+
root.withdraw()
|
|
785
|
+
size = (root.winfo_screenwidth(), root.winfo_screenheight())
|
|
786
|
+
root.destroy()
|
|
787
|
+
return size
|
|
788
|
+
except Exception:
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def startup_figsize():
|
|
793
|
+
"""The last run's window size (inches), clamped to the current screen
|
|
794
|
+
so a move to a smaller display degrades gracefully. Clamping is a
|
|
795
|
+
uniform scaling, so the aspect ratio is always preserved - unless the
|
|
796
|
+
saved aspect is daft (>10:1 either way), which we assume is user error
|
|
797
|
+
and replace with the default shape."""
|
|
798
|
+
figsize = load_config().get("figsize", list(DEFAULT_FIGSIZE))
|
|
799
|
+
try:
|
|
800
|
+
figsize = [float(figsize[0]), float(figsize[1])]
|
|
801
|
+
aspect = figsize[0] / figsize[1]
|
|
802
|
+
if aspect > 10.0 + 1e-9 or aspect < 0.1 - 1e-9: # beyond 10:1 = stupid
|
|
803
|
+
figsize = list(DEFAULT_FIGSIZE)
|
|
804
|
+
except (TypeError, ValueError, IndexError, ZeroDivisionError):
|
|
805
|
+
figsize = list(DEFAULT_FIGSIZE)
|
|
806
|
+
# Tiny-size floor (catches corrupt configs), aspect-preserving
|
|
807
|
+
scale = max(1.0, 3.0 / figsize[0], 2.0 / figsize[1])
|
|
808
|
+
figsize = [figsize[0] * scale, figsize[1] * scale]
|
|
809
|
+
# Fit to the screen LAST (leaving room for the menubar) with ONE scale
|
|
810
|
+
# factor, so the aspect ratio survives and the window always fits
|
|
811
|
+
screen = screen_size_px()
|
|
812
|
+
if screen:
|
|
813
|
+
dpi = matplotlib.rcParams["figure.dpi"]
|
|
814
|
+
scale = min(1.0, 0.95 * screen[0] / dpi / figsize[0],
|
|
815
|
+
0.88 * screen[1] / dpi / figsize[1])
|
|
816
|
+
figsize = [figsize[0] * scale, figsize[1] * scale]
|
|
817
|
+
return figsize
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
# ---------------------------------------------------------------------------
|
|
821
|
+
# Help ('?' key or the corner button) - themed HTML in the browser,
|
|
822
|
+
# else a plain-text matplotlib window
|
|
823
|
+
# ---------------------------------------------------------------------------
|
|
824
|
+
help_fig = None
|
|
825
|
+
help_button = None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def place_help_button():
|
|
829
|
+
"""Keep the '?' button a constant physical size in the top-right
|
|
830
|
+
corner, whatever the window size."""
|
|
831
|
+
w, h = fig.get_size_inches()
|
|
832
|
+
bw, bh, pad = 0.30 / w, 0.26 / h, 0.05
|
|
833
|
+
help_button.ax.set_position([1 - bw - pad / w, 1 - bh - pad / h, bw, bh])
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def make_help_button():
|
|
837
|
+
"""A '?' help button on the canvas (the native Mac toolbar cannot
|
|
838
|
+
take custom buttons)."""
|
|
839
|
+
global help_button
|
|
840
|
+
if dark_mode:
|
|
841
|
+
face, hover, fg = "#333333", "#555555", "white"
|
|
842
|
+
else:
|
|
843
|
+
face, hover, fg = "#e8e8e8", "#d0d0d0", "black"
|
|
844
|
+
help_button = Button(fig.add_axes([0.95, 0.95, 0.04, 0.04]), "?",
|
|
845
|
+
color=face, hovercolor=hover)
|
|
846
|
+
help_button.label.set_color(fg)
|
|
847
|
+
help_button.label.set_fontweight("bold")
|
|
848
|
+
help_button.on_clicked(lambda event: show_help())
|
|
849
|
+
place_help_button()
|
|
850
|
+
|
|
851
|
+
HELP_CSS = """
|
|
852
|
+
body { font-family: -apple-system, "Helvetica Neue", sans-serif;
|
|
853
|
+
max-width: 46em; margin: 2em auto; padding: 0 1em;
|
|
854
|
+
color: %(fg)s; background: %(bg)s; line-height: 1.45; }
|
|
855
|
+
h1 { font-size: 1.5em; border-bottom: 2px solid %(rule)s; padding-bottom: 0.2em; }
|
|
856
|
+
h2 { font-size: 1.15em; color: %(accent)s; margin-top: 1.4em; }
|
|
857
|
+
table { border-collapse: collapse; margin: 0.5em 0; }
|
|
858
|
+
th, td { border: 1px solid %(rule)s; padding: 0.25em 0.7em; text-align: left; }
|
|
859
|
+
th { background: %(thbg)s; }
|
|
860
|
+
td:first-child, td:nth-child(3) {
|
|
861
|
+
font-family: ui-monospace, Menlo, monospace; font-weight: bold;
|
|
862
|
+
white-space: nowrap; }
|
|
863
|
+
table.linelist td:first-child, table.linelist td:nth-child(3) {
|
|
864
|
+
font-weight: normal; }
|
|
865
|
+
code { font-family: ui-monospace, Menlo, monospace; background: %(thbg)s;
|
|
866
|
+
padding: 0 0.25em; border-radius: 3px; }
|
|
867
|
+
/* CSS-only tabs (radio button trick) */
|
|
868
|
+
input[name="tabs"] { display: none; }
|
|
869
|
+
nav.tabs { border-bottom: 2px solid %(rule)s; margin-top: 1em; }
|
|
870
|
+
nav.tabs label { display: inline-block; padding: 0.35em 1.3em; cursor: pointer;
|
|
871
|
+
border: 1px solid %(rule)s; border-bottom: none; margin: 0 0.3em -2px 0;
|
|
872
|
+
border-radius: 7px 7px 0 0; background: %(thbg)s; }
|
|
873
|
+
section.tab { display: none; }
|
|
874
|
+
#tab-keys:checked ~ #pane-keys, #tab-lines:checked ~ #pane-lines,
|
|
875
|
+
#tab-guide:checked ~ #pane-guide
|
|
876
|
+
{ display: block; }
|
|
877
|
+
#tab-keys:checked ~ nav label[for="tab-keys"],
|
|
878
|
+
#tab-lines:checked ~ nav label[for="tab-lines"],
|
|
879
|
+
#tab-guide:checked ~ nav label[for="tab-guide"]
|
|
880
|
+
{ background: %(bg)s; border-bottom: 2px solid %(bg)s; font-weight: bold; }
|
|
881
|
+
"""
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def show_help_browser(body):
|
|
885
|
+
"""Write the themed help page and open it in the default browser.
|
|
886
|
+
Returns False if that isn't possible."""
|
|
887
|
+
if body is None:
|
|
888
|
+
return False
|
|
889
|
+
import webbrowser
|
|
890
|
+
colours = ({"fg": "#ddd", "bg": "#111", "rule": "#555",
|
|
891
|
+
"accent": "#f66", "thbg": "#222"} if dark_mode else
|
|
892
|
+
{"fg": "#111", "bg": "#fff", "rule": "#bbb",
|
|
893
|
+
"accent": "crimson", "thbg": "#f0f0f0"})
|
|
894
|
+
html = ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
895
|
+
"<title>pyredshift help</title><style>%s</style></head>"
|
|
896
|
+
"<body>%s</body></html>" % (HELP_CSS % colours, body))
|
|
897
|
+
path = os.path.expanduser("~/.pyredshift-help.html")
|
|
898
|
+
try:
|
|
899
|
+
with open(path, "w") as fh:
|
|
900
|
+
fh.write(html)
|
|
901
|
+
return webbrowser.open("file://" + path)
|
|
902
|
+
except OSError:
|
|
903
|
+
return False
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def show_help():
|
|
907
|
+
global help_fig
|
|
908
|
+
body = help_html()
|
|
909
|
+
text = plain_help(body)
|
|
910
|
+
print(text)
|
|
911
|
+
if show_help_browser(body):
|
|
912
|
+
return
|
|
913
|
+
if help_fig is not None and plt.fignum_exists(help_fig.number):
|
|
914
|
+
try:
|
|
915
|
+
help_fig.canvas.manager.show() # raise the existing window
|
|
916
|
+
except Exception:
|
|
917
|
+
pass
|
|
918
|
+
return
|
|
919
|
+
# Size the window to the help text
|
|
920
|
+
nlines = text.count("\n") + 1
|
|
921
|
+
help_fig = plt.figure(figsize=(6.6, min(0.19 * nlines + 0.4, 10.0)))
|
|
922
|
+
try:
|
|
923
|
+
help_fig.canvas.manager.set_window_title("pyredshift help")
|
|
924
|
+
except AttributeError:
|
|
925
|
+
pass
|
|
926
|
+
fg = "white" if dark_mode else "black"
|
|
927
|
+
help_fig.set_facecolor("black" if dark_mode else "white")
|
|
928
|
+
help_fig.text(0.05, 0.98, text,
|
|
929
|
+
family="monospace", fontsize=9, va="top", color=fg)
|
|
930
|
+
try:
|
|
931
|
+
help_fig.show()
|
|
932
|
+
except Exception:
|
|
933
|
+
pass
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# ---------------------------------------------------------------------------
|
|
937
|
+
# Line list
|
|
938
|
+
# ---------------------------------------------------------------------------
|
|
939
|
+
def load_linelist():
|
|
940
|
+
global line_wav, line_col
|
|
941
|
+
line_name.clear()
|
|
942
|
+
line_label.clear()
|
|
943
|
+
module_dir = os.path.dirname(os.path.abspath(__file__))
|
|
944
|
+
lines_file = os.path.join(module_dir, "pyredshift.lines")
|
|
945
|
+
# lines_file = "/Users/karl/Dropbox/Templates/LineLists/Ivo-LRD-lines.dat" ## KG change temp
|
|
946
|
+
print("Reading line list (vacuum wavelengths) from", lines_file)
|
|
947
|
+
if not os.path.exists(lines_file):
|
|
948
|
+
raise FileNotFoundError("Lines file not found at %s" % lines_file)
|
|
949
|
+
# CSV format: wavelength_Angstroms, name, label, colour, comment
|
|
950
|
+
# label defaults to name, colour to red; the comment is ignored
|
|
951
|
+
wavs = []
|
|
952
|
+
line_col = []
|
|
953
|
+
with open(lines_file) as fh:
|
|
954
|
+
for line in fh:
|
|
955
|
+
line = line.strip()
|
|
956
|
+
if not line or line.startswith("#"):
|
|
957
|
+
continue
|
|
958
|
+
t = [s.strip() for s in line.split(",")]
|
|
959
|
+
name = t[1] if len(t) > 1 else ""
|
|
960
|
+
lab = t[2] if len(t) > 2 and t[2] else name
|
|
961
|
+
col = t[3] if len(t) > 3 and t[3] else "red"
|
|
962
|
+
wavs.append(float(t[0]))
|
|
963
|
+
line_name.append(name)
|
|
964
|
+
line_label.append(lab)
|
|
965
|
+
line_col.append(col)
|
|
966
|
+
line_wav = np.array(wavs)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# ---------------------------------------------------------------------------
|
|
970
|
+
# Template spectra - port of KGB::SpecUtils::get_template's search path
|
|
971
|
+
# ---------------------------------------------------------------------------
|
|
972
|
+
def get_template(name):
|
|
973
|
+
"""Read a template spectrum from the well-known locations
|
|
974
|
+
(env DATADIR is at top of search path)."""
|
|
975
|
+
candidates = [name]
|
|
976
|
+
dirs = []
|
|
977
|
+
if os.environ.get("DATADIR"):
|
|
978
|
+
dirs.append(os.environ["DATADIR"])
|
|
979
|
+
if os.environ.get("NEWMODELS"):
|
|
980
|
+
dirs.append(os.path.join(os.environ["NEWMODELS"], "Spectra"))
|
|
981
|
+
if os.environ.get("HOME"):
|
|
982
|
+
dirs.append(os.path.join(os.environ["HOME"], "Templates", "Spectra"))
|
|
983
|
+
for d in dirs:
|
|
984
|
+
candidates.append(os.path.join(d, name))
|
|
985
|
+
candidates.append(os.path.join(d, name + ".dat"))
|
|
986
|
+
for full in candidates:
|
|
987
|
+
if os.path.exists(full):
|
|
988
|
+
print("Opening file %s..." % full)
|
|
989
|
+
wt, ft = np.loadtxt(full, usecols=(0, 1), comments="#", unpack=True)
|
|
990
|
+
return wt, ft
|
|
991
|
+
raise FileNotFoundError("Unable to locate template %s" % name)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
# ---------------------------------------------------------------------------
|
|
995
|
+
# Plotting
|
|
996
|
+
# ---------------------------------------------------------------------------
|
|
997
|
+
def draw_labels(ax_, printing=False):
|
|
998
|
+
light = printing or not dark_mode
|
|
999
|
+
for i in range(len(line_wav)):
|
|
1000
|
+
if line_label[i] == "IGNORE":
|
|
1001
|
+
continue
|
|
1002
|
+
col = theme_col(line_col[i], light)
|
|
1003
|
+
wline = line_wav[i] * (1 + zshift)
|
|
1004
|
+
yvalue = yhi - yhi * 0.08 + ((i + 1) % 2) * yhi * 0.03
|
|
1005
|
+
if xstart <= wline <= xend:
|
|
1006
|
+
ls = "--" if printing else "-"
|
|
1007
|
+
ax_.text(wline, yvalue, line_label[i], color=col,
|
|
1008
|
+
ha="center", fontsize=8)
|
|
1009
|
+
ax_.plot(wline, yvalue - yhi * 0.02, marker=7, color=col, ms=4)
|
|
1010
|
+
ax_.plot([wline, wline], [yhi - yhi * 0.1, ylo],
|
|
1011
|
+
color=col, lw=0.7, ls=ls)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
MARGINS = dict(left=0.65, right=0.15, top=0.35, bottom=0.85) # inches
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def set_margins(fig_):
|
|
1018
|
+
"""Fixed physical margins around the axes (room for labels and the
|
|
1019
|
+
in-window prompt lines) whatever the window size - fractional margins
|
|
1020
|
+
waste acres of space on a big monitor."""
|
|
1021
|
+
w, h = fig_.get_size_inches()
|
|
1022
|
+
fig_.subplots_adjust(left=MARGINS["left"] / w,
|
|
1023
|
+
right=1 - MARGINS["right"] / w,
|
|
1024
|
+
top=1 - MARGINS["top"] / h,
|
|
1025
|
+
bottom=MARGINS["bottom"] / h)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def render(ax_, printing=False):
|
|
1029
|
+
"""Draw the whole plot onto ax_ - used for both screen and 'p' printing."""
|
|
1030
|
+
light = printing or not dark_mode
|
|
1031
|
+
fg = "black" if light else "white"
|
|
1032
|
+
bg = "white" if light else "black"
|
|
1033
|
+
lab_col = "crimson"
|
|
1034
|
+
spec_col = theme_col("tab:blue", light) # C0, the matplotlib default blue
|
|
1035
|
+
bin_col = theme_col("seagreen", light)
|
|
1036
|
+
smooth_col = "magenta"
|
|
1037
|
+
cuum_col = theme_col("darkorange", light)
|
|
1038
|
+
|
|
1039
|
+
ax_.clear()
|
|
1040
|
+
ax_.figure.set_facecolor(bg)
|
|
1041
|
+
ax_.set_facecolor(bg)
|
|
1042
|
+
for spine in ax_.spines.values():
|
|
1043
|
+
spine.set_color(fg)
|
|
1044
|
+
ax_.tick_params(colors=fg, direction="in", top=True, right=True)
|
|
1045
|
+
ax_.set_xlim(xstart, xend)
|
|
1046
|
+
ax_.set_ylim(ylo, yhi)
|
|
1047
|
+
ax_.set_xlabel("Wavelength / %s" % unit, color=lab_col)
|
|
1048
|
+
ax_.set_ylabel("Flux", color=lab_col)
|
|
1049
|
+
ax_.set_title(label, color=lab_col, fontsize=9)
|
|
1050
|
+
ax_.axhline(0, color=fg, lw=0.6)
|
|
1051
|
+
|
|
1052
|
+
# Don't plot the raw spectrum if a zero-offset binned/smoothed one replaces it
|
|
1053
|
+
if not ((got_bin and bin_off == 0) or (got_smooth and smooth_off == 0)):
|
|
1054
|
+
ax_.plot(w, f, drawstyle="steps-mid", color=spec_col, lw=0.7)
|
|
1055
|
+
if got_cuum:
|
|
1056
|
+
ax_.plot(w, f_cuum, color=cuum_col, lw=1.0)
|
|
1057
|
+
if got_bin:
|
|
1058
|
+
ax_.plot(w_bin, f_bin + bin_off, drawstyle="steps-mid", color=bin_col, lw=0.9)
|
|
1059
|
+
if got_smooth:
|
|
1060
|
+
ax_.plot(w, f_smooth + smooth_off, drawstyle="steps-mid",
|
|
1061
|
+
color=smooth_col, lw=0.9)
|
|
1062
|
+
if found:
|
|
1063
|
+
draw_labels(ax_, printing)
|
|
1064
|
+
ax_.text(0.0, 1.01, "z = %-10.4f" % zshift, transform=ax_.transAxes,
|
|
1065
|
+
va="bottom", color=fg, fontsize=12)
|
|
1066
|
+
if plot_template:
|
|
1067
|
+
ax_.plot(w_temp * (1 + zshift), f_temp * norm, color="red", lw=0.8)
|
|
1068
|
+
# Little marker along the bottom showing where the bad values are
|
|
1069
|
+
if anybad:
|
|
1070
|
+
ax_.plot(w, ylo + (~specgood) * (yhi - ylo) / 30.0, color="orange", lw=0.7)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def sync_limits(ax_):
|
|
1074
|
+
"""Fold toolbar pan/zoom changes back into our view state, so the next
|
|
1075
|
+
keyboard redraw keeps the view (and draws the line labels) there."""
|
|
1076
|
+
global xstart, xend, ylo, yhi
|
|
1077
|
+
xstart, xend = ax_.get_xlim()
|
|
1078
|
+
ylo, yhi = ax_.get_ylim()
|
|
1079
|
+
# Toolbar Home/Back/Forward arrive with no drag mode active - give those
|
|
1080
|
+
# a full redraw so the line labels follow immediately. (During pan/zoom
|
|
1081
|
+
# drags this fires on every mouse move, so just track the numbers there.)
|
|
1082
|
+
toolbar = getattr(fig.canvas.manager, "toolbar", None)
|
|
1083
|
+
if toolbar is not None and not getattr(toolbar, "mode", ""):
|
|
1084
|
+
draw_plot()
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def draw_plot():
|
|
1088
|
+
global cursor
|
|
1089
|
+
if cursor is not None:
|
|
1090
|
+
cursor.disconnect_events() # its artists die with ax.clear()
|
|
1091
|
+
cursor = None
|
|
1092
|
+
render(ax, printing=False)
|
|
1093
|
+
# Connect AFTER render so our own set_xlim/set_ylim don't fire it;
|
|
1094
|
+
# ax.clear() wipes these callbacks so reconnect on every redraw
|
|
1095
|
+
ax.callbacks.connect("xlim_changed", sync_limits)
|
|
1096
|
+
ax.callbacks.connect("ylim_changed", sync_limits)
|
|
1097
|
+
cursor = StickyCursor(ax, useblit=True, color="red", lw=0.8)
|
|
1098
|
+
refresh()
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
# ---------------------------------------------------------------------------
|
|
1102
|
+
# The main event: redshift($wav, $flux, $redshift, $label) -> z
|
|
1103
|
+
# ---------------------------------------------------------------------------
|
|
1104
|
+
final_png = None # final view for the notebook cell, set on 'q'
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def redshift(w_in, f_in, zz=None, label_in="", dark=0):
|
|
1108
|
+
"""Do the redshift thing - if zz is defined this is the first guess.
|
|
1109
|
+
dark=1 gives a PGPLOT-style black background. Returns the final redshift.
|
|
1110
|
+
|
|
1111
|
+
This wrapper guarantees cleanup - window teardown and (in a notebook)
|
|
1112
|
+
backend restore - however the session ends: 'q', window close,
|
|
1113
|
+
Kernel->Interrupt, or an error."""
|
|
1114
|
+
global final_png
|
|
1115
|
+
final_png = None
|
|
1116
|
+
prev_backend = ensure_gui_backend()
|
|
1117
|
+
try:
|
|
1118
|
+
return _redshift_session(w_in, f_in, zz, label_in, dark)
|
|
1119
|
+
finally:
|
|
1120
|
+
try:
|
|
1121
|
+
if help_fig is not None and plt.fignum_exists(help_fig.number):
|
|
1122
|
+
plt.close(help_fig)
|
|
1123
|
+
except Exception:
|
|
1124
|
+
pass
|
|
1125
|
+
try:
|
|
1126
|
+
if fig is not None and plt.fignum_exists(fig.number):
|
|
1127
|
+
plt.close(fig)
|
|
1128
|
+
# plt.close only SCHEDULES the window teardown - pump the
|
|
1129
|
+
# GUI event loop briefly so it actually happens; nobody
|
|
1130
|
+
# pumps it after we return (else a Qt window lingers,
|
|
1131
|
+
# beachballing)
|
|
1132
|
+
for _ in range(20):
|
|
1133
|
+
fig.canvas.flush_events()
|
|
1134
|
+
time.sleep(0.01)
|
|
1135
|
+
except Exception:
|
|
1136
|
+
pass
|
|
1137
|
+
if prev_backend is not None:
|
|
1138
|
+
try:
|
|
1139
|
+
plt.switch_backend(prev_backend)
|
|
1140
|
+
except Exception:
|
|
1141
|
+
pass
|
|
1142
|
+
if final_png is not None:
|
|
1143
|
+
show_in_cell(final_png)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _redshift_session(w_in, f_in, zz, label_in, dark):
|
|
1147
|
+
global fig, ax, cursor, message_artist, dark_mode, final_png
|
|
1148
|
+
global w, f, specgood, anybad, label, zshift, found
|
|
1149
|
+
global micron_mode, unit, med, xstart, xend, ylo, yhi
|
|
1150
|
+
global line_wav, line_col
|
|
1151
|
+
global got_cuum, f_cuum, RMS, got_bin, w_bin, f_bin, bin_off
|
|
1152
|
+
global got_smooth, f_smooth, smooth_off, plot_template, w_temp, f_temp, norm
|
|
1153
|
+
|
|
1154
|
+
w = np.asarray(w_in, float).copy()
|
|
1155
|
+
f = np.asarray(f_in, float).copy()
|
|
1156
|
+
label = label_in
|
|
1157
|
+
dark_mode = dark
|
|
1158
|
+
|
|
1159
|
+
specgood = np.isfinite(f)
|
|
1160
|
+
anybad = not specgood.all()
|
|
1161
|
+
if anybad:
|
|
1162
|
+
print("BAD values detected, will handle.")
|
|
1163
|
+
|
|
1164
|
+
got_cuum = got_bin = got_smooth = plot_template = 0
|
|
1165
|
+
f_cuum = None
|
|
1166
|
+
RMS = 0.0
|
|
1167
|
+
bin_off = smooth_off = 0.0
|
|
1168
|
+
binfac = 3
|
|
1169
|
+
fwhm = 3
|
|
1170
|
+
fluxtype = 0 # data units for 'm', asked once on first use
|
|
1171
|
+
zoomx = zoomy = 2.0
|
|
1172
|
+
|
|
1173
|
+
if zz is None or zz == "":
|
|
1174
|
+
zshift = 0.0
|
|
1175
|
+
found = 0
|
|
1176
|
+
else:
|
|
1177
|
+
zshift = float(zz)
|
|
1178
|
+
found = 1
|
|
1179
|
+
|
|
1180
|
+
micron_mode = 1 if np.nanmax(w) < 100 else 0 # use microns for IR spectra
|
|
1181
|
+
unit = "μm" if micron_mode else "Angstroms"
|
|
1182
|
+
|
|
1183
|
+
load_linelist()
|
|
1184
|
+
if micron_mode:
|
|
1185
|
+
line_wav = line_wav / 10000.0
|
|
1186
|
+
|
|
1187
|
+
# Clever autoscaling
|
|
1188
|
+
xstart = float(np.nanmin(w))
|
|
1189
|
+
xend = float(np.nanmax(w))
|
|
1190
|
+
med = float(np.nanmedian(f[specgood])) if specgood.any() else 0.0
|
|
1191
|
+
# Handle some NIR spectra with lots of zeroes which causes median=0
|
|
1192
|
+
if med == 0:
|
|
1193
|
+
nz = f[specgood]
|
|
1194
|
+
nz = nz[nz != 0.0]
|
|
1195
|
+
if nz.size:
|
|
1196
|
+
med = float(np.nanmedian(nz))
|
|
1197
|
+
ylo = -3 * med
|
|
1198
|
+
yhi = 10 * med
|
|
1199
|
+
|
|
1200
|
+
fig, ax = plt.subplots(figsize=startup_figsize())
|
|
1201
|
+
try:
|
|
1202
|
+
fig.canvas.manager.set_window_title("pyredshift")
|
|
1203
|
+
except AttributeError:
|
|
1204
|
+
pass
|
|
1205
|
+
set_margins(fig)
|
|
1206
|
+
make_help_button()
|
|
1207
|
+
make_readout()
|
|
1208
|
+
|
|
1209
|
+
def on_resize(ev):
|
|
1210
|
+
set_margins(fig)
|
|
1211
|
+
place_help_button()
|
|
1212
|
+
|
|
1213
|
+
fig.canvas.mpl_connect("resize_event", on_resize)
|
|
1214
|
+
|
|
1215
|
+
home_range = (xstart, xend, ylo, yhi) # 'h' and toolbar Home restore this
|
|
1216
|
+
|
|
1217
|
+
plt.show(block=False)
|
|
1218
|
+
draw_plot()
|
|
1219
|
+
|
|
1220
|
+
# Seed the toolbar's view history with the startup view: this enables the
|
|
1221
|
+
# Home button from the start, and makes it return all the way to this
|
|
1222
|
+
# view (not just to wherever the first pan/zoom happened to begin)
|
|
1223
|
+
toolbar = getattr(fig.canvas.manager, "toolbar", None)
|
|
1224
|
+
if toolbar is not None:
|
|
1225
|
+
toolbar.push_current()
|
|
1226
|
+
|
|
1227
|
+
redraw = 0
|
|
1228
|
+
while True: # Main loop
|
|
1229
|
+
|
|
1230
|
+
if redraw:
|
|
1231
|
+
draw_plot()
|
|
1232
|
+
redraw = 0
|
|
1233
|
+
|
|
1234
|
+
xv, yv, ch = pgband(allow_drag=True)
|
|
1235
|
+
if ch is None:
|
|
1236
|
+
continue
|
|
1237
|
+
|
|
1238
|
+
# Main character key block
|
|
1239
|
+
|
|
1240
|
+
if ch == "q":
|
|
1241
|
+
# Remember the window size for next time
|
|
1242
|
+
save_config(figsize=[float(v) for v in fig.get_size_inches()])
|
|
1243
|
+
# Leave a static image of the final view in the notebook cell
|
|
1244
|
+
# (the wrapper's cleanup displays it after the window is gone)
|
|
1245
|
+
if in_notebook():
|
|
1246
|
+
final_png = final_view_png()
|
|
1247
|
+
return zshift
|
|
1248
|
+
|
|
1249
|
+
elif ch == "h":
|
|
1250
|
+
print("Restoring initial display range...")
|
|
1251
|
+
xstart, xend, ylo, yhi = home_range
|
|
1252
|
+
redraw = 1
|
|
1253
|
+
|
|
1254
|
+
elif ch == "?":
|
|
1255
|
+
show_help()
|
|
1256
|
+
|
|
1257
|
+
# Keys below need a cursor position inside the axes
|
|
1258
|
+
elif xv is None and ch not in ("d", "w", "=", "r", "p", "b", "s", "t", "_"):
|
|
1259
|
+
win_message("Cursor is outside the plot axes")
|
|
1260
|
+
|
|
1261
|
+
############### Pan/zoom stuff ###############
|
|
1262
|
+
|
|
1263
|
+
elif ch == "i":
|
|
1264
|
+
print("Zooming along Y axis...")
|
|
1265
|
+
dy = abs(yhi - ylo) / zoomy
|
|
1266
|
+
ylo = yv - dy / 2.0
|
|
1267
|
+
yhi = yv + dy / 2.0
|
|
1268
|
+
redraw = 1
|
|
1269
|
+
elif ch == "o":
|
|
1270
|
+
print("Unzooming along Y axis...")
|
|
1271
|
+
dy = abs(yhi - ylo) * zoomy
|
|
1272
|
+
ylo = yv - dy / 2.0
|
|
1273
|
+
yhi = yv + dy / 2.0
|
|
1274
|
+
redraw = 1
|
|
1275
|
+
elif ch == "z":
|
|
1276
|
+
print("Zooming along X axis...")
|
|
1277
|
+
dx = abs(xend - xstart) / zoomx
|
|
1278
|
+
xstart = xv - dx / 2.0
|
|
1279
|
+
xend = xv + dx / 2.0
|
|
1280
|
+
redraw = 1
|
|
1281
|
+
elif ch == "u":
|
|
1282
|
+
print("Unzooming along X axis...")
|
|
1283
|
+
dx = abs(xend - xstart) * zoomx
|
|
1284
|
+
xstart = xv - dx / 2.0
|
|
1285
|
+
xend = xv + dx / 2.0
|
|
1286
|
+
redraw = 1
|
|
1287
|
+
elif ch == "x":
|
|
1288
|
+
win_message("Move cursor to other end of X range and press any key...")
|
|
1289
|
+
newx, newy, ch2 = pgband()
|
|
1290
|
+
win_message("")
|
|
1291
|
+
if newx is not None:
|
|
1292
|
+
xstart, xend = sorted((xv, newx))
|
|
1293
|
+
redraw = 1
|
|
1294
|
+
elif ch == "y":
|
|
1295
|
+
win_message("Move cursor to other end of Y range and press any key...")
|
|
1296
|
+
newx, newy, ch2 = pgband()
|
|
1297
|
+
win_message("")
|
|
1298
|
+
if newy is not None:
|
|
1299
|
+
ylo, yhi = sorted((yv, newy))
|
|
1300
|
+
redraw = 1
|
|
1301
|
+
elif ch == "e":
|
|
1302
|
+
win_message("Move cursor to other end of region and press any key...")
|
|
1303
|
+
newx, newy, ch2 = pgband()
|
|
1304
|
+
win_message("")
|
|
1305
|
+
if newx is not None:
|
|
1306
|
+
xstart, xend = sorted((xv, newx))
|
|
1307
|
+
ylo, yhi = sorted((yv, newy))
|
|
1308
|
+
redraw = 1
|
|
1309
|
+
elif ch == "drag": # rubber-band zoom; pgband already set the range
|
|
1310
|
+
print("Zooming to selected region...")
|
|
1311
|
+
redraw = 1
|
|
1312
|
+
elif ch == "[":
|
|
1313
|
+
print("Panning left...")
|
|
1314
|
+
dx = abs(xend - xstart)
|
|
1315
|
+
xstart -= 0.7 * dx
|
|
1316
|
+
xend -= 0.7 * dx
|
|
1317
|
+
redraw = 1
|
|
1318
|
+
elif ch == "]":
|
|
1319
|
+
print("Panning right...")
|
|
1320
|
+
dx = abs(xend - xstart)
|
|
1321
|
+
xstart += 0.7 * dx
|
|
1322
|
+
xend += 0.7 * dx
|
|
1323
|
+
redraw = 1
|
|
1324
|
+
elif ch == "a":
|
|
1325
|
+
print("Autoscaling Y axis...")
|
|
1326
|
+
ylo = -0.5 * med
|
|
1327
|
+
yhi = 3 * med
|
|
1328
|
+
redraw = 1
|
|
1329
|
+
elif ch == "w":
|
|
1330
|
+
print("Setting whole X range...")
|
|
1331
|
+
xstart = float(np.nanmin(w))
|
|
1332
|
+
xend = float(np.nanmax(w))
|
|
1333
|
+
redraw = 1
|
|
1334
|
+
elif ch == "d":
|
|
1335
|
+
print("Redrawing plot...")
|
|
1336
|
+
redraw = 1
|
|
1337
|
+
got_cuum = got_bin = got_smooth = 0
|
|
1338
|
+
|
|
1339
|
+
### Cuum fitting and EW measurement ###
|
|
1340
|
+
|
|
1341
|
+
elif ch == "_":
|
|
1342
|
+
mask = np.zeros(f.size)
|
|
1343
|
+
while True:
|
|
1344
|
+
win_message("Fit: define LHS of continuum...(Q to exit)")
|
|
1345
|
+
xv1, _, ch1 = pgband()
|
|
1346
|
+
if ch1 in ("q", "Q") or xv1 is None:
|
|
1347
|
+
break
|
|
1348
|
+
win_message("Fit: define RHS of continuum...(Q to exit)")
|
|
1349
|
+
xv2, _, ch2 = pgband()
|
|
1350
|
+
if ch2 in ("q", "Q") or xv2 is None:
|
|
1351
|
+
break
|
|
1352
|
+
if xv1 > xv2:
|
|
1353
|
+
xv1, xv2 = xv2, xv1
|
|
1354
|
+
ix = (w >= xv1) & (w <= xv2)
|
|
1355
|
+
ax.plot(w[ix], f[ix], drawstyle="steps-mid", lw=0.8,
|
|
1356
|
+
color=theme_col("darkorange", not dark_mode))
|
|
1357
|
+
refresh()
|
|
1358
|
+
mask[ix] = 1
|
|
1359
|
+
win_message(" ")
|
|
1360
|
+
if mask.sum() > 0.1:
|
|
1361
|
+
while True:
|
|
1362
|
+
order = nint(get_number_win("Order of continuum fit?", 1))
|
|
1363
|
+
# Note slightly different to redshift.f as uses 3 not 2 sigma
|
|
1364
|
+
mask2 = mask.copy()
|
|
1365
|
+
mask2[~specgood] = 0
|
|
1366
|
+
for _ in range(10): # Iterate
|
|
1367
|
+
good = mask2 > 0
|
|
1368
|
+
if good.sum() < order + 2:
|
|
1369
|
+
win_message("Too few pixels left for the fit!")
|
|
1370
|
+
break
|
|
1371
|
+
coef = np.polyfit(w[good], f[good], order)
|
|
1372
|
+
f_cuum = np.polyval(coef, w)
|
|
1373
|
+
resid = np.where(good, f - f_cuum, 0.0)
|
|
1374
|
+
RMS = float(np.sqrt((mask2 * resid**2).sum() / mask2.sum()))
|
|
1375
|
+
dev = np.abs(np.where(specgood, f - f_cuum, 0.0))
|
|
1376
|
+
mask2[dev > 3 * RMS] = 0
|
|
1377
|
+
pc = 100 * (1 - mask2.sum() / mask.sum())
|
|
1378
|
+
print("3 sigma clipped RMS = %g with %.1f %% of pixels rejected"
|
|
1379
|
+
% (RMS, pc))
|
|
1380
|
+
ax.plot(w, f_cuum, lw=1.0,
|
|
1381
|
+
color=theme_col("darkorange", not dark_mode))
|
|
1382
|
+
refresh()
|
|
1383
|
+
ok = get_input_win(
|
|
1384
|
+
"RMS = %.4g, %.0f%% clipped - fit acceptable?"
|
|
1385
|
+
% (RMS, pc), "yes")
|
|
1386
|
+
if ok.strip().lower().startswith("y"):
|
|
1387
|
+
break
|
|
1388
|
+
got_cuum = 1
|
|
1389
|
+
else:
|
|
1390
|
+
win_message("No continuum defined")
|
|
1391
|
+
|
|
1392
|
+
elif ch == "m": # EW
|
|
1393
|
+
if f_cuum is None:
|
|
1394
|
+
win_message("Need to define continuum first")
|
|
1395
|
+
continue
|
|
1396
|
+
xv1 = xv
|
|
1397
|
+
ewcol = theme_col("green", not dark_mode)
|
|
1398
|
+
ax.plot([xv1, xv1], [ylo, yhi], color=ewcol, lw=0.8)
|
|
1399
|
+
refresh()
|
|
1400
|
+
win_message("Now define other side of line...")
|
|
1401
|
+
xv2, _, ch2 = pgband()
|
|
1402
|
+
win_message(" ")
|
|
1403
|
+
if xv2 is None:
|
|
1404
|
+
continue
|
|
1405
|
+
ax.plot([xv2, xv2], [ylo, yhi], color=ewcol, lw=0.8)
|
|
1406
|
+
refresh()
|
|
1407
|
+
if xv1 > xv2:
|
|
1408
|
+
xv1, xv2 = xv2, xv1
|
|
1409
|
+
dw = np.roll(w, -1) - w
|
|
1410
|
+
dw[-1] = 0
|
|
1411
|
+
idx = (w >= xv1) & (w <= xv2) & specgood & np.isfinite(f_cuum)
|
|
1412
|
+
# Only ask for the data units once per session
|
|
1413
|
+
while fluxtype < 1 or fluxtype > 3:
|
|
1414
|
+
fluxtype = int(get_number_win(
|
|
1415
|
+
"Are the data units (1) Counts (2) Flambda /A or (3) Fnu /Hz ?", 2))
|
|
1416
|
+
df = RMS # Fake (constant) error
|
|
1417
|
+
|
|
1418
|
+
EW = float(((1 - f[idx] / f_cuum[idx]) * dw[idx]).sum()) / (1 + zshift)
|
|
1419
|
+
dEW = float(np.sqrt(((df / f_cuum[idx])**2 * dw[idx]**2).sum())) / (1 + zshift)
|
|
1420
|
+
|
|
1421
|
+
if fluxtype == 1:
|
|
1422
|
+
lineflux = float((f[idx] - f_cuum[idx]).sum())
|
|
1423
|
+
dlineflux = float(np.sqrt((df**2 * np.ones(idx.sum())).sum()))
|
|
1424
|
+
elif fluxtype == 2:
|
|
1425
|
+
lineflux = float(((f[idx] - f_cuum[idx]) * dw[idx]).sum())
|
|
1426
|
+
dlineflux = float(np.sqrt(((df * dw[idx])**2).sum()))
|
|
1427
|
+
else:
|
|
1428
|
+
sf = 1e6 if micron_mode else 1e10 # c in wavelength units per second
|
|
1429
|
+
dnu = C_LIGHT * sf * dw[idx] / w[idx]**2
|
|
1430
|
+
lineflux = float(((f[idx] - f_cuum[idx]) * dnu).sum())
|
|
1431
|
+
dlineflux = float(np.sqrt(((df * dnu)**2).sum()))
|
|
1432
|
+
|
|
1433
|
+
print("Measured Rest EW = %g +- %g" % (EW, dEW))
|
|
1434
|
+
print("Measured Flux = %g +- %g" % (lineflux, dlineflux))
|
|
1435
|
+
print("Wavelength range %g to %g" % (xv1, xv2))
|
|
1436
|
+
win_message("Rest EW = %.4g ± %.3g Line flux = %.4g ± %.3g "
|
|
1437
|
+
"(%.6g–%.6g %s)"
|
|
1438
|
+
% (EW, dEW, lineflux, dlineflux, xv1, xv2, unit))
|
|
1439
|
+
|
|
1440
|
+
############### Redshift guessing ###############
|
|
1441
|
+
|
|
1442
|
+
elif ch == "g":
|
|
1443
|
+
print("Guessing line at current position...")
|
|
1444
|
+
wavelength = get_number_win("Rest wavelength of line",
|
|
1445
|
+
"%.2f" % (xv / (1 + zshift)))
|
|
1446
|
+
i = int(np.argmin(np.abs(wavelength - line_wav))) # Find nearest line
|
|
1447
|
+
zshift = xv / line_wav[i] - 1
|
|
1448
|
+
print("Redshift = %10.4f" % zshift)
|
|
1449
|
+
found = 1
|
|
1450
|
+
redraw = 1
|
|
1451
|
+
|
|
1452
|
+
elif ch in ("escape", "`"):
|
|
1453
|
+
win_message("Enter shortcut key for line at position...")
|
|
1454
|
+
xv, yv, ch2 = pgband()
|
|
1455
|
+
win_message("")
|
|
1456
|
+
if xv is None:
|
|
1457
|
+
continue
|
|
1458
|
+
if ch2 in ("escape", "`"):
|
|
1459
|
+
wavelength = xv / (1.0 + zshift) # Refine redshift
|
|
1460
|
+
elif ch2 in shortcuts:
|
|
1461
|
+
wavelength = shortcuts[ch2] / (10000.0 if micron_mode else 1.0)
|
|
1462
|
+
else:
|
|
1463
|
+
win_message("No shortcut for key '%s'" % ch2)
|
|
1464
|
+
continue
|
|
1465
|
+
i = int(np.argmin(np.abs(wavelength - line_wav))) # Find nearest line
|
|
1466
|
+
zshift = xv / line_wav[i] - 1
|
|
1467
|
+
print("Redshift = %10.4f" % zshift)
|
|
1468
|
+
found = 1
|
|
1469
|
+
redraw = 1
|
|
1470
|
+
|
|
1471
|
+
elif ch == "menu": # right-click: pick a line from the quick list
|
|
1472
|
+
picked = line_menu(xv, yv)
|
|
1473
|
+
if picked is not None:
|
|
1474
|
+
i = int(np.argmin(np.abs(picked - line_wav)))
|
|
1475
|
+
zshift = xv / line_wav[i] - 1
|
|
1476
|
+
print("Redshift = %10.4f" % zshift)
|
|
1477
|
+
found = 1
|
|
1478
|
+
redraw = 1
|
|
1479
|
+
|
|
1480
|
+
elif ch == "=":
|
|
1481
|
+
zshift = get_number_win("Enter redshift", zshift)
|
|
1482
|
+
found = 1
|
|
1483
|
+
redraw = 1
|
|
1484
|
+
|
|
1485
|
+
elif ch == "r":
|
|
1486
|
+
print("Removing features from plot")
|
|
1487
|
+
found = 0
|
|
1488
|
+
redraw = 1
|
|
1489
|
+
|
|
1490
|
+
############### Other stuff ###############
|
|
1491
|
+
|
|
1492
|
+
elif ch == "b":
|
|
1493
|
+
binfac = nint(get_number_win("Enter binning factor", binfac))
|
|
1494
|
+
if binfac > 1:
|
|
1495
|
+
bin_off = get_number_win("Enter Yoffset for plot", bin_off)
|
|
1496
|
+
npix = f.size // binfac
|
|
1497
|
+
# Clever code to rebin using reshape and mean! (nanmean handles bad)
|
|
1498
|
+
f_bin = np.nanmean(f[:npix * binfac].reshape(npix, binfac), axis=1)
|
|
1499
|
+
w_bin = np.nanmean(w[:npix * binfac].reshape(npix, binfac), axis=1)
|
|
1500
|
+
got_bin = 1
|
|
1501
|
+
redraw = 1
|
|
1502
|
+
|
|
1503
|
+
elif ch == "s":
|
|
1504
|
+
fwhm = nint(get_number_win("Enter FWHM factor (pixels)", fwhm))
|
|
1505
|
+
if fwhm > 0:
|
|
1506
|
+
smooth_off = get_number_win("Enter Yoffset for plot", smooth_off)
|
|
1507
|
+
# Gaussian kernel with 3*FWHM width
|
|
1508
|
+
sig = fwhm / (2 * np.sqrt(2 * np.log(2)))
|
|
1509
|
+
klen = max(nint(3 * fwhm), 1)
|
|
1510
|
+
rv = np.abs(np.arange(klen) - (klen - 1) / 2.0)
|
|
1511
|
+
kern = np.exp(-0.5 * (rv / sig)**2)
|
|
1512
|
+
kern /= kern.sum()
|
|
1513
|
+
# Smooth with bad handling
|
|
1514
|
+
from scipy.ndimage import convolve1d
|
|
1515
|
+
f2 = np.where(specgood, f, 0.0)
|
|
1516
|
+
f_smooth = convolve1d(f2, kern, mode="reflect")
|
|
1517
|
+
# Norm of masked smooth, = 1 if all pixels good, <1 otherwise
|
|
1518
|
+
n = convolve1d(specgood.astype(float), kern, mode="reflect")
|
|
1519
|
+
f_smooth = f_smooth / n
|
|
1520
|
+
f_smooth[n < 0.5] = np.nan # require half of kernel for valid smooth
|
|
1521
|
+
got_smooth = 1
|
|
1522
|
+
redraw = 1
|
|
1523
|
+
|
|
1524
|
+
elif ch in (" ", "A"):
|
|
1525
|
+
i = int(np.argmin(np.abs(w - xv))) # Find nearest pixel in wavelength
|
|
1526
|
+
print("XY= (%8.2f, %10g) Pixel = %d Flux = %10g Rest = %8.2f"
|
|
1527
|
+
% (xv, yv, i, f[i], w[i] / (1 + zshift)))
|
|
1528
|
+
|
|
1529
|
+
elif ch == "k":
|
|
1530
|
+
ii = int(np.argmin(np.abs(line_wav - xv / (1 + zshift))))
|
|
1531
|
+
line_label[ii] = "IGNORE"
|
|
1532
|
+
line_wav[ii] = -1 # Kill line
|
|
1533
|
+
print("Redrawing plot...")
|
|
1534
|
+
redraw = 1
|
|
1535
|
+
got_cuum = got_bin = got_smooth = 0
|
|
1536
|
+
|
|
1537
|
+
elif ch == "B":
|
|
1538
|
+
win_message("Move cursor to other end of X range and press any key...")
|
|
1539
|
+
newx, newy, ch2 = pgband()
|
|
1540
|
+
win_message("")
|
|
1541
|
+
if newx is None:
|
|
1542
|
+
continue
|
|
1543
|
+
x1, x2 = sorted((xv, newx))
|
|
1544
|
+
f[(w > x1) & (w < x2)] = 0
|
|
1545
|
+
redraw = 1
|
|
1546
|
+
|
|
1547
|
+
elif ch == "p":
|
|
1548
|
+
outfile = "pyredshift.pdf"
|
|
1549
|
+
pfig, pax = plt.subplots(figsize=(10.5, 5.25))
|
|
1550
|
+
set_margins(pfig)
|
|
1551
|
+
render(pax, printing=True)
|
|
1552
|
+
pfig.savefig(outfile, facecolor="white")
|
|
1553
|
+
plt.close(pfig)
|
|
1554
|
+
print("Printed plot to %s" % outfile)
|
|
1555
|
+
win_message("Printed plot to %s" % outfile)
|
|
1556
|
+
|
|
1557
|
+
elif ch == "t": # Read a template and plot it quickly for comparison
|
|
1558
|
+
print("Plotting %s template..." % TEMPLATE_NAME)
|
|
1559
|
+
try:
|
|
1560
|
+
w_temp, f_temp = get_template(TEMPLATE_NAME)
|
|
1561
|
+
except FileNotFoundError as err:
|
|
1562
|
+
print(err)
|
|
1563
|
+
win_message(str(err))
|
|
1564
|
+
continue
|
|
1565
|
+
# Highly empirical robust normalisation scheme!
|
|
1566
|
+
# first define middle third of the displayed wavelength range
|
|
1567
|
+
x1 = 0.5 * (xstart + xend) - (xend - xstart) / 3
|
|
1568
|
+
x2 = 0.5 * (xstart + xend) + (xend - xstart) / 3
|
|
1569
|
+
# Now normalise using median to avoid outliers
|
|
1570
|
+
sel = f[(w > x1) & (w < x2)]
|
|
1571
|
+
wt = w_temp * (1 + zshift)
|
|
1572
|
+
selt = f_temp[(wt > x1) & (wt < x2)]
|
|
1573
|
+
if sel.size == 0 or selt.size == 0:
|
|
1574
|
+
win_message("Template does not overlap the displayed wavelength range")
|
|
1575
|
+
continue
|
|
1576
|
+
norm = float(np.nanmedian(sel)) / float(np.nanmedian(selt))
|
|
1577
|
+
plot_template = 1
|
|
1578
|
+
redraw = 1
|
|
1579
|
+
|
|
1580
|
+
# End of char key block
|
|
1581
|
+
# End main loop
|