"""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