pyredshift 1.9__tar.gz → 1.11__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyredshift
3
- Version: 1.9
3
+ Version: 1.11
4
4
  Summary: Interactive redshifting of 1D astronomical spectra (Python successor of pdlredshift / redshift.f)
5
5
  Home-page: https://github.com/karlglazebrook/pyredshift
6
6
  Author: Karl Glazebrook
@@ -134,7 +134,9 @@ short guide in separate tabs. The essentials:
134
134
  - **Navigate**: drag the mouse for a rubber-band zoom (a purely horizontal
135
135
  drag zooms X only), `z`/`u` and `i`/`o` zoom about the cursor, `[` `]`
136
136
  pan, `w` whole range, `a` autoscale, `h` home. The matplotlib toolbar
137
- works too and stays in sync.
137
+ works too and stays in sync. As in PGPLOT the cursor works over the
138
+ whole window — press `x`/`y`/`e` just outside the axis to select a
139
+ range flush with the plot edge.
138
140
  - **Analyse**: `b` bin, `s` smooth, `_` iterative continuum fit,
139
141
  `m` equivalent width and line flux, `B` zap artefacts, `p` print to PDF.
140
142
  - **Quit**: `q` — the final redshift is reported on the terminal.
@@ -100,7 +100,9 @@ short guide in separate tabs. The essentials:
100
100
  - **Navigate**: drag the mouse for a rubber-band zoom (a purely horizontal
101
101
  drag zooms X only), `z`/`u` and `i`/`o` zoom about the cursor, `[` `]`
102
102
  pan, `w` whole range, `a` autoscale, `h` home. The matplotlib toolbar
103
- works too and stays in sync.
103
+ works too and stays in sync. As in PGPLOT the cursor works over the
104
+ whole window — press `x`/`y`/`e` just outside the axis to select a
105
+ range flush with the plot edge.
104
106
  - **Analyse**: `b` bin, `s` smooth, `_` iterative continuum fit,
105
107
  `m` equivalent width and line flux, `B` zap artefacts, `p` print to PDF.
106
108
  - **Quit**: `q` — the final redshift is reported on the terminal.
@@ -264,9 +264,14 @@ def read_spectrum(fname, fmt=None):
264
264
  def main():
265
265
  ap = argparse.ArgumentParser(
266
266
  prog="pyredshift",
267
+ usage="pyredshift [-help] [-f FORMAT] [-z REDSHIFT] [--retro] "
268
+ "[--microns] file",
267
269
  description="Interactive redshifting of 1D spectra (port of pdlredshift).",
268
- epilog="Formats: ascii csv fits sdss table (=jwst,jwst2,gabe,dja) "
269
- "xs xs2 outthere. Default is auto-detection.")
270
+ formatter_class=argparse.RawDescriptionHelpFormatter,
271
+ # format documentation lives in the module (FORMAT_HELP) so the
272
+ # -h epilog and the '?' help page cannot drift apart
273
+ epilog=redshift_module.formats_text())
274
+ ap.add_argument("-help", action="help", help=argparse.SUPPRESS)
270
275
  ap.add_argument("file", help="spectrum file (FITS, CSV or 2-column ascii)")
271
276
  ap.add_argument("-f", "--format", default=None, metavar="FORMAT",
272
277
  help="force the input format instead of auto-detecting")
@@ -13,7 +13,7 @@ from setuptools import setup
13
13
 
14
14
  setup(
15
15
  name="pyredshift",
16
- version="1.9",
16
+ version="1.11",
17
17
  description="Interactive redshifting of 1D astronomical spectra "
18
18
  "(Python successor of pdlredshift / redshift.f)",
19
19
  long_description=open("README.md").read(),
@@ -57,6 +57,11 @@ redshift at that position (same as ESC + shortcut key)</td></tr>
57
57
  <tr><td>d</td><td>display again</td></tr>
58
58
  </table>
59
59
 
60
+ <p>The cursor works over the whole window, as in PGPLOT: keys and clicks
61
+ outside the axes box extrapolate their coordinates, so <code>x</code>,
62
+ <code>y</code> and <code>e</code> can be pressed just outside the axis to
63
+ select a range flush with (or beyond) the plot edge.</p>
64
+
60
65
  <h2>Analysis</h2>
61
66
  <table>
62
67
  <tr><th>Key</th><th>Action</th></tr>
@@ -93,13 +98,16 @@ colours as plotted):</p>
93
98
  <h2>Guide</h2>
94
99
  <p>pyredshift is a tool for measuring the redshift of a galaxy or quasar
