Skip to main content

specterm1d

CI

A terminal-based viewer for 1D spectra, with IRAF splot's keybindings.

Opens anything specutils can read — IRAF multispec, tabular-fits, wcs1d-fits, SDSS, HST/COS, HST/STIS, JWST, APOGEE and more — plus pypeit's OneSpec and spec1d products, including echelle files with their orders grouped by object.

The point is to keep the splot muscle memory intact while drawing a real matplotlib figure in the terminal, rather than an ASCII approximation of one.

The specterm1d graphics window showing a BINOSPEC IFU extraction in xgterm colours: a cyan box on black, yellow tick numbers, a white spectrum, and a red crosshair

A BINOSPEC IFU extraction in the graphics window. The crosshair is what you place measurements with; the live x/y/pix readout sits in the title bar, where your eye already is.

Install

pip install specterm1d                 # general FITS spectra
pip install 'specterm1d[pypeit]'       # adds OneSpec and spec1d support
pip install 'specterm1d[sixel]'        # optional libsixel encoder

pypeit is deliberately optional. Nothing imports it at module scope, so the tool works as a general FITS viewer without it, and files it cannot open are reported clearly rather than crashing on an import.

Requires Python 3.13+ and numpy 2.5+.

Quick start

specterm1d spec1d_J0935+0924.fits      # or the short alias: st1d

Arrow keys move a crosshair, ? pages the full keymap, q quits.

Flag Effect
--renderer kitty|iterm2|sixel|gui|text force a backend instead of probing
--theme NAME colour theme: xgterm (default), dark (default under text), or a matplotlib style
--units nm start in other dispersion units (um, GHz, anything astropy knows)
--mouse / --no-mouse override click/drag positioning (on for inline graphics)
--format NAME force a loader instead of sniffing the file
--log FILE measurement log path (default splot.log)
--cursor FILE replay a keystroke script instead of reading the keyboard
--dump OUT.png render one frame to a PNG and exit; needs no terminal
--dump-size WxH pixel size for --dump (default 1200x700)
--debug show full tracebacks instead of one-line errors

Terminal support

One matplotlib figure is rendered to an RGBA buffer, and five interchangeable backends put those pixels on screen. Axes, tick labels, error bands and fit overlays therefore look the same everywhere; what changes is the fidelity, and - for the text backend alone - the palette it opens in.

Terminal Backend Notes
kitty, Ghostty, WezTerm kitty graphics pixel-exact; PNG transport
iTerm2 graphics window its inline image path leaks; see below
Windows Terminal 1.22+, foot, xterm, Konsole, mlterm, contour sixel detected via Primary Device Attributes
stock macOS Terminal, GNOME Terminal, Alacritty graphics window no graphics protocol exists; see below
ssh with no display, tmux over ssh text always available

The text backend is first-class, not a stub - it is what runs wherever neither an inline protocol nor a window is available, and it needs nothing from the terminal but Unicode and colour. Each cell becomes one of eight Block Elements glyphs that split it on a 2x2 grid, one group of subpixels drawn in the foreground colour and the rest in the background, for 2*cols x 2*rows effective pixels. Four subpixels admit eight distinct splits, so the best one is found by trying all of them. Frames are diffed so a redraw costs only the cells that changed, and there is an xterm-256 path as well as truecolor, since Terminal.app never gained 24-bit colour.

Four subpixels sharing two colours is an approximation, and a cheap one here. On a 4097-pixel UVES order in a 116x43 Terminal.app window a screen column carries 18 spectrum pixels; measured against a full-resolution render of the same view, that coarse grid accounts for essentially all of the error (RMS 53.5, against 53.6 for the same grid with exact colour). Two colours per cell is not much of a constraint when a curve over a flat background is two colours already. The glyphs are U+2596..U+259F, Unicode 1.0, present in SF Mono, Menlo and DejaVu Sans Mono.

Even at 2*cols x 2*rows there is no room for matplotlib's own axis decoration: a 4pt tick label is 5.6 px tall, which is a smear across three cells at any font size. So the figure is rendered full bleed with nothing but data, and the terminal paints the spines, tick marks, labels, title and legend as its own glyphs at your font size. The curve ends up with more pixels than it had when matplotlib was spending margins on labels nobody could read.

