Add configurable pace-artifact filtering to replace the hardcoded cutoff
Two new Profile settings -- "minimum representative pace" and "minimum representative time" -- replace the previous hardcoded 20:00/km cutoff. A stretch of consecutive samples slower than the configured pace is now dropped from the chart (and its Y-axis scale) only if it lasts no longer than the configured time; a longer stretch is kept as a real stop or walk break rather than noise. Defaults to 12:00/km and 3 seconds. Caught a boundary bug while verifying against real data: a run lasting exactly the threshold duration survived filtering because the comparison used strict "<" instead of "<=", contradicting "not lasting more than N seconds" (which should include exactly N).
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
-- Filters brief pace "artifacts" (e.g. GPS/motion still settling right as
|
||||||
|
-- recording starts, before the run itself begins) out of the Review
|
||||||
|
-- Queue's pace chart: a stretch of samples slower than
|
||||||
|
-- min_representative_pace_sec_per_km is dropped unless it persists for at
|
||||||
|
-- least min_representative_time_seconds, in which case it's treated as a
|
||||||
|
-- real stop or walk break, not noise. Defaults match the values used to
|
||||||
|
-- design this feature (12:00/km, 3 seconds).
|
||||||
|
ALTER TABLE profile ADD COLUMN min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720;
|
||||||
|
ALTER TABLE profile ADD COLUMN min_representative_time_seconds REAL NOT NULL DEFAULT 3;
|
||||||
@@ -32,6 +32,15 @@ type Profile struct {
|
|||||||
// don't use these.
|
// don't use these.
|
||||||
WarmupMinutes, CooldownMinutes float64
|
WarmupMinutes, CooldownMinutes float64
|
||||||
|
|
||||||
|
// MinRepresentativePaceSecPerKm/MinRepresentativeTimeSeconds drive the
|
||||||
|
// Review Queue pace chart's artifact filter: a stretch of samples slower
|
||||||
|
// than MinRepresentativePaceSecPerKm is dropped from the chart (and its
|
||||||
|
// Y-axis scale) unless it persists for at least
|
||||||
|
// MinRepresentativeTimeSeconds, in which case it's treated as a real
|
||||||
|
// stop or walk break rather than noise (e.g. GPS/motion still settling
|
||||||
|
// right as recording starts, before the run itself begins).
|
||||||
|
MinRepresentativePaceSecPerKm, MinRepresentativeTimeSeconds float64
|
||||||
|
|
||||||
CreatedAt, UpdatedAt string
|
CreatedAt, UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +50,7 @@ const profileColumns = `
|
|||||||
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||||
warmup_minutes, cooldown_minutes,
|
warmup_minutes, cooldown_minutes,
|
||||||
|
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -53,6 +63,7 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
|||||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||||
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
||||||
&p.WarmupMinutes, &p.CooldownMinutes,
|
&p.WarmupMinutes, &p.CooldownMinutes,
|
||||||
|
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,6 +83,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
|||||||
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
|
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
|
||||||
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
||||||
warmup_minutes=?, cooldown_minutes=?,
|
warmup_minutes=?, cooldown_minutes=?,
|
||||||
|
min_representative_pace_sec_per_km=?, min_representative_time_seconds=?,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
WHERE id = 1`,
|
WHERE id = 1`,
|
||||||
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
||||||
@@ -79,6 +91,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
|||||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||||||
p.HRZone5MinPct, p.HRZone5MaxPct,
|
p.HRZone5MinPct, p.HRZone5MaxPct,
|
||||||
p.WarmupMinutes, p.CooldownMinutes,
|
p.WarmupMinutes, p.CooldownMinutes,
|
||||||
|
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update profile: %w", err)
|
return fmt.Errorf("update profile: %w", err)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if p.BackfillHorizonDays != 1095 {
|
if p.BackfillHorizonDays != 1095 {
|
||||||
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
|
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
|
||||||
}
|
}
|
||||||
|
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
|
||||||
|
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
|
||||||
|
}
|
||||||
|
|
||||||
maxHR, restingHR := 190.0, 50.0
|
maxHR, restingHR := 190.0, 50.0
|
||||||
p.GarminEmail = "runner@example.com"
|
p.GarminEmail = "runner@example.com"
|
||||||
@@ -34,6 +37,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
p.RestingHeartRate = &restingHR
|
p.RestingHeartRate = &restingHR
|
||||||
p.WarmupMinutes = 8
|
p.WarmupMinutes = 8
|
||||||
p.BackfillHorizonDays = 14
|
p.BackfillHorizonDays = 14
|
||||||
|
p.MinRepresentativePaceSecPerKm = 600
|
||||||
|
p.MinRepresentativeTimeSeconds = 5
|
||||||
|
|
||||||
if err := db.UpdateProfile(ctx, p); err != nil {
|
if err := db.UpdateProfile(ctx, p); err != nil {
|
||||||
t.Fatalf("UpdateProfile: %v", err)
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
@@ -55,4 +60,7 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if got.BackfillHorizonDays != 14 {
|
if got.BackfillHorizonDays != 14 {
|
||||||
t.Errorf("BackfillHorizonDays = %v, want 14", got.BackfillHorizonDays)
|
t.Errorf("BackfillHorizonDays = %v, want 14", got.BackfillHorizonDays)
|
||||||
}
|
}
|
||||||
|
if got.MinRepresentativePaceSecPerKm != 600 || got.MinRepresentativeTimeSeconds != 5 {
|
||||||
|
t.Errorf("pace artifact filter after update = %+v, want pace=600, time=5", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import type { Lap, Sample } from "../../types/api";
|
import type { Lap, Sample } from "../../types/api";
|
||||||
|
|
||||||
// Speeds below this read as an implausibly slow "pace" (20:00/km is well
|
|
||||||
// past even a very slow walk) -- in practice these come from GPS/motion
|
|
||||||
// still settling right as recording starts, before the run itself begins,
|
|
||||||
// not a real pace. Treating them as unknown keeps that artifact out of both
|
|
||||||
// the trace and the axis scale it would otherwise blow out.
|
|
||||||
const MAX_PLAUSIBLE_PACE_SEC_PER_KM = 1200;
|
|
||||||
|
|
||||||
function paceSecPerKm(mps: number | null): number | null {
|
function paceSecPerKm(mps: number | null): number | null {
|
||||||
if (mps == null || mps <= 0) return null;
|
if (mps == null || mps <= 0) return null;
|
||||||
const pace = 1000 / mps;
|
return 1000 / mps;
|
||||||
return pace <= MAX_PLAUSIBLE_PACE_SEC_PER_KM ? pace : null;
|
}
|
||||||
|
|
||||||
|
// Drops brief pace "artifacts" -- e.g. GPS/motion still settling right as
|
||||||
|
// recording starts, before the run itself begins -- without hiding a real
|
||||||
|
// stop or walk break. A stretch of consecutive points slower than
|
||||||
|
// minRepresentativePaceSecPerKm (including points with no pace at all,
|
||||||
|
// i.e. truly stationary) is nulled out unless it lasts at least
|
||||||
|
// minRepresentativeTimeSeconds, in which case it's kept as-is: long enough
|
||||||
|
// to be a real part of the run, not noise. A pace threshold of 0 (or below)
|
||||||
|
// disables filtering entirely.
|
||||||
|
function filterPaceArtifacts(
|
||||||
|
points: Array<{ t: number; pace: number | null }>,
|
||||||
|
minRepresentativePaceSecPerKm: number,
|
||||||
|
minRepresentativeTimeSeconds: number,
|
||||||
|
): Array<number | null> {
|
||||||
|
const result: Array<number | null> = points.map((p) => p.pace);
|
||||||
|
if (minRepresentativePaceSecPerKm <= 0) return result;
|
||||||
|
|
||||||
|
const isSlowOrStopped = (pace: number | null) => pace == null || pace > minRepresentativePaceSecPerKm;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < points.length) {
|
||||||
|
if (!isSlowOrStopped(points[i].pace)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j = i;
|
||||||
|
while (j < points.length && isSlowOrStopped(points[j].pace)) j++;
|
||||||
|
// "Not lasting more than" the threshold means a run exactly at the
|
||||||
|
// threshold still counts as an artifact, hence <= rather than <.
|
||||||
|
const durationSeconds = (points[j - 1].t - points[i].t) * 60;
|
||||||
|
if (durationSeconds <= minRepresentativeTimeSeconds) {
|
||||||
|
for (let k = i; k < j; k++) result[k] = null;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rounds the total seconds first, then splits into minutes/seconds -- doing
|
// Rounds the total seconds first, then splits into minutes/seconds -- doing
|
||||||
@@ -263,7 +292,17 @@ function weightedMean(pairs: Array<[number | null, number]>): number | null {
|
|||||||
// would hide real within-lap variation (a single-lap hill repeat, for
|
// would hide real within-lap variation (a single-lap hill repeat, for
|
||||||
// example, would otherwise render as a dead-flat line despite the pace/HR
|
// example, would otherwise render as a dead-flat line despite the pace/HR
|
||||||
// swinging throughout it).
|
// swinging throughout it).
|
||||||
export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: Sample[] }) {
|
export function ExpectedVsActualChart({
|
||||||
|
laps,
|
||||||
|
samples,
|
||||||
|
minRepresentativePaceSecPerKm,
|
||||||
|
minRepresentativeTimeSeconds,
|
||||||
|
}: {
|
||||||
|
laps: Lap[];
|
||||||
|
samples: Sample[];
|
||||||
|
minRepresentativePaceSecPerKm: number;
|
||||||
|
minRepresentativeTimeSeconds: number;
|
||||||
|
}) {
|
||||||
if (laps.length === 0) return null;
|
if (laps.length === 0) return null;
|
||||||
|
|
||||||
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
||||||
@@ -313,6 +352,13 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
elapsedMin = lapTotalMin;
|
elapsedMin = lapTotalMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredActualPace = filterPaceArtifacts(
|
||||||
|
points.map((p) => ({ t: p.t, pace: p.actualPace })),
|
||||||
|
minRepresentativePaceSecPerKm,
|
||||||
|
minRepresentativeTimeSeconds,
|
||||||
|
);
|
||||||
|
points = points.map((p, i) => ({ ...p, actualPace: filteredActualPace[i] }));
|
||||||
|
|
||||||
if (singleEffortType) {
|
if (singleEffortType) {
|
||||||
// The whole workout is one undifferentiated effort -- draw one flat
|
// The whole workout is one undifferentiated effort -- draw one flat
|
||||||
// average line across the entire span instead of per-lap segments.
|
// average line across the entire span instead of per-lap segments.
|
||||||
|
|||||||
@@ -26,6 +26,56 @@ function NumberField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pace is entered/displayed as "m:ss" but stored as whole seconds.
|
||||||
|
function parsePace(text: string): number | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === "") return null;
|
||||||
|
const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
|
||||||
|
if (!match) return null;
|
||||||
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPace(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaceField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(formatPace(value));
|
||||||
|
useEffect(() => setText(formatPace(value)), [value]);
|
||||||
|
|
||||||
|
function commit(raw: string) {
|
||||||
|
const parsed = parsePace(raw);
|
||||||
|
if (parsed != null) {
|
||||||
|
onChange(parsed);
|
||||||
|
} else {
|
||||||
|
setText(formatPace(value)); // invalid input -- revert to the last valid value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={(e) => commit(e.target.value)}
|
||||||
|
placeholder="12:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NullableNumberField({
|
function NullableNumberField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -178,6 +228,27 @@ export function Profile() {
|
|||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="kind-editor">
|
||||||
|
<legend>Pace chart artifacts</legend>
|
||||||
|
<div className="controls">
|
||||||
|
<PaceField
|
||||||
|
label="Minimum representative pace (m:ss/km)"
|
||||||
|
value={profile.MinRepresentativePaceSecPerKm}
|
||||||
|
onChange={(v) => set("MinRepresentativePaceSecPerKm", v)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Minimum representative time (seconds)"
|
||||||
|
value={profile.MinRepresentativeTimeSeconds}
|
||||||
|
onChange={(v) => set("MinRepresentativeTimeSeconds", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="empty-state">
|
||||||
|
A stretch of samples slower than this pace is hidden from the Review Queue's pace chart (and doesn't stretch
|
||||||
|
its scale) unless it lasts at least this long -- e.g. a brief GPS blip right as recording starts gets
|
||||||
|
dropped, but a real walk break or stop is kept.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<button onClick={save}>Save</button>
|
<button onClick={save}>Save</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
||||||
import { RawDataModal } from "../components/RawDataModal";
|
import { RawDataModal } from "../components/RawDataModal";
|
||||||
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
const UNSORTED = "__unsorted__";
|
const UNSORTED = "__unsorted__";
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@@ -30,6 +30,7 @@ export function ReviewQueue() {
|
|||||||
const [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [filterKindId, setFilterKindId] = useState<string>("");
|
const [filterKindId, setFilterKindId] = useState<string>("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||||
@@ -89,13 +90,14 @@ export function ReviewQueue() {
|
|||||||
function reload() {
|
function reload() {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setInitialLoading(true);
|
setInitialLoading(true);
|
||||||
Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds()])
|
Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds(), api.getProfile()])
|
||||||
.then(([page, workoutKinds]) => {
|
.then(([page, workoutKinds, userProfile]) => {
|
||||||
nextCursorRef.current = page.next_cursor;
|
nextCursorRef.current = page.next_cursor;
|
||||||
setItems(page.items);
|
setItems(page.items);
|
||||||
setNextCursor(page.next_cursor);
|
setNextCursor(page.next_cursor);
|
||||||
setTotal(page.total);
|
setTotal(page.total);
|
||||||
setKinds(workoutKinds);
|
setKinds(workoutKinds);
|
||||||
|
setProfile(userProfile);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(String(e)))
|
.catch((e) => setError(String(e)))
|
||||||
.finally(() => setInitialLoading(false));
|
.finally(() => setInitialLoading(false));
|
||||||
@@ -217,7 +219,12 @@ export function ReviewQueue() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExpectedVsActualChart laps={item.laps} samples={item.samples} />
|
<ExpectedVsActualChart
|
||||||
|
laps={item.laps}
|
||||||
|
samples={item.samples}
|
||||||
|
minRepresentativePaceSecPerKm={profile?.MinRepresentativePaceSecPerKm ?? 0}
|
||||||
|
minRepresentativeTimeSeconds={profile?.MinRepresentativeTimeSeconds ?? 0}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="review-item-actions">
|
<div className="review-item-actions">
|
||||||
{manuallyAssignableKinds.map((k) => (
|
{manuallyAssignableKinds.map((k) => (
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export interface Profile {
|
|||||||
HRZone5MaxPct: number;
|
HRZone5MaxPct: number;
|
||||||
WarmupMinutes: number;
|
WarmupMinutes: number;
|
||||||
CooldownMinutes: number;
|
CooldownMinutes: number;
|
||||||
|
MinRepresentativePaceSecPerKm: number;
|
||||||
|
MinRepresentativeTimeSeconds: number;
|
||||||
CreatedAt: string;
|
CreatedAt: string;
|
||||||
UpdatedAt: string;
|
UpdatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user