95
100
  from a 1D spectrum, by eye. Run it as <code>pyredshift file</code> &mdash;
96
- the format (FITS image or table, SDSS, JWST, XSHOOTER, OutThere, CSV,
97
- 2-column ascii) is auto-detected, with <code>-f</code> to force one,
98
- <code>-z</code> to start from a known redshift and <code>--retro</code>
99
- for a retro PGPLOT-style black background. It plots the spectrum histogram-style with a
101
+ the file format is auto-detected (see the table below), with
102
+ <code>-f</code> to force one, <code>-z</code> to start from a known
103
+ redshift and <code>--retro</code> for a retro PGPLOT-style black
104
+ background. It plots the spectrum histogram-style with a
100
105
  crosshair cursor, and overlays rest-frame line identifications that
101
106
  follow the trial redshift shown at top left.</p>
102
107
 
108
+ <h2>File formats</h2>
109
+ {formats}
110
+
103
111
  <p>The basic game: spot a feature you can identify, put the crosshair on
104
112
  it and press <code>ESC</code> then the line's shortcut key (or
105
113
  <code>g</code> to type any rest wavelength) &mdash; the line labels jump
@@ -44,6 +44,14 @@ V1.8 - Right-click pops up the quick line list as a menu: pick a line
44
44
  lines are now goldenrod on white, yellow on the retro background.
45
45
  V1.9 - First PyPI release. --microns flag (keeps micron wavelengths so
46
46
  micron-mode display is reachable from files); packaging metadata.
47
+ V1.10 - The cursor works over the whole window, as PGPLOT's did: keys and
48
+ clicks outside the axes box extrapolate their coordinates (so
49
+ 'x'/'y'/'e' can be pressed just outside the axis, pdlredshift
50
+ style), the crosshair and the readout follow into the margins,
51
+ and rubber-band drags may extend past the plot edge.
52
+ V1.11 - Proper file-format documentation: a per-format table in the
53
+ script's -h and a File formats section in the help page, both
54
+ generated from FORMAT_HELP here so they cannot drift apart.
47
55
  """
48
56
 
49
57
  import ctypes
@@ -83,12 +91,33 @@ try:
83
91
  except AttributeError:
84
92
  pass
85
93
 
86
- __version__ = "1.9"
94
+ __version__ = "1.11"
87
95
 
88
96
  C_LIGHT = 2.99792458e8 # m/s
89
97
 
90
98
  TEMPLATE_NAME = "GNIRS_N4608" # template for the 't' key
91
99
 
100
+ # One description per reader in the pyredshift script - the single source
101
+ # for both the script's -h epilog and the Formats section of the help page.
102
+ # Keep in sync when adding a reader to the script.
103
+ FORMAT_HELP = [
104
+ ("fits", "1D (or 2D - row 1 used) FITS image with WCS; CD1_1 "
105
+ "overrides CDELT1, IRAF/SDSS log-lambda flags handled"),
106
+ ("sdss", "SDSS/BOSS binary table (LOGLAM/FLUX, either case)"),
107
+ ("table", "generic binary table with wave+flux columns; TUNIT "
108
+ "(um, nm, A) converted automatically "
109
+ "(aliases: jwst, jwst2, gabe, dja)"),
110
+ ("xs", "XSHOOTER 1D table spectrum (nm)"),
111
+ ("xs2", "XSHOOTER as a FITS image in extension 1"),
112
+ ("outthere", "OutThere multi-extension grism spectra, stitched "
113
+ "and flat-calibrated"),
114
+ ("csv", "comma-separated, header rows skipped"),
115
+ ("ascii", "2-column text"),
116
+ ]
117
+ FORMAT_NOTES = ("Default is auto-detection. Wavelengths that look like "
118
+ "microns are converted to Angstroms (--microns keeps them); "
119
+ "NaNs are treated as bad pixels and plot as gaps.")
120
+
92
121
  CONFIG_FILE = os.path.expanduser("~/.pyredshift.json") # remembers window size
93
122
  DEFAULT_FIGSIZE = (13.0, 5.5) # inches
94
123
 
@@ -210,11 +239,35 @@ def linelist_html():
210
239
  return "<table class='linelist'>\n%s\n</table>" % "\n".join(rows)
211
240
 
212
241
 
242
+ def formats_html():
243
+ """FORMAT_HELP as table rows + notes for the help page."""
244
+ rows = ["<tr><th>Format</th><th>Description</th></tr>"]
245
+ for name, desc in FORMAT_HELP:
246
+ rows.append("<tr><td><code>%s</code></td><td>%s</td></tr>"
247
+ % (name, desc))
248
+ return ("<table>\n%s\n</table>\n<p>%s</p>"
249
+ % ("\n".join(rows), FORMAT_NOTES))
250
+
251
+
252
+ def formats_text():
253
+ """FORMAT_HELP as aligned plain text for the script's -h epilog."""
254
+ import textwrap
255
+ width = max(len(name) for name, _ in FORMAT_HELP)
256
+ lines = ["Formats (-f):"]
257
+ for name, desc in FORMAT_HELP:
258
+ lines.append(textwrap.fill(
259
+ desc, width=79, initial_indent=" %-*s " % (width, name),
260
+ subsequent_indent=" " * (width + 4)))
261
+ lines.append(textwrap.fill(FORMAT_NOTES, width=79))
262
+ return "\n".join(lines)
263
+
264
+
213
265
  def help_html():
