diff --git a/inlinino/cfg/HBB8005_Cal_new_format_example.mat b/inlinino/cfg/HBB8005_Cal_new_format_example.mat new file mode 100644 index 0000000..5b76fbb Binary files /dev/null and b/inlinino/cfg/HBB8005_Cal_new_format_example.mat differ diff --git a/inlinino/gui.py b/inlinino/gui.py index 1823b9b..64e9bc2 100644 --- a/inlinino/gui.py +++ b/inlinino/gui.py @@ -758,12 +758,14 @@ def act_browse_zsc_file(self): def act_browse_plaque_file(self): file_name, selected_filter = QtGui.QFileDialog.getOpenFileName( - caption='Choose plaque calibration file', filter='Plaque File (*.mat)') + caption='Choose plaque calibration file', + filter='Calibration File (*.mat *.hbb_cal);;Legacy MAT File (*.mat);;Current Binary (*.hbb_cal)') self.le_plaque_file.setText(file_name) def act_browse_temperature_file(self): file_name, selected_filter = QtGui.QFileDialog.getOpenFileName( - caption='Choose temperature calibration file', filter='Temperature File (*.mat)') + caption='Choose temperature calibration file', + filter='Temperature Calibration File (*.mat *.hbb_tcal);;Legacy MAT File (*.mat);;Current Binary (*.hbb_tcal)') self.le_temperature_file.setText(file_name) def act_browse_px_reg_prt(self): @@ -1077,6 +1079,9 @@ def act_save(self): except FileNotFoundError: self.notification(f"No such calibration file: {self.cfg['calibration_file']}") return + elif self.cfg['module'] == 'hyperbb': + self.cfg['manufacturer'] = 'Sequoia' + self.cfg['model'] = 'HyperBB' # Update global instrument cfg CFG.read() # Update local cfg if other instance updated cfg CFG.instruments[self.cfg_uuid] = self.cfg.copy() diff --git a/inlinino/instruments/hyperbb.py b/inlinino/instruments/hyperbb.py index c1038ea..b9dc8f1 100644 --- a/inlinino/instruments/hyperbb.py +++ b/inlinino/instruments/hyperbb.py @@ -6,7 +6,7 @@ import numpy as np from scipy.io import loadmat -from scipy.interpolate import interp2d, splrep, splev # , pchip_interpolate +from scipy.interpolate import RegularGridInterpolator, splrep, splev # , pchip_interpolate from inlinino.instruments import Instrument @@ -49,11 +49,13 @@ def setup(self, cfg): # Set HyperBB specific attributes if 'plaque_file' not in cfg.keys(): raise ValueError('Missing calibration plaque file (*.mat)') - if 'temperature_file' not in cfg.keys(): - raise ValueError('Missing calibration temperature file (*.mat)') if 'data_format' not in cfg.keys(): cfg['data_format'] = 'advanced' - self._parser = HyperBBParser(cfg['plaque_file'], cfg['temperature_file'], cfg['data_format']) + if 'cal_format' not in cfg.keys(): + cfg['cal_format'] = 'legacy' + temperature_file = cfg.get('temperature_file', None) + self._parser = HyperBBParser(cfg['plaque_file'], temperature_file, cfg['data_format'], + cfg['cal_format']) self.signal_reconstructed = np.empty(len(self._parser.wavelength)) * np.nan # Overload cfg with received data prod_var_names = ['beta_u', 'bb'] @@ -154,7 +156,7 @@ def update_active_timeseries_variables(self, name, state): LIGHT_DATA_FORMAT = 2 class HyperBBParser(): - def __init__(self, plaque_cal_file, temperature_cal_file, data_format='advanced'): + def __init__(self, plaque_cal_file, temperature_cal_file=None, data_format='advanced', cal_format='legacy'): # Frame Parser if data_format.lower() == 'legacy': self.data_format = LEGACY_DATA_FORMAT @@ -220,33 +222,187 @@ def __init__(self, plaque_cal_file, temperature_cal_file, data_format='advanced' self.saturation_level = 4000 self.theta = 135 # calls theta setter which sets Xp - # Load Temperature calibration file - t = loadmat(temperature_cal_file, simplify_cells=True) - self.wavelength = t['cal_temp']['wl'] - self.cal_t_coef = t['cal_temp']['coeff'] + # Load calibration files (supports legacy two-file format and current single-file format) + p_cal, t_cal = self._load_calibration(plaque_cal_file, temperature_cal_file, cal_format) - # Load plaque calibration file - p = loadmat(plaque_cal_file, simplify_cells=True) - self.pmt_ref_gain = p['cal']['pmtRefGain'] - self.pmt_gamma = p['cal']['pmtGamma'] - self.gain12 = p['cal']['gain12'] - self.gain23 = p['cal']['gain23'] + self.wavelength = t_cal['wl'] + self.cal_t_coef = t_cal['coeff'] + self.pmt_ref_gain = p_cal['pmtRefGain'] + self.pmt_gamma = p_cal['pmtGamma'] + self.gain12 = p_cal['gain12'] + self.gain23 = p_cal['gain23'] # Check wavelength match in all calibration files - if np.any(p['cal']['darkCalWavelength'] != p['cal']['muWavelengths']) or \ - np.any(p['cal']['darkCalWavelength'] != t['cal_temp']['wl']): + if np.any(p_cal['darkCalWavelength'] != p_cal['muWavelengths']) or \ + np.any(p_cal['darkCalWavelength'] != t_cal['wl']): raise ValueError('Wavelength from calibration files don\'t match.') + # Pre-compute temperature correction grid over an extensive temperature range. + # The grid spans 0–50°C, covering the full operational range of the LED. + # RegularGridInterpolator uses fill_value=None which extrapolates beyond the grid + # edges; log a warning if data temperatures fall outside this range. + _led_t_grid = np.arange(0, 50.01, 0.1) + _t_corr_grid = np.empty((len(self.wavelength), len(_led_t_grid))) + for k in range(len(self.wavelength)): + _t_corr_grid[k, :] = np.polyval(self.cal_t_coef[k, :], _led_t_grid) + self._f_t_correction = RegularGridInterpolator( + (self.wavelength.astype(float), _led_t_grid), + _t_corr_grid, method='linear', bounds_error=False, fill_value=None) + # Prepare interpolation tables for dark offsets - self.f_dark_cal_scat_1 = interp2d(p['cal']['darkCalPmtGain'], p['cal']['darkCalWavelength'], - p['cal']['darkCalScat1'], kind='linear') - self.f_dark_cal_scat_2 = interp2d(p['cal']['darkCalPmtGain'], p['cal']['darkCalWavelength'], - p['cal']['darkCalScat2'], kind='linear') - self.f_dark_cal_scat_3 = interp2d(p['cal']['darkCalPmtGain'], p['cal']['darkCalWavelength'], - p['cal']['darkCalScat3'], kind='linear') + _gain_vals = p_cal['darkCalPmtGain'].astype(float) + _wl_vals = p_cal['darkCalWavelength'].astype(float) + self.f_dark_cal_scat_1 = RegularGridInterpolator( + (_wl_vals, _gain_vals), p_cal['darkCalScat1'], + method='linear', bounds_error=False, fill_value=None) + self.f_dark_cal_scat_2 = RegularGridInterpolator( + (_wl_vals, _gain_vals), p_cal['darkCalScat2'], + method='linear', bounds_error=False, fill_value=None) + self.f_dark_cal_scat_3 = RegularGridInterpolator( + (_wl_vals, _gain_vals), p_cal['darkCalScat3'], + method='linear', bounds_error=False, fill_value=None) # mu calibration corrected for temperature - self.mu = p['cal']['muFactors'] * self.compute_temperature_coefficients(p['cal']['muWavelengths'], - p['cal']['muLedTemp']) + self.mu = p_cal['muFactors'] * self.compute_temperature_coefficients(p_cal['muWavelengths'], + p_cal['muLedTemp']) + + @staticmethod + def _load_calibration(plaque_cal_file, temperature_cal_file=None, cal_format='legacy'): + """Load calibration data from legacy (.mat) or current binary format. + + Legacy format: Two separate .mat files — a plaque calibration file (containing a 'cal' + struct) and a temperature calibration file (containing a 'cal_temp' struct). + + Current format: Binary plaque calibration file (.hbb_cal) and binary temperature + calibration file (.hbb_tcal) as produced by Hbb_ConvertCalibrations.m (Sequoia, 2024). + Both files are required for live data temperature correction. + + :param plaque_cal_file: Path to plaque .mat file (legacy) or .hbb_cal binary file (current). + :param temperature_cal_file: Path to temperature .mat file (legacy) or .hbb_tcal binary + file (current). Required for both calibration formats. + :param cal_format: 'legacy' for MATLAB .mat file pair, 'current' for binary .hbb_cal/.hbb_tcal. + :return: Tuple (p_cal, t_cal) where p_cal and t_cal are dicts of calibration parameters. + :raises ValueError: If required files are missing or the file format is unexpected. + """ + if cal_format == 'legacy': + p_mat = loadmat(plaque_cal_file, simplify_cells=True) + if 'cal' not in p_mat: + raise ValueError( + f"Plaque calibration file '{plaque_cal_file}' does not contain 'cal' struct. " + "Ensure the correct legacy format plaque file is specified.") + p_cal = p_mat['cal'] + if temperature_cal_file is None: + raise ValueError( + 'Missing temperature calibration file (*.mat). ' + 'The legacy calibration format requires two separate files.') + t_mat = loadmat(temperature_cal_file, simplify_cells=True) + if 'cal_temp' not in t_mat: + raise ValueError( + f"Temperature calibration file '{temperature_cal_file}' does not contain " + "'cal_temp' struct. Check that the correct file is specified.") + t_cal = t_mat['cal_temp'] + elif cal_format == 'current': + if temperature_cal_file is None: + raise ValueError( + 'Missing temperature calibration file (*.hbb_tcal). ' + 'The current calibration format requires both a plaque (.hbb_cal) ' + 'and a temperature (.hbb_tcal) calibration file.') + p_cal = HyperBBParser._read_binary_plaque_cal(plaque_cal_file) + t_cal = HyperBBParser._read_binary_temp_cal(temperature_cal_file) + else: + raise ValueError( + f"Calibration format '{cal_format}' not recognized. Use 'legacy' or 'current'.") + return p_cal, t_cal + + @staticmethod + def _read_binary_plaque_cal(filename): + """Read a HyperBB binary plaque calibration file (.hbb_cal) as produced by + Hbb_ConvertCalibrations.m (Sequoia Scientific, 2024). + + The file may contain multiple calibration records appended sequentially; the last + (most recent) record is returned, consistent with MATLAB's ``cal = cal(end)``. + + Returns a dict with the same field names as the legacy .mat 'cal' struct so the rest + of the calibration pipeline requires no changes. + """ + records = [] + with open(filename, 'rb') as fid: + fid.seek(0, 2) + eof = fid.tell() + fid.seek(0) + while fid.tell() < eof: + fid.read(24) # ID string (24 chars, e.g. 'Sequoia Hyper-bb Cal') + fid.read(2) # calLengthBytes (uint16) — read but not needed for seeking + fid.read(2) # processingVersion (uint16 / 100) + fid.read(2) # serialNumber (uint16) + fid.read(6) # cal date (6 × uint8: year-1900, month, day, hour, min, sec) + fid.read(6) # tempCalDate + pmt_ref_gain = int(np.frombuffer(fid.read(2), dtype=' float: @@ -261,14 +417,9 @@ def theta(self, value) -> None: self.Xp = float(splev(self.theta, splrep(theta_ref, Xp_ref))) def compute_temperature_coefficients(self, wl, t): - # Generate temperature correction grid - led_t = np.arange(np.min(t), np.max(t) + 0.1001, 0.1) # TODO optimize by creating grid for extensive range of value once - t_correction = np.empty((len(self.wavelength), len(led_t))) - for k in range(len(self.wavelength)): - t_correction[k, :] = np.polyval(self.cal_t_coef[k, :], led_t) - # mu temperature correction - t_correction = interp2d(led_t, self.wavelength, t_correction, kind='linear')(t, wl) - return np.diag(t_correction) if t_correction.ndim > 1 else t_correction + wl_arr = np.atleast_1d(np.asarray(wl, dtype=float)) + t_arr = np.atleast_1d(np.asarray(t, dtype=float)) + return self._f_t_correction(np.column_stack([wl_arr, t_arr])) def parse(self, raw): tmp = raw.decode().split() @@ -334,9 +485,10 @@ def calibrate(self, raw): gain[raw[:, self.idx_ChSaturated] == 2] = 1 gain[raw[:, self.idx_ChSaturated] == 1] = 0 # All signals saturated # Subtract dark offset - scat1_dark_removed = scat1 - self.f_dark_cal_scat_1(raw[:, self.idx_PmtGain], wl) - scat2_dark_removed = scat2 - self.f_dark_cal_scat_2(raw[:, self.idx_PmtGain], wl) - scat3_dark_removed = scat3 - self.f_dark_cal_scat_3(raw[:, self.idx_PmtGain], wl) + _pts = np.column_stack([wl, raw[:, self.idx_PmtGain]]) + scat1_dark_removed = scat1 - self.f_dark_cal_scat_1(_pts) + scat2_dark_removed = scat2 - self.f_dark_cal_scat_2(_pts) + scat3_dark_removed = scat3 - self.f_dark_cal_scat_3(_pts) # Apply PMT and front end gain factors g_pmt = (raw[:, self.idx_PmtGain] / self.pmt_ref_gain) ** self.pmt_gamma scat1_gain_corrected = scat1_dark_removed * self.gain12 * self.gain23 * g_pmt diff --git a/inlinino/resources/setup_hyperbb.ui b/inlinino/resources/setup_hyperbb.ui index 1dae3ad..b5a484d 100644 --- a/inlinino/resources/setup_hyperbb.ui +++ b/inlinino/resources/setup_hyperbb.ui @@ -10,7 +10,7 @@ 0 0 515 - 366 + 395 @@ -194,6 +194,50 @@ 0 + + + Cal. Format + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + legacy + + + + legacy + + + + + current + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + Plaque File @@ -203,7 +247,7 @@ - + -1 @@ -227,7 +271,7 @@ - + Temperature File @@ -237,7 +281,7 @@ - + -1 @@ -261,7 +305,7 @@ - + Data Format @@ -271,7 +315,7 @@ - + diff --git a/test/test_hyperbb.py b/test/test_hyperbb.py new file mode 100644 index 0000000..a10b2ba --- /dev/null +++ b/test/test_hyperbb.py @@ -0,0 +1,411 @@ +""" +Tests for HyperBB instrument parser. + +Tests the following: +- Legacy calibration format (two separate plaque + temperature .mat files) +- Current calibration format (binary .hbb_cal and .hbb_tcal files produced by + Hbb_ConvertCalibrations.m, Sequoia Scientific 2024) +- All data output formats (legacy, advanced, light) +- Calibration math (calibrate function) +- Frame parsing +""" +import io +import os +import struct +import sys +import tempfile + +import numpy as np +import pytest + +# Allow running from repo root or test/ directory +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from inlinino.instruments.hyperbb import ( + HyperBBParser, + LEGACY_DATA_FORMAT, + ADVANCED_DATA_FORMAT, + LIGHT_DATA_FORMAT, +) + +CFG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'inlinino', 'cfg') +PLAQUE_CAL = os.path.join(CFG_DIR, 'HBB8005_CalPlaque_20210315.mat') +TEMP_CAL = os.path.join(CFG_DIR, 'HBB8005_CalTemp_20210315.mat') + + +def _skip_if_no_cal_files(): + """Skip test if calibration .mat files are not available.""" + return pytest.mark.skipif( + not os.path.exists(PLAQUE_CAL) or not os.path.exists(TEMP_CAL), + reason='Calibration files not available' + ) + + +# --------------------------------------------------------------------------- +# Helpers: write binary calibration files from existing .mat data +# --------------------------------------------------------------------------- + +def _write_hbb_cal(p_cal, path): + """Serialise a plaque calibration dict to an .hbb_cal binary file. + + Follows the format expected by Hbb_ReadBinaryCalFile.m (Sequoia, 2024): + MATLAB writes 2-D arrays column-major, so we use Fortran order when + flattening NumPy arrays before writing. + """ + dark_wl = np.asarray(p_cal['darkCalWavelength'], dtype=float) + dark_gain = np.asarray(p_cal['darkCalPmtGain'], dtype=' 0 + + @_skip_if_no_cal_files() + def test_legacy_cal_legacy_data(self): + parser = HyperBBParser(PLAQUE_CAL, TEMP_CAL, 'legacy', cal_format='legacy') + assert parser.data_format == LEGACY_DATA_FORMAT + assert len(parser.FRAME_VARIABLES) == 30 + + @_skip_if_no_cal_files() + def test_legacy_cal_light_data(self): + parser = HyperBBParser(PLAQUE_CAL, TEMP_CAL, 'light', cal_format='legacy') + assert parser.data_format == LIGHT_DATA_FORMAT + assert len(parser.FRAME_VARIABLES) == 14 + + @_skip_if_no_cal_files() + def test_current_binary_format(self): + """Current format: binary .hbb_cal + .hbb_tcal files.""" + from scipy.io import loadmat + p_mat = loadmat(PLAQUE_CAL, simplify_cells=True)['cal'] + t_mat = loadmat(TEMP_CAL, simplify_cells=True)['cal_temp'] + with tempfile.TemporaryDirectory() as tmp: + hbb_cal = os.path.join(tmp, 'test.hbb_cal') + hbb_tcal = os.path.join(tmp, 'test.hbb_tcal') + _write_hbb_cal(p_mat, hbb_cal) + _write_hbb_tcal(t_mat, hbb_tcal) + parser = HyperBBParser(hbb_cal, hbb_tcal, 'advanced', cal_format='current') + assert parser.data_format == ADVANCED_DATA_FORMAT + assert len(parser.wavelength) > 0 + + @_skip_if_no_cal_files() + def test_current_cal_matches_legacy_cal(self): + """Binary current format should yield the same calibration as legacy .mat format.""" + from scipy.io import loadmat + p_mat = loadmat(PLAQUE_CAL, simplify_cells=True)['cal'] + t_mat = loadmat(TEMP_CAL, simplify_cells=True)['cal_temp'] + with tempfile.TemporaryDirectory() as tmp: + hbb_cal = os.path.join(tmp, 'test.hbb_cal') + hbb_tcal = os.path.join(tmp, 'test.hbb_tcal') + _write_hbb_cal(p_mat, hbb_cal) + _write_hbb_tcal(t_mat, hbb_tcal) + parser_legacy = HyperBBParser(PLAQUE_CAL, TEMP_CAL, 'advanced', cal_format='legacy') + parser_current = HyperBBParser(hbb_cal, hbb_tcal, 'advanced', cal_format='current') + np.testing.assert_array_almost_equal(parser_legacy.mu, parser_current.mu, decimal=4) + np.testing.assert_array_almost_equal(parser_legacy.wavelength, parser_current.wavelength, decimal=1) + + def test_invalid_data_format_raises(self): + with pytest.raises(ValueError, match='Data format not recognized'): + HyperBBParser('dummy.mat', 'dummy.mat', 'invalid_format') + + def test_invalid_cal_format_raises(self): + with pytest.raises(ValueError, match="Calibration format 'invalid' not recognized"): + HyperBBParser('dummy.mat', 'dummy.mat', 'advanced', cal_format='invalid') + + @_skip_if_no_cal_files() + def test_legacy_cal_without_temp_file_raises(self): + with pytest.raises(ValueError, match='Missing temperature calibration file'): + HyperBBParser(PLAQUE_CAL, None, 'advanced', cal_format='legacy') + + def test_current_cal_without_temp_file_raises(self): + with pytest.raises(ValueError, match='Missing temperature calibration file'): + # dummy .hbb_cal — error should fire before the file is read + with tempfile.NamedTemporaryFile(suffix='.hbb_cal', delete=False) as tmp: + tmp_path = tmp.name + try: + HyperBBParser(tmp_path, None, 'advanced', cal_format='current') + finally: + os.unlink(tmp_path) + + +class TestHyperBBBinaryFileReaders: + """Verify that the binary file readers round-trip data correctly.""" + + @_skip_if_no_cal_files() + def test_plaque_cal_roundtrip(self): + from scipy.io import loadmat + p_mat = loadmat(PLAQUE_CAL, simplify_cells=True)['cal'] + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'round.hbb_cal') + _write_hbb_cal(p_mat, path) + p_bin = HyperBBParser._read_binary_plaque_cal(path) + np.testing.assert_allclose(p_bin['pmtRefGain'], p_mat['pmtRefGain']) + np.testing.assert_allclose(p_bin['pmtGamma'], p_mat['pmtGamma'], rtol=1e-5) + np.testing.assert_allclose(p_bin['gain12'], p_mat['gain12'], rtol=1e-3) + np.testing.assert_allclose(p_bin['gain23'], p_mat['gain23'], rtol=1e-3) + np.testing.assert_allclose(p_bin['muWavelengths'], p_mat['muWavelengths'], atol=0.1) + np.testing.assert_allclose(p_bin['muFactors'], p_mat['muFactors'], rtol=1e-5) + np.testing.assert_allclose(p_bin['muLedTemp'], p_mat['muLedTemp'], atol=0.01) + np.testing.assert_allclose(p_bin['darkCalWavelength'], p_mat['darkCalWavelength'], atol=0.1) + np.testing.assert_allclose(p_bin['darkCalPmtGain'], p_mat['darkCalPmtGain']) + np.testing.assert_allclose(p_bin['darkCalScat1'], p_mat['darkCalScat1'], rtol=1e-5) + np.testing.assert_allclose(p_bin['darkCalScat2'], p_mat['darkCalScat2'], rtol=1e-5) + np.testing.assert_allclose(p_bin['darkCalScat3'], p_mat['darkCalScat3'], rtol=1e-5) + + @_skip_if_no_cal_files() + def test_temp_cal_roundtrip(self): + from scipy.io import loadmat + t_mat = loadmat(TEMP_CAL, simplify_cells=True)['cal_temp'] + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'round.hbb_tcal') + _write_hbb_tcal(t_mat, path) + t_bin = HyperBBParser._read_binary_temp_cal(path) + np.testing.assert_allclose(t_bin['wl'], t_mat['wl'], atol=0.1) + np.testing.assert_allclose(t_bin['coeff'], t_mat['coeff'], rtol=1e-5) + + @_skip_if_no_cal_files() + def test_plaque_cal_dark_array_orientation(self): + """darkCalScat arrays must have shape (num_wl, num_gain) — wl as rows.""" + from scipy.io import loadmat + p_mat = loadmat(PLAQUE_CAL, simplify_cells=True)['cal'] + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'orient.hbb_cal') + _write_hbb_cal(p_mat, path) + p_bin = HyperBBParser._read_binary_plaque_cal(path) + n_wl = len(p_bin['darkCalWavelength']) + n_gain = len(p_bin['darkCalPmtGain']) + assert p_bin['darkCalScat1'].shape == (n_wl, n_gain) + assert p_bin['darkCalScat2'].shape == (n_wl, n_gain) + assert p_bin['darkCalScat3'].shape == (n_wl, n_gain) + + @_skip_if_no_cal_files() + def test_multiple_records_returns_last(self): + """When a .hbb_cal file contains multiple records, the last is returned.""" + from scipy.io import loadmat + p_mat = loadmat(PLAQUE_CAL, simplify_cells=True)['cal'] + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'multi.hbb_cal') + # Write the same record twice — second should be returned + _write_hbb_cal(p_mat, path) + with open(path, 'rb') as f: + first_record = f.read() + # Modify pmtRefGain in the "second" record — PMTReferenceGain is at byte offset 42 + modified = bytearray(first_record) + struct.pack_into(' 0 + + @_skip_if_no_cal_files() + def test_calibrate_light_single_frame(self): + parser = HyperBBParser(PLAQUE_CAL, TEMP_CAL, 'light', cal_format='legacy') + n = len(parser.FRAME_VARIABLES) + raw = np.zeros((1, n)) + raw[0, parser.idx_wl] = 450.0 + raw[0, parser.idx_PmtGain] = 2000.0 + raw[0, parser.idx_NetRef] = 1000.0 + raw[0, parser.idx_NetSig1] = 100.0 + raw[0, parser.idx_NetSig2] = 90.0 + raw[0, parser.idx_NetSig3] = 80.0 + raw[0, parser.idx_LedTemp] = 36.6 + raw[0, parser.idx_ChSaturated] = 0 + _, bb, _, _, _ = parser.calibrate(raw.copy()) + assert not np.isnan(bb[0]) + assert bb[0] > 0 + + @_skip_if_no_cal_files() + def test_calibrate_current_binary_matches_legacy(self): + """Binary current format should produce the same calibration output as legacy .mat.""" + from scipy.io import loadmat + p_mat = loadmat(PLAQUE_CAL, simplify_cells=True)['cal'] + t_mat = loadmat(TEMP_CAL, simplify_cells=True)['cal_temp'] + with tempfile.TemporaryDirectory() as tmp: + hbb_cal = os.path.join(tmp, 'test.hbb_cal') + hbb_tcal = os.path.join(tmp, 'test.hbb_tcal') + _write_hbb_cal(p_mat, hbb_cal) + _write_hbb_tcal(t_mat, hbb_tcal) + parser_legacy = HyperBBParser(PLAQUE_CAL, TEMP_CAL, 'advanced', cal_format='legacy') + parser_current = HyperBBParser(hbb_cal, hbb_tcal, 'advanced', cal_format='current') + n = len(parser_legacy.FRAME_VARIABLES) + raw = np.zeros((1, n)) + raw[0, parser_legacy.idx_wl] = 450.0 + raw[0, parser_legacy.idx_PmtGain] = 2000.0 + raw[0, parser_legacy.idx_NetSig1] = 100.0 + raw[0, parser_legacy.idx_SigOn1] = 500.0 + raw[0, parser_legacy.idx_SigOn2] = 600.0 + raw[0, parser_legacy.idx_SigOn3] = 700.0 + raw[0, parser_legacy.idx_SigOff1] = 400.0 + raw[0, parser_legacy.idx_SigOff2] = 500.0 + raw[0, parser_legacy.idx_SigOff3] = 600.0 + raw[0, parser_legacy.idx_RefOn] = 1000.0 + raw[0, parser_legacy.idx_RefOff] = 900.0 + raw[0, parser_legacy.idx_LedTemp] = 36.6 + _, bb_legacy, _, _, _ = parser_legacy.calibrate(raw.copy()) + _, bb_current, _, _, _ = parser_current.calibrate(raw.copy()) + np.testing.assert_array_almost_equal(bb_legacy, bb_current, decimal=4) + + +if __name__ == '__main__': + import traceback + tests = [TestHyperBBParserCreation(), TestHyperBBBinaryFileReaders(), + TestHyperBBParserParse(), TestHyperBBCalibrate()] + passed = failed = skipped = 0 + for test_obj in tests: + for method_name in [m for m in dir(test_obj) if m.startswith('test_')]: + method = getattr(test_obj, method_name) + try: + method() + print(f'PASS: {type(test_obj).__name__}.{method_name}') + passed += 1 + except Exception as e: + if 'Skipped' in type(e).__name__ or 'skip' in type(e).__name__.lower(): + print(f'SKIP: {type(test_obj).__name__}.{method_name}: {e}') + skipped += 1 + else: + print(f'FAIL: {type(test_obj).__name__}.{method_name}: {e}') + traceback.print_exc() + failed += 1 + print(f'\nTotal: {passed} passed, {failed} failed, {skipped} skipped')