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/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'>&#9632;</span> "
203
+ "%s</td>" % (wav, col, line_label[i]))
204
+ half = (len(cells) + 1) // 2
205
+ rows = ["<tr><th>&lambda; vac</th><th>Line</th>"
206
+ "<th>&lambda; 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