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
2 changes: 1 addition & 1 deletion doc/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions doc/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 31 additions & 18 deletions src/common/model/MotionSensorModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export class MotionSensorModel implements TModel {
/** Whether recording can begin right now. */
public readonly canRecordProperty: TReadOnlyProperty<boolean>;

/**
* 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<boolean>;
private readonly awaitingProperty: BooleanProperty;

private readonly trace: Trace;

/** Leftover time not yet consumed by a whole sample period. */
Expand All @@ -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;

Expand All @@ -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],
Expand All @@ -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;
}
};
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down
5 changes: 4 additions & 1 deletion src/common/model/SensorPositionSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
92 changes: 48 additions & 44 deletions src/common/model/Trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand All @@ -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;
}

/**
Expand All @@ -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. */
Expand Down
44 changes: 34 additions & 10 deletions src/common/model/motionMath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
12 changes: 12 additions & 0 deletions src/common/view/RecordControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -155,6 +166,7 @@ export class RecordControl extends MotionSensorPanel {
new HBox({ spacing: 10, children: [recordButton, stopButton, clearButton] }),
sampleRateRow,
connectFirstText,
waitingText,
],
}),
{ minWidth: CONTROL_PANEL_WIDTH },
Expand Down
Loading
Loading