Initial commit

This commit is contained in:
2026-09-10 15:11:55 +01:00
commit d62864bd89
10 changed files with 857 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Python virtual environment (reproduce with requirements.txt)
venv/
# Python caches
__pycache__/
*.py[cod]
# Data and figures: captured spectra, derived calibration, diagnostic plots
# (all regenerated locally, not versioned)
*.csv
*.png
+168
View File
@@ -0,0 +1,168 @@
# OceanOptics
Driving an Ocean Optics Flame-S spectrometer with [python-seabreeze](https://python-seabreeze.readthedocs.io): live viewing, single grabs, a relative spectral-response calibration from a broadband source, and a temperature fit for thermal emission.
This README is the complete record of what we know about this rig. Everything learned lives here.
## The instrument
Ocean Optics Flame-S, USB id `2457:101e`, serial `FLMS01757`, 2048 pixels, wavelength range about 340 to 1018 nm. Its firmware reports the model string as `USB2000PLUS`, because the Flame-S shares the USB2000+ USB id and firmware family. Silicon CCD, so the spectral response peaks in the green and falls off toward the blue and the near infrared.
Known quirks, all verified on this unit:
- **No irradiance calibration on the device.** The EEPROM holds a wavelength calibration (pixel to nm, used automatically by `spec.wavelengths()`) and an 8-coefficient nonlinearity correction, both real. The irradiance / spectral-response slot exists but reads back as all-NaN, so the unit was never radiometrically calibrated at the factory. Any spectral-response curve has to be supplied by us (see Calibration).
- **Fixed edge artifact at pixel 1.** A roughly 27500-count spike sits at pixel index 1, near 340.13 nm. It appears at the same value in a dark frame, so it is electronic, not light. Always mask it, never treat it as signal.
- **Nonlinearity correction is applied everywhere.** The acquisition scripts call `spec.intensities(correct_nonlinearity=True)`, which uses the stored coefficients so counts are linear in incident flux. The calibration assumes this, so keep it on.
- **Never work near saturation.** Keep the peak well below the 65535 ceiling, roughly 40 to 50k counts. Close to saturation the response goes nonlinear and the red end gets contaminated, which skews the whole spectrum toward the red and makes the apparent peak wander as you change the light. Backing the light off and re-aiming fixes it. Set the exposure by eye in `live_spectrum.py`.
- **The dark grows with integration time.** It is a fixed electronic offset (about 2447 counts) plus dark current (about 97 counts per second), so a dark taken at one exposure is wrong at another. We model it, see the dark model below.
## USB access (udev rules)
A non-root user cannot claim the device until udev rules are installed, and without them `list_devices()` returns an empty list. The rules are installed on this machine at `/etc/udev/rules.d/10-oceanoptics.rules` and set the Flame-S node to mode 0666. If the device ever stops being detected, reinstall them with `seabreeze_os_setup` (needs sudo) and replug the spectrometer so the new mode applies on the plug-in event.
## Python environment
Everything is a pip package inside the project `venv/`: `seabreeze`, `numpy`, `matplotlib`, and `scipy` for the calibration and fit. Reproduce it with:
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
```
Keep the packages in the venv and ignore the apt `python3-numpy` / `python3-matplotlib`. The venv is deliberately isolated (`include-system-site-packages = false`). The seabreeze C extension is compiled against the venv's numpy 2.x, and letting the older apt numpy 1.26 leak in through `--system-site-packages` would break imports.
The one piece pip cannot provide is `tkinter`, which is bound to the interpreter and comes from the apt package `python3-tk` (already installed). That is what gives matplotlib its interactive TkAgg backend on this X11 session. If `plt.show()` windows ever stop appearing, `sudo apt install python3-tk` and recreate the venv.
## Files
- `grab_spectrum.py` grabs one spectrum, prints the peak, optionally writes a CSV.
- `live_spectrum.py` opens a live matplotlib window.
- `capture_dark.py` builds the dark model with the light blocked.
- `calibrate_response.py` builds the relative response from a measured source spectrum and a reference.
- `fit_temperature.py` fits a Planck temperature to a response-corrected emission spectrum, over a truncatable band.
- `fit_sequence.py` fits a whole burst of captures and plots temperature versus time, for a cooling or heating object.
- `response.py` shared helpers: the CSV loader, the `--response` / `--dark` arguments, the dark model, and the correction. The scripts import from here so there is one copy of each.
Every spectrum CSV is two columns with a one-line header, `wavelength_nm,value`. The dark model `dark_model.csv` is three columns, `wavelength_nm,offset_counts,rate_counts_per_us`.
## Usage
```bash
source venv/bin/activate
python grab_spectrum.py # one spectrum, 50 ms integration
python grab_spectrum.py 7000 # 7 ms integration
python grab_spectrum.py 7000 out.csv # also save the spectrum to out.csv
python live_spectrum.py # live streaming plot window
python live_spectrum.py 7000 # live plot, 7 ms integration
# apply the calibration; output is relative irradiance. Use the SAME dark model
# that built the calibration, evaluated automatically at the given integration time.
python grab_spectrum.py 7000 out.csv --response responsivity.csv --dark dark_model.csv
python live_spectrum.py 7000 --response responsivity.csv --dark dark_model.csv
```
Both acquisition scripts take `-r/--response responsivity.csv` to divide counts by R(λ), and `--dark dark_model.csv` to subtract the dark first. Outside the calibrated band the corrected values come back as NaN, since there is no response there.
`live_spectrum.py` redraws continuously. Keys while the window is focused: `q` or `Esc` quit, `a` toggles y-autoscale, `d` captures the current frame as a dark reference and subtracts it, `x` clears that dark, `space` freezes and unfreezes acquisition, `t` toggles a live Planck temperature fit. The fit runs on whatever is displayed and uses `--fit-band LO HI` (default 420 to 800 nm). The fitted Planck curve is drawn across the full wavelength range, with the band actually used to constrain the fit highlighted in a second colour on top, and the temperature with its standard error shows in the legend. Use `--response` for a meaningful temperature, since the fit assumes response-corrected relative irradiance; without it the legend is marked uncalibrated.
Minimal use from Python:
```python
from seabreeze.spectrometers import Spectrometer
spec = Spectrometer.from_first_available()
spec.integration_time_micros(7000)
wl = spec.wavelengths()
counts = spec.intensities(correct_nonlinearity=True)
spec.close()
```
## Calibration
### What it does
The device gives trustworthy wavelengths and linear counts, but no sensitivity-versus-wavelength curve. We build that relative response R(λ) by measuring a source whose spectrum we know, and dividing:
R(λ) = (measured counts - dark) / reference spectrum
Then any later spectrum is corrected into relative-irradiance units the same way:
relative irradiance = (measured counts - dark) / R(λ)
R is normalised so its median over the fit band is 1. It is a relative calibration: it fixes the shape across wavelength, not an absolute scale or physical units.
### Reproduce the calibration (no assistant needed)
Do this once, then reuse `responsivity.csv` and `dark_model.csv` for all measurements. Run every command from this directory after `source venv/bin/activate`.
1. **Set up the source in your measurement geometry.** Use the exact optical path you will measure through (for the real experiment that means the fibre pickup). The response depends on the coupling, so calibrate through the same path you measure with, and clamp everything so nothing moves afterwards. A plain tungsten-halogen bulb on a steady supply is a good source. Avoid "white" or "blue" coated automotive bulbs: their coating filters the light and its effect changes with angle.
2. **Set a non-saturating exposure by eye.** Open the live window and adjust the integration time on the command line until the peak sits around 40 to 50k counts, comfortably clear of the 65535 ceiling but well above the roughly 2500-count dark. Too dim and the fit is noisy; too bright and near-saturation contamination skews the spectrum toward the red.
```bash
python live_spectrum.py 7000 # watch the peak, then re-run with a larger or smaller number
```
Integration time scales the counts roughly linearly, so if the peak reads 20k at 7000, about 15000 us lands near 45k. Settle on a value and call it `US`.
3. **Capture the source spectrum** at that exposure:
```bash
python grab_spectrum.py US lamp.csv
```
4. **Capture the dark model.** Turn the source off and block all light (cap the fibre or aperture), then:
```bash
python capture_dark.py
```
This writes `dark_model.csv`. It takes about half a minute and only needs doing again if the detector or its temperature situation changes.
5. **Build the response.** For a plain halogen near its rated voltage use a blackbody reference at about 3300 K:
```bash
python calibrate_response.py lamp.csv --reference planck:3300 --dark dark_model.csv --integration US --band 380 950
```
This writes `responsivity.csv` and a diagnostic `calibration.png`. Open the PNG: the bottom panel should show the corrected source lying on top of the reference across the fit band.
6. **Use it.** Response-correct any measurement, at any exposure (the dark is reconstructed for that exposure automatically):
```bash
python grab_spectrum.py <exposure> spectrum.csv --response responsivity.csv --dark dark_model.csv
```
7. **Fit a temperature** (for thermal-emission work), over a band you trust:
```bash
python fit_temperature.py spectrum.csv --band 420 800
```
The input must be response-corrected. The temperature comes from the spectral slope, so restrict `--band` to where the instrument is reliable. This spectrometer is not trusted at the long-wavelength end, so the default band stops at 800 nm; widen or narrow it to taste. Add `--png fit.png` to eyeball the fit.
### Choosing the reference
`--reference` takes one of:
- `planck:<T>` an ideal blackbody at `<T>` kelvin. Use this for a plain halogen (about 3300 K near rated voltage, hotter if driven harder). The exact temperature only tilts the slope of R, so the shape is approximate.
- a certificate CSV (`wavelength_nm,irradiance`) from a calibrated lamp. This is the accurate route and beats a Planck model, because a real filament's emissivity is not flat. With the certificate in known units and its geometry reproduced, the result can be absolute.
### The dark, and the mistake to avoid
The dark subtracted when building R and the dark subtracted when applying R must be the same. `capture_dark.py` fits, per pixel, `dark(t) = offset + rate·t` and stores it in `dark_model.csv`, so `--dark dark_model.csv` gives the correct dark at whatever integration time each measurement used.
Do not fabricate a dark from the spectrum itself, for instance as a low percentile of the signal: that removes real faint signal and makes corrected spectra climb wrongly toward the red. Always measure the dark with the light blocked.
### Thermal emission from hot graphite
The intended use is reading a temperature from hot graphite's own emission. The trick that makes this clean: calibrate the response off the same graphite spot while it is cold, lit by the bulb, then measure it hot. Because the calibration light and the emission travel the identical path off the identical surface, the instrument-plus-optics response cancels exactly. Concretely, the cold capture gives `response = reflectance x system`, the hot capture is `emissivity x Planck(T) x system`, and applying the response leaves `(emissivity / reflectance) x Planck(T)`. Kirchhoff gives emissivity = 1 - reflectance, and graphite is nearly gray, so that prefactor is close to a flat constant and the corrected spectrum is proportional to Planck(T). Fit it with `fit_temperature.py`.
Two approximations remain, both fine for non-sub-K work: graphite treated as spectrally gray, and the bulb treated as a 3300 K blackbody. Each is a gentle tilt worth tens of K, not fractions. Two practical cautions: the temperature comes from the slope in this band, so the surface must be hot enough to give real counts out toward 700 to 1000 nm; and keep the geometry clamped between the cold calibration and the hot run, since heating can shift or warp the surface and break the cancellation.
For a cooling or heating object, do not average across the transient (it smears T together). Either watch it live with `live_spectrum.py --response ... --dark ...` and `t`, or grab a burst of single frames and run `fit_sequence.py *.csv --response responsivity.csv --dark dark_model.csv --integration US --band 650 850`, which fits each and plots temperature against time (from the file timestamps, or `--interval`). At this signal level a single frame is shot-noise limited, so expect tens of K of scatter frame to frame.
### What this calibration is not
It is a relative, non-traceable calibration: it fixes shape, not absolute units. For traceable work use a NIST-traceable tungsten-halogen source (Ocean HL-3plus-CAL or LS-1-CAL, or a DH-series lamp for the UV) with its certificate. Absolute irradiance additionally needs the certificate in known units, its stated geometry reproduced, and a defined collection area such as a cosine corrector.
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python
"""Relative spectral-response calibration from a broadband source.
Divides a measured spectrum of a known source by that source's reference
spectrum to get the instrument's relative responsivity R(lambda), then smooths
the continuum:
R(lambda) ~ (counts - dark) / reference(lambda)
Apply it later as irradiance_rel = (counts - dark) / R (grab/live --response).
The reference is chosen with --reference:
planck:<T> ideal blackbody at <T> kelvin, e.g. planck:3300 for a halogen lamp
<file.csv> a lamp calibration certificate: wavelength_nm,irradiance
The calibration is relative and non-traceable unless the certificate is in
absolute units with its geometry reproduced. Calibrate through the same optical
path you measure with, since the response depends on the coupling. See README.
Usage: python calibrate_response.py source.csv [--reference REF] [--dark D.csv]
[--integration US] [--band LO HI]
[--out R.csv] [--png fig.png] [--show]
"""
import argparse
import os
import numpy as np
from scipy.signal import medfilt, savgol_filter
from response import dark_at, load_csv, planck
EDGE_PIXELS = 5 # drop this many pixels at each array end (fixed edge artifact)
def reference_spectrum(kind, wl):
"""Return (S on grid `wl`, label) for a --reference value."""
if kind.startswith("planck:"):
temperature = float(kind.split(":", 1)[1])
return planck(wl, temperature), f"Planck {temperature:.0f} K"
rwl, rval = load_csv(kind) # otherwise a certificate CSV
return np.interp(wl, rwl, rval), os.path.basename(kind)
def _odd(n):
n = int(n)
return n if n % 2 == 1 else n + 1
def build_responsivity(wl, counts, s, dark=None, band=(380.0, 950.0)):
"""Return (r_raw, r_smooth, m, valid): responsivity vs the source reference.
`s` is the reference on grid `wl`. `dark` (same grid) is subtracted if
given; never fabricate one from the spectrum itself.
"""
lo, hi = band
m = counts.astype(float)
if dark is not None:
m = m - dark
# Validity mask: fit band, positive signal, away from the edges.
valid = np.ones_like(wl, dtype=bool)
valid[:EDGE_PIXELS] = False
valid[-EDGE_PIXELS:] = False
valid &= (wl >= lo) & (wl <= hi)
valid &= m > 0
# Raw ratio (responsivity) where valid.
r_raw = np.full_like(wl, np.nan)
r_raw[valid] = m[valid] / s[valid]
# Smooth the continuum: interpolate across gaps, then median + Savitzky-Golay.
r_interp = np.interp(wl, wl[valid], r_raw[valid])
win = _odd(max(11, int(round(0.03 * wl.size)))) # ~3% of pixels, odd
r_smooth = savgol_filter(medfilt(r_interp, kernel_size=_odd(9)),
window_length=win, polyorder=3)
# Normalise to a dimensionless relative response (median over valid = 1),
# scaling the raw ratio by the same factor so both share one axis. Normalise
# before clipping: the reference scale is arbitrary (a Planck curve is ~1e27),
# so an absolute floor here would wipe out the shape. After /norm R is order 1.
norm = np.median(r_smooth[valid])
r_smooth = np.clip(r_smooth / norm, 1e-6, None)
r_raw /= norm
# Outside the fit band we have no calibration; NaN rather than extrapolate.
r_smooth[(wl < lo) | (wl > hi)] = np.nan
return r_raw, r_smooth, m, valid
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("source_csv", help="measured spectrum of the known source")
p.add_argument("--reference", default="planck:3300",
help="planck:<T> | a certificate CSV (default: planck:3300)")
p.add_argument("--dark", metavar="CSV", default=None,
help="dark_model.csv from capture_dark.py, subtracted at "
"--integration. Apply the calibration with the same --dark.")
p.add_argument("--integration", type=int, metavar="US", default=None,
help="integration time (us) the source was captured at; "
"required with --dark")
p.add_argument("--band", nargs=2, type=float, metavar=("LO", "HI"),
default=[380.0, 950.0], help="fit band in nm (default: 380 950)")
p.add_argument("--out", default="responsivity.csv")
p.add_argument("--png", default="calibration.png")
p.add_argument("--show", action="store_true", help="open an interactive window")
args = p.parse_args()
import matplotlib
if not args.show:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
wl, counts = load_csv(args.source_csv)
s, ref_label = reference_spectrum(args.reference, wl)
dark = None
if args.dark:
if args.integration is None:
p.error("--integration US is required with --dark")
dwl, dvals = dark_at(args.dark, args.integration)
dark = np.interp(wl, dwl, dvals)
print(f"subtracting dark: {args.dark} at {args.integration} us")
band = tuple(args.band)
r_raw, r, m, valid = build_responsivity(wl, counts, s, dark=dark, band=band)
np.savetxt(args.out, np.column_stack([wl, r]), delimiter=",",
header="wavelength_nm,relative_responsivity", comments="")
print(f"reference: {ref_label} band: {band[0]:.0f}-{band[1]:.0f} nm")
print(f"wrote {args.out} (relative response, median=1 over fit band)")
corrected = m / r # response-corrected source, for the sanity plot
fig, ax = plt.subplots(3, 1, figsize=(11, 10), sharex=True)
ax[0].plot(wl, m, lw=0.7, color="C0")
ax[0].set_ylabel("measured (counts,\ndark-subtracted)")
ax[0].set_title(f"{ref_label} source -> relative spectral response")
ax[1].plot(wl, r_raw, ".", ms=2, color="0.6", label="raw ratio")
ax[1].plot(wl, r, lw=1.6, color="C3", label="smoothed R(λ)")
ax[1].set_ylabel("relative\nresponsivity")
ax[1].legend(loc="upper right", fontsize=8)
def norm(y):
return y / np.median(y[valid])
ax[2].plot(wl, norm(corrected), lw=0.7, color="C2", label="corrected source (norm.)")
ax[2].plot(wl, norm(s), lw=1.2, color="k", alpha=0.6, label=f"{ref_label} reference (norm.)")
ax[2].set_ylabel("normalised\nirradiance")
ax[2].set_xlabel("wavelength (nm)")
ax[2].set_ylim(-0.3, 2.0)
ax[2].legend(loc="upper right", fontsize=8)
for a in ax:
a.grid(True, alpha=0.3)
a.axvspan(wl[0], band[0], color="0.85", alpha=0.5, lw=0)
a.axvspan(band[1], wl[-1], color="0.85", alpha=0.5, lw=0)
fig.tight_layout()
fig.savefig(args.png, dpi=110)
print(f"wrote {args.png} (grey = out of band)")
if args.show:
plt.show()
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python
"""Characterise the detector dark as a per-pixel line: dark(t) = offset + rate * t_us.
Block ALL light (sources off, aperture capped), then this captures dark frames
at several integration times and least-squares fits, for each pixel, a fixed
electronic offset plus a dark-current rate. Writes dark_model.csv
(wavelength_nm, offset_counts, rate_counts_per_us), which reconstructs the dark
at ANY integration time, so grab / live / calibrate can subtract the right dark
without a fresh capture per measurement.
Usage: python capture_dark.py [--times US [US ...]] [--frames N] [--out dark_model.csv]
"""
import argparse
import numpy as np
from seabreeze.spectrometers import Spectrometer
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--times", type=int, nargs="+",
default=[50000, 500000, 1000000, 1500000],
help="integration times in us to sample (default: 0.05 .. 1.5 s)")
p.add_argument("--frames", type=int, default=10, help="frames to average per time")
p.add_argument("--out", default="dark_model.csv")
args = p.parse_args()
spec = Spectrometer.from_first_available()
wl = spec.wavelengths()
times = np.array(sorted(args.times), dtype=float)
stack = np.empty((times.size, wl.size))
for i, us in enumerate(times):
spec.integration_time_micros(int(us))
spec.intensities() # flush one frame after changing integration time
acc = np.zeros_like(wl)
for _ in range(args.frames):
acc += spec.intensities(correct_nonlinearity=True)
stack[i] = acc / args.frames
print(f" {us / 1e6:.3f}s: median {np.median(stack[i]):.0f} counts")
spec.close()
# Per-pixel linear fit: counts = offset + rate * t_us.
rate, offset = np.polyfit(times, stack, 1)
resid = stack - (offset + np.outer(times, rate))
print(f"fit residual rms: {np.sqrt(np.mean(resid ** 2)):.1f} counts")
print(f"offset median {np.median(offset):.0f} counts, "
f"rate median {np.median(rate) * 1e6:.1f} counts/s")
np.savetxt(args.out, np.column_stack([wl, offset, rate]), delimiter=",",
header="wavelength_nm,offset_counts,rate_counts_per_us", comments="")
print(f"wrote {args.out}")
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python
"""Fit a temperature to each spectrum in a sequence and plot T versus time.
For a cooling or heating object: grab a burst of frames, then fit each and watch
the temperature track. Reuses the Planck fit from fit_temperature.py.
Inputs are spectrum CSVs in time order. With --response (and --dark plus
--integration) each is response-corrected first, exactly as grab_spectrum.py
--response would; otherwise they are assumed already corrected. Time comes from
the file modification times (first file = 0), or pass --interval for even spacing.
Note the printed +/- is the formal fit error and understates the truth; the
real scatter is frame-to-frame noise and the gray-body and reference systematics.
Usage: python fit_sequence.py f1.csv f2.csv ... [--response R.csv --dark D.csv
--integration US] [--band LO HI] [--interval S]
[--out T.csv] [--png fig.png]
"""
import argparse
import os
import numpy as np
from response import add_calibration_args, apply_response, dark_at, load_csv
from fit_temperature import fit_planck
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("spectra", nargs="+", help="spectrum CSVs, in time order")
p.add_argument("--integration", type=int, metavar="US",
help="integration time (us) the spectra were captured at; required with --dark")
p.add_argument("--band", nargs=2, type=float, default=[420.0, 800.0],
metavar=("LO", "HI"), help="fit band in nm (default: 420 800)")
p.add_argument("--interval", type=float, default=None,
help="seconds between frames (default: use file modification times)")
p.add_argument("--out", default=None, help="write a time,temperature_K,dT_K CSV")
p.add_argument("--png", default="temperature_sequence.png")
add_calibration_args(p)
args = p.parse_args()
cal = load_csv(args.response) if args.response else None
dark = None
if args.dark:
if args.integration is None:
p.error("--integration US is required with --dark")
dark = dark_at(args.dark, args.integration)
if args.interval is not None:
times = np.arange(len(args.spectra)) * args.interval
else:
t0 = os.path.getmtime(args.spectra[0])
times = np.array([os.path.getmtime(f) - t0 for f in args.spectra])
rows = []
for t, f in zip(times, args.spectra):
wl, y = load_csv(f)
if cal is not None:
y = apply_response(wl, y, cal, dark)
result = fit_planck(wl, y, tuple(args.band))
if result is None:
print(f"{f}: no fit (too few usable points in band)")
continue
temperature, dT, _ = result
rows.append((t, temperature, dT))
print(f"{f}: t = {t:7.2f} s T = {temperature:.2f} +/- {dT:.2f} K")
if not rows:
p.error("no spectra could be fit; check --band and inputs")
rows = np.array(rows)
if args.out:
np.savetxt(args.out, rows, delimiter=",",
header="time_s,temperature_K,dT_K", comments="")
print(f"wrote {args.out}")
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 5))
ax.errorbar(rows[:, 0], rows[:, 1], yerr=rows[:, 2], fmt="o-", capsize=3)
ax.set_xlabel("time (s)")
ax.set_ylabel("temperature (K)")
ax.set_title(f"Temperature sequence, fit {args.band[0]:.0f}-{args.band[1]:.0f} nm")
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(args.png, dpi=110)
print(f"wrote {args.png}")
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python
"""Fit a temperature to a response-corrected emission spectrum.
Takes a spectrum written by `grab_spectrum.py ... --response` (relative
irradiance vs wavelength) and fits a Planck curve over a chosen band, returning
the temperature. The fit is relative: an amplitude absorbs the arbitrary scale
and any flat greybody factor, so only the spectral SHAPE sets T.
Restrict the band with --band LO HI. The default 420 to 800 nm avoids the weak
blue response and the long-wavelength end this spectrometer is not trusted at.
The temperature comes entirely from the slope across the band, so a too-narrow
or too-noisy band gives a poorly constrained T.
Usage: python fit_temperature.py spectrum.csv [--band LO HI] [--png fit.png]
"""
import argparse
import numpy as np
from scipy.optimize import curve_fit
from response import load_csv, planck
def fit_planck(wl, y, band=(420.0, 800.0)):
"""Fit a Planck curve to a response-corrected spectrum over `band`.
Returns (temperature_K, dT_K, predict), or None if there are too few usable
points. `predict(wl_nm)` evaluates the fitted curve at any wavelengths, using
the same normalisation as the fit, so it can be drawn beyond the fit band.
The model peak is normalised to a free amplitude, so only the spectral slope
sets T and the fit is well conditioned regardless of the Planck absolute scale.
"""
lo, hi = band
keep = np.isfinite(y) & (y > 0) & (wl >= lo) & (wl <= hi)
if keep.sum() < 10:
return None
x, yy = wl[keep], y[keep]
def model(wl_nm, amplitude, temperature):
b = planck(wl_nm, temperature)
return amplitude * b / b.max()
try:
popt, pcov = curve_fit(model, x, yy, p0=[yy.max(), 2500.0],
bounds=([0, 300], [np.inf, 6000]), maxfev=20000)
except (RuntimeError, ValueError):
return None
amplitude, temperature = popt
norm = planck(x, temperature).max() # the scale used over the fit band
predict = lambda wl_nm: amplitude * planck(wl_nm, temperature) / norm
return temperature, float(np.sqrt(pcov[1, 1])), predict
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("spectrum", help="response-corrected spectrum CSV")
p.add_argument("--band", nargs=2, type=float, default=[420.0, 800.0],
metavar=("LO", "HI"), help="fit band in nm (default: 420 800)")
p.add_argument("--png", default=None, help="optional plot of data and fit")
args = p.parse_args()
wl, y = load_csv(args.spectrum)
lo, hi = args.band
result = fit_planck(wl, y, (lo, hi))
if result is None:
p.error(f"too few usable points in {lo:.0f}-{hi:.0f} nm; widen --band "
"or check the spectrum is response-corrected")
temperature, dT, predict = result
print(f"fit band {lo:.0f}-{hi:.0f} nm")
print(f"T = {temperature:.2f} +/- {dT:.2f} K (formal fit error; systematics dominate)")
if args.png:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 5))
band = (wl >= lo) & (wl <= hi)
ax.plot(wl, y, lw=0.6, color="0.7", label="corrected spectrum")
ax.plot(wl, predict(wl), lw=1.2, color="C1", label="Planck fit (extrapolated)")
ax.plot(wl[band], predict(wl[band]), lw=2.0, color="C3",
label=f"used {lo:.0f}-{hi:.0f} nm, T = {temperature:.2f} K")
ax.set_xlabel("wavelength (nm)")
ax.set_ylabel("relative irradiance")
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
fig.savefig(args.png, dpi=110)
print(f"wrote {args.png}")
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python
"""Grab one spectrum from the first available Ocean Optics spectrometer.
Prints the peak and, with --response, applies a relative-response calibration.
Usage: python grab_spectrum.py [integration_us] [out.csv] [--response R.csv --dark D.csv]
"""
import argparse
import numpy as np
from seabreeze.spectrometers import Spectrometer
from response import add_calibration_args, apply_response, dark_at, load_csv
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("outfile", nargs="?", default=None,
help="optional CSV to write (wavelength, value)")
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)
spec = Spectrometer.from_first_available()
spec.integration_time_micros(args.integration_us)
wl = spec.wavelengths()
y = spec.intensities(correct_nonlinearity=True)
print(f"{spec.model} serial={spec.serial_number} pixels={spec.pixels}")
if cal is not None:
y = apply_response(wl, y, cal, dark)
unit = "relative_irradiance"
print(f"applied response {args.response}" + (f" + dark {args.dark}" if dark else ""))
else:
unit = "counts"
pk = int(np.nanargmax(y))
print(f"integration: {args.integration_us} us")
print(f"range: {wl[0]:.2f} - {wl[-1]:.2f} nm")
print(f"peak: {wl[pk]:.2f} nm {unit}={y[pk]:.4g}")
if args.outfile:
np.savetxt(args.outfile, np.column_stack([wl, y]),
delimiter=",", header=f"wavelength_nm,{unit}", comments="")
print(f"wrote {args.outfile}")
spec.close()
+152
View File
@@ -0,0 +1,152 @@
#!/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()
+13
View File
@@ -0,0 +1,13 @@
contourpy==1.3.3
cycler==0.12.1
fonttools==4.64.0
kiwisolver==1.5.1
matplotlib==3.11.1
numpy==2.5.3
packaging==26.3
pillow==12.3.0
pyparsing==3.3.2
python-dateutil==2.9.0.post0
scipy==1.18.1
seabreeze==2.11.0
six==1.17.0
+65
View File
@@ -0,0 +1,65 @@
"""Shared spectrum I/O and relative spectral-response correction.
A "spectrum CSV" throughout this project is two columns with a one-line header:
`wavelength_nm,value`. Measured spectra, dark frames and responsivity.csv all
share that shape, so one loader serves them all.
"""
import numpy as np
_H, _C, _K = 6.62607015e-34, 2.99792458e8, 1.380649e-23
def planck(wl_nm, temperature):
"""Blackbody spectral radiance vs wavelength, arbitrary scale.
A greybody with flat emissivity. Used both as a calibration reference (a
tungsten lamp near this shape) and as the fit model for a hot emitter. A
real filament's emissivity falls slowly toward the NIR, a gentle tilt we
accept for relative work.
"""
lam = wl_nm * 1e-9
return 1.0 / (lam ** 5 * np.expm1(_H * _C / (lam * _K * temperature)))
def load_csv(path):
"""Load a two-column spectrum CSV -> (wavelength_nm, value) arrays."""
wl, value = np.loadtxt(path, delimiter=",", skiprows=1, unpack=True)
return wl, value
def add_calibration_args(parser):
"""Add the shared --response / --dark options to an argparse parser."""
parser.add_argument("-r", "--response", metavar="CSV", default=None,
help="responsivity.csv from calibrate_response.py; "
"output becomes relative irradiance")
parser.add_argument("--dark", metavar="CSV", default=None,
help="dark_model.csv from capture_dark.py; the dark is "
"reconstructed for this measurement's integration time")
def dark_at(dark_path, integration_us):
"""Reconstruct the dark at an integration time from a dark_model.csv.
Returns a (wavelength_nm, counts) tuple, or None if `dark_path` is falsy.
The model is per-pixel `offset + rate * t_us` (see capture_dark.py).
"""
if not dark_path:
return None
wl, offset, rate = np.loadtxt(dark_path, delimiter=",", skiprows=1, unpack=True)
return wl, offset + rate * integration_us
def apply_response(wl, counts, cal, dark=None):
"""Response-correct `counts` on grid `wl` into relative-irradiance units.
corrected = (counts - dark) / R, with dark and R interpolated onto `wl`.
`cal` and `dark` are (wl, value) tuples; `dark` may be None. The dark here
MUST match the one used to build the calibration. Non-positive or non-finite
R (i.e. outside the calibrated band) yields NaN.
"""
signal = counts.astype(float)
if dark is not None:
signal = signal - np.interp(wl, dark[0], dark[1])
r = np.interp(wl, cal[0], cal[1])
r = np.where(np.isfinite(r) & (r > 0), r, np.nan)
return signal / r