214
266
  """The help HTML body with the placeholders filled in."""
215
267
  if HELP_RAW is None:
216
268
  return None
217
- return HELP_RAW.format(template=TEMPLATE_NAME, linelist=linelist_html())
269
+ return HELP_RAW.format(template=TEMPLATE_NAME, linelist=linelist_html(),
270
+ formats=formats_html())
218
271
 
219
272
 
220
273
  # ---------------------------------------------------------------------------
@@ -260,6 +313,19 @@ norm = 1.0
260
313
  # ---------------------------------------------------------------------------
261
314
  # Cursor and blocking input - the pgband() replacement
262
315
  # ---------------------------------------------------------------------------
316
+ def surface_xy(ax_, ev):
317
+ """Data coords for an event anywhere on the figure surface.
318
+
319
+ PGPLOT's cursor was not confined to the axes box - outside it (margins,
320
+ over widgets) extrapolate from the pixel position."""
321
+ if ev.inaxes is ax_:
322
+ return ev.xdata, ev.ydata
323
+ if ev.x is None or ev.y is None:
324
+ return None, None
325
+ xd, yd = ax_.transData.inverted().transform((ev.x, ev.y))
326
+ return float(xd), float(yd)
327
+
328
+
263
329
  class StickyCursor(Cursor):
264
330
  """Cursor that survives full canvas repaints and toolbar pan/zoom.
265
331
 
@@ -268,19 +334,37 @@ class StickyCursor(Cursor):
268
334
  after every repaint. Also reimplements onmove() without the base class's
269
335
  widgetlock check, so the crosshair stays live while the toolbar pan/zoom
270
336
  mode is switched on.
337
+
338
+ The crosshair spans the whole window like PGPLOT's, not just the axes
339
+ box, so the background snapshot and blit cover the full figure - and the
340
+ cursor re-draws the (animated) readout text as part of its pass, since a
341
+ full-figure restore would otherwise erase it.
271
342
  """
272
343
 
273
344
  def __init__(self, ax_, **kwargs):
274
345
  self._last_event = None
275
346
  super().__init__(ax_, **kwargs)
347
+ self.linev.set_clip_on(False) # let the crosshair escape the box
348
+ self.lineh.set_clip_on(False)
276
349
  self.connect_event("draw_event", self._redraw)
277
350
 
351
+ def clear(self, event):
352
+ # As Cursor.clear (mpl 3.9), but snapshot the whole figure: the
353
+ # crosshair now extends into the margins. Runs on draw_event while
354
+ # animated artists are excluded, so the snapshot is clean.
355
+ if self.ignore(event) or self.canvas.is_saving():
356
+ return
357
+ if self.useblit:
358
+ self.background = self.canvas.copy_from_bbox(self.canvas.figure.bbox)
359
+
278
360
  def onmove(self, event):
279
361
  # Copy of Cursor.onmove from matplotlib 3.9, minus the widgetlock
280
- # check (version sensitive - revisit if matplotlib is upgraded)
362
+ # check (version sensitive - revisit if matplotlib is upgraded),
363
+ # with whole-window coords and full-figure blitting.
281
364
  if self.ignore(event):
282
365
  return
283
- if not self.ax.contains(event)[0]:
366
+ xdata, ydata = surface_xy(self.ax, event)
367
+ if xdata is None:
284
368
  self._last_event = None # or _redraw() would resurrect it
285
369
  self.linev.set_visible(False)
286
370
  self.lineh.set_visible(False)
@@ -290,10 +374,16 @@ class StickyCursor(Cursor):
290
374
  return
