From ee41b78427de39769314582800f9bfb55f1ae7a1 Mon Sep 17 00:00:00 2001 From: William Ellis Date: Mon, 29 Jun 2026 16:06:17 +0100 Subject: [PATCH 1/2] refactor: both rebound compression scatter and histogram use the same data. refactor: added a lower bound to stop noise populating the suspension data --- .../graphs/domain/ReboundCompressionPlot.tsx | 31 +---- .../graphs/domain/VelocityHistogram.tsx | 128 +++++++++++------- .../app/components/runs/summary-section.tsx | 2 +- apps/frontend/app/lib/run-analysis.ts | 46 +++++++ apps/frontend/app/lib/telemetryUtils.ts | 48 ------- 5 files changed, 132 insertions(+), 123 deletions(-) diff --git a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx index a175986..58aa149 100644 --- a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx @@ -12,6 +12,7 @@ import { } from "app/lib/telemetryUtils"; import { processCompressions, + filterLowActivityOutliers, SuspensionActivity, } from "app/lib/run-analysis"; import { getSeriesColor } from "app/lib/graphColors"; @@ -77,30 +78,6 @@ type PreparedSeries = { const flipY = (pt: LinePoint): LinePoint => ({ x: pt.x, y: Math.abs(pt.y) }); -function filterOutliers( - suspensionActivity: SuspensionActivity[], -): SuspensionActivity[] { - if (suspensionActivity.length === 0) return suspensionActivity; - - const velocities = suspensionActivity.map((a) => a.velocity); - const displacements = suspensionActivity.map((a) => a.displacement); - - const mean = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length; - const std = (arr: number[], m: number) => - Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length); - - const vMean = mean(velocities); - const vStd = std(velocities, vMean); - const dMean = mean(displacements); - const dStd = std(displacements, dMean); - - return suspensionActivity.filter( - (a) => - Math.abs(a.velocity - vMean) < 5 * vStd && - Math.abs(a.displacement - dMean) < 5 * dStd, - ); -} - export const ReboundCompressionPlot: React.FC = ({ title, series, @@ -127,11 +104,13 @@ export const ReboundCompressionPlot: React.FC = ({ y: a.displacement, }); - const compressions = filterOutliers( + const compressions = filterLowActivityOutliers( activities.filter((a) => a.type === "compression"), + length, ); - const rebounds = filterOutliers( + const rebounds = filterLowActivityOutliers( activities.filter((a) => a.type === "rebound"), + length, ); const compressionPoints = compressions.map(toPoint); diff --git a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx index 3aac247..b170fce 100644 --- a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx +++ b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx @@ -1,74 +1,106 @@ import React, { useMemo } from "react"; -import { - LineHistogram, +import { + LineHistogram, LineHistogramSeries } from "../base/LineHistogram"; +import { RawSuspensionData } from '../../../lib/telemetryUtils'; import { - buildVelocitySamples, - RawSuspensionData} from '../../../lib/telemetryUtils'; + processCompressions, + filterLowActivityOutliers, +} from "app/lib/run-analysis"; import { getSeriesColor } from "app/lib/graphColors"; +interface VelocityHistogramSeries { + label: string; + rawData: RawSuspensionData[]; + freq: number; + fillColor?: string; + min?: number; + max?: number; + length?: number; +} + interface VelocityHistogramProps { - rawData?: RawSuspensionData[]; - freq?: number; - series?: { - label: string; - rawData: RawSuspensionData[]; - freq: number; - fillColor?: string; - min?: number; - max?: number; - }[]; + series: VelocityHistogramSeries[]; title?: string; fillColor?: string; height?: number; - min?: number; - max?: number; } -// Renders a histogram of velocity values +// Builds the same stroke-velocity points (mm/s) the speed scatter plots use, +// so the histogram is a distribution view of identical, identically-filtered data. +function buildFilteredVelocities(seriesItem: VelocityHistogramSeries): number[] { + const length = seriesItem.length ?? 220; + const min = seriesItem.min ?? 0; + const max = seriesItem.max ?? 4096; + + const activities = processCompressions( + seriesItem.rawData, + seriesItem.freq, + length, + min, + max, + ); + + // Filter compression and rebound subsets separately, exactly as the scatter + // does, then combine so the histogram bins precisely the scatter's points. + const kept = [ + ...filterLowActivityOutliers( + activities.filter((a) => a.type === "compression"), + length, + ), + ...filterLowActivityOutliers( + activities.filter((a) => a.type === "rebound"), + length, + ), + ]; + + return kept.map((a) => a.velocity); +} + +// Renders a histogram of suspension stroke speeds (mm/s) export const VelocityHistogram: React.FC = ({ - rawData = [], - freq = 100, series, - title = "Suspension Velocity", + title = "Suspension Speed", fillColor = "hsl(var(--chart-1))", height = 200, - min, - max, }) => { - // buildVelocitySamples on rawData to get velocity samples - const histogramSeries = useMemo(() => { - if (series && series.length > 0) { - return series.map((seriesItem, index) => ({ - label: seriesItem.label, - color: getSeriesColor(index, seriesItem.fillColor, fillColor), - data: buildVelocitySamples(seriesItem.rawData, seriesItem.freq, seriesItem.min, seriesItem.max).map(s => s.velocity), - })); - } - - return [ - { - label: title, - color: fillColor, - data: buildVelocitySamples(rawData, freq, min, max).map(s => s.velocity), - }, - ]; - }, [series, rawData, freq, min, max, fillColor, title]); + const histogramSeries = useMemo( + () => + series.map((seriesItem, index) => ({ + label: seriesItem.label, + color: getSeriesColor(index, seriesItem.fillColor, fillColor), + data: buildFilteredVelocities(seriesItem), + })), + [series, fillColor], + ); - // Keep layout stable - if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) { - return
No data
; + // Auto-scale the x-axis to the actual mm/s velocity range (symmetric about 0). + const xDomain = useMemo<[number, number]>(() => { + let maxAbs = 0; + for (const s of histogramSeries) { + for (const v of s.data) { + const abs = Math.abs(v); + if (abs > maxAbs) maxAbs = abs; + } } + const bound = maxAbs > 0 ? maxAbs * 1.05 : 100; + return [-bound, bound]; + }, [histogramSeries]); + + // Keep layout stable + if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) { + return
No data
; + } - return ( + return (
- + />
- ); + ); }; diff --git a/apps/frontend/app/components/runs/summary-section.tsx b/apps/frontend/app/components/runs/summary-section.tsx index 9192ff1..e70128f 100644 --- a/apps/frontend/app/components/runs/summary-section.tsx +++ b/apps/frontend/app/components/runs/summary-section.tsx @@ -32,7 +32,7 @@ function getComponentRecommendations( if (bottomOutCount > BOTTOM_OUT_COUNT_THRESHOLD) { items.push(`${sagWarning}Add a volume spacer (${bottomOutCount} bottom-outs)`); } else if (bottomOutCount === 0 && maxTravel !== null && maxTravel < BOTTOM_OUT_TRAVEL_MIN) { - items.push(`${sagWarning}Remove volume spacer (never reached travel)`); + items.push(`${sagWarning}Remove volume spacer (never reached full travel)`); } } diff --git a/apps/frontend/app/lib/run-analysis.ts b/apps/frontend/app/lib/run-analysis.ts index e0b5eb4..6683acb 100644 --- a/apps/frontend/app/lib/run-analysis.ts +++ b/apps/frontend/app/lib/run-analysis.ts @@ -28,6 +28,52 @@ export interface SuspensionActivity extends VelocityReading { type: "rebound" | "compression" } +// Low-activity threshold fractions, expressed relative to the suspension's full travel. +export const MIN_VELOCITY_TRAVEL_FRACTION = 0.4 // 40% of full travel, used as mm/s +export const MIN_DISPLACEMENT_TRAVEL_FRACTION = 0.03 // 3% of full travel, in mm + +interface VelocityDisplacement { + velocity: number // mm/s + displacement: number // mm of movement +} + +// Removes statistical outliers (>5 std dev) and low-activity events (slow AND +// small relative to full travel) from velocity/displacement data. +export function filterLowActivityOutliers( + items: T[], + fullTravel: number, +): T[] { + if (items.length === 0) return items + + const velocities = items.map((a) => a.velocity) + const displacements = items.map((a) => a.displacement) + + const mean = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length + const std = (arr: number[], m: number) => + Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length) + + const vMean = mean(velocities) + const vStd = std(velocities, vMean) + const dMean = mean(displacements) + const dStd = std(displacements, dMean) + + const minVelocity = MIN_VELOCITY_TRAVEL_FRACTION * fullTravel + const minDisplacement = MIN_DISPLACEMENT_TRAVEL_FRACTION * fullTravel + + return items.filter((a) => { + const passesStdDev = + Math.abs(a.velocity - vMean) < 5 * vStd && + Math.abs(a.displacement - dMean) < 5 * dStd + + // Drop only low-activity points: slow AND small relative to full travel. + const isLowActivity = + Math.abs(a.velocity) < minVelocity && + Math.abs(a.displacement) < minDisplacement + + return passesStdDev && !isLowActivity + }) +} + function convertDisplacementToMm(reading: RawReading, suspensionLength: number, min: number, max: number): Reading { const displacementPercentage = (reading.displacement - min) / (max - min) diff --git a/apps/frontend/app/lib/telemetryUtils.ts b/apps/frontend/app/lib/telemetryUtils.ts index f4bfb5e..6685cd3 100644 --- a/apps/frontend/app/lib/telemetryUtils.ts +++ b/apps/frontend/app/lib/telemetryUtils.ts @@ -16,15 +16,6 @@ export interface NormalizedPoint { y: number; } -export interface VelocitySample { - index: number; - time: number; - displacement: number; - normalized: number; - velocity: number; - speed: number; -} - export interface LinePoint { x: number; y: number; @@ -221,45 +212,6 @@ export function calculateMovingAverage( } -// Velocity samples (mm/s) derived from displacement time-series. -export function buildVelocitySamples( - dataArr: RawSuspensionData[], - freq: number, - min?: number, - max?: number, -): VelocitySample[] { - const cleanData = standardizeData(dataArr, freq); - if (cleanData.length < 2) return []; - - const samples: VelocitySample[] = []; - - for (let i = 1; i < cleanData.length; i++) { - const prev = cleanData[i - 1]; - const curr = cleanData[i]; - if (!prev || !curr) continue; - - const dt = curr.time - prev.time; - if (!Number.isFinite(dt) || dt <= 0) continue; - - const displacement = curr.val; - const velocity = (curr.val - prev.val) / dt; - if (!Number.isFinite(velocity)) continue; - - const normalized = normalizeToPercentage(displacement, min, max); - - samples.push({ - index: i, - time: curr.time, - displacement, - normalized, - velocity, - speed: Math.abs(velocity), - }); - } - - return samples; -} - export function fitLine( points: LinePoint[], ): { slope: number; intercept: number } | null { From e811a6bbbd97dced9a0270ac5d5c6b27604361ef Mon Sep 17 00:00:00 2001 From: William Ellis Date: Mon, 29 Jun 2026 16:17:15 +0100 Subject: [PATCH 2/2] fix: code review fixes --- .../graphs/domain/VelocityHistogram.tsx | 31 +++++++++++++++++-- apps/frontend/app/lib/run-analysis.ts | 4 +-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx index b170fce..d7ae559 100644 --- a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx +++ b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx @@ -26,16 +26,41 @@ interface VelocityHistogramProps { height?: number; } +// Cache filtered velocities keyed by the stable rawData array reference, so the +// heavy processCompressions + filtering work is not repeated on every render. +const velocityCache = new WeakMap< + RawSuspensionData[], + { + freq: number; + length: number; + min: number; + max: number; + result: number[]; + } +>(); + // Builds the same stroke-velocity points (mm/s) the speed scatter plots use, // so the histogram is a distribution view of identical, identically-filtered data. function buildFilteredVelocities(seriesItem: VelocityHistogramSeries): number[] { const length = seriesItem.length ?? 220; const min = seriesItem.min ?? 0; const max = seriesItem.max ?? 4096; + const freq = seriesItem.freq; + + const cached = velocityCache.get(seriesItem.rawData); + if ( + cached && + cached.freq === freq && + cached.length === length && + cached.min === min && + cached.max === max + ) { + return cached.result; + } const activities = processCompressions( seriesItem.rawData, - seriesItem.freq, + freq, length, min, max, @@ -54,7 +79,9 @@ function buildFilteredVelocities(seriesItem: VelocityHistogramSeries): number[] ), ]; - return kept.map((a) => a.velocity); + const result = kept.map((a) => a.velocity); + velocityCache.set(seriesItem.rawData, { freq, length, min, max, result }); + return result; } // Renders a histogram of suspension stroke speeds (mm/s) diff --git a/apps/frontend/app/lib/run-analysis.ts b/apps/frontend/app/lib/run-analysis.ts index 6683acb..6b2f472 100644 --- a/apps/frontend/app/lib/run-analysis.ts +++ b/apps/frontend/app/lib/run-analysis.ts @@ -62,8 +62,8 @@ export function filterLowActivityOutliers( return items.filter((a) => { const passesStdDev = - Math.abs(a.velocity - vMean) < 5 * vStd && - Math.abs(a.displacement - dMean) < 5 * dStd + (vStd === 0 || Math.abs(a.velocity - vMean) < 5 * vStd) && + (dStd === 0 || Math.abs(a.displacement - dMean) < 5 * dStd) // Drop only low-activity points: slow AND small relative to full travel. const isLowActivity =