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.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -41,6 +50,7 @@ const profileColumns = `
|
||||
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||
warmup_minutes, cooldown_minutes,
|
||||
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||
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.HRZone5MinPct, &p.HRZone5MaxPct,
|
||||
&p.WarmupMinutes, &p.CooldownMinutes,
|
||||
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
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_zone5_min_pct=?, hr_zone5_max_pct=?,
|
||||
warmup_minutes=?, cooldown_minutes=?,
|
||||
min_representative_pace_sec_per_km=?, min_representative_time_seconds=?,
|
||||
updated_at=datetime('now')
|
||||
WHERE id = 1`,
|
||||
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.HRZone5MinPct, p.HRZone5MaxPct,
|
||||
p.WarmupMinutes, p.CooldownMinutes,
|
||||
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update profile: %w", err)
|
||||
|
||||
@@ -25,6 +25,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
if p.BackfillHorizonDays != 1095 {
|
||||
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
|
||||
p.GarminEmail = "runner@example.com"
|
||||
@@ -34,6 +37,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
p.RestingHeartRate = &restingHR
|
||||
p.WarmupMinutes = 8
|
||||
p.BackfillHorizonDays = 14
|
||||
p.MinRepresentativePaceSecPerKm = 600
|
||||
p.MinRepresentativeTimeSeconds = 5
|
||||
|
||||
if err := db.UpdateProfile(ctx, p); err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
@@ -55,4 +60,7 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
if got.BackfillHorizonDays != 14 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user