291
375
  self._last_event = event
292
376
  self.needclear = True
293
- xdata, ydata = self._get_data_coords(event)
294
- self.linev.set_xdata((xdata, xdata))
377
+ # Span the full window: line extents are in axes-fraction coords
378
+ figbb = self.canvas.figure.bbox
379
+ axbb = self.ax.bbox
380
+ self.linev.set_data((xdata, xdata),
381
+ ((figbb.y0 - axbb.y0) / axbb.height,
382
+ (figbb.y1 - axbb.y0) / axbb.height))
295
383
  self.linev.set_visible(self.visible and self.vertOn)
296
- self.lineh.set_ydata((ydata, ydata))
384
+ self.lineh.set_data(((figbb.x0 - axbb.x0) / axbb.width,
385
+ (figbb.x1 - axbb.x0) / axbb.width),
386
+ (ydata, ydata))
297
387
  self.lineh.set_visible(self.visible and self.horizOn)
298
388
  if not (self.visible and (self.vertOn or self.horizOn)):
299
389
  return
@@ -303,13 +393,21 @@ class StickyCursor(Cursor):
303
393
  self.canvas.restore_region(self.background)
304
394
  self.ax.draw_artist(self.linev)
305
395
  self.ax.draw_artist(self.lineh)
306
- self.canvas.blit(self.ax.bbox)
396
+ if readout_artist is not None:
397
+ set_readout_text(event) # restore wiped the corner -
398
+ fig.draw_artist(readout_artist) # redraw with fresh values
399
+ self.canvas.blit(figbb)
307
400
  else:
308
401
  self.canvas.draw_idle()
309
402
 
310
403
  def _redraw(self, event):
311
- if self._last_event is not None:
404
+ if self.active and self._last_event is not None:
312
405
  self.onmove(self._last_event)
406
+ elif readout_lastev is not None:
407
+ # No crosshair to re-draw (fresh cursor after draw_plot, or
408
+ # deactivated while the line menu is up) - the readout must
409
+ # still survive the repaint
410
+ update_readout(readout_lastev)
313
411
 
314
412
 
315
413
  def normkey(ch):
@@ -325,6 +423,11 @@ def pgband(allow_drag=False):
325
423
  Mouse button gives ch='A', as PGPLOT did. Returns ch='q' if the window
326
424
  is closed.
327
425
 
426
+ Like the PGPLOT cursor, key presses and left clicks work over the whole
427
+ window, not just inside the axes box - coordinates are extrapolated from
428
+ the pixel position (handy for 'x'/'y'/'e' just outside the axis).
429
+ Clicks on widgets (the ? button) are still ignored.
430
+
328
431
  With allow_drag=True a left-button drag rubber-band zooms (like 'e') and
329
432
  returns ch='drag' after updating the view state; a purely horizontal drag
330
433
  zooms X only, a purely vertical one Y only. Short drags (<5 pixels)
@@ -343,31 +446,40 @@ def pgband(allow_drag=False):
343
446
  toolbar = getattr(fig.canvas.manager, "toolbar", None)
344
447
  return getattr(toolbar, "mode", "") if toolbar is not None else ""
345
448
 
449
+ def data_xy(ev):
450
+ # PGPLOT-style whole-surface cursor read (see surface_xy)
451
+ return surface_xy(ax, ev)
452
+
346
453
  def on_key(ev):
347
- done(ev.xdata, ev.ydata, normkey(ev.key))
454
+ xd, yd = data_xy(ev)
455
+ done(xd, yd, normkey(ev.key))
348
456
 
349
457
  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:
458
+ if ev.inaxes is not ax and ev.inaxes is not None:
459
+ return # clicks on widgets (the ? button) aren't cursor reads
460
+ if allow_drag and ev.button == 3 and ev.inaxes is ax:
353
461
  done(ev.xdata, ev.ydata, "menu") # right-click: quick line menu
354
462
  return
355
- if allow_drag and ev.button == 1 and not toolbar_mode():
463
+ xd, yd = data_xy(ev)
464
+ if allow_drag and ev.button == 1 and ev.inaxes is ax and not toolbar_mode():
356
465
  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
466
+ drag["x0"], drag["y0"] = xd, yd
467
+ drag["x1"], drag["y1"] = xd, yd
359
468
  drag["rect"] = ax.add_patch(Rectangle(
360
- (ev.xdata, ev.ydata), 0, 0, fill=False,
469
+ (xd, yd), 0, 0, fill=False,
361
470
  edgecolor="red", lw=0.8, ls="--"))
