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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "app/lib/telemetryUtils";
import {
processCompressions,
filterLowActivityOutliers,
SuspensionActivity,
} from "app/lib/run-analysis";
import { getSeriesColor } from "app/lib/graphColors";
Expand Down Expand Up @@ -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<ReboundCompressionPlotProps> = ({
title,
series,
Expand All @@ -127,11 +104,13 @@ export const ReboundCompressionPlot: React.FC<ReboundCompressionPlotProps> = ({
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);
Expand Down
155 changes: 107 additions & 48 deletions apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,133 @@
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
// 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,
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,
),
];

const result = kept.map((a) => a.velocity);
velocityCache.set(seriesItem.rawData, { freq, length, min, max, result });
return result;
}
Comment on lines +44 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The buildFilteredVelocities function performs heavy computations (including processCompressions and multiple array filtering/mapping operations) on telemetry data. Since the series prop is often recreated on every render by parent components, useMemo will re-run this function frequently, leading to UI lag. We can introduce a WeakMap cache keyed by the stable rawData array reference to cache the computed velocities and avoid redundant calculations.

const velocityCache = new WeakMap<
  RawSuspensionData[],
  {
    freq: number;
    length: number;
    min: number;
    max: number;
    result: number[];
  }
>();

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,
    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,
    ),
  ];

  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)
export const VelocityHistogram: React.FC<VelocityHistogramProps> = ({
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<LineHistogramSeries[]>(() => {
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<LineHistogramSeries[]>(
() =>
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 <div className="h-40 flex items-center justify-center text-gray-500 text-sm">No data</div>;
// 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 <div className="h-40 flex items-center justify-center text-gray-500 text-sm">No data</div>;
}

return (
return (
<div className="w-full">
<LineHistogram
<LineHistogram
series={histogramSeries}
xDomain={[-4000,4000]}
xDomain={xDomain}
height={height}
title={title}
binCount={50}
/>
/>
</div>
);
);
};
2 changes: 1 addition & 1 deletion apps/frontend/app/components/runs/summary-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);
}
}

Expand Down
46 changes: 46 additions & 0 deletions apps/frontend/app/lib/run-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends VelocityDisplacement>(
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 =
(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 =
Math.abs(a.velocity) < minVelocity &&
Math.abs(a.displacement) < minDisplacement

return passesStdDev && !isLowActivity
})
Comment on lines +63 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If all elements in items have the same velocity or displacement (for example, if there is only one item in the array), the standard deviation (vStd or dStd) will be 0. In this case, the expression Math.abs(a.velocity - vMean) < 5 * vStd evaluates to 0 < 0 (which is false), causing all items to be filtered out. We should handle the case where the standard deviation is 0 to prevent valid data from being incorrectly discarded.

Suggested change
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
})
return items.filter((a) => {
const passesStdDev =
(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 =
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)
Expand Down
48 changes: 0 additions & 48 deletions apps/frontend/app/lib/telemetryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading