diff --git a/daq-server/.gitignore b/daq-server/.gitignore index 9cdcc4a3..a10de6cc 100644 --- a/daq-server/.gitignore +++ b/daq-server/.gitignore @@ -2,6 +2,7 @@ # Build artifacts build/ +build-ns/ *.o *.a *.so @@ -230,6 +231,10 @@ scripts/calibration/calibrations/adjustments.json # Clear. cubic_calibration.default.json (NOT ignored) is the tracked seed the service copies here # on first run when this file is missing; promote a good cal into it via promote_default.sh. scripts/calibration/calibrations/cubic_calibration.json +# Live load-cell tare state, written by calibration_service and cleared by the backend at session +# start. Operator display state for one session on one stand — never a thing to commit, and there +# is no tracked seed for it (absent == every load cell reads absolute, which is the right default). +scripts/calibration/calibrations/lc_tare.json # Raw-ADC abort thresholds the calibration service regenerates from the live cal on every # capture/clear/reload (uid target_psi raw_adc), read by config_broadcast. Runtime-derived, per-rig. diff --git a/daq-server/config/profiles/digital-twin/config.toml b/daq-server/config/profiles/digital-twin/config.toml index 6c475154..c2181f06 100644 --- a/daq-server/config/profiles/digital-twin/config.toml +++ b/daq-server/config/profiles/digital-twin/config.toml @@ -85,6 +85,12 @@ role = "Low Press" limits = "" color = "#ff0088" +[[gui.pressure_bars]] +label = "LOX Upstream" +role = "LOX Upstream" +limits = "" +color = "#888888" + [gui.groups] LOX = [ "LOX Upper", "LOX Mid", "LOX Lower", "COPV RTD" ] Fuel = [ "Fuel Upstream" ] @@ -297,12 +303,14 @@ active_connectors = [ 1, 2 ] "Fuel Upstream" = 2 "Dome CTRL" = 3 "Low Press" = 4 +"LOX Upstream" = 5 [calibration_model_pt_board] "Fuel Fill Vent" = "physics" "Fuel Upstream" = "cubic" "Dome CTRL" = "cubic" "Low Press" = "physics" +"LOX Upstream" = "cubic" [calibration_full_scale_pt_board] "Fuel Fill Vent" = 1000 @@ -673,11 +681,11 @@ is_flow = true [actuator_roles] "Fuel Upstream" = [ "NC", 1, 12 ] "High Press CTRL" = [ "NC", 2, 12 ] -"Dome CTRL" = [ "NO", 3, 12 ] +"Dome CTRL" = [ "NC", 3, 12 ] "Fuel Main" = [ "NC", 6, 12 ] -"fuel vent" = [ "NC", 7, 12 ] -"lox vent" = [ "NC", 9, 12 ] -"Ox Upstream" = [ "NO", 10, 12 ] +"fuel vent" = [ "NO", 7, 12 ] +"lox vent" = [ "NO", 9, 12 ] +"Ox Upstream" = [ "NC", 10, 12 ] "Fuel Fill Press" = [ "NC", 2, 14 ] "High Press Vent" = [ "NC", 3, 14 ] "Fuel Fill Vent" = [ "NC", 5, 14 ] @@ -690,3 +698,15 @@ is_flow = true "Excitation V" = "physics" "Dome Mid" = "cubic" "High Press" = "cubic" + +[sensor_roles_lc_board] +"Fuel Scale" = 1 + +[sensor_roles_lc_board_2] +"LOX Scale" = 1 + +[calibration_model_lc_board] +"Fuel Scale" = "cubic" + +[calibration_model_lc_board_2] +"LOX Scale" = "cubic" diff --git a/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv b/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv index 1f1bd43b..4f68adbb 100644 --- a/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv +++ b/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv @@ -1,12 +1,14 @@ ,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test Fuel Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0 High Press CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -Dome CTRL Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -Fuel Main,0,0,0,0,0,0,0,0,0,0,0,0,1,1 Dome CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +Fuel Main,0,0,0,0,0,0,0,0,0,0,0,0,1,1 +fuel vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +lox vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Ox Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Fuel Fill Press,0,0,0,0,0,0,0,0,0,0,0,0,0,0 High Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Fuel Fill Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Low Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Ox Main,0,0,0,0,0,0,0,0,0,0,0,0,0,1 +Dome CTRL Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 diff --git a/daq-server/config/profiles/digital-twin/state_machine_actuators.csv b/daq-server/config/profiles/digital-twin/state_machine_actuators.csv index 014178e7..e6097330 100644 --- a/daq-server/config/profiles/digital-twin/state_machine_actuators.csv +++ b/daq-server/config/profiles/digital-twin/state_machine_actuators.csv @@ -1,12 +1,14 @@ ,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test Fuel Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN High Press CTRL,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE -Dome CTRL Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE -Fuel Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN Dome CTRL,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE -Ox Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN +Fuel Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN +fuel vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE +lox vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE +Ox Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN Fuel Fill Press,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE High Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE Fuel Fill Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE Low Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE Ox Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN +Dome CTRL Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE diff --git a/daq-server/config/profiles/digital-twin/state_transitions.csv b/daq-server/config/profiles/digital-twin/state_transitions.csv index 11605cd9..82f602e0 100644 --- a/daq-server/config/profiles/digital-twin/state_transitions.csv +++ b/daq-server/config/profiles/digital-twin/state_transitions.csv @@ -7,9 +7,9 @@ COPV Fill,0,0,1,0,1,0,0,0,0,0,0,0,0,0 Fuel Press,0,0,1,0,0,1,0,0,0,0,0,0,0,0 LOX Press,0,0,1,0,0,0,1,0,0,0,0,0,0,0 Vent,0,0,1,0,0,0,0,1,1,1,1,1,0,0 -Dome Vent,0,0,0,0,0,0,0,1,1,0,0,0,0,0 -High Press Vent,0,0,0,0,0,0,0,1,0,1,0,0,0,0 -Fuel Tank Vent,0,0,0,0,0,0,0,1,0,0,1,0,0,0 -Ready,0,0,0,0,0,0,0,0,0,0,0,1,1,1 +Dome Vent,0,0,1,0,0,0,0,1,1,0,0,0,0,0 +High Press Vent,0,0,1,0,0,0,0,1,0,1,0,0,0,0 +Fuel Tank Vent,0,0,1,0,0,0,0,1,0,0,1,0,0,0 +Ready,0,0,1,0,0,0,0,0,0,0,0,1,1,1 Fire,0,1,0,0,0,0,0,0,0,0,0,0,1,0 Flow Test,0,0,1,0,0,0,0,0,0,0,0,1,0,1 diff --git a/daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts b/daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts new file mode 100644 index 00000000..73f4acad --- /dev/null +++ b/daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts @@ -0,0 +1,285 @@ +/** + * Load-cell tare: the backend half. + * + * Every case here names a specific way the tare could put a wrong number in front of an + * operator, or make one disappear. The two that matter most: + * - a torn read must not clear the map (every LC plot would jump by the tare amount for one + * poll interval and then jump back), and + * - the derived value must reach history and the client outbox identically, or a reconnect + * shows gross where the live stream showed tared. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// The module resolves its file path through routes/calibration-profiles.js, which walks the repo +// for scripts/calibration/calibrations. Point it at a scratch dir instead. +let TMP: string; +let TARE_FILE: string; + +vi.mock('../routes/calibration-profiles.js', () => ({ + tarePath: () => TARE_FILE, + livePath: () => path.join(path.dirname(TARE_FILE), 'cubic_calibration.json'), + profilesDir: () => path.join(path.dirname(TARE_FILE), 'profiles'), +})); + +const { expandWithTare, loadTareMap, tareOffsetKg, clearTareFile, resetTareState, setRunDir, currentTares } = + await import('../lc-tare.js'); + +function writeTares(entries: Array<{ entity: string; uid?: number; adc?: number; kg: number }>): void { + const tares = entries.map((e) => ({ + uid: e.uid ?? 4201, + entity: e.entity, + adc_at_tare: e.adc ?? 1000, + offset_kg: e.kg, + set_at_ms: 1757800000000, + curve_fp: 7, + })); + fs.writeFileSync(TARE_FILE, JSON.stringify({ version: 1, tares })); +} + +/** One calibrated LC packet as elodin-protocol parses it: the value plus its two raw echoes. */ +function lcPacket(entity: string, grossKg: number, rawAdc = 12345) { + return [ + { entity, component: 'force_kg', value: grossKg, timestamp: 1000 }, + { entity, component: 'raw_adc_counts', value: rawAdc, timestamp: 1000 }, + { entity, component: 'raw_adc', value: rawAdc, timestamp: 1000 }, + ]; +} + +beforeEach(() => { + TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-tare-test-')); + TARE_FILE = path.join(TMP, 'lc_tare.json'); + resetTareState(); + setRunDir(null); +}); + +afterEach(() => { + resetTareState(); + setRunDir(null); + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +describe('expandWithTare', () => { + it('adds force_kg_tared without touching force_kg or the raw echoes', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + const out = expandWithTare(lcPacket('LC2_Cal.CH1', 50)); + + // force_kg stays gross: the calibration page reads it, and the operator types the true + // weight of a known mass against that reading. + expect(out.find((p) => p.component === 'force_kg')!.value).toBe(50); + expect(out.find((p) => p.component === 'force_kg_tared')!.value).toBe(30); + // Raw echoes are the calibration capture path's input. Taring them would poison the fit. + expect(out.find((p) => p.component === 'raw_adc_counts')!.value).toBe(12345); + expect(out.find((p) => p.component === 'raw_adc')!.value).toBe(12345); + // Exactly one derived point, and it came from force_kg. Checking only the raw values above + // is not enough: deriving a SECOND tared point off a raw echo leaves those values intact and + // would sail past, while putting a 12325-kg spike in the same series as the real reading. + expect(out.filter((p) => p.component === 'force_kg_tared')).toHaveLength(1); + expect(out).toHaveLength(4); + }); + + it('emits force_kg_tared even when untared, so a 0 offset is not mistaken for a dead stream', () => { + const out = expandWithTare(lcPacket('LC2_Cal.CH1', 50)); + const tared = out.find((p) => p.component === 'force_kg_tared'); + expect(tared).toBeDefined(); + expect(tared!.value).toBe(50); + }); + + it('leaves non-load-cell packets exactly as they arrived', () => { + const pt = [{ entity: 'PT1_Cal.CH3', component: 'pressure_psi', value: 220, timestamp: 1 }]; + expect(expandWithTare(pt)).toEqual(pt); + }); + + it('treats a NaN offset as no tare rather than poisoning the series', () => { + // A NaN would survive to the caller's own Number.isFinite guard, which drops the point — + // so the whole tared series would silently vanish from the plot with nothing logged. + fs.writeFileSync( + TARE_FILE, + '{"version":1,"tares":[{"uid":4201,"entity":"LC2_Cal.CH1","adc_at_tare":1000,"offset_kg":null,"set_at_ms":1,"curve_fp":7}]}', + ); + const out = expandWithTare(lcPacket('LC2_Cal.CH1', 50)); + const tared = out.find((p) => p.component === 'force_kg_tared')!; + expect(Number.isFinite(tared.value)).toBe(true); + expect(tared.value).toBe(50); + + // And it never enters the map at all, so the status endpoint the calibration UI polls cannot + // report a tare that is not a number. Asserting only the streamed value above would pass on + // the strength of the second guard in tareOffsetKg and leave this one untested. + expect(currentTares()).toEqual([]); + }); +}); + +describe('the tare map', () => { + it('keeps the previous map when the file is torn, and empties only on ENOENT', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + + // A reader can still lose a race with the service's tmp+rename. Clearing here would make + // every LC plot jump by 20 kg for one poll interval and then jump back. + fs.writeFileSync(TARE_FILE, '{"version":1,"tares":[{"entity":"LC2_Cal.C'); + fs.utimesSync(TARE_FILE, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + + // A removed file is different: that is the session-start clear, and it must take effect. + fs.unlinkSync(TARE_FILE); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(0); + }); + + it('picks up a changed offset when the file mtime moves', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + writeTares([{ entity: 'LC2_Cal.CH1', kg: 25 }]); + fs.utimesSync(TARE_FILE, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(25); + }); + + it('clearTareFile removes the file and forgets the held map', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + clearTareFile(); + expect(fs.existsSync(TARE_FILE)).toBe(false); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(0); + expect(currentTares()).toEqual([]); + }); + + it('clearTareFile on an already-absent file is not an error', () => { + expect(() => clearTareFile()).not.toThrow(); + }); +}); + +describe('live stream and reconnect backfill agree', () => { + // The bug guarded here: applying the tare in the outbox drain instead of before + // history.record() leaves the live plot tared and the backfill after a reconnect gross, so a + // dropped WebSocket silently changes every load-cell number on screen. + // + // server.ts calls httpServer.listen() at import, so emitSensorWindow cannot be driven + // directly from a unit test. Both halves of the invariant are pinned instead: + + it('feeds one already-tared array to both sinks, which then hold identical values', async () => { + const { HistoryCache } = await import('../history-cache.js'); + const { ClientOutbox } = await import('../client-outbox.js'); + + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + const history = new HistoryCache({ maxPoints: 256, maxKeys: 32, staleMs: 60_000 }); + const outbox = new ClientOutbox(); + + // Exactly what emitSensorWindow does: record to history, then stage to the client, from the + // same points. Anything derived before this call reaches both; anything derived after + // reaches only one. + for (const grossKg of [50, 51, 52]) { + const tared = expandWithTare(lcPacket('LC2_Cal.CH1', grossKg)) + .find((p) => p.component === 'force_kg_tared')!; + const key = `${tared.entity}.${tared.component}`; + const points = [{ tMs: 1000 + grossKg, value: tared.value }]; + history.record(key, points[0].tMs, points[0].value); + outbox.push(key, tared.entity, tared.component, points); + } + + const staged = outbox.drain().flatMap((s) => s.points.map((p) => p.value)); + const backfilled = Array.from( + history.buildPayload({ keys: ['LC2_Cal.CH1.force_kg_tared'] }, 1000)[ + 'LC2_Cal.CH1.force_kg_tared' + ].values, + ); + + expect(staged).toEqual([30, 31, 32]); + expect(backfilled).toEqual(staged); + }); + + it('server.ts derives the tare at the parse site, not in the drain loop', () => { + // A wiring assertion, deliberately narrow: it checks the one line that decides which side of + // history.record() the subtraction lands on. Moving the tare into the drain means deleting + // this wrapper, and this test is what notices. + const src = fs.readFileSync(path.join(__dirname, '..', 'server.ts'), 'utf8'); + expect(src).toContain('expandWithTare(parseElodinPacket('); + + const drain = src.slice(src.indexOf('cs.outbox.drain()'), src.indexOf('cs.pacer.noteFlush')); + expect(drain).not.toContain('force_kg_tared'); + expect(drain).not.toContain('tareOffset'); + }); +}); + +describe('the session-start clear happens in the only safe window', () => { + // The trap: unlinking beside snapshotRunConfig looks equivalent and is not. At that point the + // PREVIOUS run's calibration_service may still be alive, holding its tares in memory, and it + // rewrites the file from them on its next periodic save or clean shutdown — so the session + // starts with the last run's offsets and nothing says so. + const controllerSrc = () => + fs.readFileSync(path.join(__dirname, '..', 'service-controller.ts'), 'utf8'); + + it('clears strictly between waitUntilSettled and the pipeline start', () => { + const src = controllerSrc(); + const settled = src.indexOf('await waitUntilSettled(pipelineUnits(true))'); + const cleared = src.indexOf('clearTareFile()'); + const started = src.indexOf("await runSystemctl('start', pipelineUnits(simulated))"); + + expect(settled).toBeGreaterThan(-1); + expect(cleared).toBeGreaterThan(settled); + expect(cleared).toBeLessThan(started); + // And specifically NOT up beside the config snapshot, where a live service could still win. + expect(cleared).toBeGreaterThan(src.indexOf('snapshotRunConfig(dbDir, simulated)')); + }); + + it('clears in the mock branch too, which is the mode the tests run in', () => { + const src = controllerSrc(); + const mock = src.slice(src.indexOf('(mock) start pipeline')); + expect(mock).toContain('clearTareFile()'); + }); + + it('forgets the held map when the run stops', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'session-manager.ts'), 'utf8'); + const stop = src.slice(src.indexOf('this.onStopped();') - 600, src.indexOf('this.onStopped();')); + expect(stop).toContain('resetTareState()'); + expect(stop).toContain('setRunDir(null)'); + }); +}); + +describe('the run record', () => { + it('appends one line per change and never rewrites', () => { + const runDir = path.join(TMP, 'daq_20260914_120000'); + setRunDir(runDir); + const jsonl = path.join(runDir, 'lc_tare.jsonl'); + + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + loadTareMap(); + + // A re-cal rewrites offset_kg with NO operator action, at the same ADC code. Missing this + // line makes every reconstruction wrong from the re-cal onward. + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20.44 }]); + fs.utimesSync(TARE_FILE, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + loadTareMap(); + + const lines = fs.readFileSync(jsonl, 'utf8').trim().split('\n').map((l) => JSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines[0].event).toBe('set'); + expect(lines[0].offsetKg).toBe(20); + expect(lines[1].event).toBe('recal'); + expect(lines[1].offsetKg).toBe(20.44); + expect(lines[1].appliedAtMs).toBeGreaterThanOrEqual(lines[0].appliedAtMs); + }); + + it('records a clear when the tare goes away', () => { + const runDir = path.join(TMP, 'daq_20260914_120001'); + setRunDir(runDir); + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + loadTareMap(); + fs.unlinkSync(TARE_FILE); + loadTareMap(); + + const lines = fs + .readFileSync(path.join(runDir, 'lc_tare.jsonl'), 'utf8') + .trim().split('\n').map((l) => JSON.parse(l)); + expect(lines.map((l) => l.event)).toEqual(['set', 'clear']); + }); + + it('applies the tare but writes nothing when there is no active session', () => { + setRunDir(null); + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(() => loadTareMap()).not.toThrow(); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + expect(fs.readdirSync(TMP).filter((f) => f.endsWith('.jsonl'))).toEqual([]); + }); +}); diff --git a/daq-server/diablo_server/backend/src/api-server.ts b/daq-server/diablo_server/backend/src/api-server.ts index 703d926b..285895e3 100644 --- a/daq-server/diablo_server/backend/src/api-server.ts +++ b/daq-server/diablo_server/backend/src/api-server.ts @@ -34,6 +34,7 @@ import { otaBuildFlash, otaFlashFirmwareFile } from './ota-service-cmd.js'; import { ElodinQueryClient, QueryOptions } from './elodin-query.js'; import { getBoardLogHistory, getBoardLogStats } from './board-logs.js'; import type { SensorUpdate } from './shared-types.js'; +import { currentTares } from './lc-tare.js'; // ── Sensor config helpers ────────────────────────────────────────────────── @@ -813,6 +814,19 @@ export function createAPIHandler(opts: APIHandlerOptions = {}): (req: IncomingMe try { JSON.parse(body); res.end(body); } catch { res.end(JSON.stringify({ cubic_state: {} })); } // partial/corrupt → empty } + } else if (url.pathname === '/api/lc_tare' && req.method === 'GET') { + // The live load-cell tares, for the UI to badge tared channels and show their offsets. + // + // Read from the same file the stream subtraction uses, so the badge and the number on + // the plot can never disagree. This is the authoritative source for tare UI state — do + // NOT let the page assume a tare landed because it sent the command; [0x46,0x00] carries + // no reply, so only the file says whether the service accepted it. + res.writeHead(200, { 'Content-Type': 'application/json' }); + try { + res.end(JSON.stringify({ tares: currentTares() })); + } catch { + res.end(JSON.stringify({ tares: [] })); + } } else if (url.pathname === '/api/feed-char/results') { /** * Feed-characterization results, stored WITH the run rather than in the browser. diff --git a/daq-server/diablo_server/backend/src/calibration-handler.ts b/daq-server/diablo_server/backend/src/calibration-handler.ts index d2161778..4a3d05d9 100644 --- a/daq-server/diablo_server/backend/src/calibration-handler.ts +++ b/daq-server/diablo_server/backend/src/calibration-handler.ts @@ -70,6 +70,19 @@ export function publishCalibrationReload(host: CalibrationHost): void { publishCalibrationCommand(host, 7, 0, 0); } +/** + * Tell a RUNNING calibration service to drop every load-cell tare (cmd 8, clear, all channels). + * + * The primary session-start clear is the backend unlinking lc_tare.json while the service is + * down — synchronous and verifiable. This is the mock-mode companion, where the pipeline is + * already up and a live service would otherwise rewrite the file from memory. Like every + * [0x46,0x00] publish it is fire-and-forget: if the service is down the packet is dropped, which + * is harmless here because the unlink already did the work. + */ +export function publishClearAllTares(host: CalibrationHost): void { + publishCalibrationCommand(host, 8, 0, 1); +} + function getActiveChannels(host: CalibrationHost): number[] { const channels = new Set(); @@ -210,6 +223,44 @@ export function handleCalibrationCommand( console.log(`🗑️ Cubic clear: CH${sensorId} (Board ${boardId}) → calibration_service`); break; } + case 'tare_lc': + case 'clear_tare_lc': { + // Display-only. Never enters a fit, never reaches control or abort, never changes what + // Elodin records — the archive keeps carrying absolute force_kg. That is the whole + // reason this is a separate command from 'zero_all', which captures a REAL 0 kg point + // into the shared fit: correct for a vented PT, and wrong for a load cell holding a + // tank, where the point would be false and would tilt the entire cubic. + const clearing = commandType === 'clear_tare_lc'; + // sensorId 0 (or omitted) means every load cell. + const all = sensorId == null || sensorId === 0; + if (!all && uniqueId == null) { + host.send(ws, { + type: MessageType.ERROR, timestamp: Date.now(), + payload: { message: `${commandType} requires sensorId and boardId, or neither for all` } + }); + return; + } + if (!all) { + const activeChannels = getActiveChannels(host); + if (uniqueId == null || !activeChannels.includes(uniqueId)) { + host.send(ws, { + type: MessageType.ERROR, timestamp: Date.now(), + payload: { message: `Unknown channel for ${commandType}: CH${sensorId} on board ${boardId}` } + }); + return; + } + } + if (!host.elodin) { + host.send(ws, { + type: MessageType.ERROR, timestamp: Date.now(), + payload: { message: `Elodin not connected — cannot forward ${commandType}.` } + }); + return; + } + publishCalibrationCommand(host, 8, all ? 0 : uniqueId!, clearing ? 1 : 0); + console.log(`⚖️ LC ${clearing ? 'tare clear' : 'tare'}: ${all ? 'all load cells' : `CH${sensorId} (Board ${boardId})`} → calibration_service`); + break; + } case 'capture_point': { // Unified capture: forward (channel, ref PSI); calibration_service pairs it with the // current ADC and routes it to the channel's configured model (cubic fit OR robust RLS). diff --git a/daq-server/diablo_server/backend/src/lc-tare.ts b/daq-server/diablo_server/backend/src/lc-tare.ts new file mode 100644 index 00000000..2aa65498 --- /dev/null +++ b/daq-server/diablo_server/backend/src/lc-tare.ts @@ -0,0 +1,237 @@ +/** + * Load-cell tare: the display-only subtraction, and the per-run record of it. + * + * WHY THE SUBTRACTION LIVES HERE AND NOT IN C++ + * --------------------------------------------- + * The calibration service pushes raw ADC into its CaptureWindow *before* it computes any + * kilograms, which is what makes a calibration capture structurally immune to a standing tare — + * an operator can tare a loaded tank and still calibrate in absolute weight afterwards. Moving + * the subtraction into C++ would put a tared number in front of that boundary and destroy the + * property. So Elodin keeps carrying gross `force_kg` forever, every archive stays comparable, + * and the tare is applied on the way to the browser and nowhere else. + * + * WHY A PARALLEL COMPONENT AND NOT AN IN-PLACE EDIT + * ------------------------------------------------- + * The calibration page must be able to read absolute kilograms — the operator types the true + * weight of a known mass, and a tared readout beside that input is how a false point gets into + * the fit. Mutating `force_kg` would leave that page no way to get gross. So this emits + * `force_kg_tared` alongside the untouched original and the frontend picks which to render. + * + * This is the backend's first derived stream component; everything else it publishes is verbatim + * from the packet. That is a deliberate exception, for the reason above. + */ + +import fs from 'fs'; +import path from 'path'; +import type { ParsedSensorData } from './elodin-protocol.js'; +import { tarePath } from './routes/calibration-profiles.js'; + +/** One channel's tare as the calibration service wrote it. */ +interface TareRecord { + uid: number; + entity: string; + adc_at_tare: number; + offset_kg: number; + set_at_ms: number; + curve_fp: number; +} + +export interface TareEntry { + uid: number; + offsetKg: number; + adcAtTare: number; + setAtMs: number; +} + +/** entity (e.g. "LC2_Cal.CH1") -> tare. Keyed on the string C++ writes; never re-derived here. */ +type TareMap = Map; + +const COMPONENT_GROSS = 'force_kg'; +export const COMPONENT_TARED = 'force_kg_tared'; + +let _cache: { mtimeMs: number; map: TareMap } | null = null; +/** The map currently applied to the stream. Survives a torn read; see loadTareMap(). */ +let _held: TareMap = new Map(); +let _dbDir: string | null = null; + +/** Where the per-run record goes. Null between sessions — see appendRunRecord(). */ +export function setRunDir(dbDir: string | null): void { + _dbDir = dbDir; +} + +/** + * Forget everything. Called at session stop and on an Elodin reconnect: without it a stale + * offset from the previous run is applied to the next run's first poll interval, which is + * exactly the window in which nobody is looking closely yet. + */ +export function resetTareState(): void { + _cache = null; + _held = new Map(); +} + +function parseTareFile(text: string): TareMap { + const map: TareMap = new Map(); + const root = JSON.parse(text) as { tares?: TareRecord[] }; + if (!root || !Array.isArray(root.tares)) throw new Error('no tares array'); + for (const t of root.tares) { + if (!t || typeof t.entity !== 'string' || !t.entity) continue; + // A non-finite offset is refused rather than clamped to 0: it means the service wrote + // something it should not have, and silently treating it as "no tare" would hide that. + if (!Number.isFinite(t.offset_kg)) continue; + map.set(t.entity, { + uid: Number(t.uid) || 0, + offsetKg: t.offset_kg, + adcAtTare: Number.isFinite(t.adc_at_tare) ? t.adc_at_tare : 0, + setAtMs: Number.isFinite(t.set_at_ms) ? t.set_at_ms : 0, + }); + } + return map; +} + +/** + * The live tare map, reloaded when the file's mtime moves. + * + * Three outcomes, deliberately distinguished by error code rather than a bare catch: + * - file absent (ENOENT) -> empty map. This is the normal state after a session-start clear. + * - file unreadable/torn -> KEEP the previous map. The service writes tmp+rename, but a + * reader can still lose a race; clearing on a transient parse + * failure would make every LC plot jump by the tare amount for one + * poll interval and then jump back. + * - file parsed -> adopt it, and append any change to the run record. + */ +export function loadTareMap(): TareMap { + let stat: fs.Stats; + try { + stat = fs.statSync(tarePath()); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + if (_held.size > 0) { + recordChanges(_held, new Map()); + _held = new Map(); + } + _cache = null; + return _held; + } + return _held; // permissions, I/O — hold what we have + } + + if (_cache && _cache.mtimeMs === stat.mtimeMs) return _cache.map; + + let next: TareMap; + try { + next = parseTareFile(fs.readFileSync(tarePath(), 'utf8')); + } catch { + return _held; // torn or malformed — keep the last good map, do NOT clear + } + + recordChanges(_held, next); + _held = next; + _cache = { mtimeMs: stat.mtimeMs, map: next }; + return next; +} + +/** The offset to subtract for a cal entity. 0 when untared, absent, or non-finite. */ +export function tareOffsetKg(entity: string): number { + const t = loadTareMap().get(entity); + if (!t) return 0; + return Number.isFinite(t.offsetKg) ? t.offsetKg : 0; +} + +/** + * Add `force_kg_tared` for every calibrated load-cell point in `parsed`. + * + * Emits unconditionally, with a 0 offset when untared: a component that appears only while a + * tare is set renders identically to a dead stream, and "is that channel untared or is the board + * gone?" is not a question to ask an operator mid-procedure. + * + * Never touches `force_kg`, and never touches the `raw_adc`/`raw_adc_counts` echoes that ride in + * the same array — those are the raw truth and feed calibration capture. + */ +export function expandWithTare(parsed: ParsedSensorData[]): ParsedSensorData[] { + let out: ParsedSensorData[] | null = null; + for (const p of parsed) { + if (p.component !== COMPONENT_GROSS) continue; + const offset = tareOffsetKg(p.entity); + const value = p.value - (Number.isFinite(offset) ? offset : 0); + // Guard again after the arithmetic: this value is produced downstream of the protocol + // layer's finite check, so a NaN here would be dropped by the caller's own guard and the + // entire tared series would vanish from the plot with nothing logged anywhere. + if (!Number.isFinite(value)) continue; + if (!out) out = [...parsed]; + out.push({ entity: p.entity, component: COMPONENT_TARED, value, timestamp: p.timestamp }); + } + return out ?? parsed; +} + +/** + * Remove the live tare file. Session start only, and only in the window where the calibration + * service is confirmed down — a running service holds its tares in memory and would rewrite the + * file from them on its next periodic save or clean shutdown, so an unlink at the wrong moment + * silently fails to clear. + * + * This is the one place Node writes to a file C++ owns in steady state. It is safe precisely + * because nothing is running to race with. + */ +export function clearTareFile(): void { + try { + fs.unlinkSync(tarePath()); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[LcTare] could not remove the live tare file:', e); + } + } + resetTareState(); +} + +/** + * Append what changed to /lc_tare.jsonl. + * + * The record is written by whoever applies the subtraction, at the instant the applied value + * changes — not when the operator clicks, and not when C++ captures. Two reasons. The browser + * can close between the command and the response while the tare still applies, which would + * leave a run whose archive cannot be reconstructed for a tare that demonstrably happened. And + * a re-cal rewrites offset_kg with no operator action at all, so there is no click to hang a + * write off; this path covers it for free. + */ +function recordChanges(prev: TareMap, next: TareMap): void { + if (!_dbDir) return; // a tare with no active session still applies; it just is not recorded + const lines: string[] = []; + const at = Date.now(); + + for (const [entity, t] of next) { + const before = prev.get(entity); + if (before && before.offsetKg === t.offsetKg && before.adcAtTare === t.adcAtTare) continue; + // Same code, different offset means the curve moved under a standing tare, not a new tare. + const event = !before ? 'set' : before.adcAtTare === t.adcAtTare ? 'recal' : 'set'; + lines.push( + JSON.stringify({ + entity, uid: t.uid, event, offsetKg: t.offsetKg, + adcAtTare: t.adcAtTare, setAtMs: t.setAtMs, appliedAtMs: at, + }), + ); + } + for (const [entity, t] of prev) { + if (next.has(entity)) continue; + lines.push( + JSON.stringify({ + entity, uid: t.uid, event: 'clear', offsetKg: 0, + adcAtTare: t.adcAtTare, setAtMs: t.setAtMs, appliedAtMs: at, + }), + ); + } + if (lines.length === 0) return; + + try { + fs.mkdirSync(_dbDir, { recursive: true }); + // Append, never rewrite: each line is a thing that happened. There is no terminal line at + // session stop, so a missing trailing 'clear' unambiguously means "in effect to end of run". + fs.appendFileSync(path.join(_dbDir, 'lc_tare.jsonl'), lines.join('\n') + '\n'); + } catch (e) { + console.warn('[LcTare] could not append the run record:', e); + } +} + +/** The live tares, for the status endpoint the calibration UI polls. */ +export function currentTares(): Array { + return [...loadTareMap()].map(([entity, t]) => ({ entity, ...t })); +} diff --git a/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts b/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts index 097d2635..5ee221b0 100644 --- a/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts +++ b/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts @@ -46,6 +46,18 @@ function getCalibrationsDir(): string { export function livePath(): string { return path.join(getCalibrationsDir(), 'cubic_calibration.json'); } +/** + * The live load-cell tare state, written by the calibration service and read here to apply the + * display subtraction. Lives beside the cubic store on purpose — same directory, same atomic + * write discipline, same "read the file to learn the answer" contract, because [0x46,0x00] is + * one-way and the service never sends a reply. + * + * NOT the per-run record: that is /lc_tare.jsonl, appended by lc-tare.ts. This file is + * overwritten on every change and removed at session start, so it cannot serve as a record. + */ +export function tarePath(): string { + return path.join(getCalibrationsDir(), 'lc_tare.json'); +} function defaultPath(): string { return path.join(getCalibrationsDir(), 'cubic_calibration.default.json'); } diff --git a/daq-server/diablo_server/backend/src/server.ts b/daq-server/diablo_server/backend/src/server.ts index 7023acf1..9559f13c 100644 --- a/daq-server/diablo_server/backend/src/server.ts +++ b/daq-server/diablo_server/backend/src/server.ts @@ -26,6 +26,7 @@ import * as http from 'http'; import WebSocket, { WebSocketServer } from 'ws'; import { ElodinClient } from './elodin-client.js'; import { parseElodinPacket } from './elodin-protocol.js'; +import { expandWithTare, resetTareState, setRunDir } from './lc-tare.js'; import { loadSensorRoleMap, hpBoardNumbers } from './sensor-config.js'; import { registerVTables, clearSubscriptionState, noteSubscriptionRejected } from './elodin-vtable-registry.js'; import { createAPIHandler } from './api-server.js'; @@ -43,7 +44,7 @@ import { ClientOutbox, FlushPacer, SOCKET_IDLE_BYTES, linkStatus } from './clien import { sendBackfill, type HistoryPayload } from './history-backfill.js'; import { HistoryCache } from './history-cache.js'; import { startGuiStaticServer } from './static-gui.js'; -import { handleCalibrationCommand, publishCalibrationReload, type CalibrationHost } from './calibration-handler.js'; +import { publishClearAllTares, handleCalibrationCommand, publishCalibrationReload, type CalibrationHost } from './calibration-handler.js'; import { loadPTCalibration, type CalibrationCoefficients } from './calibration.js'; import { MessageType, SystemState } from '../../shared/types.js'; import { isOperator } from './operators.js'; @@ -1530,7 +1531,13 @@ elodin.on('packet', (header: any, payload: Buffer) => { } // ── Parse sensor/actuator/state packets ────────────────────────────────── - const parsedList = parseElodinPacket(header.packetId, payload, _hpBoardNumbers); + // expandWithTare appends a derived `force_kg_tared` for each calibrated load-cell point and + // leaves `force_kg` alone. It belongs HERE rather than inside parseElodinPacket (a pure, + // stateless decoder that elodin-query.ts also uses to replay the archive — taring there + // would apply today's offset to yesterday's samples) and rather than in the outbox drain + // (which would leave the live stream tared and the reconnect backfill gross, because + // emitSensorWindow records to history before it stages to any client). + const parsedList = expandWithTare(parseElodinPacket(header.packetId, payload, _hpBoardNumbers)); if (parsedList.length === 0) { if (high >= 0x40) { @@ -1709,7 +1716,12 @@ httpServer.listen(WS_PORT, () => { sessionManager.init(broadcast, broadcastNotification, () => { loadBoardsFromConfig(); broadcastBoardStatus(); - }, applyDeployedConfigChange); + }, applyDeployedConfigChange, () => { + // The run pipeline is up. Every session starts with every load cell reading absolute: the + // controller already removed lc_tare.json, and this drops the copy a still-running service + // holds in memory (mock mode, where nothing went down to reload it). + publishClearAllTares(calibrationHost); +}); // Board diagnostic logs (type-15 LOGS forwarded by daq_bridge over loopback UDP). startBoardLogReceiver(broadcast); }); diff --git a/daq-server/diablo_server/backend/src/service-controller.ts b/daq-server/diablo_server/backend/src/service-controller.ts index 803b320f..b8e1ba8e 100644 --- a/daq-server/diablo_server/backend/src/service-controller.ts +++ b/daq-server/diablo_server/backend/src/service-controller.ts @@ -14,6 +14,7 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync, copyFileSync } from import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; +import { clearTareFile, setRunDir } from './lc-tare.js'; // daq-server repo root (…/daq-server), from …/daq-server/diablo_server/backend/{src,dist}. const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -168,6 +169,15 @@ export class ServiceController { // Never start onto a not-yet-torn-down previous run — a lingering daq_bridge // still owns :5006 and the new one would crash-loop. Wait for a clean slate. await waitUntilSettled(pipelineUnits(true)); + // Every session begins with every load cell reading absolute. + // + // This must sit between waitUntilSettled and the start below, and nowhere else. Earlier — + // beside snapshotRunConfig, say — the PREVIOUS run's calibration_service may still be + // alive; it holds its tares in memory and rewrites the file from them on its next periodic + // save or clean shutdown, so the unlink silently fails to clear. Only here is every + // process confirmed gone and the next one not yet started. + clearTareFile(); + setRunDir(dbDir); await runSystemctl('start', pipelineUnits(simulated)); // A run isn't real unless the DB actually came up. If elodin-db is missing/broken, // sensor-elodin hard-fails (AssertPathExists) or crash-loops — without this check the @@ -181,6 +191,15 @@ export class ServiceController { } } else { console.log(`[Session] (mock) start pipeline → ${dbDir} (simulated=${simulated})`); + // Mock mode is a second lifecycle: the pipeline is already up, so there is no + // service-is-down window and the unlink above would race a live writer. Clear the file AND + // tell the running service to drop its in-memory tares. The fire-and-forget weakness of + // that command is acceptable here precisely because the service IS up to receive it. + // + // Doing this only in the systemd branch would leave every dev and test session inheriting + // the previous session's tares — and mock is the mode the tests run in. + clearTareFile(); + setRunDir(dbDir); // In mock mode the pipeline is already running; only the simulator is ours // to start/stop for the run. if (simulated) this.spawnSimulator(); diff --git a/daq-server/diablo_server/backend/src/session-manager.ts b/daq-server/diablo_server/backend/src/session-manager.ts index 335fdf57..a5316f5b 100644 --- a/daq-server/diablo_server/backend/src/session-manager.ts +++ b/daq-server/diablo_server/backend/src/session-manager.ts @@ -16,6 +16,7 @@ import { ServiceController, getSessionServiceMode } from './service-controller.j import { loadSession, saveSession } from './session-state.js'; import { deployActiveProfile } from './routes/config-profiles.js'; import { validateActiveProfile, ConfigIssuesError } from './config-validation.js'; +import { resetTareState, setRunDir } from './lc-tare.js'; // Warn the operator at each of these leads before auto-stop. Default: 5 min and // 1 min. Override with SESSION_WARN_LEADS_MS (comma-separated ms) to exercise the @@ -91,6 +92,11 @@ class SessionManager { * its config-derived caches and tell open browsers to refetch. Without it a profile edited * during a run reached config.toml here and no client ever heard about it. */ private onConfigDeployed: () => void = () => {}; + /** Fired once the run pipeline is up, so the backend can tell a RUNNING calibration service to + * drop its in-memory load-cell tares. The file itself is already gone by then (the controller + * unlinks it while the service is down), but in mock mode the service never went down and + * would rewrite the file from memory on its next save. */ + private onSessionStarted: () => void = () => {}; private active = false; private dbDir: string | null = null; @@ -106,11 +112,13 @@ class SessionManager { notify: Notify, onStopped: () => void = () => {}, onConfigDeployed: () => void = () => {}, + onSessionStarted: () => void = () => {}, ): void { this.broadcast = broadcast; this.notify = notify; this.onStopped = onStopped; this.onConfigDeployed = onConfigDeployed; + this.onSessionStarted = onSessionStarted; if (!this.enabled) return; // Recover a session that outlived a backend restart. const persisted = loadSession(); @@ -294,6 +302,7 @@ class SessionManager { } } await this.controller.start(this.dbDir, this.simulated); + this.onSessionStarted(); this.active = true; this.scheduleTimers(); this.persist(); @@ -336,6 +345,10 @@ class SessionManager { this.simulated = false; this.persist(); this.emit(); + // Forget the tare: there is no run to record against any more, and a held offset would + // otherwise be applied to the next run's first poll interval before the file check notices. + setRunDir(null); + resetTareState(); this.onStopped(); // revert board status to the disconnected baseline } diff --git a/daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts b/daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts new file mode 100644 index 00000000..e8f7d1f9 --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts @@ -0,0 +1,103 @@ +/** + * A dead board must not borrow a live sibling's stream. + * + * Two LC boards routinely declare the SAME connector number (41 and 42 both on CH1 on the + * stand). That makes the generic key LC_Cal.CH1. ambiguous: it maps onto BOTH boards. + * Panes are board-scoped to avoid it — but data-cache's findSeries() walks a REVERSE alias, + * and the walk can leave the board it was asked about: + * + * ask LC2_Cal.CH1.force_kg_tared (LOX Scale, board dead — no data) + * → reverse index → canonical LC_Cal.CH1.force_kg_tared + * → that canonical's fallbacks are [LC1_Cal.CH1…, LC2_Cal.CH1…] + * → first one with data wins → LC1 (Fuel Scale, alive) + * + * The plot then draws Fuel Scale's live weight under LOX Scale's label and colour, while the + * readout beside it — which resolves through the store, with no reverse path — correctly shows + * nothing. Seen on the stand 2026-09-16 with board 42 dead. + */ +import { describe, it, expect, vi } from 'vitest'; + +const listeners = new Map void>(); + +vi.mock('@/lib/websocket', () => ({ + getWebSocketClient: () => ({ + on: (type: string, cb: (payload: unknown) => void) => { + listeners.set(type, cb); + return () => listeners.delete(type); + }, + setHistoricalQueryProvider: () => {}, + onConnectionStatus: () => () => {}, + isConnected: () => true, + connect: () => {}, + send: () => {}, + }), + getApiBaseUrl: () => 'http://localhost:8081', +})); + +const T0 = 1_700_000_000_000; + +/** The shipped digital-twin shape: two LC boards, both on connector 1. */ +const TWO_LC_BOARDS = { + boards: { + lc_board: { type: 'LC', board_id: 41, enabled: true, active_connectors: [1] }, + lc_board_2: { type: 'LC', board_id: 42, enabled: true, active_connectors: [1] }, + }, + sensor_roles_lc_board: { 'Fuel Scale': 1 }, + sensor_roles_lc_board_2: { 'LOX Scale': 1 }, +}; + +async function freshModules() { + vi.resetModules(); + listeners.clear(); + const plotTime = await import('@/lib/plot-time'); + plotTime.resetPlotTimeForTests(); + const store = await import('@/lib/store'); + const dataCache = await import('@/lib/data-cache'); + return { store, dataCache }; +} + +describe('LC board-scoped series never resolve to a sibling board', () => { + it('leaves the dead board\'s column empty instead of filling it from the live board', async () => { + const { store, dataCache } = await freshModules(); + store.buildAliasesFromConfig(TWO_LC_BOARDS); + + const cache = dataCache.getDataCache(); + // Only board 41 (Fuel Scale) is alive. Board 42 (LOX Scale) never reports. + for (let i = 0; i < 10; i++) { + cache.addDataPoint('LC1_Cal.CH1', 'force_kg_tared', 100 + i, T0 + i * 50); + } + + const out = cache.getAlignedHistory( + ['LC1_Cal.CH1', 'LC2_Cal.CH1'], + ['force_kg_tared', 'force_kg_tared'], + 60, + ); + expect(out).not.toBeNull(); + + // Fuel Scale draws its own data. + expect(out!.values[0].some((v) => Number.isFinite(v))).toBe(true); + expect(out!.values[0][9]).toBe(109); + + // LOX Scale is dead: every sample must be NaN. A finite number here is Fuel Scale's + // weight wearing LOX Scale's name — the failure this test exists for. + expect(out!.values[1].every((v) => Number.isNaN(v))).toBe(true); + }); + + it('still resolves a generic LC_Cal key when only one LC board is enabled', async () => { + const { store, dataCache } = await freshModules(); + store.buildAliasesFromConfig({ + boards: { lc_board: { type: 'LC', board_id: 41, enabled: true, active_connectors: [1] } }, + sensor_roles_lc_board: { 'Fuel Scale': 1 }, + }); + + const cache = dataCache.getDataCache(); + for (let i = 0; i < 5; i++) { + cache.addDataPoint('LC1_Cal.CH1', 'force_kg', 10 + i, T0 + i * 50); + } + + // Unambiguous: one board claims CH1, so the generic name may still find it. + const out = cache.getAlignedHistory(['LC_Cal.CH1'], ['force_kg'], 60); + expect(out).not.toBeNull(); + expect(out!.values[0][4]).toBe(14); + }); +}); diff --git a/daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts b/daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts new file mode 100644 index 00000000..9d306ded --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts @@ -0,0 +1,136 @@ +/** + * Load-cell tare: the frontend half. + * + * The frontend does no tare arithmetic at all — the backend publishes `force_kg_tared` as its own + * component and the page picks which one to render. What can still go wrong is picking the wrong + * one, and both directions are bad in a specific way: + * + * - a readout left on `force_kg` while the plot under it uses `force_kg_tared` shows 0 beside a + * trace sitting at 20, which is exactly the confusion the feature exists to remove; + * - the calibration page moved onto `force_kg_tared` would show a tared number beside the input + * where the operator types the true weight of a known mass, and that false point would tilt + * the whole cubic. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { useSensorStore, useLoadCellForceKg, buildAliasesFromConfig } from '@/lib/store'; +import { renderHook } from '@testing-library/react'; +import { waitForSensorFlush } from './waitForSensorFlush'; + +const src = (rel: string) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + +describe('useLoadCellForceKg', () => { + beforeEach(() => { + useSensorStore.setState({ sensorData: {} }); + }); + + it('renders the tared component, not gross', async () => { + const { updateSensor } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg', value: 50, timestamp: Date.now() }); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg_tared', value: 30, timestamp: Date.now() }); + await waitForSensorFlush(); + + const { result } = renderHook(() => useLoadCellForceKg('LC2_Cal.CH1')); + expect(result.current).toBe(30); + }); + + it('falls back to null rather than gross when the tared component is absent', async () => { + // Not a cosmetic choice: silently substituting gross would make an untared readout + // indistinguishable from a backend that stopped publishing the derived component. + const { updateSensor } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg', value: 50, timestamp: Date.now() }); + await waitForSensorFlush(); + + const { result } = renderHook(() => useLoadCellForceKg('LC2_Cal.CH1')); + expect(result.current).toBeNull(); + }); +}); + +describe('force_lbf / force_n stay derived from absolute weight', () => { + it('derives from force_kg and ignores force_kg_tared', async () => { + // These are archive-comparable legacy aliases. Re-taring them silently produces "why does the + // N readout disagree with the kg readout" with nothing in the code pointing at the cause. + useSensorStore.setState({ sensorData: {} }); + const { updateSensor } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg', value: 10, timestamp: Date.now() }); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg_tared', value: 1, timestamp: Date.now() }); + await waitForSensorFlush(); + + const data = useSensorStore.getState().sensorData; + expect(data['LC2_Cal.CH1.force_n']).toBeCloseTo(10 * 9.80665, 4); + expect(data['LC2_Cal.CH1.force_lbf']).toBeCloseTo(10 * 2.2046226218, 4); + }); +}); + +describe('which page reads which component', () => { + it('the calibration page reads absolute weight only', () => { + const cal = src('app/calibration/page.tsx'); + expect(cal).toContain("'force_kg'"); + expect(cal).not.toContain('force_kg_tared'); + }); + + it('the LC readout and the plot beneath it read the same component', () => { + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + // The readout goes through useLoadCellForceKg (tared); the plot must match it. A bare + // component="force_kg" here is the split-screen bug. + expect(page).toContain('useLoadCellForceKg'); + expect(page).toContain('component="force_kg_tared"'); + expect(page).not.toContain('component="force_kg"'); + }); + + it('the chamber page plots the same component its readout uses', () => { + const page = src('app/plots/chamber/page.tsx'); + expect(page).toContain('useLoadCellForceKg'); + expect(page).toContain('component="force_kg_tared"'); + expect(page).not.toContain('component="force_kg"'); + }); + + it('the alias table carries force_kg_tared, or role-name lookups cannot resolve it', async () => { + // Behavioural, not a string search: store.ts mentions force_kg_tared in several places, so + // grepping for it would pass even with the component missing from lcComponents — and the + // symptom would be a role-named readout (LC2_Cal.LOX_Scale) silently reading nothing. + useSensorStore.setState({ sensorData: {} }); + buildAliasesFromConfig({ + boards: { lc_board: { type: 'LC', board_id: 42, active_connectors: [1] } }, + sensor_roles_lc_board: { 'LOX Scale': 1 }, + }); + const { updateSensor, getSensorValue } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg_tared', value: 30, timestamp: Date.now() }); + await waitForSensorFlush(); + expect(getSensorValue('LC2_Cal.LOX_Scale', 'force_kg_tared')).toBe(30); + }); +}); + +describe('the tare uid', () => { + it('comes from the config row, never from the entity string', () => { + // "LC2_Cal.CH1" carries the Elodin slot (board_id % 10), not the board id, so parsing a uid + // back out of it collides two boards that share a slot — the failure that once wrote a load + // cell's curve over a 5000 psi transducer's. + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + expect(page).toContain('r.boardId * 100 + r.channel'); + expect(page).not.toMatch(/LC(\d+)_Cal.*match|match.*LC\\d\+_Cal/); + }); + + it('sends sensorId and boardId split back out of that uid', () => { + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + expect(page).toContain('sensorId: uid % 100'); + expect(page).toContain('boardId: Math.floor(uid / 100)'); + }); +}); + +describe('tare state comes from the backend', () => { + it('polls the status endpoint rather than assuming a click worked', () => { + // [0x46,0x00] carries no reply and the service can refuse a tare outright when the stream is + // stale. An optimistic zero would be a lie about a load cell. + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + expect(page).toContain('/api/lc_tare'); + // Narrow to the command sender: asserting the page merely mentions setTarePending passes on + // the strength of its own useState declaration. What matters is that sending a command marks + // the tare unconfirmed and does NOT write the offset straight into local state. + const sender = page.slice(page.indexOf('const sendTareCmd'), page.indexOf('const anyTared')); + expect(sender).toContain('setTarePending(true)'); + expect(sender).not.toContain('setLcTares'); + }); +}); diff --git a/daq-server/diablo_server/frontend/app/calibration/page.tsx b/daq-server/diablo_server/frontend/app/calibration/page.tsx index b8e5e44f..7e21409e 100644 --- a/daq-server/diablo_server/frontend/app/calibration/page.tsx +++ b/daq-server/diablo_server/frontend/app/calibration/page.tsx @@ -298,6 +298,23 @@ export default function CalibrationPage() { return () => clearInterval(id); }, [fetchCubic]); + // Whether any load cell currently has a standing tare. This page always shows ABSOLUTE weight + // — the operator types the true weight of a known mass, and a tared reading beside that input + // is exactly how a false point gets into the fit — so when a tare is live it has to say so, + // or the number here silently disagrees with every other screen. + const [anyLcTared, setAnyLcTared] = useState(false); + useEffect(() => { + const poll = () => { + fetch(`${getApiBaseUrl()}/api/lc_tare`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setAnyLcTared(((d?.tares ?? []) as unknown[]).length > 0)) + .catch(() => {}); + }; + poll(); + const id = setInterval(poll, 2000); + return () => clearInterval(id); + }, []); + const sendCalCmd = useCallback((cmd: CalibrationCommand) => { ws.send({ type: MessageType.CALIBRATION_COMMAND, timestamp: Date.now(), payload: cmd }); setTimeout(fetchCubic, 350); @@ -465,7 +482,14 @@ export default function CalibrationPage() { )} {/* Global action: Zero all — captures a 0 reference point on every cubic/robust PT + LC - sensor. It's a real point (feeds the shared fit + persists), not a tare. */} + sensor. It's a real point (feeds the shared fit + persists), NOT a tare. + + The distinction, since load cells now have a Tare button on the LC/TC/RTD page: a + vented PT genuinely IS at 0 psig, so a zero is a true reference point and belongs in + the fit. A load cell holding a tank is NOT at 0 kg — capturing that would inject a + false point, and because the fit is least-squares over every point it would tilt the + whole cubic rather than shift its intercept. So LC gets a display-only tare that + never touches the fit, and this button still means "unloaded". */} {(counts.cubic + counts.robust + lcCounts.cubic) > 0 && (
+ {tared && ( + + )} +
+ + ); } export default function LCS_TCS_RTDPage() { @@ -140,6 +227,16 @@ export default function LCS_TCS_RTDPage() { const [lcEntities, setLcEntities] = useState([]); const [lcCalEntities, setLcCalEntities] = useState([]); const [lcLabels, setLcLabels] = useState([]); + // uid = boardId * 100 + connector, taken from the config rows. NEVER parsed back out of an + // entity string: the number in "LC2_Cal.CH1" is the Elodin slot (board_id % 10), not the board + // id, so two boards sharing a slot would resolve to the same uid — the collision that once put + // a load cell's curve on a 5000 psi transducer. + const [lcUids, setLcUids] = useState([]); + /** entity -> offset kg, polled from the backend. The source of truth for what is tared. */ + const [lcTares, setLcTares] = useState>({}); + const [sessionActive, setSessionActive] = useState(false); + /** Set when a tare command was sent and the backend has not confirmed it yet. */ + const [tarePending, setTarePending] = useState(false); const loadChannelConfig = useCallback(() => { Promise.all([ @@ -176,7 +273,16 @@ export default function LCS_TCS_RTDPage() { if (lc.length) { setLcEntities(lc.map((r) => r.entity)); setLcCalEntities(lc.map((r) => r.calEntity)); - setLcLabels(lc.map((r) => r.label)); + // Prefer the configured role ("Fuel Scale") over the generated "LC41 Ch1", the way the + // RTD rows above already do — a board id and a connector number say nothing about which + // tank an operator is looking at. The role stands alone: it is what the operator calls + // the channel, and the board id only earns space here if a role goes missing, which is + // when the generated label comes back with the board scope already in it. + setLcLabels(lc.map((r) => { + const role = sensorConfig?.find((s) => s.calEntity === r.calEntity)?.role; + return role ?? r.label; + })); + setLcUids(lc.map((r) => r.boardId * 100 + r.channel)); } }).catch(() => {}); }, []); @@ -191,6 +297,58 @@ export default function LCS_TCS_RTDPage() { return () => { unsub(); }; }, [ws, loadChannelConfig]); + // A tare is only real once the backend says so. [0x46,0x00] carries no reply, so a click tells + // us nothing: the calibration service can refuse a tare outright when the stream is stale, and + // an optimistic zero would be a lie about a load cell. Poll the file the subtraction itself + // reads, so the badge and the number can never disagree. + const fetchTares = useCallback(() => { + fetch(`${getApiBaseUrl()}/api/lc_tare`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!d) return; + const next: Record = {}; + for (const t of (d.tares ?? []) as Array<{ entity: string; offsetKg: number }>) { + if (Number.isFinite(t.offsetKg)) next[t.entity] = t.offsetKg; + } + setLcTares(next); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchTares(); + const id = setInterval(fetchTares, 2000); + return () => clearInterval(id); + }, [fetchTares]); + + // No live stream means no fresh ADC to tare against, so the service would refuse anyway. + useEffect(() => { + const unsub = ws.on(MessageType.SESSION_UPDATE, (p: unknown) => + setSessionActive(!!(p as { active?: boolean })?.active)); + fetch(`${getApiBaseUrl()}/api/config/profiles`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (d) setSessionActive(!!d.sessionActive); }) + .catch(() => {}); + return () => { unsub(); }; + }, [ws]); + + const sendTareCmd = useCallback((commandType: 'tare_lc' | 'clear_tare_lc', uid?: number) => { + ws.send({ + type: MessageType.CALIBRATION_COMMAND, + timestamp: Date.now(), + payload: uid == null + ? { commandType } + : { commandType, sensorId: uid % 100, boardId: Math.floor(uid / 100) }, + }); + // Accelerate the poll rather than assuming an outcome. If nothing changes within ~2 s the + // banner says so, instead of a button that looks like it worked. + setTarePending(true); + const quick = setInterval(fetchTares, 120); + setTimeout(() => { clearInterval(quick); setTarePending(false); }, 2200); + }, [ws, fetchTares]); + + const anyTared = lcCalEntities.some((e) => lcTares[e] != null); + const tcEntities = tcData.map((d) => d.entity); // d.calEntity, never a string replace: entities are board-scoped (TC1.CH2), so // 'TC1.CH2'.replace('TC.', 'TC_Cal.') silently returns the raw entity unchanged. @@ -305,18 +463,52 @@ export default function LCS_TCS_RTDPage() { {/* ── LC (right column) ─────────────────────────────────────────────── */}
-
+

Load cells (LCS)

+ {lcEntities.length > 0 && ( +
+ {anyTared && ( + + )} + +
+ )}
{lcEntities.length > 0 ? ( <> + {tarePending && ( +
+ Waiting for the calibration service to confirm\u2026 +
+ )}
{lcEntities.map((entity, i) => ( sendTareCmd('tare_lc', lcUids[i])} + onClearTare={() => sendTareCmd('clear_tare_lc', lcUids[i])} + disabled={!sessionActive || lcUids[i] == null} + disabledReason={sessionActive ? 'No uid for this channel in config.' : 'Start a session to tare \u2014 a tare needs a live stream.'} key={entity} entity={entity} calEntity={lcCalEntities[i]} @@ -341,7 +533,7 @@ export default function LCS_TCS_RTDPage() { 0) return s; - const cFallbacks = ALIASES[canonical]; - if (cFallbacks) { - for (const fb of cFallbacks) { - s = this.cache.get(fb); - if (s && s.len > 0) return s; - } - } } return null; } diff --git a/daq-server/diablo_server/frontend/lib/store.ts b/daq-server/diablo_server/frontend/lib/store.ts index 192a6622..b8b9fdb9 100644 --- a/daq-server/diablo_server/frontend/lib/store.ts +++ b/daq-server/diablo_server/frontend/lib/store.ts @@ -76,8 +76,6 @@ interface SensorSystemState { boards: Record; /** From config [adc]; used by sense conversions (TC ref, actuator threshold). */ voltageRefNominals: VoltageRefNominals; - /** Load cell zero offsets (lbf) by cal entity e.g. LC_Cal.CH1. Display = raw_lbf - offset. Persisted to localStorage. */ - loadCellZeroOffsets: Record; notifications: NotificationEntry[]; /** Per-board live diagnostic log lines (ring buffer; accumulates while app is open). */ boardLogs: Record; @@ -85,7 +83,6 @@ interface SensorSystemState { boardLogStats: Record; updateSensor: (update: SensorUpdate) => void; - setLoadCellZeroOffset: (calEntity: string, offsetLbf: number | null) => void; updateActuator: (update: ActuatorUpdate) => void; setActuatorState: (entity: string, state: ActuatorState) => void; setActuatorCommandedOverride: (entity: string, state: ActuatorState | null) => void; @@ -111,19 +108,6 @@ interface SensorSystemState { clearPressureHistoryHidden: () => void; } -const LC_ZERO_STORAGE_KEY = 'sensor_system_loadCellZeroOffsets'; - -function loadStoredLcZeroOffsets(): Record { - try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(LC_ZERO_STORAGE_KEY) : null; - if (raw) { - const parsed = JSON.parse(raw) as Record; - if (parsed && typeof parsed === 'object') return parsed; - } - } catch (_) { /* ignore */ } - return {}; -} - // ── Dynamic alias system ───────────────────────────────────────────────────── // Data flows with generic TYPE.CH entity names. Components can still reference // named entities (e.g. PT_Cal.Fuel_Upstream) — the alias system resolves them @@ -172,7 +156,7 @@ export function buildAliasesFromConfig(config: any): void { const ptComponents = ['pressure_psi', 'raw_adc_counts', 'raw_adc', 'current_ma', 'sense_voltage', 'excitation_voltage']; const tcComponents = ['temperature_c', 'raw_adc_counts', 'raw_adc']; const rtdComponents = ['temperature_c', 'raw_adc_counts', 'raw_adc']; - const lcComponents = ['force_kg', 'force_n', 'raw_adc_counts', 'raw_adc']; + const lcComponents = ['force_kg', 'force_kg_tared', 'force_n', 'raw_adc_counts', 'raw_adc']; const actComponents = ['raw_adc_counts', 'actuator_state_commanded', 'current_a', 'status']; const actCmdComponents = ['actuator_state_commanded']; @@ -417,7 +401,6 @@ export const useSensorStore = create((set, get) => ({ boardLogs: {}, boardLogStats: {}, voltageRefNominals: { internalV: 2.5, absolute5vV: 5 }, - loadCellZeroOffsets: loadStoredLcZeroOffsets(), notifications: [], pressureHistoryHiddenEntities: {}, @@ -438,20 +421,6 @@ export const useSensorStore = create((set, get) => ({ clearPressureHistoryHidden: () => set({ pressureHistoryHiddenEntities: {} }), - setLoadCellZeroOffset: (calEntity: string, offsetLbf: number | null) => { - set((s) => { - const next = { ...s.loadCellZeroOffsets }; - if (offsetLbf == null) delete next[calEntity]; - else next[calEntity] = offsetLbf; - if (typeof localStorage !== 'undefined') { - try { - localStorage.setItem(LC_ZERO_STORAGE_KEY, JSON.stringify(next)); - } catch (_) { /* ignore */ } - } - return { loadCellZeroOffsets: next }; - }); - }, - updateSensor: (update: SensorUpdate) => { const key = `${update.entity}.${update.component}`; @@ -494,6 +463,11 @@ export const useSensorStore = create((set, get) => ({ // Back-compat for legacy LC panes expecting force_lbf / force_n. // Backend now publishes canonical force_kg. + // + // These stay derived from ABSOLUTE force_kg, never from force_kg_tared. They are + // archive-comparable legacy aliases with no consumers outside the alias list, and silently + // re-taring them would produce "why does the N readout disagree with the kg readout" with + // nothing in the code pointing at the cause. if (update.component === 'force_kg') { const lbf = update.value * 2.2046226218; const n = update.value * 9.80665; @@ -784,12 +758,19 @@ export function useActuatorStateByEntity(entity: string): ActuatorState | null { return useSensorStore((s) => s.actuatorStateByEntity[entity] ?? null); } -/** Load cell force (kg) with zero offset applied. Use for display: displayKg = raw - offset. */ +/** + * Load cell force (kg) as the operator should read it: tared when a tare is standing, absolute + * otherwise. The backend derives `force_kg_tared` and always publishes it — a 0 offset when + * untared — so there is no arithmetic here and no way for this to disagree with the plots. + * + * Use `useSensorValue(calEntity, 'force_kg')` directly where ABSOLUTE weight is required. The + * calibration page is the one that must: the operator types the true weight of a known mass, and + * a tared reading beside that input is how a false point gets into the fit. + */ export function useLoadCellForceKg(calEntity: string): number | null { - const raw = useSensorValue(calEntity, 'force_kg'); - const offset = useSensorStore((s) => s.loadCellZeroOffsets[calEntity] ?? 0); - if (raw == null || !Number.isFinite(raw)) return null; - return raw - offset; + const v = useSensorValue(calEntity, 'force_kg_tared'); + if (v == null || !Number.isFinite(v)) return null; + return v; } /** @deprecated Use useLoadCellForceKg instead. Legacy alias for backwards compatibility. */ diff --git a/daq-server/diablo_server/lib/CMakeLists.txt b/daq-server/diablo_server/lib/CMakeLists.txt index be509e14..d3f2fdba 100644 --- a/daq-server/diablo_server/lib/CMakeLists.txt +++ b/daq-server/diablo_server/lib/CMakeLists.txt @@ -19,6 +19,7 @@ set(FSW_SOURCES src/control/PressureStateMachine.cpp src/calibration/PTCalibration.cpp src/calibration/CubicCalibrationStore.cpp + src/calibration/LcTareStore.cpp src/calibration/CaptureWindow.cpp src/calibration/SensorCalibration.cpp src/calibration/AllanVariance.cpp @@ -82,6 +83,14 @@ target_link_libraries(test_capture_window fsw_daq_lib daq_comms_lib Threads::Threads pthread ${RT_LIBRARY}) add_test(NAME capture_window COMMAND test_capture_window) +add_executable(test_lc_tare test/test_lc_tare.cpp) +target_include_directories(test_lc_tare PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include +) +target_link_libraries(test_lc_tare + fsw_daq_lib daq_comms_lib Threads::Threads pthread ${RT_LIBRARY}) +add_test(NAME lc_tare COMMAND test_lc_tare) + add_executable(test_cubic_store_namespace test/test_cubic_store_namespace.cpp) target_include_directories(test_cubic_store_namespace PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include diff --git a/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp b/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp index d135d379..797b2ba4 100644 --- a/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp +++ b/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp @@ -141,6 +141,14 @@ class CubicCalibrationStore { /** Load the JSON record, resume points, and re-fit each channel. Returns channels loaded. */ size_t load(); + /** True when load() could not read an existing file, so every curve here is a fallback rather + * than the operator's. Callers that DERIVE from a curve (the LC tare recompute) must skip + * rather than persist a number computed against one. */ + bool load_failed() const { + std::lock_guard lock(mutex_); + return load_failed_; + } + static constexpr size_t kMaxPoints = 20; private: diff --git a/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp new file mode 100644 index 00000000..8b44aac9 --- /dev/null +++ b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp @@ -0,0 +1,161 @@ +#ifndef LC_TARE_STORE_HPP +#define LC_TARE_STORE_HPP + +#include +#include +#include +#include +#include +#include + +namespace fsw { +namespace calibration { + +/** + * The publish-path entity name for an LC channel: "LC_Cal.CH", where slot is + * board_id % 10 with 0 -> 10 — the same rule DatabaseConfig uses to name the calibrated LC + * VTables, and the same board_number the LC publish path already holds. + * + * It exists as one shared function because the failure mode of a second copy is specific and + * has already happened: slot is NOT board_id, so PT board 22 and LC board 42 are both slot 2, + * and deriving a uid back out of an entity string collides them. Node must key on this string + * rather than re-derive it. + */ +std::string lc_tare_entity(uint8_t board_id, uint8_t connector); + +/** + * One load cell's tare: the scale reads zero at the load it was holding when the operator + * pressed Tare. + * + * A tare is NOT a calibration point, and the difference is the reason this store exists + * separately from CubicCalibrationStore. A vented PT genuinely is at 0 psig, so capturing a + * zero on one is a true reference point and belongs in the shared fit. A load cell holding a + * tank is not at 0 kg: capturing that as a zero would inject a false point, and because + * add_point re-runs a least-squares fit over every point, it would tilt the whole cubic rather + * than shift its intercept. LC therefore gets a display-only tare, and its zero-capture keeps + * meaning "unloaded". + * + * WHAT IS STORED IS THE ADC CODE, NOT THE KILOGRAMS. + * + * `offset_kg` is a derived cache, re-evaluated from `adc_at_tare` through the current curve on + * every re-fit, clear, and profile swap. Persisting kilograms as the truth is the bug this + * layout exists to prevent: tare a 20 kg tank against a bad two-point fit that reads it as 18, + * improve the fit until the same tank evaluates to 20, and a frozen 18 kg offset displays 2 kg + * for a tank that never moved — a plausible-looking wrong number on a pad display. Re-deriving + * from the code gives 20 - 20 = 0. + * + * Subtraction happens in kilograms, never in counts: the curve is a cubic, so offsetting its + * input shifts the slope you get instead of translating its output. + */ +struct LcTare { + uint16_t uid = 0; // board_id*100 + connector + std::string entity; // publish-path identity, e.g. "LC2_Cal.CH1" — see set() + double adc_at_tare = 0.0; // the truth + double offset_kg = 0.0; // derived from adc_at_tare through the live curve; a cache + double set_at_ms = 0.0; // unix milliseconds, for "tared 2h ago" + uint64_t curve_fp = 0; // fingerprint of the curve offset_kg was computed against +}; + +/** + * Owns the per-channel tares for the calibration service. Thread-safe, persisted atomically to + * one JSON file (temp + rename) that the Node backend reads to apply the subtraction — the + * service never sends the value, because [0x46,0x00] is one-way and there is no reply packet. + * This mirrors how cubic calibration already reaches the UI. + * + * Ownership, which is deliberately asymmetric: C++ is the STEADY-STATE writer. Node may remove + * the file, but only while sensor-calibration is not running. That is what lets a tare be + * cleared at session start and still survive a service restart inside a session — two + * requirements that together force a file that is persistent AND externally cleared. Neither + * half is redundant; do not "simplify" one away. + */ +class LcTareStore { +public: + explicit LcTareStore(std::string file_path); + + /** adc -> kg through whichever model this uid streams (select_lc_kg: cubic fit or the + * datasheet physics conversion). Taken as a callable so this class never learns which. */ + using Evaluator = std::function; + + /** + * Record a tare for `uid` at `adc_at_tare` and derive its offset. `entity` is the + * publish-path name (LC_Cal.CH) computed by the caller from the same + * board_number/channel the LC publish path uses; Node keys on this string and must never + * re-derive it, because slot is board_id % 10 and two boards of different kinds share a + * slot — the collision that put a load cell's curve on a 5000 psi transducer in Sep 2026. + * + * Returns false and records nothing when the curve yields a non-finite offset (a degenerate + * one-point fit does), because a NaN offset downstream silently kills the whole series. + */ + bool set(uint16_t uid, const std::string& entity, double adc_at_tare, const Evaluator& eval); + + void clear(uint16_t uid); + void clear_all(); + + /** Re-derive offset_kg for one uid under the current curve. No-op when the uid is untared. */ + void recompute(uint16_t uid, const Evaluator& eval); + + /** Re-derive every tare; `eval_for` supplies each uid's evaluator. Call after any change to + * an LC curve: a capture, a clear, a profile swap, or startup. */ + void recompute_all(const std::function& eval_for); + + /** + * Re-derive only the tares whose recorded fingerprint disagrees with the curve now live, and + * return how many those were. Zero is the expected answer: every path that moves a curve is + * supposed to recompute already. + * + * A non-zero answer means one didn't, and the caller should say so loudly — it is the audit + * that makes a missed recompute hook visible instead of silently subtracting kilograms + * derived from a curve that no longer exists. In particular it catches the startup ordering + * mistake of reading this file AFTER the live store reload, where the reload's own recompute + * runs over an empty map and every restored offset stays stale. + */ + size_t recompute_stale(const std::function& eval_for); + + /** + * False while the curves cannot be trusted — set when the cubic store failed to load. A + * recompute then keeps the last good offset rather than replacing it with one computed + * against a zeroed curve, which would read as a confident wrong number. + */ + void set_curves_trusted(bool trusted); + bool curves_trusted() const; + + const LcTare* tare_for(uint16_t uid) const; + std::vector uids() const; + size_t size() const; + + /** Atomically write the JSON record (temp + rename). Blocked while load_failed(). */ + bool save() const; + + /** Load the JSON record. Returns tares loaded. */ + size_t load(); + + /** Set when load() could not read an existing file; blocks save() so an unreadable store is + * never replaced by the empty one we fell back to. Mirrors CubicCalibrationStore. */ + bool load_failed() const; + + /** + * A fingerprint of what a curve *does*, not of the coefficients that describe it: the + * evaluator is sampled at fixed probe codes and the results hashed. Model-agnostic by + * construction, so a cubic re-fit, a cleared channel and a flip to the physics conversion + * all move it, and a no-op re-save does not. Cheap defence in depth — if some future path + * mutates a curve without calling recompute, a mismatched fingerprint makes the stale + * offset visible instead of silently subtracted. + */ + static uint64_t fingerprint(const Evaluator& eval); + +private: + mutable std::mutex mutex_; + std::string file_path_; + bool load_failed_ = false; + bool curves_trusted_ = true; + std::map tares_; + + std::string serialize() const; // caller holds mutex_ + /** Caller holds mutex_. Returns false when the curve yields a non-finite offset. */ + bool recompute_locked(LcTare& t, const Evaluator& eval); +}; + +} // namespace calibration +} // namespace fsw + +#endif // LC_TARE_STORE_HPP diff --git a/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp b/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp new file mode 100644 index 00000000..8bbd8bca --- /dev/null +++ b/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp @@ -0,0 +1,281 @@ +#include "calibration/LcTareStore.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fsw { +namespace calibration { + +namespace { + +double unix_now_ms() { + return std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +/** Probe codes spanning the signed 32-bit ADC range the LC boards report. Fixed forever: they + * only have to be the SAME codes across two fingerprints, never meaningful loads. */ +constexpr double kProbes[] = {-1.0e9, -1.0e6, 0.0, 1.0e6, 1.0e9}; + +} // namespace + +std::string lc_tare_entity(uint8_t board_id, uint8_t connector) { + const int m = static_cast(board_id) % 10; + const int slot = (m == 0 ? 10 : m); + return "LC" + std::to_string(slot) + "_Cal.CH" + std::to_string(static_cast(connector)); +} + +LcTareStore::LcTareStore(std::string file_path) : file_path_(std::move(file_path)) { +} + +uint64_t LcTareStore::fingerprint(const Evaluator& eval) { + if (!eval) + return 0; + // FNV-1a over the raw bits of each probe's result. A non-finite result is folded in as a + // fixed sentinel so a blown-up curve still fingerprints deterministically rather than + // hashing whichever NaN payload the FPU produced. + uint64_t h = 1469598103934665603ull; + for (double probe : kProbes) { + const double v = eval(probe); + uint64_t bits; + if (std::isfinite(v)) + std::memcpy(&bits, &v, sizeof(bits)); + else + bits = 0xDEADBEEFDEADBEEFull; + for (size_t i = 0; i < sizeof(bits); ++i) { + h ^= static_cast((bits >> (i * 8)) & 0xFF); + h *= 1099511628211ull; + } + } + return h; +} + +bool LcTareStore::recompute_locked(LcTare& t, const Evaluator& eval) { + if (!eval) + return false; + const double kg = eval(t.adc_at_tare); + if (!std::isfinite(kg)) + return false; + t.offset_kg = kg; + t.curve_fp = fingerprint(eval); + return true; +} + +bool LcTareStore::set(uint16_t uid, const std::string& entity, double adc_at_tare, + const Evaluator& eval) { + std::lock_guard lock(mutex_); + if (!std::isfinite(adc_at_tare)) + return false; + + LcTare t; + t.uid = uid; + t.entity = entity; + t.adc_at_tare = adc_at_tare; + t.set_at_ms = unix_now_ms(); + if (!recompute_locked(t, eval)) { + // Nothing is recorded. A tare whose offset cannot be evaluated is worse than no tare: + // Node would subtract it from every sample on the channel. + std::cout << "[LcTare] uid " << static_cast(uid) + << ": curve yields no finite offset at adc " << adc_at_tare + << " — tare not recorded" << std::endl; + return false; + } + tares_[uid] = t; + return true; +} + +void LcTareStore::clear(uint16_t uid) { + std::lock_guard lock(mutex_); + tares_.erase(uid); +} + +void LcTareStore::clear_all() { + std::lock_guard lock(mutex_); + tares_.clear(); +} + +void LcTareStore::recompute(uint16_t uid, const Evaluator& eval) { + std::lock_guard lock(mutex_); + if (!curves_trusted_) + return; + auto it = tares_.find(uid); + if (it == tares_.end()) + return; + LcTare probe = it->second; + if (recompute_locked(probe, eval)) + it->second = probe; + // else: keep the last good offset. Writing through a curve that evaluates to NaN would + // replace a usable number with one that kills the series downstream. +} + +void LcTareStore::recompute_all(const std::function& eval_for) { + std::lock_guard lock(mutex_); + if (!curves_trusted_) + return; + if (!eval_for) + return; + for (auto& [uid, t] : tares_) { + LcTare probe = t; + if (recompute_locked(probe, eval_for(uid))) + t = probe; + } +} + +size_t LcTareStore::recompute_stale(const std::function& eval_for) { + std::lock_guard lock(mutex_); + if (!curves_trusted_ || !eval_for) + return 0; + size_t stale = 0; + for (auto& [uid, t] : tares_) { + const Evaluator eval = eval_for(uid); + if (fingerprint(eval) == t.curve_fp) + continue; + ++stale; + LcTare probe = t; + if (recompute_locked(probe, eval)) + t = probe; + } + return stale; +} + +void LcTareStore::set_curves_trusted(bool trusted) { + std::lock_guard lock(mutex_); + curves_trusted_ = trusted; +} + +bool LcTareStore::curves_trusted() const { + std::lock_guard lock(mutex_); + return curves_trusted_; +} + +const LcTare* LcTareStore::tare_for(uint16_t uid) const { + std::lock_guard lock(mutex_); + auto it = tares_.find(uid); + return it == tares_.end() ? nullptr : &it->second; +} + +std::vector LcTareStore::uids() const { + std::lock_guard lock(mutex_); + std::vector out; + out.reserve(tares_.size()); + for (const auto& [uid, t] : tares_) + out.push_back(uid); + return out; +} + +size_t LcTareStore::size() const { + std::lock_guard lock(mutex_); + return tares_.size(); +} + +bool LcTareStore::load_failed() const { + std::lock_guard lock(mutex_); + return load_failed_; +} + +std::string LcTareStore::serialize() const { + nlohmann::json root; + root["version"] = 1; + nlohmann::json arr = nlohmann::json::array(); + for (const auto& [uid, t] : tares_) { + nlohmann::json tj; + tj["uid"] = t.uid; + tj["entity"] = t.entity; + tj["adc_at_tare"] = t.adc_at_tare; + tj["offset_kg"] = t.offset_kg; + tj["set_at_ms"] = t.set_at_ms; + tj["curve_fp"] = t.curve_fp; + arr.push_back(tj); + } + root["tares"] = arr; + return root.dump(2); +} + +bool LcTareStore::save() const { + std::lock_guard lock(mutex_); + if (load_failed_) { + // Same rule as CubicCalibrationStore: a file we could not read is a file we must not + // replace, because this store is empty precisely because the read failed. + return false; + } + try { + std::filesystem::path p(file_path_); + if (p.has_parent_path()) + std::filesystem::create_directories(p.parent_path()); + const std::string tmp = file_path_ + ".tmp"; + { + std::ofstream f(tmp, std::ios::trunc); + if (!f.is_open()) + return false; + f << serialize(); + f.flush(); + if (!f.good()) + return false; + } + std::filesystem::rename(tmp, file_path_); // atomic replace on same filesystem + return true; + } catch (...) { + return false; + } +} + +size_t LcTareStore::load() { + std::lock_guard lock(mutex_); + tares_.clear(); + + std::ifstream f(file_path_); + if (!f.is_open()) { + // No file is the normal state: the backend unlinks it at session start, and every + // session begins with every load cell reading absolute. Not a failure. + load_failed_ = false; + return 0; + } + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + + nlohmann::json root; + try { + root = nlohmann::json::parse(content); + } catch (...) { + load_failed_ = true; + std::cout << "[LcTare] " << file_path_ << " could not be parsed — refusing to overwrite it" + << std::endl; + return 0; + } + if (!root.contains("tares") || !root["tares"].is_array()) { + load_failed_ = true; + std::cout << "[LcTare] " << file_path_ << " has no tares array — refusing to overwrite it" + << std::endl; + return 0; + } + load_failed_ = false; + + size_t loaded = 0; + for (const auto& tj : root["tares"]) { + if (!tj.is_object()) + continue; + LcTare t; + t.uid = static_cast(tj.value("uid", 0)); + if (t.uid == 0) + continue; + t.entity = tj.value("entity", std::string()); + t.adc_at_tare = tj.value("adc_at_tare", 0.0); + t.offset_kg = tj.value("offset_kg", 0.0); + t.set_at_ms = tj.value("set_at_ms", 0.0); + t.curve_fp = tj.value("curve_fp", static_cast(0)); + if (!std::isfinite(t.adc_at_tare) || !std::isfinite(t.offset_kg)) + continue; + tares_[t.uid] = t; + ++loaded; + } + return loaded; +} + +} // namespace calibration +} // namespace fsw diff --git a/daq-server/diablo_server/lib/test/test_lc_tare.cpp b/daq-server/diablo_server/lib/test/test_lc_tare.cpp new file mode 100644 index 00000000..3fa67d5a --- /dev/null +++ b/daq-server/diablo_server/lib/test/test_lc_tare.cpp @@ -0,0 +1,311 @@ +/** + * LcTareStore — the guards that keep a load-cell tare from becoming a confident wrong number. + * + * The bug this whole store exists to prevent, in the operator's words: a 20 kg tank sits on a + * scale whose calibration is a bad two-point fit reading it as 18. The operator tares and the + * display reads 0. Later they add points, the fit improves, and the same tank evaluates to 20. + * A tare persisted as "18 kg" then displays 2 kg for a tank that never moved — plausible enough + * that nobody questions it. Persisting the ADC CODE instead and re-deriving through the current + * curve gives 20 - 20 = 0. Case 1 pins exactly that. + * + * 1. recompute_carries_tare_across_a_better_fit — the 2 kg bug + * 2. untrusted_curves_keep_the_last_good_offset — a failed cubic load must not zero a tare + * 3. non_finite_offset_is_never_recorded — a degenerate fit kills the series downstream + * 4. entity_matches_the_publish_path — Node keys on this string; slot != board_id + * 5. loaded_tare_is_corrected_by_recompute — startup ordering: stale offset must self-heal + * 6. fingerprint_tracks_what_the_curve_does — staleness is detectable, not assumed + * 7. stale_audit_finds_a_missed_recompute — a hook nobody added still gets caught + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "calibration/LcTareStore.hpp" + +using fsw::calibration::lc_tare_entity; +using fsw::calibration::LcTare; +using fsw::calibration::LcTareStore; +using json = nlohmann::json; + +static int g_failures = 0; + +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + g_failures++; \ + std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \ + std::printf(__VA_ARGS__); \ + std::printf("\n"); \ + } \ + } while (0) + +namespace { + +/** A unique scratch path per test — hermetic, no committed fixtures, parallel-safe. */ +std::string scratch(const char* name) { + static int seq = 0; + auto p = std::filesystem::temp_directory_path() / + ("lc_tare_" + std::string(name) + "_" + std::to_string(++seq) + ".json"); + std::filesystem::remove(p); + return p.string(); +} + +std::string read_text(const std::string& path) { + std::ifstream f(path); + return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); +} + +/** A linear adc->kg curve, standing in for select_lc_kg. */ +LcTareStore::Evaluator linear(double kg_per_count) { + return [kg_per_count](double adc) { + return adc * kg_per_count; + }; +} + +// ── 1. the 2 kg bug ───────────────────────────────────────────────────────── + +void recompute_carries_tare_across_a_better_fit() { + LcTareStore s(scratch("recal")); + + // Bad fit: the tank's true 20 kg reads as 18. adc 1000 -> 18 kg. + const auto bad = linear(0.018); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, bad), "set should succeed"); + const LcTare* t = s.tare_for(4201); + CHECK(t != nullptr, "tare should exist"); + CHECK(std::fabs(t->offset_kg - 18.0) < 1e-9, "offset under the bad fit should be 18, got %f", + t->offset_kg); + + // The operator clears the scale, hangs a known mass, types its TRUE weight; the fit improves + // so that the same code now evaluates to the tank's real 20 kg. + const auto good = linear(0.020); + s.recompute(4201, good); + + t = s.tare_for(4201); + CHECK(std::fabs(t->offset_kg - 20.0) < 1e-9, + "offset must re-derive to 20 under the better fit, got %f", t->offset_kg); + CHECK(std::fabs(t->adc_at_tare - 1000.0) < 1e-9, "adc_at_tare is the truth and must not move"); + + // What the operator sees with the tank back on the scale: good(1000) - offset == 0. + const double displayed = good(1000.0) - t->offset_kg; + CHECK(std::fabs(displayed) < 1e-9, "the tank must still read 0 after the re-cal, got %f kg", + displayed); +} + +// ── 2. an untrusted curve must not overwrite a good offset ────────────────── + +void untrusted_curves_keep_the_last_good_offset() { + LcTareStore s(scratch("untrusted")); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set should succeed"); + const double good_offset = s.tare_for(4201)->offset_kg; + CHECK(std::fabs(good_offset - 20.0) < 1e-9, "baseline offset should be 20, got %f", + good_offset); + + // The cubic store failed to load, so every curve currently evaluates to a flat zero. A + // recompute through it would replace 20 kg with 0 and the tank would suddenly read its full + // weight, with nothing on screen to say why. + s.set_curves_trusted(false); + s.recompute(4201, linear(0.0)); + + CHECK(std::fabs(s.tare_for(4201)->offset_kg - good_offset) < 1e-9, + "an untrusted recompute must keep the last good offset, got %f", + s.tare_for(4201)->offset_kg); + + // And once the curves are trustworthy again it does update. + s.set_curves_trusted(true); + s.recompute(4201, linear(0.030)); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, + "a trusted recompute must update, got %f", s.tare_for(4201)->offset_kg); +} + +// ── 3. non-finite offsets are never recorded ──────────────────────────────── + +void non_finite_offset_is_never_recorded() { + LcTareStore s(scratch("nonfinite")); + + // A degenerate one-point fit can evaluate to inf/NaN. Node subtracts offset_kg from every + // sample; a NaN there is dropped by the finite guard downstream and the whole series simply + // vanishes from the plot with no error anywhere. + const auto blown_up = [](double) { + return std::numeric_limits::quiet_NaN(); + }; + CHECK(!s.set(4201, lc_tare_entity(42, 1), 1000.0, blown_up), "a NaN offset must be refused"); + CHECK(s.tare_for(4201) == nullptr, "nothing may be recorded for a refused tare"); + + const auto inf_curve = [](double) { + return std::numeric_limits::infinity(); + }; + CHECK(!s.set(4202, lc_tare_entity(42, 2), 1000.0, inf_curve), "an inf offset must be refused"); + CHECK(s.size() == 0, "store must still be empty, has %zu", s.size()); + + // A good tare that later sees a blown-up curve keeps its offset rather than adopting NaN. + CHECK(s.set(4203, lc_tare_entity(42, 3), 500.0, linear(0.02)), "good set should succeed"); + s.recompute(4203, blown_up); + CHECK(std::isfinite(s.tare_for(4203)->offset_kg), "offset must stay finite after a bad curve"); + CHECK(std::fabs(s.tare_for(4203)->offset_kg - 10.0) < 1e-9, "offset must be unchanged, got %f", + s.tare_for(4203)->offset_kg); +} + +// ── 4. the entity string Node keys on ─────────────────────────────────────── + +void entity_matches_the_publish_path() { + // DatabaseConfig names the calibrated LC VTable LC_Cal.CH, where + // board_number is board_id % 10 with 0 -> 10. Getting this wrong means C++ writes a key the + // backend never looks up, and the tare silently never applies. + CHECK(lc_tare_entity(42, 1) == "LC2_Cal.CH1", "board 42 ch 1 -> LC2_Cal.CH1, got %s", + lc_tare_entity(42, 1).c_str()); + CHECK(lc_tare_entity(41, 6) == "LC1_Cal.CH6", "board 41 ch 6 -> LC1_Cal.CH6, got %s", + lc_tare_entity(41, 6).c_str()); + // slot is NOT board_id: board 42 is slot 2, not 42. + CHECK(lc_tare_entity(42, 1) != "LC42_Cal.CH1", "must use the slot, not the raw board id"); + // 0 -> 10, the documented edge (id 10, id 20). + CHECK(lc_tare_entity(10, 3) == "LC10_Cal.CH3", "board 10 ch 3 -> LC10_Cal.CH3, got %s", + lc_tare_entity(10, 3).c_str()); +} + +// ── 5. a stale offset from disk self-heals on recompute ───────────────────── + +void loaded_tare_is_corrected_by_recompute() { + const std::string path = scratch("startup"); + + // Hand-write a record whose offset_kg belongs to a curve that is no longer live — exactly + // what a profile swapped while the service was down leaves behind. + json root; + root["version"] = 1; + json t; + t["uid"] = 4201; + t["entity"] = "LC2_Cal.CH1"; + t["adc_at_tare"] = 1000.0; + t["offset_kg"] = 18.0; // stale: computed against the old curve + t["set_at_ms"] = 1757800000000.0; + t["curve_fp"] = 12345; + root["tares"] = json::array({t}); + { + std::ofstream f(path); + f << root.dump(2); + } + + LcTareStore s(path); + CHECK(s.load() == 1, "one tare should load"); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 18.0) < 1e-9, "loads the stale value verbatim"); + + // The startup recompute is what corrects it. If the tare file is loaded AFTER the live store + // reload instead of before, this never runs and the stand carries the stale offset all run. + s.recompute_all([](uint16_t) { + return linear(0.020); + }); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, + "the startup recompute must correct a stale offset, got %f", s.tare_for(4201)->offset_kg); +} + +// ── 6. the staleness fingerprint ──────────────────────────────────────────── + +void fingerprint_tracks_what_the_curve_does() { + const auto a = linear(0.020); + const auto b = linear(0.030); + + CHECK(LcTareStore::fingerprint(a) == LcTareStore::fingerprint(linear(0.020)), + "the same curve must fingerprint the same — otherwise every read looks stale"); + CHECK(LcTareStore::fingerprint(a) != LcTareStore::fingerprint(b), + "a changed curve must change the fingerprint"); + + // The fingerprint follows the curve the offset was computed against. + LcTareStore s(scratch("fp")); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, a), "set should succeed"); + CHECK(s.tare_for(4201)->curve_fp == LcTareStore::fingerprint(a), "fp must match the set curve"); + s.recompute(4201, b); + CHECK(s.tare_for(4201)->curve_fp == LcTareStore::fingerprint(b), + "fp must follow a recompute onto a new curve"); +} + +// ── 7. the audit that catches a missed recompute hook ─────────────────────── + +void stale_audit_finds_a_missed_recompute() { + LcTareStore s(scratch("audit")); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set should succeed"); + + // Nothing has changed: the audit must report zero, or it would cry wolf on every startup and + // the warning would stop meaning anything. + CHECK(s.recompute_stale([](uint16_t) { + return linear(0.020); + }) == 0, + "an unchanged curve must not be reported stale"); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, "and the offset is untouched"); + + // Now the curve moves WITHOUT a recompute — the shape of a missed hook, and of reading the + // tare file after the startup reload instead of before it. + const size_t stale = s.recompute_stale([](uint16_t) { + return linear(0.030); + }); + CHECK(stale == 1, "a moved curve must be reported stale, got %zu", stale); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, "and must be re-derived, got %f", + s.tare_for(4201)->offset_kg); + + // Having fixed it, a second audit is quiet. + CHECK(s.recompute_stale([](uint16_t) { + return linear(0.030); + }) == 0, + "the audit must be quiet once it has healed"); +} + +// ── round trip ────────────────────────────────────────────────────────────── + +void save_load_round_trip() { + const std::string path = scratch("roundtrip"); + { + LcTareStore s(path); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set"); + CHECK(s.set(4206, lc_tare_entity(42, 6), -250.0, linear(0.020)), "set negative adc"); + CHECK(s.save(), "save"); + } + LcTareStore s2(path); + CHECK(s2.load() == 2, "two tares should load"); + CHECK(!s2.load_failed(), "a good file is not a failed load"); + CHECK(s2.tare_for(4201)->entity == "LC2_Cal.CH1", "entity round-trips"); + CHECK(std::fabs(s2.tare_for(4206)->offset_kg + 5.0) < 1e-9, + "negative offset round-trips, got %f", s2.tare_for(4206)->offset_kg); + + // A missing file is the normal post-session-start state, not a failure — it must not block + // the next save the way an unreadable file does. + LcTareStore s3(scratch("absent")); + CHECK(s3.load() == 0, "absent file loads nothing"); + CHECK(!s3.load_failed(), "an absent file is not a failed load"); +} + +void unreadable_file_is_not_overwritten() { + const std::string path = scratch("corrupt"); + { + std::ofstream f(path); + f << "{ this is not json"; + } + LcTareStore s(path); + CHECK(s.load() == 0, "corrupt file loads nothing"); + CHECK(s.load_failed(), "corrupt file must set load_failed"); + CHECK(!s.save(), "save must be refused while load_failed"); + CHECK(read_text(path) == "{ this is not json", "the file must be left exactly as found"); +} + +} // namespace + +int main() { + std::printf("LcTareStore tests\n"); + recompute_carries_tare_across_a_better_fit(); + untrusted_curves_keep_the_last_good_offset(); + non_finite_offset_is_never_recorded(); + entity_matches_the_publish_path(); + loaded_tare_is_corrected_by_recompute(); + fingerprint_tracks_what_the_curve_does(); + stale_audit_finds_a_missed_recompute(); + save_load_round_trip(); + unreadable_file_is_not_overwritten(); + + if (g_failures == 0) + std::printf(" ✅ all passed\n"); + else + std::printf(" %d failure(s)\n", g_failures); + return g_failures ? 1 : 0; +} diff --git a/daq-server/diablo_server/services/calibration/calibration_main.cpp b/daq-server/diablo_server/services/calibration/calibration_main.cpp index 14768eff..a2e73dd9 100644 --- a/daq-server/diablo_server/services/calibration/calibration_main.cpp +++ b/daq-server/diablo_server/services/calibration/calibration_main.cpp @@ -48,6 +48,7 @@ #include "calibration/CaptureWindow.hpp" #include "calibration/CubicCalibrationStore.hpp" +#include "calibration/LcTareStore.hpp" #include "calibration/PTCalibration.hpp" #include "calibration/RobustCalibrationManager.hpp" #include "calibration/SensorCalibration.hpp" @@ -884,6 +885,12 @@ int main(int argc, char* argv[]) { // feeds both this cubic fit and the robust learner. fsw::calibration::CubicCalibrationStore cubic_store( "scripts/calibration/calibrations/cubic_calibration.json"); + // Load-cell tares: display-only offsets the backend subtracts on the way to the browser. + // Deliberately NOT part of the cubic store — a tare is not a calibration point, and folding + // one in as a captured zero would tilt the whole fit rather than shift its intercept. + // Cleared by the backend at session start (while this service is down); survives a restart + // inside a session. See LcTareStore.hpp. + fsw::calibration::LcTareStore lc_tare_store("scripts/calibration/calibrations/lc_tare.json"); // What a capture records. Bounded in TIME, not in samples: the old 128-sample ring was // sized for a 250 Hz PT and spanned 9.3 s on a 13.7 Hz load cell, so a capture taken // soon after a load change averaged the old load in. See CaptureWindow.hpp. @@ -1046,6 +1053,46 @@ int main(int argc, char* argv[]) { // boards' vent-to-safe gate tracks the new calibration within a broadcast cycle. write_abort_thresholds(); }; + // adc -> kg exactly as the LC publish path computes it (see the 0x23 branch): the cubic fit + // if this uid streams one, else the datasheet physics conversion. A tare offset is derived + // through the SAME model selection the live sample goes through, so flipping a uid between + // cubic and physics carries its tare correctly instead of being a fourth special case. + auto lc_eval_for = [&](uint16_t uid) -> fsw::calibration::LcTareStore::Evaluator { + return [&, uid](double adc) -> double { + uint8_t board_number = static_cast((uid / 100) % 10); + if (board_number == 0) + board_number = 10; + const uint8_t connector = static_cast(uid % 100); + const uint8_t lc_log_ch = + fsw::calibration::pt_logical_calibration_channel(board_number, connector); + const int32_t code = static_cast(adc); + const bool cubic_ok = lc_calibration.is_calibrated(lc_log_ch); + const double kg_cubic = cubic_ok ? lc_calibration.calculate(lc_log_ch, code) : 0.0; + const double kg_phys = convert_lc_adc_to_force( + code, lc_sensitivity_for(uid, lc_sensitivity_mv_per_v), + lc_pga_gain_for(uid, lc_pga_gain), lc_full_scale_for(uid, lc_full_scale_value)); + return select_lc_kg(uid, kg_cubic, kg_phys, cubic_ok); + }; + }; + // Re-derive a standing tare's kilograms from the ADC code it was taken at. Called from EVERY + // site that can change an LC curve; miss one and the stand carries an offset computed against + // a curve that no longer exists — the "tank reads 2 kg after a better fit" bug. + // + // curves_trusted tracks the cubic store: if its file could not be read, every curve here is a + // fallback, and persisting an offset derived from one would replace a good number with a + // confident wrong one. + auto recompute_tare = [&](uint16_t uid) { + lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); + lc_tare_store.recompute(uid, lc_eval_for(uid)); + lc_tare_store.save(); + }; + auto recompute_all_tares = [&]() { + lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); + lc_tare_store.recompute_all([&](uint16_t u) { + return lc_eval_for(u); + }); + lc_tare_store.save(); + }; // LC capture: cubic fit only — no robust learner (LC doesn't need drift-learning) and no abort // thresholds (a PT-only concept; abort_pts names PT roles). auto apply_lc_capture = [&](uint16_t uid, double adc_avg, double ref) { @@ -1055,6 +1102,8 @@ int main(int argc, char* argv[]) { lc_calibration.set_calibration(cch->logical_ch, fsw::calibration::PolynomialCalibration( fit.A, fit.B, fit.C, fit.D, "kg")); cubic_store.save(); + // The curve just moved under any standing tare on this channel. + recompute_tare(uid); }; // Every capture/clear is routed by uid kind (g_lc_uids), so cmd_type 0/3/4/5/6 in the // CalibrationCommand handler below work unchanged for both PT and LC uids. @@ -1112,6 +1161,9 @@ int main(int argc, char* argv[]) { lc_calibration.clear_calibration(cch->logical_ch); cubic_store.clear_channel(uid); cubic_store.save(); + // A cleared channel falls back to the physics conversion, which is a different curve. + // The tare survives and re-derives through it, so the same load still reads zero. + recompute_tare(uid); }; auto apply_clear = [&](uint16_t uid) { if (is_lc_uid(uid)) @@ -1199,6 +1251,10 @@ int main(int argc, char* argv[]) { // Whole-rig cal just (re)loaded — startup or a live profile swap (cmd 7). Emit the abort // thresholds from it so the boards' vent-to-safe gate matches the newly active calibration. write_abort_thresholds(); + // Every LC curve may have just changed, so every standing tare's kilograms are stale. + // This is why the tare file is loaded BEFORE the first call to this lambda: recomputing + // an empty map does nothing, and the stale offsets would then survive all session. + recompute_all_tares(); return loaded; }; @@ -1206,11 +1262,42 @@ int main(int argc, char* argv[]) { std::cout << "[Calibration] (override with --adjustments, CAL_BACKUP_PATH, or " "calibration_backups/calibration_backup_*.json mtime)" << std::endl; + // Resume standing tares BEFORE the live store reload below, not after. + // + // Ordering is load-bearing. reload_live_store() ends by recomputing every tare against the + // curves it just applied; if the tare file were read after that call, the recompute would run + // over an empty map and never happen. The case that bites is a calibration profile swapped on + // disk while this service was down: the offsets on disk belong to the old curves, and without + // the startup recompute a tared tank reads a wrong nonzero at rest with nothing to explain it. + const size_t tares_loaded = lc_tare_store.load(); + if (tares_loaded > 0) + std::cout << "[Calibration] LC tare: resumed " << tares_loaded + << " standing tare(s) from lc_tare.json" << std::endl; + // Resume previously captured points + learned robust state from disk. const size_t cubic_loaded = reload_live_store(/*restore_learned=*/true); if (cubic_loaded > 0) std::cout << "[Calibration] Cubic: resumed " << cubic_loaded << " channel(s) from cubic_calibration.json" << std::endl; + // Audit, not a second recompute: by here every restored tare should already have been + // re-derived by the reload above, so the expected answer is zero. A non-zero answer means a + // curve moved without its recompute running — most likely this file was read after the + // reload rather than before it — and the offsets it just fixed were being applied against a + // curve that no longer exists. Self-healing, but never silently. + { + lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); + const size_t stale = lc_tare_store.recompute_stale([&](uint16_t u) { + return lc_eval_for(u); + }); + if (stale > 0) { + std::cout << "[Calibration] LC tare: WARNING — " << stale + << " tare(s) were stale against the live curves and have been re-derived. " + "A curve changed without recomputing its tare; check that lc_tare_store" + ".load() still runs BEFORE the first reload_live_store()." + << std::endl; + lc_tare_store.save(); + } + } // Always persist at startup so the record (with each channel's active_model) exists immediately // — the UI / cal_model_select read it before any capture. cubic_store.save(); @@ -1488,6 +1575,63 @@ int main(int argc, char* argv[]) { const size_t n = reload_live_store(/*restore_learned=*/false); std::cout << "[Cal] Reloaded live calibration store (" << n << " channel(s)) after profile swap" << std::endl; + } else if (cmd_type == 8) { // LC tare — display only, never touches the fit + // ref_val: 0 = set, 1 = clear. sensor_id 0 = every LC channel. + // + // A tare is NOT a captured zero, and this is the one command where that + // distinction is the whole point. Zero-All (cmd 0) records a real 0 kg + // reference point into the shared fit, which is correct for a vented PT and + // wrong for a load cell holding a tank: that tank is not at 0 kg, so the + // point would be false and, because the fit is least-squares over every + // point, it would tilt the whole cubic rather than shift its intercept. + const bool clearing = ref_val >= 0.5f; + std::vector targets; + if (sensor_id == 0) { + if (clearing) { + targets = lc_tare_store.uids(); + } else { + // Only channels actually streaming can be tared — same rule Zero-All + // uses. A uid in a last-value map may have had its board go away. + for (uint16_t id : capture_window.uids()) + if (is_lc_uid(id)) + targets.push_back(id); + } + } else if (is_lc_uid(sensor_id)) { + targets.push_back(sensor_id); + } else { + std::cout << "[Cal] Tare: uid " << static_cast(sensor_id) + << " is not a load cell — ignored" << std::endl; + } + + size_t done = 0; + for (uint16_t id : targets) { + if (clearing) { + lc_tare_store.clear(id); + ++done; + continue; + } + // Through the capture window, never last_adc_map: a tare taken from a + // stale code silently biases every reading on the channel for the rest + // of the run, which is the same failure take_capture exists to refuse. + const fsw::calibration::CaptureResult r = take_capture(id, "Tare"); + if (!r.ok) + continue; + const uint8_t board_id = static_cast(id / 100); + const uint8_t connector = static_cast(id % 100); + if (lc_tare_store.set(id, + fsw::calibration::lc_tare_entity(board_id, connector), + r.adc_avg, lc_eval_for(id))) { + ++done; + const fsw::calibration::LcTare* t = lc_tare_store.tare_for(id); + std::cout << "[Cal] Tare uid=" << static_cast(id) << " " + << capture_detail(r) + << " offset=" << (t != nullptr ? t->offset_kg : 0.0) << "kg" + << std::endl; + } + } + lc_tare_store.save(); + std::cout << "[Cal] " << (clearing ? "Tare clear" : "Tare") << ": " << done + << " load cell(s)" << std::endl; } } continue; diff --git a/daq-server/diablo_server/shared/types.d.ts b/daq-server/diablo_server/shared/types.d.ts index 94478b21..5d727a5a 100644 --- a/daq-server/diablo_server/shared/types.d.ts +++ b/daq-server/diablo_server/shared/types.d.ts @@ -233,7 +233,7 @@ export interface CalibrationStatusPayload { calibrationFilePath?: string | null; } /** Commands the frontend sends to drive the calibration engine */ -export type CalibrationCommandType = 'capture_reference' | 'fit_channel' | 'reset_channel' | 'enable_phase2' | 'disable_phase2' | 'zero_all' | 'save_coefficients' | 'clear_calibration' | 'capture_cubic_point' | 'clear_cubic_channel' | 'capture_point' | 'new_calibration'; +export type CalibrationCommandType = 'capture_reference' | 'fit_channel' | 'reset_channel' | 'enable_phase2' | 'disable_phase2' | 'zero_all' | 'save_coefficients' | 'clear_calibration' | 'capture_cubic_point' | 'clear_cubic_channel' | 'capture_point' | 'new_calibration' | 'tare_lc' | 'clear_tare_lc'; export interface CalibrationCommand { commandType: CalibrationCommandType; sensorId?: number; diff --git a/daq-server/diablo_server/shared/types.d.ts.map b/daq-server/diablo_server/shared/types.d.ts.map index 959a950b..ff6c7549 100644 --- a/daq-server/diablo_server/shared/types.d.ts.map +++ b/daq-server/diablo_server/shared/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/daq-server/diablo_server/shared/types.js.map b/daq-server/diablo_server/shared/types.js.map index 182fbd2a..98483dec 100644 --- a/daq-server/diablo_server/shared/types.js.map +++ b/daq-server/diablo_server/shared/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/daq-server/diablo_server/shared/types.ts b/daq-server/diablo_server/shared/types.ts index f7346475..93e738bf 100644 --- a/daq-server/diablo_server/shared/types.ts +++ b/daq-server/diablo_server/shared/types.ts @@ -298,7 +298,9 @@ export type CalibrationCommandType = | 'capture_cubic_point' // add one (current ADC, ref PSI) point to a channel's cubic fit | 'clear_cubic_channel' // drop a channel's cubic points and revert to the factory cubic | 'capture_point' // unified: add one (current ADC, ref PSI) point; service routes by config model - | 'new_calibration'; // unified: start fresh for a channel; service routes clear by config model + | 'new_calibration' // unified: start fresh for a channel; service routes clear by config model + | 'tare_lc' // load cell: display-only zero at the current load; never enters the fit + | 'clear_tare_lc'; // load cell: drop the tare, back to absolute export interface CalibrationCommand { commandType: CalibrationCommandType; diff --git a/daq-server/docs/adding-sensor-streams.md b/daq-server/docs/adding-sensor-streams.md index 8a2557a9..ac44140a 100644 --- a/daq-server/docs/adding-sensor-streams.md +++ b/daq-server/docs/adding-sensor-streams.md @@ -29,6 +29,8 @@ Each sensor type gets a **VTable ID** — a two-byte tuple `[high, low]` that un | `0x22` | `0x11-0x14` | RTD calibrated | `temperature_c` | 21 bytes | | `0x23` | `0x01-0x14` | LC raw | `raw_adc_counts` | 21 bytes | | `0x23` | `0x11-0x24` | LC calibrated | `force_units` | 21 bytes | +| `0x24` | `0x01-0x14` | Encoder raw | `raw_adc_counts` | 21 bytes | +| `0x24` | `0x11-0x24` | Encoder calibrated | `position_deg` | 21 bytes | | `0x30` | `0x01-0x0A` | Actuator feedback | `raw_adc_counts` | 21 bytes | | `0x31` | `0x01-0x14` | Actuator state (current-sense) | `actuator_state` | 10 bytes | | `0x32` | `0x01-0x14` | Actuator commanded state | `actuator_state_commanded` | 10 bytes | @@ -37,12 +39,65 @@ Each sensor type gets a **VTable ID** — a two-byte tuple `[high, low]` that un | `0x42` | `0x00` | Controller measurement | - | 80 bytes | | `0x43` | `0x00` | PSM state transition | - | 11 bytes | | `0x44` | `0x00` | FIRE state | - | 18 bytes | +| `0x46` | `0x00` | CalibrationCommand (backend → calibration_service) | `type` + `sensor_id` + `reference_value` | 16 bytes | | `0x50` | `0x00` | SequencerState | state + bitmask + debug | **17 bytes** (see below) | | `0x50` | `0x60-0x66` | PSM actuator commands | - | 15 bytes | | `0x60` | `0x01-0xFF` | Self-test results | `sensor_id` + `result` | 10 bytes | **Raw vs Calibrated convention:** Raw channels use `low = channel_id` (1-based). Calibrated channels use `low = 0x10 + channel_id`. Example: PT channel 3 raw = `[0x20, 0x03]`, calibrated = `[0x20, 0x13]`. +**Free high bytes**, if you need a new stream: `0x25`, `0x45`, `0x47`–`0x4F`, `0x51`–`0x5F`. Anything +`>= 0x80` is off limits: by convention a high byte of `0x80` or above is a VTable **registration +ACK** rather than data, and `calibration_main.cpp` reads it that way (see its `type_hi < 0x80` +packet filter). Add a row here when you take one, or the next person reads a stale map +(this table sat without `0x24` and `0x46` for a while, which is how you end up debugging a +collision instead of picking a free byte). + +## CalibrationCommand `[0x46, 0x00]` + +The one operator-command channel into `calibration_service`. **Strictly one-way**: the service +subscribes and never publishes a reply, so nothing can wait on an acknowledgement. Callers learn +what happened by reading the file the service writes — `cubic_calibration.json` for captures, +`lc_tare.json` for tares — which is also why a UI must never assume a command landed because it +was sent. A command published while the service is down is silently dropped. + +Layout (16 bytes, and note byte 9 is covered by no field, so Elodin zeroes it — `sensor_id` sits at +the even offset 10 precisely so its high byte survives, since `uid = board_id*100 + connector` +exceeds 255): + +``` +Offset Size Type Field +0 8 uint64 timestamp_ns +8 1 uint8 type (cmd_type, below) +9 1 - padding (NOT a field — always arrives 0) +10 2 uint16 sensor_id (uid = board_id*100 + connector; 0 = "all") +12 4 float32 reference_value +``` + +| cmd | name | `sensor_id` | `reference_value` | effect | +|---|---|---|---|---| +| 0 | Zero / Zero All | uid, or 0 for all | — | Captures a **real** 0 reference point into the shared fit. Correct for a vented PT; see the note below for why it is not a load-cell tare. | +| 1 | Capture Reference | uid | reference | Feeds the robust learner only (PT). | +| 2 | Save | — | — | Persists robust adjustments. | +| 3 | Capture cubic point | uid | reference | Legacy; equivalent to 5 without the capture-quality record. | +| 4 | Clear channel | uid | — | Drops points + curve. | +| 5 | Capture point | uid | reference | The unified capture the UI uses; routed by the channel's configured model. | +| 6 | New calibration | uid | — | Unified clear. | +| 7 | Reload live store | — | — | Re-read `cubic_calibration.json` after the backend swapped a profile. | +| 8 | **LC tare** | uid, or 0 for all | `0` = set, `1` = clear | Display-only zero for a load cell. Never enters a fit, never reaches control or abort, never changes what Elodin records. | + +**Why a load-cell tare is command 8 and not command 0.** A vented PT genuinely *is* at 0 psig, so +capturing a zero on one is a true reference point and belongs in the shared fit. A load cell +holding a tank is *not* at 0 kg: the same capture would inject a false point, and because the fit +is least-squares over every point it would tilt the whole cubic rather than shift its intercept. + +The tare is therefore stored as the **ADC code** it was taken at, never as kilograms — `offset_kg` +in `lc_tare.json` is a cache the service re-derives on every capture, clear and profile swap. Tare +a 20 kg tank against a poor two-point fit that reads it as 18, then improve the fit until the same +tank evaluates to 20, and a frozen 18 kg offset would display 2 kg for a tank that never moved. +Re-deriving from the code gives 20 − 20 = 0. `test/ws_data_flow_test.ts`'s `cal_lc_tare` check +proves this through the full stack; `diablo_server/lib/test/test_lc_tare.cpp` pins the store. + ## Standard 21-Byte Sensor Message Layout All raw and calibrated sensor messages use this layout: diff --git a/daq-server/test/test_integration.sh b/daq-server/test/test_integration.sh index af208c55..00ed37e8 100644 --- a/daq-server/test/test_integration.sh +++ b/daq-server/test/test_integration.sh @@ -398,6 +398,19 @@ sedi 's/^bind_ip = .*/bind_ip = "127.0.0.1"/' "$TEST_CONFIG" # just has to be distinguishable from FireManager's 6000 ms default — no need to sit through a # realistic burn on every CI run. sedi 's/^duration_ms = .*/duration_ms = 1500/' "$TEST_CONFIG" +# Put LC board 2 CH1 in CUBIC mode (config_base leaves every load cell on the datasheet physics +# conversion, where select_lc_kg ignores captures entirely). cal_lc_tare needs a channel whose +# curve an operator can actually move, because the property it exists to prove is that a tare +# re-derives its kilograms from the stored ADC code when the curve changes underneath it. The +# other two connectors (2, 6) stay on physics, so cal_lc_capture and cal_stability are unaffected. +cat >> "$TEST_CONFIG" <<'LCCUBIC' + +[sensor_roles_lc_board_2] +"Thrust" = 1 + +[calibration_model_lc_board_2] +"Thrust" = "cubic" +LCCUBIC sedi 's/^extended_ms = .*/extended_ms = 3000/' "$TEST_CONFIG" # ── Flow Test: a gated timed hold, constructed here rather than shipped ─────────────────────── # The sequencer can time a hold around ONE actuator rather than around the state, adding that @@ -696,6 +709,11 @@ if [ -n "$CALIB_SVC" ]; then # a fresh checkout does — otherwise a stale cubic_calibration.json from a prior local run would # carry captured cubics into this run. The service regenerates it at startup. rm -f "$REPO_ROOT/scripts/calibration/calibrations/cubic_calibration.json" 2>/dev/null || true + # Same isolation for load-cell tares: a tare left by a previous run would be re-applied to this + # one's stream, and cal_lc_tare's first assertion (untared trace == absolute trace) would fail + # for a reason that has nothing to do with the code under test. The backend clears this at + # session start in production; the integration stack has no session, so do it here. + rm -f "$REPO_ROOT/scripts/calibration/calibrations/lc_tare.json" 2>/dev/null || true (cd "$REPO_ROOT" && "$CALIB_SVC" --config "$TEST_CONFIG" --adjustments "$CAL_ADJ" \ --elodin-host 127.0.0.1 --elodin-port "$TEST_ELODIN_PORT" \ > "$REPO_ROOT/.tmp/integration_calibration_$$.log" 2>&1) & diff --git a/daq-server/test/ws_data_flow_test.ts b/daq-server/test/ws_data_flow_test.ts index b5d47ef4..fd73222d 100644 --- a/daq-server/test/ws_data_flow_test.ts +++ b/daq-server/test/ws_data_flow_test.ts @@ -15,7 +15,7 @@ * bash test/test_integration.sh --only=sensor_data * * --only runs a subset of tests (comma-separated). IDs: sensor_config, sensor_data, - * cal_stability, raw_cal_presence, heartbeat, board_status (Boards pane: all enabled boards connected), + * cal_stability, cal_lc_tare, raw_cal_presence, heartbeat, board_status (Boards pane: all enabled boards connected), * selftest, state_transition, * state_debug, actuator_ws, actuator_udp, elodin_sync, controller, timestamps, * conservation, config_validate — or numbers 1–6, 10–12, 14–15 @@ -94,6 +94,7 @@ function parseOnlyTests(): Set | null { const allowed = new Set([ 'sensor_config', 'sensor_data', 'cal_stability', 'raw_cal_presence', 'cal_values', 'cal_model_select', 'cal_robust_learn', 'cal_shared_points', 'cal_clear', 'cal_lc_capture', + 'cal_lc_tare', 'heartbeat', 'board_status', 'selftest', 'backend_debug_api', 'state_transition', 'state_debug', 'actuator_ws', 'actuator_udp', 'elodin_sync', 'controller', 'timestamps', 'conservation', 'board_logs', 'board_log_mode', @@ -2125,6 +2126,136 @@ function readCalRecord(uid: number): Record | null { } catch { return null; } } +// Read the service's lc_tare.json entry for a cal entity (fresh on every tare/recompute/clear). +function readTareRecord(entity: string): Record | null { + const dir = findCalDir(); + if (!dir) return null; + try { + const j = JSON.parse(fs.readFileSync(`${dir}/lc_tare.json`, 'utf-8')); + const tares = Array.isArray(j?.tares) ? j.tares : []; + return (tares.find((t: Record) => t.entity === entity) as Record) ?? null; + } catch { return null; } +} + +/** Mean of a component's SENSOR_UPDATE values over `ms`, or null if none arrived. */ +async function meanOf(ws: WebSocket, entity: string, component: string, ms: number): Promise { + const vals: number[] = []; + await new Promise((resolve) => { + const handler = (data: WebSocket.Data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type !== MessageType.SENSOR_UPDATE) return; + const p = msg.payload; + if (p?.entity === entity && p?.component === component && Number.isFinite(p.value)) vals.push(p.value); + } catch { /* ignore */ } + }; + ws.on('message', handler); + setTimeout(() => { ws.removeListener('message', handler); resolve(); }, ms); + }); + if (vals.length === 0) return null; + return vals.reduce((a, b) => a + b, 0) / vals.length; +} + +// ── Test: the load-cell tare, end to end through every process in the stack ────────────────── +// +// This is the only check that exercises the whole loop the feature actually lives in: +// WS client → backend → Elodin [0x46,0x00] → calibration_service → lc_tare.json +// → backend mtime poll → force_kg_tared over WS → back to this client. +// +// Four things are asserted, each of which is a wrong number an operator would otherwise believe: +// 1. an untared channel's tared trace equals its absolute one (a 0 offset is not a dead stream); +// 2. after a tare the tared trace sits at ~0 while force_kg KEEPS reading the real load — +// Elodin's archive stays absolute, which is the premise the whole design rests on; +// 3. a capture that moves the curve RE-DERIVES the offset from the stored ADC code rather than +// leaving the kilograms it was first computed with. That is the "tank reads 2 kg after a +// better fit" bug, and this is the only place it is proved through the real service; +// 4. clearing returns the tared trace to absolute. +async function testLcTare(ws: WebSocket): Promise { + console.log('\n⚖️ Test 22: LC tare end-to-end (display tared, archive absolute)'); + const CH = 1, BOARD = 42, UID = BOARD * 100 + CH; // lc_board_2, active_connectors incl. 1 + const ENTITY = 'LC2_Cal.CH1'; + const SETTLE_MS = 2500; + + // This channel is put in CUBIC mode by test_integration.sh. That matters: on the datasheet + // physics conversion (every load cell's default) a capture cannot move the curve at all, so + // the re-derivation this test exists to prove would be unobservable. Build a curve first. + const captureAt = async (ref: number) => { + for (let i = 0; i < 10; i++) { + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'capture_point', sensorId: CH, boardId: BOARD, referencePressure: ref } }); + await sleep(120); + } + await sleep(1500); + }; + await captureAt(100); + + // ── 1. untared: the derived trace must equal the absolute one ────────────── + const grossBefore = await meanOf(ws, ENTITY, 'force_kg', SETTLE_MS); + const taredBefore = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + if (grossBefore === null || taredBefore === null) { + assert(false, `cal_lc_tare: no ${ENTITY} force_kg/force_kg_tared traffic (gross=${grossBefore} tared=${taredBefore})`); + return; + } + assert(Math.abs(grossBefore - taredBefore) < 0.5, + `cal_lc_tare: untared, tared trace tracks absolute (gross ${grossBefore.toFixed(2)} vs tared ${taredBefore.toFixed(2)})`); + + // ── 2. tare: display goes to ~0, archive keeps the real load ─────────────── + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'tare_lc', sensorId: CH, boardId: BOARD } }); + let rec: Record | null = null; + for (let i = 0; i < 12; i++) { rec = readTareRecord(ENTITY); if (rec) break; await sleep(400); } + if (!rec) { assert(false, `cal_lc_tare: service wrote no tare for ${ENTITY} (uid ${UID})`); return; } + const offset1 = rec.offset_kg as number; + const adcAtTare = rec.adc_at_tare as number; + console.log(` tared: offset=${offset1?.toFixed?.(3)}kg adc_at_tare=${adcAtTare}`); + assert(Number.isFinite(offset1), `cal_lc_tare: offset is a finite number (${offset1})`); + + const grossAfter = await meanOf(ws, ENTITY, 'force_kg', SETTLE_MS); + const taredAfter = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + assert(taredAfter !== null && Math.abs(taredAfter) < 0.5, + `cal_lc_tare: tared trace reads ~0 at the tared load (${taredAfter?.toFixed(3)} kg)`); + assert(grossAfter !== null && Math.abs(grossAfter - grossBefore) < 0.5, + `cal_lc_tare: force_kg stays ABSOLUTE — the archive never sees the tare (${grossBefore.toFixed(2)} → ${grossAfter?.toFixed(2)})`); + + // ── 3. the re-cal case: a moved curve must re-derive the offset ──────────── + // Capture at a reference far from the first batch, so the fit moves and the SAME ADC code now + // evaluates to something else. A tare that had stored KILOGRAMS would keep offset1 here, and + // the unchanged physical load would stop reading zero — the "tank reads 2 kg after a better + // fit" bug, observed through the real service rather than a unit-test stub. + await captureAt(250); + let rec2: Record | null = null; + for (let i = 0; i < 12; i++) { + rec2 = readTareRecord(ENTITY); + if (rec2 && (rec2.offset_kg as number) !== offset1) break; + await sleep(400); + } + const offset2 = rec2?.offset_kg as number; + console.log(` after re-cal: offset=${offset2?.toFixed?.(3)}kg adc_at_tare=${rec2?.adc_at_tare}`); + assert(rec2 !== null && (rec2.adc_at_tare as number) === adcAtTare, + `cal_lc_tare: adc_at_tare is the stored truth and does not move (${adcAtTare} → ${rec2?.adc_at_tare})`); + assert(Number.isFinite(offset2) && offset2 !== offset1, + `cal_lc_tare: a changed curve RE-DERIVES the offset (${offset1?.toFixed?.(3)} → ${offset2?.toFixed?.(3)} kg) — not a stale kg value`); + const taredRecal = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + assert(taredRecal !== null && Math.abs(taredRecal) < 0.5, + `cal_lc_tare: the same load still reads ~0 under the new curve (${taredRecal?.toFixed(3)} kg)`); + + // ── 4. clear: back to absolute ───────────────────────────────────────────── + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'clear_tare_lc', sensorId: CH, boardId: BOARD } }); + let gone = false; + for (let i = 0; i < 12; i++) { if (!readTareRecord(ENTITY)) { gone = true; break; } await sleep(400); } + assert(gone, 'cal_lc_tare: clearing removes the tare from the store'); + const taredCleared = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + const grossCleared = await meanOf(ws, ENTITY, 'force_kg', SETTLE_MS); + assert(taredCleared !== null && grossCleared !== null && Math.abs(taredCleared - grossCleared) < 0.5, + `cal_lc_tare: cleared, tared trace tracks absolute again (${taredCleared?.toFixed(2)} vs ${grossCleared?.toFixed(2)})`); + + // Leave the shared cubic store as we found it. + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'new_calibration', sensorId: CH, boardId: BOARD } }); + await sleep(1500); +} + // ── Test: one capture feeds BOTH the cubic fit and the robust learner (shared points) ─ // The headline guarantee of the merge. Captures on a CUBIC sensor must land in the cubic store's // points AND be fed to the robust learner — the service samples robust into `fitCurve` for any @@ -3158,6 +3289,7 @@ async function main(): Promise { if (IS_THIN && canRunCommandTests && runTest('cal_shared_points')) await testSharedPoints(ws); if (IS_THIN && canRunCommandTests && runTest('cal_clear')) await testClearToNothing(ws); if (IS_THIN && canRunCommandTests && runTest('cal_lc_capture')) await testLcCapture(ws); + if (IS_THIN && canRunCommandTests && runTest('cal_lc_tare')) await testLcTare(ws); } finally { ws.close(); } diff --git a/daq-server/tools/postprocessing/webviewer/backend/lc_tare.py b/daq-server/tools/postprocessing/webviewer/backend/lc_tare.py new file mode 100644 index 00000000..29b48285 --- /dev/null +++ b/daq-server/tools/postprocessing/webviewer/backend/lc_tare.py @@ -0,0 +1,127 @@ +"""Load-cell tares recorded during a run, and the tared series derived from them. + +Elodin records ABSOLUTE force, always and forever — `LC_Cal.CH.force_kg` means the same +thing in every run ever archived, and this feature did not change that. A tare is display state: +the backend subtracts it on the way to the browser and appends what it did to +`/lc_tare.jsonl`, so what the operator was looking at stays reconstructible afterwards. + +This module replays that record. It exposes the reconstruction as a synthetic component, +`…force_kg_tared`, rather than as a toggle on the real one. A toggle would be a query parameter +threaded through three independent read paths (series_json, long_csv_rows, wide_csv_rows), and +missing one means the exported CSV disagrees with the plot the operator was looking at. As a +component name there is a single choke point in load_series, and series, both CSV shapes and the +download all follow for free. + +The sidecar is append-only, one JSON object per line: + + {"entity":"LC2_Cal.CH1","uid":4201,"event":"set","offsetKg":20.13, + "adcAtTare":8412331,"setAtMs":...,"appliedAtMs":1757800123456} + +`appliedAtMs` is the instant the published stream changed, not when a button was pressed — the +backend writes the line from the same code that changes the subtraction. `event` is "set" (a new +tare), "recal" (a re-fit moved the offset with no operator action) or "clear". A recal line is +load-bearing: ignore it and every reconstruction is wrong from the re-fit onward. + +There is no terminal line at session stop, so a tare with no following "clear" was in effect to +the end of the run. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from . import config + +#: The component this module synthesises, and the real one it derives from. +TARED_SUFFIX = ".force_kg_tared" +GROSS_FIELD = "force_kg" + + +def sidecar_path(run_id: str) -> Path: + """Inside the run dir, beside the DB — not a sibling like .toml. + + That file is a sibling only because it is written before elodin-db creates the directory. + This one is written mid-run when the directory certainly exists, and living inside it means + the run's own deletion takes it too, with no orphan class to clean up. + """ + return config.ELODIN_DIR / run_id / "lc_tare.jsonl" + + +def load(run_id: str) -> dict[str, list[tuple[float, float]]]: + """entity -> [(applied_at_seconds, offset_kg), ...] ascending. + + Missing or malformed → {}. A viewer must never fail to open a run over its metadata, and a + partially-written last line is expected: the file is appended to while the run is live. + """ + p = sidecar_path(run_id) + try: + text = p.read_text() + except OSError: + return {} + + events: dict[str, list[tuple[float, float]]] = {} + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + # A torn final line is normal mid-run; earlier lines stay usable. + continue + entity = rec.get("entity") + applied = rec.get("appliedAtMs") + if not isinstance(entity, str) or not isinstance(applied, (int, float)): + continue + offset = 0.0 if rec.get("event") == "clear" else rec.get("offsetKg") + if not isinstance(offset, (int, float)) or not np.isfinite(float(offset)): + continue + events.setdefault(entity, []).append((float(applied) / 1000.0, float(offset))) + + for ev in events.values(): + ev.sort(key=lambda e: e[0]) + return events + + +def tared_components(run_id: str, components: list[dict]) -> list[dict]: + """The synthetic component entries to add to an index, one per tared LC channel. + + Only channels the sidecar actually names get one. Synthesising a tared twin for every load + cell would make an untared run indistinguishable from a tared one whose offsets happened to + be zero — the toggle would be lying about whether a tare existed. + """ + events = load(run_id) + if not events: + return [] + out = [] + for comp in components: + if comp.get("field") != GROSS_FIELD: + continue + entity = comp.get("entity", "") + if entity not in events: + continue + d = dict(comp) + d["name"] = f"{entity}{TARED_SUFFIX}" + d["field"] = "force_kg_tared" + out.append(d) + return out + + +def apply(run_id: str, entity: str, t: np.ndarray, v: np.ndarray) -> np.ndarray: + """Subtract the offset that was in effect at each sample time. + + A step function, not one offset across the whole array: a tare set partway through a run must + leave everything before it absolute, or the pre-tare portion of every run reads wrong. + """ + events = load(run_id).get(entity) + if not events or len(t) == 0: + return v + times = np.array([e[0] for e in events], dtype=float) + offsets = np.array([e[1] for e in events], dtype=float) + # side="right": a sample exactly at the applied instant already carried the new offset. + idx = np.searchsorted(times, t, side="right") - 1 + applied = np.where(idx >= 0, offsets[np.clip(idx, 0, len(offsets) - 1)], 0.0) + return v - applied diff --git a/daq-server/tools/postprocessing/webviewer/backend/naming.py b/daq-server/tools/postprocessing/webviewer/backend/naming.py index e72fb0ad..ec5925fa 100644 --- a/daq-server/tools/postprocessing/webviewer/backend/naming.py +++ b/daq-server/tools/postprocessing/webviewer/backend/naming.py @@ -58,7 +58,7 @@ # resistance (RTD raw) "raw_resistance": "Ω", "raw_resistance_counts": "counts", # force (load cells + thrust estimate/reference) - "force_n": "N", "force_kg": "kg", + "force_n": "N", "force_kg": "kg", "force_kg_tared": "kg", "f_ref": "N", "f_estimated": "N", # current (actuator sense) "current_a": "A", diff --git a/daq-server/tools/postprocessing/webviewer/backend/run_config.py b/daq-server/tools/postprocessing/webviewer/backend/run_config.py index 69f886b9..6b187e53 100644 --- a/daq-server/tools/postprocessing/webviewer/backend/run_config.py +++ b/daq-server/tools/postprocessing/webviewer/backend/run_config.py @@ -26,7 +26,7 @@ import tomllib from pathlib import Path -from . import config +from . import config, lc_tare # The state ids as the C++ enum defines them (control/StateMachine.hpp), named the way # config_base.toml's [[states]] does. Only a fallback: a snapshot's [[states]] wins. @@ -205,6 +205,15 @@ def annotate(index: dict, run_id: str) -> dict: takes effect on the next open instead of needing the export thrown away. """ cfg = load(run_id) + # Tared load-cell channels are synthesised HERE rather than in build_index, for the same + # reason names are: the sidecar is a separate file from the parquet cache, so a run exported + # before its tare record was written still shows its tared traces on the next open instead of + # needing the export thrown away. This is also why INDEX_VERSION does not move for this + # feature — bumping it would invalidate every cached export on the box for nothing. + comps = index.get("components", []) + comps.extend(lc_tare.tared_components(run_id, comps)) + index["components"] = comps + labels = entity_labels(cfg) for comp in index.get("components", []): comp["label"] = label_for(comp.get("entity", ""), labels) diff --git a/daq-server/tools/postprocessing/webviewer/backend/series.py b/daq-server/tools/postprocessing/webviewer/backend/series.py index 66327b0c..c5090cfb 100644 --- a/daq-server/tools/postprocessing/webviewer/backend/series.py +++ b/daq-server/tools/postprocessing/webviewer/backend/series.py @@ -57,7 +57,7 @@ import numpy as np import pandas as pd -from . import export_cache, run_config +from . import export_cache, lc_tare, run_config from .naming import classify @@ -152,6 +152,18 @@ def load_series( With time_source="sensor" the x-axis is the row's own sample time where the publisher stamps a real clock, falling back to the DB's write time where it does not.""" + # A tared load-cell channel is derived, not exported: read its absolute twin and replay the + # run's tare record over it. Done here because this is the one place a component name becomes + # data, so series, long CSV, wide CSV and the whole-run download all get it with no signature + # change — and cannot disagree with each other, which a per-request toggle would allow. + tared = component.endswith(lc_tare.TARED_SUFFIX) + if tared: + entity = component[: -len(lc_tare.TARED_SUFFIX)] + if not lc_tare.load(run_id).get(entity): + # No recorded tare for this channel: the component does not exist for this run. + raise FileNotFoundError(f"component not in run: {component}") + component = f"{entity}.{lc_tare.GROSS_FIELD}" + df = pd.read_parquet(_parquet_path(run_id, component)) value_col = next(c for c in df.columns if c != "time") t = _epoch_seconds(df["time"]) @@ -164,7 +176,13 @@ def load_series( if own is not None and len(own[0]) == len(t): t = own[0] order = np.argsort(t, kind="stable") - return t[order], v[order] + t, v = t[order], v[order] + if tared: + # After the sort and after the clock substitution: the sidecar stamps wall-clock, which is + # the same clock the sensor axis uses. On the DB axis the same instant sits a few ms later + # (write latency), which shifts where the step lands by less than one sample. + v = lc_tare.apply(run_id, entity, t, v) + return t, v def _slice(t: np.ndarray, v: np.ndarray, start, end) -> tuple[np.ndarray, np.ndarray]: diff --git a/daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py b/daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py new file mode 100644 index 00000000..57df11b7 --- /dev/null +++ b/daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py @@ -0,0 +1,192 @@ +"""Load-cell tare reconstruction in the viewer. + +Run: cd webviewer && .venv/bin/python -m pytest backend/test_lc_tare.py -q + +Hermetic — no elodin-db and no real run. Elodin archives ABSOLUTE force forever; the tare is +display state the backend recorded beside the run, and these tests pin the replay of it. + +Every case names the wrong number it prevents: + * a step applied across the whole array makes the PRE-tare part of every run read wrong; + * a missed 'recal' line makes everything after a re-fit read wrong; + * synthesising a tared twin unconditionally makes an untared run look tared; + * synthesising in build_index instead of at serve time means a run exported before its sidecar + landed never shows its tare, with the cached index quietly winning. +""" + +from __future__ import annotations + +import json + +import numpy as np +import pandas as pd +import pytest + +from . import config, export_cache, lc_tare, run_config, series + + +@pytest.fixture +def run(tmp_path, monkeypatch): + """A scratch run: parquet cache and ELODIN_DIR both under tmp_path.""" + cache = tmp_path / "cache" + cache.mkdir() + elodin = tmp_path / "elodin" + (elodin / "daq_20260914_120000").mkdir(parents=True) + monkeypatch.setattr(config, "ELODIN_DIR", elodin) + monkeypatch.setattr(export_cache, "cache_dir", lambda run_id: cache) + # Mandatory for anything touching load_series: both caches are keyed on (run_id, component) + # and would serve a previous test's arrays. + series.load_series.cache_clear() + series.sensor_clock.cache_clear() + return {"id": "daq_20260914_120000", "cache": cache, "elodin": elodin} + + +def write_gross(run, entity="LC2_Cal.CH1", times=(100.0, 200.0, 300.0), values=(50.0, 51.0, 52.0)): + df = pd.DataFrame({ + "time": pd.to_datetime(np.array(times) * 1e9, unit="ns"), + "value": list(values), + }) + df.to_parquet(run["cache"] / f"{entity}.force_kg.parquet") + + +def write_sidecar(run, lines): + p = run["elodin"] / run["id"] / "lc_tare.jsonl" + p.write_text("".join(json.dumps(l) + "\n" for l in lines)) + + +# ── the step function ─────────────────────────────────────────────────────── + +def test_offset_applies_only_after_the_tare_was_set(run): + # Subtracting the final offset across the whole array would make every sample before the + # operator pressed Tare read 20 kg light — the pre-tare part of the run, silently wrong. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "uid": 4201, "event": "set", + "offsetKg": 20.0, "adcAtTare": 1000, "appliedAtMs": 250_000}, + ]) + t, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert list(t) == [100.0, 200.0, 300.0] + assert v == pytest.approx([50.0, 51.0, 32.0]) + + +def test_a_recal_line_moves_the_offset_again(run): + # A re-fit rewrites the offset with NO operator action. Reading only the first line leaves + # everything after the re-cal wrong by the difference. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + {"entity": "LC2_Cal.CH1", "event": "recal", "offsetKg": 22.0, "appliedAtMs": 250_000}, + ]) + _, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert v == pytest.approx([50.0, 31.0, 30.0]) + + +def test_a_clear_returns_the_trace_to_absolute(run): + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + {"entity": "LC2_Cal.CH1", "event": "clear", "offsetKg": 0.0, "appliedAtMs": 250_000}, + ]) + _, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert v == pytest.approx([50.0, 31.0, 52.0]) + + +def test_the_absolute_series_is_never_touched(run): + # The whole premise: force_kg means the same thing in every run ever archived. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + _, gross = series.load_series(run["id"], "LC2_Cal.CH1.force_kg") + assert gross == pytest.approx([50.0, 51.0, 52.0]) + + +def test_gross_and_tared_do_not_share_a_cache_entry(run): + # Both go through one lru_cache keyed on (run_id, component, time_source). Stripping the + # suffix before the lookup would make the tared request return the absolute arrays. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + _, gross = series.load_series(run["id"], "LC2_Cal.CH1.force_kg") + _, tared = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert list(gross) != list(tared) + + +# ── existence ─────────────────────────────────────────────────────────────── + +def test_no_sidecar_means_no_tared_component(run): + # Synthesising one anyway would render identically to gross and lie about whether a tare ever + # existed on this run. + write_gross(run) + with pytest.raises(FileNotFoundError): + series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + idx = run_config.annotate({"components": [ + {"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}, + ]}, run["id"]) + assert [c["name"] for c in idx["components"]] == ["LC2_Cal.CH1.force_kg"] + + +def test_a_channel_with_no_tare_gets_no_twin(run): + # Two load cells, one tared. The untared one must not grow a tared component. + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + idx = run_config.annotate({"components": [ + {"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}, + {"name": "LC2_Cal.CH2.force_kg", "entity": "LC2_Cal.CH2", "field": "force_kg"}, + ]}, run["id"]) + names = [c["name"] for c in idx["components"]] + assert "LC2_Cal.CH1.force_kg_tared" in names + assert "LC2_Cal.CH2.force_kg_tared" not in names + + +def test_a_sidecar_dropped_in_later_takes_effect_without_a_re_export(run): + # The reason synthesis lives in annotate() and not build_index(): a run indexed before its + # tare record was written must still show its tared traces on the next open. If this moved + # into build_index the cached index would win and the tare would never appear. + comps = [{"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}] + before = run_config.annotate({"components": list(comps)}, run["id"]) + assert "LC2_Cal.CH1.force_kg_tared" not in [c["name"] for c in before["components"]] + + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + after = run_config.annotate({"components": list(comps)}, run["id"]) + assert "LC2_Cal.CH1.force_kg_tared" in [c["name"] for c in after["components"]] + + +def test_index_version_did_not_move(run): + # Bumping it would invalidate every cached export on the postprocessing box, for a feature + # that is layered on at read time and needs no re-export at all. + assert export_cache.INDEX_VERSION == 3 + + +# ── degradation ───────────────────────────────────────────────────────────── + +def test_a_torn_final_line_does_not_lose_the_earlier_ones(run): + # The backend appends to this file while the run is live, so a reader can catch it mid-write. + p = run["elodin"] / run["id"] / "lc_tare.jsonl" + p.write_text( + json.dumps({"entity": "LC2_Cal.CH1", "event": "set", + "offsetKg": 20.0, "appliedAtMs": 150_000}) + "\n" + + '{"entity":"LC2_Cal.CH1","event":"rec' + ) + assert lc_tare.load(run["id"]) == {"LC2_Cal.CH1": [(150.0, 20.0)]} + + +def test_a_missing_sidecar_is_empty_not_an_error(run): + assert lc_tare.load(run["id"]) == {} + + +def test_a_non_finite_offset_is_skipped(run): + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": None, "appliedAtMs": 150_000}, + ]) + assert lc_tare.load(run["id"]) == {} + + +def test_the_unit_matches_its_absolute_twin(run): + # Different units put the two traces on different y-axes, so the tared and absolute lines for + # one load cell would not be comparable on the same chart. + from .naming import classify + assert classify("LC2_Cal.CH1.force_kg_tared").unit == classify("LC2_Cal.CH1.force_kg").unit