362
471
  else:
363
- done(ev.xdata, ev.ydata, "A")
472
+ done(xd, yd, "A")
364
473
 
365
474
  def on_motion(ev):
366
- if "rect" not in drag or ev.inaxes is not ax:
475
+ if "rect" not in drag or (ev.inaxes is not ax and ev.inaxes is not None):
367
476
  return
368
- drag["x1"], drag["y1"] = ev.xdata, ev.ydata
477
+ xd, yd = data_xy(ev)
478
+ if xd is None:
479
+ return
480
+ drag["x1"], drag["y1"] = xd, yd
369
481
  drag["rect"].set_bounds(drag["x0"], drag["y0"],
370
- ev.xdata - drag["x0"], ev.ydata - drag["y0"])
482
+ xd - drag["x0"], yd - drag["y0"])
371
483
  fig.canvas.draw_idle()
372
484
 
373
485
  def on_release(ev):
@@ -547,31 +659,40 @@ def make_readout():
547
659
 
548
660
  def snapshot_readout(ev):
549
661
  """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."""
662
+ artist is animated, so it is never part of the cached image). Only a
663
+ snapshot: re-rendering here would bake the text into the buffer before
664
+ the cursor takes ITS (full-figure) snapshot; the cursor's _redraw
665
+ re-renders the readout after every repaint instead."""
553
666
  global readout_bg
554
667
  W, H = fig.bbox.width, fig.bbox.height
555
668
  box = Bbox([[0.40 * W, 0.0], [W, 0.055 * H]])
556
669
  readout_bg = (fig.canvas.copy_from_bbox(box), box)
557
- if readout_lastev is not None:
558
- update_readout(readout_lastev)
559
670
 
560
671
 
561
- def update_readout(ev):
672
+ def set_readout_text(ev):
673
+ """Recompute the readout for an event - separate from the blit so the
674
+ cursor can refresh the text mid-pass (a new redshift must update the
675
+ rest wavelength immediately, not on the next mouse move)."""
562
676
  global readout_lastev
563
- if readout_artist is None or readout_bg is None or f is None:
677
+ if readout_artist is None or f is None:
564
678
  return
565
679
  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)))
680
+ xd, yd = surface_xy(ax, ev)
681
+ if xd is not None: # whole window, like the crosshair (pix clamps at ends)
682
+ i = int(np.argmin(np.abs(w - xd)))
568
683
  wfmt = "%.5f" if micron_mode else "%.2f"
569
- rest = wfmt % (ev.xdata / (1 + zshift)) if found else "-"
684
+ rest = wfmt % (xd / (1 + zshift)) if found else "-"
570
685
  text = ("pix %d y %.4g λ %s rest %s flux %.4g"
571
- % (i, ev.ydata, wfmt % ev.xdata, rest, f[i]))
686
+ % (i, yd, wfmt % xd, rest, f[i]))
572
687
  else:
573
688
  text = ""
574
689
  readout_artist.set_text(text)
690
+
691
+
692
+ def update_readout(ev):
693
+ if readout_artist is None or readout_bg is None or f is None:
694
+ return
695
+ set_readout_text(ev)
575
696
  bg, box = readout_bg
576
697
  fig.canvas.restore_region(bg)
577
698
  fig.draw_artist(readout_artist)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyredshift
3
- Version: 1.9
3
+ Version: 1.11
4
4
  Summary: Interactive redshifting of 1D astronomical spectra (Python successor of pdlredshift / redshift.f)
5
5
  Home-page: https://github.com/karlglazebrook/pyredshift
6
6
  Author: Karl Glazebrook
@@ -134,7 +134,9 @@ short guide in separate tabs. The essentials:
134
134
  - **Navigate**: drag the mouse for a rubber-band zoom (a purely horizontal
135
135
  drag zooms X only), `z`/`u` and `i`/`o` zoom about the cursor, `[` `]`
136
136
  pan, `w` whole range, `a` autoscale, `h` home. The matplotlib toolbar
137
- works too and stays in sync.
137
+ works too and stays in sync. As in PGPLOT the cursor works over the
138
+ whole window — press `x`/`y`/`e` just outside the axis to select a
139
+ range flush with the plot edge.
138
140
  - **Analyse**: `b` bin, `s` smooth, `_` iterative continuum fit,
139
141
  `m` equivalent width and line flux, `B` zap artefacts, `p` print to PDF.
140
142
  - **Quit**: `q` — the final redshift is reported on the terminal.
File without changes
File without changes
File without changes