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:
2026-07-19 17:38:50 +02:00
parent 619277b0ee
commit 513418123f
7 changed files with 170 additions and 14 deletions

View File

@@ -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)