specterm1d 0.1.0__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.
- specterm1d/__init__.py +0 -0
- specterm1d/cli.py +155 -0
- specterm1d/commands/__init__.py +9 -0
- specterm1d/commands/colon.py +174 -0
- specterm1d/commands/display.py +347 -0
- specterm1d/commands/measure.py +139 -0
- specterm1d/commands/transform.py +156 -0
- specterm1d/cursorscript.py +102 -0
- specterm1d/fitting.py +433 -0
- specterm1d/io/__init__.py +8 -0
- specterm1d/io/pypeit_io.py +179 -0
- specterm1d/io/registry.py +81 -0
- specterm1d/io/specutils_io.py +100 -0
- specterm1d/keymap.py +188 -0
- specterm1d/logfile.py +79 -0
- specterm1d/plot.py +475 -0
- specterm1d/session.py +702 -0
- specterm1d/spec.py +214 -0
- specterm1d/term/__init__.py +36 -0
- specterm1d/term/base.py +92 -0
- specterm1d/term/caps.py +229 -0
- specterm1d/term/chrome.py +213 -0
- specterm1d/term/gui.py +279 -0
- specterm1d/term/halfblock.py +128 -0
- specterm1d/term/input.py +220 -0
- specterm1d/term/iterm2.py +45 -0
- specterm1d/term/kitty.py +83 -0
- specterm1d/term/sixel.py +151 -0
- specterm1d/transcript.py +35 -0
- specterm1d/view.py +239 -0
- specterm1d-0.1.0.dist-info/METADATA +276 -0
- specterm1d-0.1.0.dist-info/RECORD +36 -0
- specterm1d-0.1.0.dist-info/WHEEL +5 -0
- specterm1d-0.1.0.dist-info/entry_points.txt +3 -0
- specterm1d-0.1.0.dist-info/licenses/LICENSE +29 -0
- specterm1d-0.1.0.dist-info/top_level.txt +1 -0
specterm1d/__init__.py
ADDED
|
File without changes
|
specterm1d/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import io
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import specterm1d.commands
|
|
10
|
+
import specterm1d.term # noqa: F401 - registers renderer factories
|
|
11
|
+
from specterm1d.io import registry
|
|
12
|
+
from specterm1d.logfile import SplotLog
|
|
13
|
+
from specterm1d.plot import SpectrumPlot
|
|
14
|
+
from specterm1d.session import Session
|
|
15
|
+
from specterm1d.term import caps as caps_mod
|
|
16
|
+
|
|
17
|
+
RENDERERS = ("kitty", "iterm2", "sixel", "gui", "halfblock")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="specterm1d",
|
|
23
|
+
description="Terminal 1D spectrum viewer with IRAF splot keybindings",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("files", nargs="*", type=Path, help="FITS spectra to view")
|
|
26
|
+
parser.add_argument("--renderer", choices=RENDERERS,
|
|
27
|
+
help="force a renderer instead of probing the terminal")
|
|
28
|
+
parser.add_argument("--gui", action="store_true",
|
|
29
|
+
help="shortcut for --renderer gui (a matplotlib window)")
|
|
30
|
+
parser.add_argument("--format", help="force a loader instead of sniffing")
|
|
31
|
+
parser.add_argument("--units", help="initial dispersion units, e.g. nm, um, GHz")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--mouse", action=argparse.BooleanOptionalAction, default=None,
|
|
34
|
+
help="toggle mouse positioning (default: on for inline graphics)",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--log", default="splot.log", help="measurement log file")
|
|
37
|
+
parser.add_argument("--debug", action="store_true",
|
|
38
|
+
help="show full tracebacks instead of one-line errors")
|
|
39
|
+
parser.add_argument("--cursor", type=Path,
|
|
40
|
+
help="run a cursor script instead of reading the keyboard")
|
|
41
|
+
parser.add_argument("--dump", type=Path,
|
|
42
|
+
help="render one frame to a PNG and exit (no tty needed)")
|
|
43
|
+
parser.add_argument("--dump-size", default="1200x700",
|
|
44
|
+
help="pixel size for --dump, as WxH")
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_renderer_choice(args) -> str | None:
|
|
49
|
+
"""--gui is a shortcut; an explicit --renderer always wins."""
|
|
50
|
+
if args.renderer:
|
|
51
|
+
return args.renderer
|
|
52
|
+
return "gui" if args.gui else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def attach_or_fall_back(renderer, plot, caps, out):
|
|
56
|
+
"""Open the renderer's window, or warn once and use halfblock.
|
|
57
|
+
|
|
58
|
+
A viewer that refuses to start over a missing window is worse than one
|
|
59
|
+
that draws coarsely, so this is never fatal - even for an explicit
|
|
60
|
+
--renderer gui. Terminal backends have no attach() and pass straight
|
|
61
|
+
through.
|
|
62
|
+
"""
|
|
63
|
+
from specterm1d.term.gui import GuiUnavailable
|
|
64
|
+
from specterm1d.term.halfblock import HalfblockRenderer
|
|
65
|
+
|
|
66
|
+
attach = getattr(renderer, "attach", None)
|
|
67
|
+
if attach is None:
|
|
68
|
+
return renderer
|
|
69
|
+
try:
|
|
70
|
+
attach(plot)
|
|
71
|
+
except GuiUnavailable as exc:
|
|
72
|
+
print(f"graphics window unavailable ({exc}); using halfblock",
|
|
73
|
+
file=sys.stderr)
|
|
74
|
+
return HalfblockRenderer(out=out, truecolor=caps.truecolor)
|
|
75
|
+
return renderer
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> int:
|
|
79
|
+
args = build_parser().parse_args(argv)
|
|
80
|
+
|
|
81
|
+
if not args.files:
|
|
82
|
+
build_parser().print_help()
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
collection = registry.load(args.files[0], format=args.format)
|
|
87
|
+
except registry.LoaderError as exc:
|
|
88
|
+
print(str(exc), file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
# Headless: --dump and --cursor must work with no tty at all, so this
|
|
92
|
+
# branches before the tty check.
|
|
93
|
+
if args.dump or args.cursor:
|
|
94
|
+
from specterm1d.cursorscript import parse_script, run_script
|
|
95
|
+
from specterm1d.term.halfblock import HalfblockRenderer
|
|
96
|
+
|
|
97
|
+
width, height = (int(v) for v in args.dump_size.lower().split("x"))
|
|
98
|
+
caps = caps_mod.TerminalCaps(
|
|
99
|
+
kitty=False, iterm2=False, sixel=False, truecolor=True,
|
|
100
|
+
rows=height // 2, cols=width, pixel_width=width,
|
|
101
|
+
pixel_height=height, is_tty=False,
|
|
102
|
+
)
|
|
103
|
+
session = Session(collection, HalfblockRenderer(out=io.StringIO()),
|
|
104
|
+
SpectrumPlot(width, height), out=io.StringIO(),
|
|
105
|
+
caps=caps)
|
|
106
|
+
session.debug = args.debug
|
|
107
|
+
session.log = SplotLog(args.log)
|
|
108
|
+
if args.units:
|
|
109
|
+
import astropy.units as u
|
|
110
|
+
session.view.set_axis(unit=u.Unit(args.units))
|
|
111
|
+
if args.cursor:
|
|
112
|
+
run_script(session, parse_script(args.cursor.read_text()))
|
|
113
|
+
if args.dump:
|
|
114
|
+
session.dump_png(args.dump, size=(width, height))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
caps = caps_mod.detect(is_tty=sys.stdout.isatty())
|
|
118
|
+
if not caps.is_tty:
|
|
119
|
+
print("specterm1d needs a terminal; stdout is not a tty.", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
renderer = caps_mod.choose_renderer(caps, override=resolve_renderer_choice(args),
|
|
123
|
+
out=sys.stdout)
|
|
124
|
+
width, height = renderer.target_pixels(caps.rows - 2, caps.cols)
|
|
125
|
+
plot = SpectrumPlot(width, height)
|
|
126
|
+
renderer = attach_or_fall_back(renderer, plot, caps, out=sys.stdout)
|
|
127
|
+
session = Session(collection, renderer, plot, out=sys.stdout, caps=caps)
|
|
128
|
+
session.debug = args.debug
|
|
129
|
+
|
|
130
|
+
if args.log:
|
|
131
|
+
session.log = SplotLog(args.log)
|
|
132
|
+
|
|
133
|
+
if args.units:
|
|
134
|
+
import astropy.units as u
|
|
135
|
+
session.view.set_axis(unit=u.Unit(args.units))
|
|
136
|
+
|
|
137
|
+
mouse_enabled = args.mouse
|
|
138
|
+
if mouse_enabled is None:
|
|
139
|
+
mouse_enabled = bool(getattr(renderer, "inline_graphics", False))
|
|
140
|
+
if mouse_enabled:
|
|
141
|
+
session.set_mouse(True)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
session.run()
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
session.teardown()
|
|
147
|
+
if args.debug:
|
|
148
|
+
raise
|
|
149
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
150
|
+
return 1
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Colon commands.
|
|
2
|
+
|
|
3
|
+
splot's set is reproduced except :dispaxis and :nsum, which are 2D-image
|
|
4
|
+
concerns - this tool ingests 1D. New commands cover the toggles splot had no
|
|
5
|
+
concept of: overlays, the sigma band, the mask, and the variant.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import astropy.units as u
|
|
10
|
+
|
|
11
|
+
from specterm1d.keymap import command
|
|
12
|
+
|
|
13
|
+
_TRUE = {"yes", "y", "true", "on", "1", "+"}
|
|
14
|
+
_FALSE = {"no", "n", "false", "off", "0", "-"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_colon(text: str) -> tuple[str, list[str]]:
|
|
18
|
+
stripped = text.strip()
|
|
19
|
+
if not stripped:
|
|
20
|
+
return ("", [])
|
|
21
|
+
if stripped.startswith("#"):
|
|
22
|
+
return ("#", [stripped[1:].strip()])
|
|
23
|
+
head, _, tail = stripped.partition(" ")
|
|
24
|
+
return (head, tail.split() if tail.strip() else [])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _boolean(args: list[str], current: bool) -> bool:
|
|
28
|
+
if not args:
|
|
29
|
+
return not current
|
|
30
|
+
value = args[0].lower()
|
|
31
|
+
if value in _TRUE:
|
|
32
|
+
return True
|
|
33
|
+
if value in _FALSE:
|
|
34
|
+
return False
|
|
35
|
+
return not current
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _view_toggle(field: str, label: str):
|
|
39
|
+
def handler(session, args):
|
|
40
|
+
current = getattr(session.view, field)
|
|
41
|
+
new = _boolean(args, current)
|
|
42
|
+
setattr(session.view, field, new)
|
|
43
|
+
if field in ("zero_base",):
|
|
44
|
+
session.view.rescale_y()
|
|
45
|
+
session.message(f"{label} {'on' if new else 'off'}")
|
|
46
|
+
return handler
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _overlay_toggle(name: str):
|
|
50
|
+
def handler(session, args):
|
|
51
|
+
if name in session.view.overlays:
|
|
52
|
+
session.view.overlays.discard(name)
|
|
53
|
+
session.message(f"{name} overlay off")
|
|
54
|
+
else:
|
|
55
|
+
session.view.overlays.add(name)
|
|
56
|
+
available = session.view.current_spec().overlays
|
|
57
|
+
if name not in available:
|
|
58
|
+
session.message(f"{name} overlay on (not present in this file)")
|
|
59
|
+
else:
|
|
60
|
+
session.message(f"{name} overlay on")
|
|
61
|
+
return handler
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_log(session, args):
|
|
65
|
+
session.log.enable()
|
|
66
|
+
session.message(f"logging to {session.log.path}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cmd_nolog(session, args):
|
|
70
|
+
session.log.disable()
|
|
71
|
+
session.message("logging disabled")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_show(session, args):
|
|
75
|
+
session.showing_log = not session.showing_log
|
|
76
|
+
session.page_index = 0
|
|
77
|
+
if not session.showing_log:
|
|
78
|
+
session._close_text_page()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_units(session, args):
|
|
82
|
+
if not args:
|
|
83
|
+
session.message(f"units {session.view.axis.unit.to_string()}")
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
unit = u.Unit(args[0])
|
|
87
|
+
except ValueError:
|
|
88
|
+
session.message(f"unknown unit: {args[0]}")
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
session.view.set_axis(mode="wave", unit=unit)
|
|
92
|
+
except u.UnitConversionError:
|
|
93
|
+
session.message(f"cannot express the dispersion in {args[0]}")
|
|
94
|
+
return
|
|
95
|
+
session.view.rescale_y()
|
|
96
|
+
session.message(f"units {unit.to_string()}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cmd_comment(session, args):
|
|
100
|
+
session.log.comment(args[0] if args else "")
|
|
101
|
+
session.message("comment added to the log")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_variant(session, args):
|
|
105
|
+
keys = session.view.entry.variant_keys()
|
|
106
|
+
if not args:
|
|
107
|
+
session.message("variants: " + " ".join(keys))
|
|
108
|
+
return
|
|
109
|
+
if args[0] not in keys:
|
|
110
|
+
session.message(f"no variant {args[0]!r}; have: {' '.join(keys)}")
|
|
111
|
+
return
|
|
112
|
+
session.view.variant = args[0]
|
|
113
|
+
session.view.rescale_y()
|
|
114
|
+
session.message(f"variant {args[0]}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_renderer(session, args):
|
|
118
|
+
session.message(f"renderer {session.renderer.name} "
|
|
119
|
+
"(change with --renderer at startup)")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cmd_mouse(session, args):
|
|
123
|
+
session.set_mouse(_boolean(args, session.mouse_enabled))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_not_applicable(name: str):
|
|
127
|
+
def handler(session, args):
|
|
128
|
+
session.message(f":{name} applies to 2D images; specterm1d ingests 1D")
|
|
129
|
+
return handler
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
COLON_COMMANDS = {
|
|
133
|
+
"log": _cmd_log,
|
|
134
|
+
"nolog": _cmd_nolog,
|
|
135
|
+
"show": _cmd_show,
|
|
136
|
+
"units": _cmd_units,
|
|
137
|
+
"#": _cmd_comment,
|
|
138
|
+
"variant": _cmd_variant,
|
|
139
|
+
"renderer": _cmd_renderer,
|
|
140
|
+
"mouse": _cmd_mouse,
|
|
141
|
+
"zero": _view_toggle("zero_base", "zero base"),
|
|
142
|
+
"hist": _view_toggle("histogram", "histogram"),
|
|
143
|
+
"flip": _view_toggle("flip", "flipped coordinates"),
|
|
144
|
+
"wreset": _view_toggle("window_reset", "window reset"),
|
|
145
|
+
"overplot": _view_toggle("overplot_next", "overplot"),
|
|
146
|
+
"sigma": _view_toggle("show_sigma", "sigma band"),
|
|
147
|
+
"mask": _view_toggle("show_mask", "mask highlight"),
|
|
148
|
+
"sky": _overlay_toggle("sky"),
|
|
149
|
+
"telluric": _overlay_toggle("telluric"),
|
|
150
|
+
"model": _overlay_toggle("obj_model"),
|
|
151
|
+
"dispaxis": _cmd_not_applicable("dispaxis"),
|
|
152
|
+
"nsum": _cmd_not_applicable("nsum"),
|
|
153
|
+
}
|
|
154
|
+
# splot accepts these but they are no-ops here: we always draw our own frame.
|
|
155
|
+
COLON_COMMANDS["auto"] = _view_toggle("window_reset", "autodraw")
|
|
156
|
+
COLON_COMMANDS["nosysid"] = lambda s, a: s.message("nosysid has no effect here")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@command("colon.prompt")
|
|
160
|
+
def colon_prompt(session):
|
|
161
|
+
def submitted(sess, text):
|
|
162
|
+
if text is None:
|
|
163
|
+
sess.message("cancelled")
|
|
164
|
+
return
|
|
165
|
+
name, args = parse_colon(text)
|
|
166
|
+
if not name:
|
|
167
|
+
return
|
|
168
|
+
handler = COLON_COMMANDS.get(name)
|
|
169
|
+
if handler is None:
|
|
170
|
+
sess.message(f"unknown colon command: {name}")
|
|
171
|
+
return
|
|
172
|
+
handler(sess, args)
|
|
173
|
+
|
|
174
|
+
session.await_line(":", submitted)
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Display and navigation commands - splot's viewing keys."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from specterm1d.keymap import STATUS_HINTS, command
|
|
7
|
+
|
|
8
|
+
SHIFT_FRACTION = 0.25
|
|
9
|
+
ZOOM_FACTOR = 2.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _spec_and_pixel(session, x: float):
|
|
13
|
+
spec = session.view.display_spec()
|
|
14
|
+
pixel = int(np.clip(np.searchsorted(spec.wave, x), 0, spec.npix - 1))
|
|
15
|
+
return spec, pixel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@command("display.report")
|
|
19
|
+
def report(session):
|
|
20
|
+
"""<space>: cursor position and the nearest pixel's value."""
|
|
21
|
+
x = session.view.cursor_x
|
|
22
|
+
if x is None:
|
|
23
|
+
session.message("no cursor")
|
|
24
|
+
return
|
|
25
|
+
spec, pixel = _spec_and_pixel(session, x)
|
|
26
|
+
session.message(
|
|
27
|
+
f"x={x:.6g} y={spec.flux[pixel]:.6g} pix={pixel} "
|
|
28
|
+
f"wave={spec.wave[pixel]:.6g}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@command("display.expand")
|
|
33
|
+
def expand(session):
|
|
34
|
+
"""a: expand between two cursors; the same point twice autoscales all."""
|
|
35
|
+
def done(sess, positions):
|
|
36
|
+
xs = [x for x, _ in positions]
|
|
37
|
+
lo, hi = min(xs), max(xs)
|
|
38
|
+
if np.isclose(lo, hi):
|
|
39
|
+
sess.view.reset_limits()
|
|
40
|
+
sess.message("autoscaled the whole spectrum")
|
|
41
|
+
return
|
|
42
|
+
sess.view.xlim = (float(lo), float(hi))
|
|
43
|
+
sess.view.rescale_y()
|
|
44
|
+
sess.message(f"expanded to {lo:.6g} - {hi:.6g}")
|
|
45
|
+
|
|
46
|
+
session.await_cursor(2, "mark two positions to expand between", done)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@command("display.zero_base")
|
|
50
|
+
def zero_base(session):
|
|
51
|
+
session.view.zero_base = not session.view.zero_base
|
|
52
|
+
session.view.rescale_y()
|
|
53
|
+
session.message(f"zero base {'on' if session.view.zero_base else 'off'}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@command("display.clear")
|
|
57
|
+
def clear(session):
|
|
58
|
+
session.view.markers.clear()
|
|
59
|
+
session.view.fits.clear()
|
|
60
|
+
session.overlay_specs.clear()
|
|
61
|
+
session.view.reset_limits()
|
|
62
|
+
session.message("cleared windowing")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@command("display.redraw")
|
|
66
|
+
def redraw(session):
|
|
67
|
+
session.message("redrawn")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@command("display.zoom")
|
|
71
|
+
def zoom(session):
|
|
72
|
+
lo, hi = session.view.xlim
|
|
73
|
+
centre = session.view.cursor_x if session.view.cursor_x is not None \
|
|
74
|
+
else (lo + hi) / 2
|
|
75
|
+
half = (hi - lo) / (2 * ZOOM_FACTOR)
|
|
76
|
+
session.view.xlim = (float(centre - half), float(centre + half))
|
|
77
|
+
session.view.rescale_y()
|
|
78
|
+
session.message(f"zoomed to {session.view.xlim[0]:.6g} - "
|
|
79
|
+
f"{session.view.xlim[1]:.6g}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _shift(session, sign: float):
|
|
83
|
+
lo, hi = session.view.xlim
|
|
84
|
+
step = (hi - lo) * SHIFT_FRACTION * sign
|
|
85
|
+
session.view.xlim = (float(lo + step), float(hi + step))
|
|
86
|
+
session.view.rescale_y()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@command("display.shift_left")
|
|
90
|
+
def shift_left(session):
|
|
91
|
+
_shift(session, -1.0)
|
|
92
|
+
session.message("shifted left")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@command("display.shift_right")
|
|
96
|
+
def shift_right(session):
|
|
97
|
+
_shift(session, +1.0)
|
|
98
|
+
session.message("shifted right")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _goto(session, index: int):
|
|
102
|
+
session.view.index = index
|
|
103
|
+
session.view.variant = None
|
|
104
|
+
session.view.markers.clear()
|
|
105
|
+
session.view.fits.clear()
|
|
106
|
+
if session.view.window_reset:
|
|
107
|
+
session.view.reset_limits()
|
|
108
|
+
session.view.cursor_x = float(np.mean(session.view.xlim))
|
|
109
|
+
session.view.follow_flux()
|
|
110
|
+
session.message(f"{session.view.entry.label} "
|
|
111
|
+
f"({index + 1}/{len(session.collection)})")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@command("display.prev")
|
|
115
|
+
def previous(session):
|
|
116
|
+
if session.view.index == 0:
|
|
117
|
+
session.message("already at the first spectrum")
|
|
118
|
+
return
|
|
119
|
+
_goto(session, session.view.index - 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@command("display.next")
|
|
123
|
+
def next_entry(session):
|
|
124
|
+
if session.view.index >= len(session.collection) - 1:
|
|
125
|
+
session.message("already at the last spectrum")
|
|
126
|
+
return
|
|
127
|
+
_goto(session, session.view.index + 1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@command("display.goto")
|
|
131
|
+
def goto(session):
|
|
132
|
+
def done(sess, text):
|
|
133
|
+
if not text:
|
|
134
|
+
sess.message("cancelled")
|
|
135
|
+
return
|
|
136
|
+
key = int(text) - 1 if text.strip().lstrip("-").isdigit() else text.strip()
|
|
137
|
+
try:
|
|
138
|
+
sess_index = sess.collection.find(key)
|
|
139
|
+
except KeyError:
|
|
140
|
+
sess.message(f"no spectrum matching {text!r}")
|
|
141
|
+
return
|
|
142
|
+
_goto(sess, sess_index)
|
|
143
|
+
|
|
144
|
+
session.await_line(f"spectrum (1-{len(session.collection)} or name): ", done)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@command("display.variant")
|
|
148
|
+
def variant(session):
|
|
149
|
+
keys = session.view.entry.variant_keys()
|
|
150
|
+
if len(keys) < 2:
|
|
151
|
+
session.message(f"only one variant available: {keys[0]}")
|
|
152
|
+
return
|
|
153
|
+
current = session.view.variant or session.view.entry.default
|
|
154
|
+
nxt = keys[(keys.index(current) + 1) % len(keys)]
|
|
155
|
+
session.view.variant = nxt
|
|
156
|
+
if session.view.window_reset:
|
|
157
|
+
session.view.rescale_y()
|
|
158
|
+
session.message(f"variant {nxt}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@command("display.pixel_coords")
|
|
162
|
+
def pixel_coords(session):
|
|
163
|
+
mode = "wave" if session.view.axis.mode == "pixel" else "pixel"
|
|
164
|
+
session.view.set_axis(mode=mode)
|
|
165
|
+
session.view.rescale_y()
|
|
166
|
+
session.message(f"{mode} coordinates")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@command("display.velocity")
|
|
170
|
+
def velocity(session):
|
|
171
|
+
if session.view.axis.mode == "velocity":
|
|
172
|
+
session.view.set_axis(mode="wave")
|
|
173
|
+
session.message("wavelength scale")
|
|
174
|
+
return
|
|
175
|
+
x = session.view.cursor_x
|
|
176
|
+
spec = session.view.current_spec()
|
|
177
|
+
origin = float(session.view.axis.to_wave(spec, np.array([x]))[0])
|
|
178
|
+
session.view.set_axis(mode="velocity", velocity_origin=origin)
|
|
179
|
+
session.message(f"velocity scale about {origin:.6g}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@command("display.overplot")
|
|
183
|
+
def overplot(session):
|
|
184
|
+
session.view.overplot_next = True
|
|
185
|
+
session.message("the next spectrum will overplot")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@command("display.get")
|
|
189
|
+
def get_spectrum(session):
|
|
190
|
+
def done(sess, text):
|
|
191
|
+
if not text:
|
|
192
|
+
sess.message("cancelled")
|
|
193
|
+
return
|
|
194
|
+
sess.load_path(text.strip())
|
|
195
|
+
|
|
196
|
+
session.await_line("file: ", done)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@command("display.quit")
|
|
200
|
+
def quit_command(session):
|
|
201
|
+
if session.file_index + 1 < len(session.files):
|
|
202
|
+
session.file_index += 1
|
|
203
|
+
session.load_path(session.files[session.file_index])
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@command("display.interrupt")
|
|
209
|
+
def interrupt(session):
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@command("help.page")
|
|
214
|
+
def help_page(session):
|
|
215
|
+
session.showing_help = not session.showing_help
|
|
216
|
+
session.page_index = 0
|
|
217
|
+
if not session.showing_help:
|
|
218
|
+
session._close_text_page()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@command("help.cycle")
|
|
222
|
+
def help_cycle(session):
|
|
223
|
+
session.hint_index = (session.hint_index + 1) % len(STATUS_HINTS)
|
|
224
|
+
session.message(STATUS_HINTS[session.hint_index])
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---- w: the gtools window submode -----------------------------------
|
|
228
|
+
#
|
|
229
|
+
# Transcribed from IRAF pkg/xtools/gtools/gtwindow.x and lib/scr/gtools.key,
|
|
230
|
+
# so every key keeps the meaning a splot user already has in their fingers.
|
|
231
|
+
# Shifts move 0.75 of the window, zooms are cursor +/- d/4 (a factor of two),
|
|
232
|
+
# and 'p' pans to cursor +/- d, which doubles the window - all as gt_window1
|
|
233
|
+
# does it. 'e' takes two cursors and, following gt_window2, applies each axis
|
|
234
|
+
# only if the two marks differ by more than 0.001 of that axis's span.
|
|
235
|
+
|
|
236
|
+
SHIFT_WINDOW = 0.75
|
|
237
|
+
EXPAND_TOLERANCE = 0.001
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _window_action(session, char):
|
|
241
|
+
view = session.view
|
|
242
|
+
x1, x2 = view.xlim
|
|
243
|
+
y1, y2 = view.ylim
|
|
244
|
+
dx, dy = x2 - x1, y2 - y1
|
|
245
|
+
cx = view.cursor_x if view.cursor_x is not None else (x1 + x2) / 2
|
|
246
|
+
cy = view.cursor_y if view.cursor_y is not None else (y1 + y2) / 2
|
|
247
|
+
|
|
248
|
+
if char == "?":
|
|
249
|
+
session.showing_help = True
|
|
250
|
+
session.message("window keys")
|
|
251
|
+
elif char == "a":
|
|
252
|
+
view.reset_limits()
|
|
253
|
+
session.message("autoscaled x and y")
|
|
254
|
+
elif char == "b":
|
|
255
|
+
view.ylim = (float(cy), float(y2))
|
|
256
|
+
session.message(f"bottom edge {cy:.6g}")
|
|
257
|
+
elif char == "c":
|
|
258
|
+
view.xlim = (float(cx - dx / 2), float(cx + dx / 2))
|
|
259
|
+
view.ylim = (float(cy - dy / 2), float(cy + dy / 2))
|
|
260
|
+
session.message("centred at cursor")
|
|
261
|
+
elif char == "d":
|
|
262
|
+
view.ylim = (float(y1 - SHIFT_WINDOW * dy), float(y2 - SHIFT_WINDOW * dy))
|
|
263
|
+
session.message("shifted down")
|
|
264
|
+
elif char == "f":
|
|
265
|
+
view.flip = not view.flip
|
|
266
|
+
session.message(f"x axis flip {'on' if view.flip else 'off'}")
|
|
267
|
+
elif char == "g":
|
|
268
|
+
view.flip_y = not view.flip_y
|
|
269
|
+
session.message(f"y axis flip {'on' if view.flip_y else 'off'}")
|
|
270
|
+
elif char == "j":
|
|
271
|
+
view.xlim = (float(cx), float(x2))
|
|
272
|
+
session.message(f"left edge {cx:.6g}")
|
|
273
|
+
elif char == "k":
|
|
274
|
+
view.xlim = (float(x1), float(cx))
|
|
275
|
+
session.message(f"right edge {cx:.6g}")
|
|
276
|
+
elif char == "l":
|
|
277
|
+
view.xlim = (float(x1 - SHIFT_WINDOW * dx), float(x2 - SHIFT_WINDOW * dx))
|
|
278
|
+
session.message("shifted left")
|
|
279
|
+
elif char == "m":
|
|
280
|
+
spec = view.display_spec()
|
|
281
|
+
good = spec.wave[spec.good]
|
|
282
|
+
if good.size:
|
|
283
|
+
view.xlim = (float(good.min()), float(good.max()))
|
|
284
|
+
session.message("autoscaled x")
|
|
285
|
+
elif char == "n":
|
|
286
|
+
view.rescale_y()
|
|
287
|
+
session.message("autoscaled y")
|
|
288
|
+
elif char == "p":
|
|
289
|
+
view.xlim = (float(cx - dx), float(cx + dx))
|
|
290
|
+
view.ylim = (float(cy - dy), float(cy + dy))
|
|
291
|
+
session.message("panned about cursor")
|
|
292
|
+
elif char == "r":
|
|
293
|
+
view.xlim = (float(x1 + SHIFT_WINDOW * dx), float(x2 + SHIFT_WINDOW * dx))
|
|
294
|
+
session.message("shifted right")
|
|
295
|
+
elif char == "t":
|
|
296
|
+
view.ylim = (float(y1), float(cy))
|
|
297
|
+
session.message(f"top edge {cy:.6g}")
|
|
298
|
+
elif char == "u":
|
|
299
|
+
view.ylim = (float(y1 + SHIFT_WINDOW * dy), float(y2 + SHIFT_WINDOW * dy))
|
|
300
|
+
session.message("shifted up")
|
|
301
|
+
elif char == "x":
|
|
302
|
+
view.xlim = (float(cx - dx / 4), float(cx + dx / 4))
|
|
303
|
+
session.message("zoomed x")
|
|
304
|
+
elif char == "y":
|
|
305
|
+
view.ylim = (float(cy - dy / 4), float(cy + dy / 4))
|
|
306
|
+
session.message("zoomed y")
|
|
307
|
+
elif char == "z":
|
|
308
|
+
view.xlim = (float(cx - dx / 4), float(cx + dx / 4))
|
|
309
|
+
view.ylim = (float(cy - dy / 4), float(cy + dy / 4))
|
|
310
|
+
session.message("zoomed x and y")
|
|
311
|
+
else:
|
|
312
|
+
session.message(f"window: {char!r} is not a window key")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _window_expand(session):
|
|
316
|
+
"""gtools 'e': mark two corners; each axis moves only if the marks differ."""
|
|
317
|
+
x1, x2 = session.view.xlim
|
|
318
|
+
y1, y2 = session.view.ylim
|
|
319
|
+
dx, dy = x2 - x1, y2 - y1
|
|
320
|
+
|
|
321
|
+
def done(sess, positions):
|
|
322
|
+
(mx1, my1), (mx2, my2) = positions
|
|
323
|
+
moved = []
|
|
324
|
+
if abs(mx2 - mx1) > EXPAND_TOLERANCE * abs(dx):
|
|
325
|
+
sess.view.xlim = (float(min(mx1, mx2)), float(max(mx1, mx2)))
|
|
326
|
+
moved.append("x")
|
|
327
|
+
if abs(my2 - my1) > EXPAND_TOLERANCE * abs(dy):
|
|
328
|
+
sess.view.ylim = (float(min(my1, my2)), float(max(my1, my2)))
|
|
329
|
+
moved.append("y")
|
|
330
|
+
sess.message(f"expanded {' and '.join(moved)}" if moved
|
|
331
|
+
else "marks too close; window unchanged")
|
|
332
|
+
|
|
333
|
+
session.await_cursor(2, "mark two corners of the new window", done)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@command("display.window")
|
|
337
|
+
def window(session):
|
|
338
|
+
def chosen(sess, char):
|
|
339
|
+
if char == "e":
|
|
340
|
+
_window_expand(sess)
|
|
341
|
+
elif char == "I":
|
|
342
|
+
sess.message("cancelled")
|
|
343
|
+
else:
|
|
344
|
+
_window_action(sess, char)
|
|
345
|
+
|
|
346
|
+
session.await_key(
|
|
347
|
+
"window: a b c d e f g j k l m n p r t u x y z, ? for help", chosen, {})
|