Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/components/config/CalibrationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
9 changes: 8 additions & 1 deletion src/components/config/SensorsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
80 changes: 77 additions & 3 deletions src/lib/analysis/calibrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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. */
Expand Down
5 changes: 4 additions & 1 deletion src/lib/analysis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/lib/analysis/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 7 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading