Files
2026-09-10 15:11:55 +01:00

169 lines
13 KiB
Markdown

# 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.