From 22bef93085bcbd56b638c1639a1e37daa17bcadb Mon Sep 17 00:00:00 2001 From: martin veillette Date: Mon, 12 Oct 2026 23:04:04 -0400 Subject: [PATCH] fix(sensor): say when a recording is waiting for the first reading An out-of-range sensor used to sit at 0.0 s under Stop, and stopping before any sample left an empty trace. Velocity and acceleration are now extended one sample at a time. Co-authored-by: Cursor --- doc/implementation-notes.md | 2 +- doc/model.md | 6 ++ src/common/model/MotionSensorModel.ts | 49 ++++++---- src/common/model/SensorPositionSource.ts | 5 +- src/common/model/Trace.ts | 92 ++++++++++--------- src/common/model/motionMath.ts | 44 +++++++-- src/common/view/RecordControl.ts | 12 +++ src/common/view/currentDetailsProperty.ts | 9 +- src/i18n/strings_en.json | 3 +- src/i18n/strings_es.json | 3 +- src/i18n/strings_fr.json | 3 +- tests/common/model/MotionSensorModel.test.ts | 63 ++++++++++++- .../common/model/SensorPositionSource.test.ts | 10 ++ 13 files changed, 219 insertions(+), 82 deletions(-) diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index 3fa0ce7..2b100d6 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -207,7 +207,7 @@ devices that reject it. | `?showDiagnostics=` | false | Show the device's measurement list and raw readings | | `?pollIntervalMs=` | 40 | Sensor sample period (poll or stream); raise it when debugging a flaky link | | `?sensorStreaming=` | true | `false` forces polling on a transport that could stream (USB) | -| `?usbBringUp=` | off | `probe` claims a USB device and reports its descriptors without sending anything; `all` drops the vendor filter; `probeAll` does both | +| `?usbBringUp=` | off | `probe` claims a USB device and reports its descriptors without sending anything, so Record stays disabled; `all` drops the vendor filter; `probeAll` does both | ## Testing diff --git a/doc/model.md b/doc/model.md index 73939ac..b6350cd 100644 --- a/doc/model.md +++ b/doc/model.md @@ -151,6 +151,12 @@ t = 0, and with *zero at start* on it is also the reading the zero is taken from so t = 0 is exactly 0 m. On the Simulation screen the walker is always current and t = 0 is taken at the press itself. +The wait is not always short. A sensor with nothing within range to echo off +answers nothing usable, and its readings are dropped by the range gate, so the +record control says that it is waiting for the first reading and where to +stand, and a screen reader hears the same sentence. Pressing Stop before any +reading arrived returns to Ready rather than to an empty Stopped run. + If the sensor disconnects or stops answering mid-recording, the recording ends there. What was recorded stands; sampling the last reading for the rest of the minute would draw a student standing still who was never there. A poll that diff --git a/src/common/model/MotionSensorModel.ts b/src/common/model/MotionSensorModel.ts index 4bfdc64..89d39f6 100644 --- a/src/common/model/MotionSensorModel.ts +++ b/src/common/model/MotionSensorModel.ts @@ -102,6 +102,18 @@ export class MotionSensorModel implements TModel { /** Whether recording can begin right now. */ public readonly canRecordProperty: TReadOnlyProperty; + /** + * True from Record until the source has a reading taken for this run. The + * clock does not start until then: t = 0 is where the student stood when the + * recording began, not where a silent sensor last saw them. + * + * Public because the wait is not always short: a sensor with nothing in range + * to echo off answers nothing usable, and a recording that sits at 0.0 s + * without saying why looks broken. The view says what it is waiting for. + */ + public readonly awaitingFirstReadingProperty: TReadOnlyProperty; + private readonly awaitingProperty: BooleanProperty; + private readonly trace: Trace; /** Leftover time not yet consumed by a whole sample period. */ @@ -121,13 +133,6 @@ export class MotionSensorModel implements TModel { /** How many samples the current recording holds. Integer, never derived from a float. */ private sampleIndex = 0; - /** - * True from Record until the source has a reading taken for this run. The - * clock does not start until then: t = 0 is where the student stood when the - * recording began, not where a silent sensor last saw them. - */ - private awaitingFirstReading = false; - /** Mirrors the source onto positionProperty while idle, so the graph is live before Record. */ private readonly sourcePositionListener: (position: number) => void; @@ -154,6 +159,8 @@ export class MotionSensorModel implements TModel { this.accelerationProperty = new NumberProperty(0, { units: "m/s^2" }); this.sampleEmitter = new Emitter(); this.traceChangedProperty = new BooleanProperty(false); + this.awaitingProperty = new BooleanProperty(false); + this.awaitingFirstReadingProperty = this.awaitingProperty; this.canRecordProperty = new DerivedProperty( [this.runStateProperty, this.source.isAvailableProperty], @@ -166,7 +173,7 @@ export class MotionSensorModel implements TModel { // value instead, so that the value the graph samples is the one that went // into the trace. this.sourcePositionListener = (position: number) => { - if (this.runStateProperty.value !== RunState.RECORDING || this.awaitingFirstReading) { + if (this.runStateProperty.value !== RunState.RECORDING || this.awaitingProperty.value) { this.positionProperty.value = position; } }; @@ -223,24 +230,29 @@ export class MotionSensorModel implements TModel { } this.resetRecording(); this.source.startSampling(); - this.runStateProperty.value = RunState.RECORDING; // t = 0 is a real sample, not an empty origin: the graph should show a point // the instant recording starts rather than a period later. A source that // has nothing current yet — a sensor between runs — gets it on the first - // step after its first reading instead. - this.awaitingFirstReading = true; + // step after its first reading instead. Set before the run state, so that + // anything listening for RECORDING already sees whether it is waiting. + this.awaitingProperty.value = true; + this.runStateProperty.value = RunState.RECORDING; this.recordFirstSampleIfReady(); } - /** Ends recording early. The trace stands. */ + /** + * Ends recording early. The trace stands — unless there is none, because the + * source never gave a first reading, in which case there is nothing to look + * at and the run returns to READY rather than to an empty STOPPED. + */ public stopRecording(): void { if (this.runStateProperty.value !== RunState.RECORDING) { return; } this.source.stopSampling(); - this.awaitingFirstReading = false; - this.runStateProperty.value = RunState.STOPPED; + this.awaitingProperty.value = false; + this.runStateProperty.value = this.trace.isEmpty ? RunState.READY : RunState.STOPPED; this.traceChangedProperty.value = !this.traceChangedProperty.value; } @@ -262,7 +274,7 @@ export class MotionSensorModel implements TModel { this.source.step(clampedDt); // The clock starts at the first reading; until then there is no time to keep. - if (this.awaitingFirstReading) { + if (this.awaitingProperty.value) { this.recordFirstSampleIfReady(); return; } @@ -294,8 +306,8 @@ export class MotionSensorModel implements TModel { /** Takes the t = 0 sample once the source has a reading for this run. */ private recordFirstSampleIfReady(): void { - if (this.awaitingFirstReading && this.source.hasFreshReading()) { - this.awaitingFirstReading = false; + if (this.awaitingProperty.value && this.source.hasFreshReading()) { + this.awaitingProperty.value = false; this.recordSample(); this.traceChangedProperty.value = !this.traceChangedProperty.value; } @@ -332,7 +344,7 @@ export class MotionSensorModel implements TModel { */ private resetRecording(): void { this.source.stopSampling(); - this.awaitingFirstReading = false; + this.awaitingProperty.value = false; const sampleRate = this.sampleRateProperty.value; this.samplePeriod = 1 / sampleRate; this.maximumSamples = Math.round(MAX_RECORD_DURATION_S * sampleRate); @@ -367,6 +379,7 @@ export class MotionSensorModel implements TModel { this.accelerationProperty.dispose(); this.sampleEmitter.dispose(); this.traceChangedProperty.dispose(); + this.awaitingProperty.dispose(); this.source.stopSampling(); this.source.dispose(); } diff --git a/src/common/model/SensorPositionSource.ts b/src/common/model/SensorPositionSource.ts index ddd6ace..58317c2 100644 --- a/src/common/model/SensorPositionSource.ts +++ b/src/common/model/SensorPositionSource.ts @@ -272,7 +272,10 @@ export class SensorPositionSource implements TPositionSource { this.deviceNameProperty.value = device.name; this.measurementListProperty.value = POSITION_MEASUREMENT; this.connectionStateProperty.value = ConnectionState.CONNECTED; - this.availableProperty.value = true; + // A probed device is connected but never sampled (startSampling refuses), + // so it is not a source to record from: offering Record would start a run + // that waits for a first reading forever. + this.availableProperty.value = !this.usbProbeOnly; this.consecutiveFailures = 0; this.diagnosticsStartTimeMs = performance.now(); diff --git a/src/common/model/Trace.ts b/src/common/model/Trace.ts index 84d56ef..4600463 100644 --- a/src/common/model/Trace.ts +++ b/src/common/model/Trace.ts @@ -19,7 +19,7 @@ */ import { DEFAULT_SAMPLE_RATE_HZ, windowSamplesForRate } from "../../MotionSensorConstants.js"; -import { differentiateTrailing, type Sample } from "./motionMath.js"; +import { type Sample, trailingDerivativeAt } from "./motionMath.js"; export class Trace { /** Position samples, in metres, in recording order. */ @@ -33,42 +33,68 @@ export class Trace { */ private windowSamples = windowSamplesForRate(DEFAULT_SAMPLE_RATE_HZ); - /** Cached velocity series; invalidated whenever a sample is added. */ - private velocityCache: Sample[] | null = null; - /** Cached acceleration series; invalidated whenever a sample is added. */ - private accelerationCache: Sample[] | null = null; - /** Causal smoothed values, finalized as each raw sample arrives. */ + /** + * The derived series, each extended by one value per sample. Every stage is + * causal, so a value is final the moment it is computed: appending is exact, + * and costs one window's work per sample rather than a pass over the whole + * recording — which, at 50 Hz near the end of a minute, used to mean + * thousands of small arrays allocated per sample, twice. + */ private smoothedSamples: Sample[] = []; + private velocitySamples: Sample[] = []; + private accelerationSamples: Sample[] = []; /** - * Sets the rate the next samples are recorded at. Only meaningful on an empty - * trace — call it from the model as a recording is being reset. + * Sets the rate the next samples are recorded at. Meant for an empty trace — + * the model calls it as a recording is being reset — but anything already + * recorded is re-derived at the new window rather than left inconsistent. */ public setSampleRate(sampleRateHz: number): void { this.windowSamples = windowSamplesForRate(sampleRateHz); - this.velocityCache = null; - this.accelerationCache = null; + const recorded = this.samples; + this.clear(); + for (const sample of recorded) { + this.add(sample.time, sample.value); + } } - /** Records one position sample. */ + /** + * Records one position sample and extends each derived series by one value. + * + * Each stage waits for the one before it: a smoothed value is a full mean + * only from sample `windowSamples - 1` on (earlier ones average fewer points, + * which lags a ramp by a varying amount and would halve its slope), so + * velocity is defined from one window after that, and acceleration from one + * window after velocity. Before then each reads 0. + */ public add(time: number, position: number): void { this.samples.push({ time: time, value: position }); - const smoothingStart = Math.max(0, this.samples.length - this.windowSamples); - const smoothingWindow = this.samples.slice(smoothingStart); - this.smoothedSamples.push({ + const index = this.samples.length - 1; + const window = this.windowSamples; + + const smoothingStart = Math.max(0, index - window + 1); + let sum = 0; + for (let i = smoothingStart; i <= index; i++) { + sum += (this.samples[i] as Sample).value; + } + this.smoothedSamples.push({ time: time, value: sum / (index - smoothingStart + 1) }); + + this.velocitySamples.push({ + time: time, + value: trailingDerivativeAt(this.smoothedSamples, index, window, window - 1), + }); + this.accelerationSamples.push({ time: time, - value: smoothingWindow.reduce((sum, sample) => sum + sample.value, 0) / smoothingWindow.length, + value: trailingDerivativeAt(this.velocitySamples, index, window, 2 * (window - 1)), }); - this.velocityCache = null; - this.accelerationCache = null; } /** Discards the run. */ public clear(): void { this.samples = []; this.smoothedSamples = []; - this.velocityCache = null; - this.accelerationCache = null; + this.velocitySamples = []; + this.accelerationSamples = []; } public get length(): number { @@ -92,24 +118,9 @@ export class Trace { return this.smoothedSamples; } - /** - * The causal velocity series, recomputed lazily from finalized values. - * - * Each stage waits for the one before it: a smoothed value is a full mean - * only from sample `windowSamples - 1` on (earlier ones average fewer points, - * which lags a ramp by a varying amount and would halve its slope), so - * velocity is defined from one window after that, and acceleration from one - * window after velocity. Before then each reads 0. - */ + /** The causal velocity series, differentiated from the smoothed position. */ public getVelocitySamples(): readonly Sample[] { - if (this.velocityCache === null) { - this.velocityCache = differentiateTrailing( - this.getSmoothedPositionSamples(), - this.windowSamples, - this.windowSamples - 1, - ); - } - return this.velocityCache; + return this.velocitySamples; } /** @@ -118,14 +129,7 @@ export class Trace { * some distance the roughest of the three series — see doc/model.md. */ public getAccelerationSamples(): readonly Sample[] { - if (this.accelerationCache === null) { - this.accelerationCache = differentiateTrailing( - this.getVelocitySamples(), - this.windowSamples, - 2 * (this.windowSamples - 1), - ); - } - return this.accelerationCache; + return this.accelerationSamples; } /** Value of the most recent sample in a series, or 0 before the first one. */ diff --git a/src/common/model/motionMath.ts b/src/common/model/motionMath.ts index 6dee5dd..cbcdd37 100644 --- a/src/common/model/motionMath.ts +++ b/src/common/model/motionMath.ts @@ -18,14 +18,24 @@ export type Sample = { * avoids a division by zero at the ends of a trace. */ export function estimateDerivative(points: readonly Sample[]): number { - const n = points.length; + return slopeOverRange(points, 0, points.length); +} + +/** + * {@link estimateDerivative} over `points[start, end)`, without copying the + * range out first — the trailing derivative runs once per recorded sample, and + * a slice per call is garbage the collector has to chase at 50 Hz. + */ +function slopeOverRange(points: readonly Sample[], start: number, end: number): number { + const n = end - start; if (n < 2) { return 0; } let sumT = 0; let sumV = 0; - for (const point of points) { + for (let i = start; i < end; i++) { + const point = points[i] as Sample; sumT += point.time; sumV += point.value; } @@ -34,7 +44,8 @@ export function estimateDerivative(points: readonly Sample[]): number { let numerator = 0; let denominator = 0; - for (const point of points) { + for (let i = start; i < end; i++) { + const point = points[i] as Sample; const dt = point.time - meanT; numerator += dt * (point.value - meanV); denominator += dt * dt; @@ -79,11 +90,24 @@ export function differentiate(samples: readonly Sample[], windowSize: number): S * exactly where one stale or zeroed reading makes that slope enormous. */ export function differentiateTrailing(samples: readonly Sample[], windowSize: number, firstDefinedIndex = 0): Sample[] { - return samples.map((sample, index) => { - const start = index - windowSize + 1; - if (start < firstDefinedIndex) { - return { time: sample.time, value: 0 }; - } - return { time: sample.time, value: estimateDerivative(samples.slice(start, index + 1)) }; - }); + return samples.map((sample, index) => ({ + time: sample.time, + value: trailingDerivativeAt(samples, index, windowSize, firstDefinedIndex), + })); +} + +/** + * One value of {@link differentiateTrailing}: the slope at `index`. Because it + * reads only `samples[index - windowSize + 1 .. index]`, a series can be + * extended one sample at a time and every earlier value stays exactly as it + * was — which is what lets the trace append instead of recomputing. + */ +export function trailingDerivativeAt( + samples: readonly Sample[], + index: number, + windowSize: number, + firstDefinedIndex = 0, +): number { + const start = index - windowSize + 1; + return start < firstDefinedIndex ? 0 : slopeOverRange(samples, start, index + 1); } diff --git a/src/common/view/RecordControl.ts b/src/common/view/RecordControl.ts index cf8c7b7..0b45af2 100644 --- a/src/common/view/RecordControl.ts +++ b/src/common/view/RecordControl.ts @@ -144,6 +144,17 @@ export class RecordControl extends MotionSensorPanel { lineWrap: CONTROL_PANEL_WIDTH - 40, }); + // A recording's clock starts at the sensor's first reading, and a sensor + // with nothing in range to echo off never gives one. Without this the + // readout would sit at 0.0 s under a Stop button and look broken. + const waitingText = new RichText(runStrings.waitingForReadingStringProperty, { + font: MESSAGE_FONT, + fill: MotionSensorColors.textColorProperty, + visibleProperty: model.awaitingFirstReadingProperty, + align: "center", + lineWrap: CONTROL_PANEL_WIDTH - 40, + }); + super( new VBox({ align: "center", @@ -155,6 +166,7 @@ export class RecordControl extends MotionSensorPanel { new HBox({ spacing: 10, children: [recordButton, stopButton, clearButton] }), sampleRateRow, connectFirstText, + waitingText, ], }), { minWidth: CONTROL_PANEL_WIDTH }, diff --git a/src/common/view/currentDetailsProperty.ts b/src/common/view/currentDetailsProperty.ts index be7b52f..a2845fe 100644 --- a/src/common/view/currentDetailsProperty.ts +++ b/src/common/view/currentDetailsProperty.ts @@ -12,7 +12,7 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; import { DerivedProperty, PatternStringProperty } from "scenerystack/axon"; -import type { ScreenA11yStrings } from "../../i18n/StringManager.js"; +import { type ScreenA11yStrings, StringManager } from "../../i18n/StringManager.js"; import type { MotionSensorModel } from "../model/MotionSensorModel.js"; import { PositionSourceType } from "../model/PositionSource.js"; import { RunState } from "../model/RunState.js"; @@ -37,13 +37,16 @@ export function createCurrentDetailsProperty(model: MotionSensorModel, a11y: Scr const property = new DerivedProperty( [ model.runStateProperty, + model.awaitingFirstReadingProperty, model.source.isAvailableProperty, a11y.currentDetails.readyStringProperty, recordingProperty, stoppedProperty, a11y.currentDetails.waitingForSensorStringProperty, + // The visible message is already a whole sentence, so it is spoken as is. + StringManager.getInstance().getRunStrings().waitingForReadingStringProperty, ], - (state, available, ready, recording, stopped, waiting) => { + (state, awaitingFirstReading, available, ready, recording, stopped, waiting, waitingForReading) => { // On the sensor screen, "ready to record" is untrue until something is // connected; say what is actually blocking instead. if (!available && model.sourceType === PositionSourceType.MOTION_SENSOR) { @@ -51,7 +54,7 @@ export function createCurrentDetailsProperty(model: MotionSensorModel, a11y: Scr } switch (state) { case RunState.RECORDING: - return recording; + return awaitingFirstReading ? waitingForReading : recording; case RunState.STOPPED: return stopped; default: diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index bb7a326..f29368e 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -19,7 +19,8 @@ "sampleRate": "Sample rate", "sampleRatePattern": "{{rate}} Hz", "elapsedPattern": "{{time}} s", - "connectFirst": "Connect a sensor to record" + "connectFirst": "Connect a sensor to record", + "waitingForReading": "Waiting for the sensor's first reading. Stand in front of it, at least 15 cm away." }, "graph": { "vs": "vs", diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index 7ecb46a..4cadaa9 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -19,7 +19,8 @@ "sampleRate": "Frecuencia de muestreo", "sampleRatePattern": "{{rate}} Hz", "elapsedPattern": "{{time}} s", - "connectFirst": "Conecta un sensor para grabar" + "connectFirst": "Conecta un sensor para grabar", + "waitingForReading": "Esperando la primera lectura del sensor. Colócate frente a él, al menos a 15 cm." }, "graph": { "vs": "frente a", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index bf23138..c9095d7 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -19,7 +19,8 @@ "sampleRate": "Fréquence d'échantillonnage", "sampleRatePattern": "{{rate}} Hz", "elapsedPattern": "{{time}} s", - "connectFirst": "Connectez un capteur pour enregistrer" + "connectFirst": "Connectez un capteur pour enregistrer", + "waitingForReading": "En attente de la première mesure du capteur. Placez-vous devant lui, à au moins 15 cm." }, "graph": { "vs": "selon", diff --git a/tests/common/model/MotionSensorModel.test.ts b/tests/common/model/MotionSensorModel.test.ts index f3f587d..5268502 100644 --- a/tests/common/model/MotionSensorModel.test.ts +++ b/tests/common/model/MotionSensorModel.test.ts @@ -11,6 +11,7 @@ import { BooleanProperty, NumberProperty, type TReadOnlyProperty } from "scenerystack/axon"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MotionSensorModel } from "../../../src/common/model/MotionSensorModel.js"; +import { differentiateTrailing, type Sample } from "../../../src/common/model/motionMath.js"; import { PointerPositionSource } from "../../../src/common/model/PointerPositionSource.js"; import { PositionSourceType, type TPositionSource } from "../../../src/common/model/PositionSource.js"; import { RunState } from "../../../src/common/model/RunState.js"; @@ -20,6 +21,7 @@ import { DEFAULT_SAMPLE_RATE_HZ, MAX_RECORD_DURATION_S, SAMPLE_RATE_CHOICES_HZ, + windowSamplesForRate, } from "../../../src/MotionSensorConstants.js"; /** @@ -405,13 +407,37 @@ describe("MotionSensorModel with a hardware source", () => { expect(model.getPositionSamples()).toHaveLength(recorded); }); - it("stops a recording that was still waiting for its first reading when the sensor goes away", () => { + it("ends a recording that was still waiting for its first reading when the sensor goes away", () => { model.startRecording(); source.availableProperty.value = false; - expect(model.runStateProperty.value).toBe(RunState.STOPPED); + // Nothing was recorded, so there is no trace to look at: back to READY. + expect(model.runStateProperty.value).toBe(RunState.READY); + expect(model.awaitingFirstReadingProperty.value).toBe(false); expect(model.getPositionSamples()).toHaveLength(0); }); + it("says it is waiting from Record until the first reading", () => { + expect(model.awaitingFirstReadingProperty.value).toBe(false); + model.startRecording(); + advance(model, 0.5); + expect(model.awaitingFirstReadingProperty.value).toBe(true); + + source.deliver(1); + model.step(DEFAULT_SAMPLE_PERIOD_S); + expect(model.awaitingFirstReadingProperty.value).toBe(false); + }); + + it("returns to READY when Stop is pressed before any reading arrived", () => { + // A student standing out of range gives the sensor nothing to answer with. + model.startRecording(); + advance(model, 2); + model.stopRecording(); + expect(model.runStateProperty.value).toBe(RunState.READY); + expect(model.awaitingFirstReadingProperty.value).toBe(false); + expect(source.isSampling).toBe(false); + expect(model.canRecordProperty.value).toBe(true); + }); + it("survives being disposed twice", () => { model.dispose(); expect(() => model.dispose()).not.toThrow(); @@ -437,6 +463,39 @@ describe("Trace start-up", () => { expect(acceleration.slice(0, 9)).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0]); }); + it("builds the same series sample by sample as a full pass over the recording", () => { + const trace = new Trace(); + trace.setSampleRate(50); + const positions: Sample[] = []; + for (let index = 0; index < 400; index++) { + const time = index * 0.02; + // A walk with a turnaround and some sensor-like jitter. + const position = 1 + 0.6 * Math.sin(1.3 * time) + 0.004 * Math.sin(97 * index); + trace.add(time, position); + positions.push({ time: time, value: position }); + } + const window = windowSamplesForRate(50); + const smoothed = trace.getSmoothedPositionSamples(); + const velocity = differentiateTrailing(smoothed, window, window - 1); + const acceleration = differentiateTrailing(velocity, window, 2 * (window - 1)); + expect(trace.getPositionSamples()).toEqual(positions); + expect(trace.getVelocitySamples()).toEqual(velocity); + expect(trace.getAccelerationSamples()).toEqual(acceleration); + }); + + it("re-derives what it holds when its rate changes", () => { + const trace = new Trace(); + trace.setSampleRate(20); + for (let index = 0; index < 20; index++) { + trace.add(index * 0.1, 0.3 * index * 0.1); + } + trace.setSampleRate(10); + const window = windowSamplesForRate(10); + expect(trace.getVelocitySamples()).toEqual( + differentiateTrailing(trace.getSmoothedPositionSamples(), window, window - 1), + ); + }); + it("reads a steady walk at its true speed from the first defined sample", () => { const trace = new Trace(); trace.setSampleRate(20); diff --git a/tests/common/model/SensorPositionSource.test.ts b/tests/common/model/SensorPositionSource.test.ts index f8bfa7a..d1d54e3 100644 --- a/tests/common/model/SensorPositionSource.test.ts +++ b/tests/common/model/SensorPositionSource.test.ts @@ -153,6 +153,16 @@ describe("SensorPositionSource", () => { }); }); + describe("a probed device", () => { + it("is connected but not offered for recording, which it could never feed", async () => { + const source = new SensorPositionSource({ usbProbeOnly: true }); + await source.connect(SensorTransport.USB); + expect(source.connectionStateProperty.value).toBe(ConnectionState.CONNECTED); + expect(source.isAvailableProperty.value).toBe(false); + source.dispose(); + }); + }); + describe("zero at start", () => { it("zeroes on the run's first reading, not on a republished stale one", async () => { const { source, device } = await connectedSource();