OceanOptics
Driving an Ocean Optics Flame-S spectrometer with python-seabreeze: 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:
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.pygrabs one spectrum, prints the peak, optionally writes a CSV.live_spectrum.pyopens a live matplotlib window.capture_dark.pybuilds the dark model with the light blocked.calibrate_response.pybuilds the relative response from a measured source spectrum and a reference.fit_temperature.pyfits a Planck temperature to a response-corrected emission spectrum, over a truncatable band.fit_sequence.pyfits a whole burst of captures and plots temperature versus time, for a cooling or heating object.response.pyshared helpers: the CSV loader, the--response/--darkarguments, 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
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:
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.
-
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.
-
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.
python live_spectrum.py 7000 # watch the peak, then re-run with a larger or smaller numberIntegration 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. -
Capture the source spectrum at that exposure:
python grab_spectrum.py US lamp.csv -
Capture the dark model. Turn the source off and block all light (cap the fibre or aperture), then:
python capture_dark.pyThis writes
dark_model.csv. It takes about half a minute and only needs doing again if the detector or its temperature situation changes. -
Build the response. For a plain halogen near its rated voltage use a blackbody reference at about 3300 K:
python calibrate_response.py lamp.csv --reference planck:3300 --dark dark_model.csv --integration US --band 380 950This writes
responsivity.csvand a diagnosticcalibration.png. Open the PNG: the bottom panel should show the corrected source lying on top of the reference across the fit band. -
Use it. Response-correct any measurement, at any exposure (the dark is reconstructed for that exposure automatically):
python grab_spectrum.py <exposure> spectrum.csv --response responsivity.csv --dark dark_model.csv -
Fit a temperature (for thermal-emission work), over a band you trust:
python fit_temperature.py spectrum.csv --band 420 800The input must be response-corrected. The temperature comes from the spectral slope, so restrict
--bandto 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.pngto 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.