Under tmux, nothing the terminal answers describes the terminal, so specterm1d asks tmux instead.

kitty graphics need allow-passthrough. Add this to ~/.tmux.conf:

set -g allow-passthrough on

Every graphics escape is then wrapped in tmux's DCS passthrough and handed to the terminal outside; without the option tmux discards them and you get a window or the text backend. An unwrapped APC is worse than useless there — tmux eats the introducer and prints the payload into your status line.

Under tmux the mouse is coarse, and the arrow keys are not. tmux has no SGR-Pixels mouse mode — asked about DECSET 1016 it answers "never heard of it" — so it reports the pointer in whole cells, and a click lands on a cell boundary rather than where you aimed. This matters for anything you place by eye: a continuum level for a gaussian fit, the edges of an equivalent-width region. Click to get close, then nudge with the arrow keys, which move the crosshair by 0.2% of the visible range (5% with shift) and are far finer than a cell. --gui is the way out if you want true pixel pointing under tmux; the graphics window has its own mouse and tmux is not in the way of it.

Expect some flicker as well. Each frame is a few hundred KB of base64 that tmux parses and re-emits in 4 KB pieces, interleaved with its own screen updates. tmux also does not know an image is present, so a pane repaint (a resize, a pane switch, leaving copy mode) blanks the plot until the next keystroke redraws it.

