Initial commit
This commit is contained in:
+11
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user