diff --git a/src/components/config/CalibrationModal.tsx b/src/components/config/CalibrationModal.tsx index 4a8a0ba..f23c1fc 100644 --- a/src/components/config/CalibrationModal.tsx +++ b/src/components/config/CalibrationModal.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Box, Flex, Input, Table, Text } from "@chakra-ui/react"; import { Card, Chip, Icon, Mono } from "@/components/primitives"; import { useNovaStore, sel } from "@/lib/store"; -import type { SensorEntry } from "@/lib/types"; +import { isRobustMethod, repeatedMedianFit, theilSenFit } from "@/lib/analysis/calibrate"; +import type { ConvertMethod, SensorEntry } from "@/lib/types"; import { TABLE_CSS, IconButton } from "./fields"; const CAPTURE_MS = 10_000; @@ -34,6 +35,14 @@ function linearFit(pairs: Pair[]): { m: number; b: number } | null { return { m, b }; } +/** The line the backend will apply for this sensor's conversion method. */ +function fitFor(method: ConvertMethod | undefined, pairs: Pair[]): { m: number; b: number } | null { + if (!isRobustMethod(method)) return linearFit(pairs); + const fit = method === "theil_sen" ? theilSenFit : repeatedMedianFit; + const line = fit(pairs.map((p) => p[0]), pairs.map((p) => p[1])); + return line && { m: line.slope, b: line.intercept }; +} + export interface CalibrationModalProps { sensor: SensorEntry; onSave: (calibration: Pair[]) => void; @@ -119,7 +128,7 @@ export function CalibrationModal({ sensor, onSave, onClose }: CalibrationModalPr // Δ = valueNow / m. Requires a trend (≥2 points), a non-flat slope, and a // live value. const liveValue = live?.value ?? null; - const fit = linearFit(pairs); + const fit = fitFor(sensor.convert?.method, pairs); const canZero = liveValue != null && fit != null && fit.m !== 0; function zeroAtCurrent() { diff --git a/src/components/config/SensorsTable.tsx b/src/components/config/SensorsTable.tsx index 6db494e..89ec9e3 100644 --- a/src/components/config/SensorsTable.tsx +++ b/src/components/config/SensorsTable.tsx @@ -17,7 +17,14 @@ import { CalibrationModal } from "./CalibrationModal"; const SENSOR_TYPES: SensorType[] = ["PT", "LC", "TC"]; const SOURCES = ["GCS", "TCS", "FAS"] as const; -const CONVERT_METHODS: ConvertMethod[] = ["none", "zero_offset", "linear", "polynomial"]; +const CONVERT_METHODS: ConvertMethod[] = [ + "none", + "zero_offset", + "linear", + "polynomial", + "theil_sen", + "repeated_median", +]; export interface SensorsTableProps { sensors: SensorEntry[]; diff --git a/src/lib/analysis/calibrate.ts b/src/lib/analysis/calibrate.ts index 72e8952..cfa8966 100644 --- a/src/lib/analysis/calibrate.ts +++ b/src/lib/analysis/calibrate.ts @@ -7,16 +7,75 @@ * linear interpolation between breakpoints; outside it, the end segment's slope * is extended rather than clamped, so an over-range reading stays visibly * over-range instead of flat-topping at the last calibrated point. + * + * The `theil_sen` and `repeated_median` methods instead fit one robust + * straight line through all the points, matching the backend's + * `data_service.py`, so a single bad point doesn't bend the result. The line + * also carries on past the outermost points. */ -import type { ConvertSpec, SensorBinding, SensorEntry } from "@/lib/types"; +import type { ConvertMethod, ConvertSpec, SensorBinding, SensorEntry } from "@/lib/types"; import type { ChannelBinding } from "./types"; +/** `value = slope * raw + intercept`. */ +export interface CalibrationLine { + slope: number; + intercept: number; +} + export interface CalibrationTable { /** Raw breakpoints, sorted ascending and de-duplicated. */ xs: Float64Array; /** Engineering values at each breakpoint. */ ys: Float64Array; + /** + * Set only for a robust method: the fitted line, used instead of the + * breakpoints. `null` when the points give no slope (fewer than two + * different raw values), and the raw value then passes through, as in the + * backend. + */ + line?: CalibrationLine | null; +} + +export type RobustMethod = "theil_sen" | "repeated_median"; + +export function isRobustMethod(method: ConvertMethod | undefined): method is RobustMethod { + return method === "theil_sen" || method === "repeated_median"; +} + +function median(values: number[]): number { + const s = [...values].sort((a, b) => a - b); + const mid = s.length >> 1; + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +/** Slopes from point i to every other point with a different raw value. */ +function slopesFrom(i: number, xs: number[], ys: number[], onlyAfter: boolean): number[] { + const out: number[] = []; + for (let j = onlyAfter ? i + 1 : 0; j < xs.length; j++) { + if (j === i || xs[j] === xs[i]) continue; + out.push((ys[j] - ys[i]) / (xs[j] - xs[i])); + } + return out; +} + +function lineWithSlope(slope: number, xs: number[], ys: number[]): CalibrationLine { + return { slope, intercept: median(xs.map((x, i) => ys[i] - slope * x)) }; +} + +/** Theil–Sen: slope is the median of the slopes between every pair of points. Ignores up to ~29% bad points. */ +export function theilSenFit(xs: number[], ys: number[]): CalibrationLine | null { + const slopes = xs.flatMap((_, i) => slopesFrom(i, xs, ys, true)); + return slopes.length ? lineWithSlope(median(slopes), xs, ys) : null; +} + +/** Siegel's repeated median: each point's median slope to the others, then the median of those. Ignores up to half the points being bad. */ +export function repeatedMedianFit(xs: number[], ys: number[]): CalibrationLine | null { + const perPoint = xs + .map((_, i) => slopesFrom(i, xs, ys, false)) + .filter((s) => s.length > 0) + .map(median); + return perPoint.length ? lineWithSlope(median(perPoint), xs, ys) : null; } export class CalibrationError extends Error {} @@ -64,11 +123,19 @@ export function validateCalibrationTable( */ export function applyCalibration( raw: Float64Array, - { xs, ys }: CalibrationTable, + { xs, ys, line }: CalibrationTable, ): Float64Array { const n = xs.length; const out = new Float64Array(raw.length); + if (line !== undefined) { + for (let i = 0; i < raw.length; i++) { + const v = raw[i]; + out[i] = !Number.isFinite(v) ? NaN : line ? line.slope * v + line.intercept : v; + } + return out; + } + if (n === 0) return out.fill(NaN); if (n === 1) { for (let i = 0; i < raw.length; i++) { @@ -140,7 +207,14 @@ export function calibrationFor(convert: ConvertSpec | undefined): CalibrationTab const table = convert?.calibration; if (!table || table.length === 0) return null; if (convert?.method === "none") return null; - return validateCalibrationTable(table); + const validated = validateCalibrationTable(table); + if (!isRobustMethod(convert?.method)) return validated; + + // Fit every point, duplicated raw values included, as the backend does. + const xs = table.map((p) => Number(p[0])); + const ys = table.map((p) => Number(p[1])); + const fit = convert.method === "theil_sen" ? theilSenFit : repeatedMedianFit; + return { ...validated, line: fit(xs, ys) }; } /** True when the sensor declares a usable calibration table. */ diff --git a/src/lib/analysis/index.ts b/src/lib/analysis/index.ts index f71b3e7..9ee4e3e 100644 --- a/src/lib/analysis/index.ts +++ b/src/lib/analysis/index.ts @@ -19,9 +19,12 @@ export { bindingColumnKey, calibrationFor, hasCalibration, + isRobustMethod, + repeatedMedianFit, + theilSenFit, validateCalibrationTable, } from "./calibrate"; -export type { CalibrationTable } from "./calibrate"; +export type { CalibrationLine, CalibrationTable, RobustMethod } from "./calibrate"; export { buildChannelOwners, deriveActuationEvents, diff --git a/src/lib/analysis/pipeline.ts b/src/lib/analysis/pipeline.ts index c19c2f9..a0e7bef 100644 --- a/src/lib/analysis/pipeline.ts +++ b/src/lib/analysis/pipeline.ts @@ -171,7 +171,11 @@ export function buildDataset({ if (calibration) { values = applyCalibration(col.values, calibration); origin = "calibrated"; - applied.push(`calibration (${calibration.xs.length} pts)`); + applied.push( + calibration.line !== undefined + ? `calibration (${sensor.convert?.method}, ${sensor.convert?.calibration?.length ?? 0} pts)` + : `calibration (${calibration.xs.length} pts)`, + ); } else { values = Float64Array.from(col.values); origin = "raw"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 4605146..944854b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,7 +19,13 @@ export type ActuatorType = | "powered_gpio_device" | "gpio_device" | "motor"; -export type ConvertMethod = "none" | "zero_offset" | "linear" | "polynomial"; +export type ConvertMethod = + | "none" + | "zero_offset" + | "linear" + | "polynomial" + | "theil_sen" + | "repeated_median"; export type ClientRole = "viewer" | "pad" | "operator" | "admin"; export type SensorBinding =