The sixel bit in the Device Attributes reply describes tmux, which answers it whenever tmux was built with --enable-sixel, with no client attached at all. So it is checked against what tmux says its client can do (#{client_termfeatures}). Otherwise you get the placeholder tmux draws for an image it cannot pass on — SIXEL IMAGE (134x44) padded out with + until it fills the window — instead of a plot.

--renderer kitty or --renderer sixel forces the issue where the probe is too cautious. Naming a backend also stops the graphics probes being sent at all - nothing but the choice of renderer reads them - which keeps the screen clean on a terminal that prints an unrecognised query instead of swallowing it. Stock Terminal.app is one: it answers a kitty graphics probe with the probe.

Two-window mode

Terminals with no inline-graphics protocol get a real matplotlib window instead of block glyphs. This is what IRAF splot did on a Tektronix-emulating terminal like xgterm: you point at a feature in the graphics window and press a key, while prompts and measurement results scroll past in the text terminal.

The terminal is a plain scrolling transcript in this mode — no full-screen layout, no raw mode, no pinned status line. The live x/y/pix readout moves to the window title, where your eye already is. ? and :show scroll past rather than paging.

Every binding means the same thing in both modes; that is the point.

terminal renderer
kitty, Ghostty, WezTerm kitty protocol, inline
iTerm2 graphics window (--renderer iterm2 to force inline)
xterm with sixel sixel, inline
Terminal.app, GNOME Terminal, Alacritty graphics window
xterm on Linux with X11 graphics window
ssh with no display, tmux over ssh text

Inline graphics still win where the terminal supports them — one window beats two — with iTerm2 the one exception. The text backend is the last resort: correct everywhere, comfortable nowhere.

Why iTerm2 gets a window

iTerm2 never frees an inline image. Every distinct frame costs it about a decoded bitmap of resident memory for the life of the session, so panning a spectrum grows the terminal process by roughly 1.7 MB per keystroke — measured over 100 cursor moves on iTerm2 3.6.11, against 0.05 MB/frame for the same loop drawing text. kitty's protocol replaces a placement in situ through a stable image id and does not do this; OSC 1337 has neither an id nor a delete verb, and nothing the application can send collects the images. Its sixel path leaks too, at 4 MB/frame, so both inline backends step aside where a window is available. --renderer iterm2 still forces the inline path.

This is not specific to specterm1d. It has been reported upstream twice — #3943 in 2015 and #10420 in 2022, the latter reaching about 20 GB and surviving a scrollback clear and a session close — and closed both times. The behaviour is still present in 3.6.11, and has driven a machine into the OOM killer at 138 GB. There is no open upstream issue to wait on, so the window is where iTerm2 stays.

To force either mode:

specterm1d --gui spec1d.fits              # or --renderer gui
specterm1d --renderer text spec1d.fits

The window opens at 1200x800 and is then yours to resize; resizing re-renders at the new size. If no window can be opened — no DISPLAY, no usable toolkit — specterm1d prints one line to stderr and falls back to the text backend rather than refusing to start.

Keys

The complete reference is in the key and command reference. The most-used:

Key Action
<space> report the cursor position and nearest pixel
a expand between two marks; the same point twice autoscales everything
c / r clear all windowing / redraw keeping it
z , . zoom by two about the cursor; pan left; pan right
( ) # previous / next spectrum; go to one by index or name
e equivalent width by summation
m mean, RMS and S/N over a region
k + g/l/v fit a gaussian, lorentzian or voigt profile
h + a/b/c/l/r/k equivalent width from a measured width
s boxcar smooth
U undo the last transform
w the gtools window submode
: colon commands (:units nm, :sigma, :sky, :mask, …)
q next input spectrum, then exit

Multi-point commands are explicit: press the command key to arm it, then mark each point with <space>. The crosshair's y matters — e, k and h take their continuum from the cursor's y at each marked point, which is what IRAF's sumflux.x does with eqy1/eqy2.

Colours

The default palette is xgterm's, because that is the window splot was read in for thirty years: a cyan box on black, yellow numbers, green captions, and a white spectrum on a DarkSlateGray surround. It is high contrast by design - these are the X11 primaries, chosen to stay legible on a CRT across a room.

specterm1d --theme dark spec.fits        # blue on charcoal; the pre-1.0 look
specterm1d --theme ggplot spec.fits      # or any matplotlib style name

The same spectrum under the bmh matplotlib style: a light grey panel with a dashed grid and a blue spectrum

The same extraction under --theme bmh. A matplotlib style brings its grid across as well as its colours - the dashed rules here, drawn under the spectrum.

The text backend is the exception: it defaults to dark. Its decoration is terminal text around a plot of 2x2 block glyphs, and xgterm's palette asks more of that than it can give - three inks around the box, and a slate surround meeting a black plot on a seam the quantizer has to resolve. dark is one foreground on one ground, which is the same simplification the backend already is. --theme xgterm overrides it, as an explicit --theme overrides any default.

A theme names colours by role, and every backend honours all of them: the matplotlib figure, the terminal-drawn chrome of the text backend, and the sixel palette, which is derived from the active theme rather than fixed.

Role xgterm What it draws
figure DarkSlateGray around the box - and the ground the text chrome sits on
plot black inside the box
spine cyan spines, tick marks, the │ └ ┬ ┤ glyphs
tick_label yellow the numbers on the axes
text green title, axis labels, legend
line white the spectrum
sigma light blue the error band
mask / fit red / magenta masked columns, fitted profiles
cursor red crosshair and markers
overlay yellow, coral, magenta overlay arrays
grid none rules across the plot, where a matplotlib style asks for them

--dump and --cursor keep xgterm whichever backend they borrow. What they write is a full matplotlib figure, so tying its palette to a renderer picked for needing no terminal would leave a dumped PNG disagreeing with the window showing the same data.

--theme also accepts any name in matplotlib.style.available - ggplot, dark_background, Solarize_Light2, tableau-colorblind10 and the rest. Misspell one and the error lists the full set. A style's colours and its grid come across; its fonts, line widths and padding do not, because those are tuned here against the terminal's pixel budget, where a stylesheet written for a page would put a 12 pt label on an 86 px figure. The grid keeps the style's own colour, dashes, weight and stacking order - ggplot without its white rules is not ggplot - with one exception: under the text backend the terminal draws the tick marks from tick values of its own, and a grid ruled anywhere but on them would read as a fault rather than a style. The roles a stylesheet has no opinion about are derived from the ones it does - the error band is the line blended halfway into the plot background, and the mask takes the warmest colour in the style's property cycle.

There is no per-role override on the command line. A script that wants one builds the theme itself - roles are ordinary dataclass fields:

from dataclasses import replace

from specterm1d import theme

theme.use(replace(theme.XGTERM, name="mine", line="#ffff00"))

Not implemented yet

These keys are registered and report "not implemented in v1" when pressed. They are never silently absent and never rebound to something else, so muscle memory cannot misfire:

d deblend · t ICFIT · f arithmetic · i write to file · j set pixel to cursor · x etch-a-sketch · p linear wavelength scale · u user coordinate scale · y standard-star overplot

Differences from splot

Three, stated plainly:

  • The cursor is always keyboard-driven and optionally mouse-driven. Arrow keys move a 2D crosshair by 0.2% of the visible range, 5% with shift. Inline Kitty, sixel and iTerm2 graphics enable click/drag positioning and draw the full crosshair by default. Terminals that answer DECRQM for DECSET 1016 - kitty, ghostty and the sixel terminals among them - report the pointer in pixels rather than cells, so the cursor tracks it instead of snapping to the character grid. Terminals that do not, and tmux, which has no pixel mouse mode at all, keep cell coordinates: click to get close and then nudge with the arrows, which are finer than a cell. The text backend leaves mouse reporting off, having no pixels to place. Use --no-mouse or :mouse no when you want the terminal's normal text selection instead.
  • % cycles the extraction/calibration variant (OPT/COUNTS, BOX/COUNTS, OPT/FLAM, …) rather than an image band, which is the useful analogue for pypeit products.
  • U undoes a transform. splot has no equivalent: there, s is destructive with no recovery short of reloading the file.

Beyond that, four display features splot had no data for: a one-sigma error band (:sigma), masked-pixel highlighting (:mask), sky/telluric/model overlays (:sky, :telluric, :model), and inverse-variance weighting of profile fits.

Measurement log

Measurements append to splot.log in IRAF's own column formats, taken from anshdr.x, eqwidth.x, gfit.x and avgsnr.x — including the detail that the m key suppresses the column header. Existing log-parsing scripts keep working:

    center      cont      flux       eqw      core     gfwhm     lfwhm
    5183.6     1.234    -0.456      0.37
avg:        1.5  rms:       0.25   snr:     6.00

:nolog stops writing, :log resumes, and :# some text adds a comment.

Batch use

--cursor replays a keystroke script, reproducing splot's cursor parameter. Combined with --dump it runs with no terminal at all:

cat > measure.txt <<'EOF'
5200 1.0 e
5200 1.0 <space>
5400 1.0 <space>
EOF

specterm1d spec.fits --cursor measure.txt --log out.log --dump frame.png

Licence

BSD-3-Clause.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

specterm1d-0.2.0.tar.gz (415.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

specterm1d-0.2.0-py3-none-any.whl (89.8 kB view details)

Uploaded Python 3

File details

Details for the file specterm1d-0.2.0.tar.gz.

File metadata

  • Download URL: specterm1d-0.2.0.tar.gz
  • Upload date:
  • Size: 415.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for specterm1d-0.2.0.tar.gz
Algorithm Hash digest
SHA256 3b3862f008d9e381ca2bc56439cb196dfa8f515914ea793f8e10bddc0ebda139
MD5 8aaf0a2245311450b866698ae4d6824b
BLAKE2b-256 650efab3e83b9520b374dd8d663e9d20a24caedc720fd312d3fc310c0b642c46

See more details on using hashes here.

Provenance

The following attestation bundles were made for specterm1d-0.2.0.tar.gz:

Publisher: release.yml on tepickering/specterm1d

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file specterm1d-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: specterm1d-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 89.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for specterm1d-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 085ecd9f75de2aec11f4a4c8e9efa65f5b40bbc4e927a7821583ff9f633b30de
MD5 8140ee8a7ea8b489b1c6dcb5da7e0ded
BLAKE2b-256 b841c194ec2ffe71c6c7f1b92e5f442a7018577043e68a16c317edaaac392393

See more details on using hashes here.

Provenance

The following attestation bundles were made for specterm1d-0.2.0-py3-none-any.whl:

Publisher: release.yml on tepickering/specterm1d

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.2

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page