Files
pyrometry/live_spectrum.py
2026-09-10 15:11:55 +01:00

153 lines
5.6 KiB
Python

#!/usr/bin/env python
"""Live-streaming spectrum viewer for an Ocean Optics spectrometer.
Opens a matplotlib window and continuously redraws the current spectrum.
Usage: python live_spectrum.py [integration_time_us]
Keys (while the plot window is focused):
q / Esc quit
a toggle autoscale of the y-axis on/off
d capture the current spectrum as a dark reference
x clear the dark reference
space freeze / unfreeze acquisition
t toggle a live Planck temperature fit (use --response for a real T)
"""
import argparse
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from seabreeze.spectrometers import Spectrometer
from response import add_calibration_args, apply_response, dark_at, load_csv
from fit_temperature import fit_planck
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("integration_us", nargs="?", type=int, default=50000,
help="integration time in microseconds (default: 50000)")
parser.add_argument("--interval", type=int, default=30,
help="redraw interval in ms (default: 30)")
parser.add_argument("--fit-band", nargs=2, type=float, default=[420.0, 800.0],
metavar=("LO", "HI"),
help="band (nm) for the live temperature fit (default: 420 800)")
add_calibration_args(parser)
args = parser.parse_args()
cal = load_csv(args.response) if args.response else None
dark = dark_at(args.dark, args.integration_us)
EDGE_PIXELS = 5 # ignore the fixed edge artifacts (e.g. the ~340 nm pixel-1 spike)
spec = Spectrometer.from_first_available()
spec.integration_time_micros(args.integration_us)
wl = spec.wavelengths()
print(f"{spec.model} serial={spec.serial_number} pixels={spec.pixels}")
print(f"range {wl[0]:.1f}-{wl[-1]:.1f} nm integration {args.integration_us} us")
if cal is not None:
print(f"applied response calibration: {args.response}")
print("keys: q quit | a autoscale | d dark | x clear dark | space freeze | t fit T")
state = {"autoscale": True, "frozen": False, "dark": None, "fit": False}
fig, ax = plt.subplots(figsize=(10, 5))
(line,) = ax.plot(wl, np.zeros_like(wl), lw=0.8)
# Two lines for the fit: the full-range curve (extrapolated) and, on top, the
# segment over the band actually used to constrain it.
(fit_full,) = ax.plot([], [], lw=1.2, color="C1")
(fit_band,) = ax.plot([], [], lw=2.0, color="C3")
fit_full.set_visible(False)
fit_band.set_visible(False)
ax.set_xlim(wl[0], wl[-1])
ax.set_ylim(0, 4096)
ax.set_xlabel("wavelength (nm)")
ax.set_ylabel("relative irradiance" if cal is not None else "intensity (counts)")
title = ax.set_title("")
ax.grid(True, alpha=0.3)
def on_key(event):
if event.key in ("q", "escape"):
plt.close(fig)
elif event.key == "a":
state["autoscale"] = not state["autoscale"]
elif event.key == " ":
state["frozen"] = not state["frozen"]
elif event.key == "d":
state["dark"] = spec.intensities(correct_nonlinearity=True)
print("dark reference captured")
elif event.key == "x":
state["dark"] = None
print("dark reference cleared")
elif event.key == "t":
state["fit"] = not state["fit"]
if state["fit"] and cal is None:
print("fitting uncalibrated counts; use --response for a meaningful T")
fig.canvas.mpl_connect("key_press_event", on_key)
def _hide_fit():
fit_full.set_visible(False)
fit_band.set_visible(False)
if ax.get_legend():
ax.get_legend().remove()
def update(_frame):
if state["frozen"]:
return line, fit_full, fit_band, title
y = spec.intensities(correct_nonlinearity=True)
if state["dark"] is not None:
y = y - state["dark"]
if cal is not None:
y = apply_response(wl, y, cal, dark)
line.set_ydata(y)
if state["autoscale"]:
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
pad = max(1e-9, 0.05 * (ymax - ymin))
ax.set_ylim(ymin - pad, ymax + pad)
# Peak over the interior only, so the fixed edge spikes never win.
pk = EDGE_PIXELS + int(np.nanargmax(y[EDGE_PIXELS:-EDGE_PIXELS]))
if state["fit"]:
result = fit_planck(wl, y, tuple(args.fit_band))
if result is None:
_hide_fit()
else:
temperature, dT, predict = result
lo, hi = args.fit_band
model = predict(wl)
in_band = (wl >= lo) & (wl <= hi)
fit_full.set_data(wl, model) # extrapolated over the full range
fit_band.set_data(wl[in_band], model[in_band]) # region used for the fit
note = "" if cal is not None else " (uncalibrated)"
fit_full.set_label("Planck fit (extrapolated)")
fit_band.set_label(f"used {lo:.0f}-{hi:.0f} nm, "
f"T = {temperature:.2f} +/- {dT:.2f} K{note}")
fit_full.set_visible(True)
fit_band.set_visible(True)
ax.legend(loc="upper right", fontsize=9)
else:
_hide_fit()
flags = []
if state["dark"] is not None:
flags.append("dark-sub")
if state["frozen"]:
flags.append("FROZEN")
if not state["autoscale"]:
flags.append("fixed-y")
suffix = (" [" + " ".join(flags) + "]") if flags else ""
title.set_text(f"peak {wl[pk]:.2f} nm = {y[pk]:.2f}{suffix}")
return line, fit_full, fit_band, title
# blit=False because autoscale mutates the axes limits each frame.
ani = FuncAnimation(fig, update, interval=args.interval, blit=False,
cache_frame_data=False)
try:
plt.show()
finally:
spec.close()