fix: update tests to match current algorithm — thresholds, _best_climb tuples, ComputedMetrics fields
CI / Python tests (push) Waiting to run
CI / Frontend build (push) Waiting to run

This commit is contained in:
Davide Scaini
2026-06-03 22:18:17 +02:00
parent f167c6eed7
commit 060bdf5114
4 changed files with 36 additions and 29 deletions
+3 -3
View File
@@ -154,10 +154,10 @@ def test_hysteresis_recalc_barometric(tmp_path):
result = recalculate_elevation_hysteresis(tmp_path, "test-act") result = recalculate_elevation_hysteresis(tmp_path, "test-act")
assert result["altitude_source"] == "barometric" assert result["altitude_source"] == "barometric"
assert result["threshold_m"] == pytest.approx(1.0) assert result["threshold_m"] == pytest.approx(1.5)
# Edge effect is ≤1% on a 30-min ramp # Edge effect is ≤1% on a 30-min ramp
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02) assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
assert result["elevation_loss_m"] == pytest.approx(0.0, abs=1.0) assert result["elevation_loss_m"] == pytest.approx(0.0, abs=1.5)
def test_hysteresis_recalc_gps(tmp_path): def test_hysteresis_recalc_gps(tmp_path):
@@ -166,7 +166,7 @@ def test_hysteresis_recalc_gps(tmp_path):
result = recalculate_elevation_hysteresis(tmp_path, "test-act") result = recalculate_elevation_hysteresis(tmp_path, "test-act")
assert result["threshold_m"] == pytest.approx(3.0) assert result["threshold_m"] == pytest.approx(2.0)
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02) assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
+5 -5
View File
@@ -73,25 +73,25 @@ class TestHysteresisEndpoint:
assert "elevation_loss_m" in body assert "elevation_loss_m" in body
assert body["elevation_gain_m"] > 0 assert body["elevation_gain_m"] > 0
assert body["altitude_source"] == "barometric" assert body["altitude_source"] == "barometric"
assert body["threshold_m"] == pytest.approx(1.0) assert body["threshold_m"] == pytest.approx(1.5)
def test_gps_source_uses_3m_threshold(self, tmp_path): def test_gps_source_uses_2m_threshold(self, tmp_path):
elevations = [float(i) for i in range(1801)] elevations = [float(i) for i in range(1801)]
_make_activity(tmp_path, self.AID, elevations, altitude_source="gps") _make_activity(tmp_path, self.AID, elevations, altitude_source="gps")
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis") r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["threshold_m"] == pytest.approx(3.0) assert r.json()["threshold_m"] == pytest.approx(2.0)
def test_unknown_source_falls_back_to_gps_threshold(self, tmp_path): def test_unknown_source_uses_1_5m_threshold(self, tmp_path):
elevations = [float(i) for i in range(1801)] elevations = [float(i) for i in range(1801)]
_make_activity(tmp_path, self.AID, elevations, altitude_source="unknown") _make_activity(tmp_path, self.AID, elevations, altitude_source="unknown")
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis") r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["threshold_m"] == pytest.approx(3.0) assert r.json()["threshold_m"] == pytest.approx(1.5)
def test_uses_original_elevation_when_dem_backup_present(self, tmp_path): def test_uses_original_elevation_when_dem_backup_present(self, tmp_path):
original = [float(i) for i in range(1801)] # real 1800 m climb original = [float(i) for i in range(1801)] # real 1800 m climb
+25 -21
View File
@@ -29,13 +29,14 @@ def _pt(offset_s: int, **kw) -> DataPoint:
return DataPoint(timestamp=_ts(offset_s), **kw) return DataPoint(timestamp=_ts(offset_s), **kw)
def _activity(points: list[DataPoint], sport: str = "cycling") -> ParsedActivity: def _activity(points: list[DataPoint], sport: str = "cycling", altitude_source: str = "unknown") -> ParsedActivity:
return ParsedActivity( return ParsedActivity(
points=points, points=points,
sport=sport, sport=sport,
started_at=_ts(0), started_at=_ts(0),
source_file="test.fit", source_file="test.fit",
source_hash="sha256:abc", source_hash="sha256:abc",
altitude_source=altitude_source,
) )
@@ -110,12 +111,13 @@ def test_compute_moving_time_excludes_stops():
def test_compute_elevation_gain(): def test_compute_elevation_gain():
# Barometric source: no MA smoothing, so even 3 points produce correct gain.
pts = [ pts = [
_pt(0, lat=48.0, lon=11.0, elevation_m=100.0), _pt(0, lat=48.0, lon=11.0, elevation_m=100.0),
_pt(10, lat=48.001, lon=11.0, elevation_m=150.0), _pt(10, lat=48.001, lon=11.0, elevation_m=150.0),
_pt(20, lat=48.002, lon=11.0, elevation_m=120.0), _pt(20, lat=48.002, lon=11.0, elevation_m=120.0),
] ]
m = compute(_activity(pts)) m = compute(_activity(pts, altitude_source="barometric"))
assert m.elevation_gain_m == 50.0 assert m.elevation_gain_m == 50.0
assert m.elevation_loss_m == 30.0 assert m.elevation_loss_m == 30.0
@@ -134,8 +136,8 @@ def _ele_pts(elevations: list[float]) -> list[DataPoint]:
def test_elevation_hysteresis_large_step_always_counted(): def test_elevation_hysteresis_large_step_always_counted():
# A single 50m step is way above any threshold — both sources should count it. # A 50m step with 5 points per level so the GPS moving average doesn't flatten it.
pts = _ele_pts([100.0, 150.0]) pts = _ele_pts([100.0] * 5 + [150.0] * 5)
gain_baro, _ = _elevation(pts, "barometric") gain_baro, _ = _elevation(pts, "barometric")
gain_gps, _ = _elevation(pts, "gps") gain_gps, _ = _elevation(pts, "gps")
assert gain_baro == 50.0 assert gain_baro == 50.0
@@ -143,26 +145,25 @@ def test_elevation_hysteresis_large_step_always_counted():
def test_elevation_hysteresis_flat_gps_noise_suppressed(): def test_elevation_hysteresis_flat_gps_noise_suppressed():
# Flat coastal route: 16m of GPS noise oscillating within ±8m. # GPS noise within ±0.5m — peak-to-peak 1.0m, well below the 2.0m GPS threshold.
# All steps are sub-1m — hysteresis should return ~0 gain.
import math
n = 1000 n = 1000
elevations = [100.0 + 3.0 * math.sin(i * 0.1) for i in range(n)] elevations = [100.0 + 0.5 * math.sin(i * 0.1) for i in range(n)]
pts = _ele_pts(elevations) pts = _ele_pts(elevations)
gain, loss = _elevation(pts, "gps") gain, loss = _elevation(pts, "gps")
# With threshold=10m no oscillation within ±3m should ever commit.
assert gain == 0.0 assert gain == 0.0
assert loss == 0.0 assert loss == 0.0
def test_elevation_hysteresis_barometric_threshold_lower(): def test_elevation_hysteresis_barometric_threshold_lower():
# Steps of exactly 7m — above barometric (5m) but below GPS (10m) threshold. # 1.7m steps at 100m baseline (avoids sensor-dropout suppression which
elevations = [0.0, 7.0, 0.0, 7.0] # skips values near 0): above the 1.5m barometric threshold but, after GPS
# MA smoothing, the effective diff stays below the 2.0m GPS threshold.
elevations = [100.0, 101.7, 100.0, 101.7]
pts = _ele_pts(elevations) pts = _ele_pts(elevations)
gain_baro, _ = _elevation(pts, "barometric") gain_baro, _ = _elevation(pts, "barometric")
gain_gps, _ = _elevation(pts, "gps") gain_gps, _ = _elevation(pts, "gps")
assert gain_baro == 14.0 # both 7m steps committed assert gain_baro == pytest.approx(3.4) # both 1.7m steps committed
assert gain_gps == 0.0 # 7m < 10m threshold suppressed assert gain_gps == 0.0 # MA + 2.0m threshold suppresses
def test_elevation_hysteresis_real_climb_approximated(): def test_elevation_hysteresis_real_climb_approximated():
@@ -350,33 +351,36 @@ def test_best_efforts_no_targets_for_sport():
# ── best climb ──────────────────────────────────────────────────────────────── # ── best climb ────────────────────────────────────────────────────────────────
def test_best_climb_simple_ascent(): def test_best_climb_simple_ascent():
# 0 → 100 m with no gaps # 0 → 100 m with no gaps; x is cumulative distance (m)
ele = [float(i) for i in range(101)] ele = [(float(i), float(i)) for i in range(101)]
result = _best_climb(ele) result = _best_climb(ele)
assert result == 100.0 assert result == 100.0
def test_best_climb_with_descent(): def test_best_climb_with_descent():
# Up 50, down 20, up 80 → best contiguous window = 80 # Up 50, down 20, up 80 → best contiguous window = 80
ele = list(range(0, 51)) + list(range(50, 30, -1)) + list(range(30, 111)) vals = list(range(0, 51)) + list(range(50, 30, -1)) + list(range(30, 111))
ele = [(float(i), float(v)) for i, v in enumerate(vals)]
result = _best_climb(ele) result = _best_climb(ele)
assert result is not None assert result is not None
assert result >= 80.0 assert result >= 80.0
def test_best_climb_none_gap_resets_window(): def test_best_climb_none_gap_resets_window():
# 50 m up, then a GPS gap, then 30 m up — windows don't bridge the gap # 50 m up, then a GPS gap (skipped), then 30 m up — windows don't bridge the gap.
ele: list = list(range(0, 51)) + [None] + list(range(0, 31)) # None elevations are excluded when building dist_ele, so the climb restarts at 0.
result = _best_climb(ele) ele_up1 = [(float(i), float(i)) for i in range(51)]
ele_up2 = [(float(51 + i), float(i)) for i in range(31)]
result = _best_climb(ele_up1 + ele_up2)
assert result == 50.0 assert result == 50.0
def test_best_climb_only_descent(): def test_best_climb_only_descent():
ele = [100.0, 80.0, 60.0, 40.0] ele = [(float(i), v) for i, v in enumerate([100.0, 80.0, 60.0, 40.0])]
result = _best_climb(ele) result = _best_climb(ele)
assert result is None assert result is None
def test_best_climb_too_few_samples(): def test_best_climb_too_few_samples():
assert _best_climb([]) is None assert _best_climb([]) is None
assert _best_climb([100.0]) is None assert _best_climb([(0.0, 100.0)]) is None
+3
View File
@@ -70,6 +70,7 @@ def _dummy_metrics(**overrides):
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None, avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
bbox=None, start_latlng=None, end_latlng=None, bbox=None, start_latlng=None, end_latlng=None,
mmp=None, best_efforts=None, best_climb_m=None, mmp=None, best_efforts=None, best_climb_m=None,
climbing_vam_mh=None, climbing_time_s=None,
) )
defaults.update(overrides) defaults.update(overrides)
return ComputedMetrics(**defaults) return ComputedMetrics(**defaults)
@@ -216,6 +217,8 @@ def test_build_summary_required_fields():
mmp=None, mmp=None,
best_efforts=None, best_efforts=None,
best_climb_m=None, best_climb_m=None,
climbing_vam_mh=None,
climbing_time_s=None,
) )
summary = build_summary(act, metrics, "2024-06-01T073012Z-test-ride") summary = build_summary(act, metrics, "2024-06-01T073012Z-test-ride")
# Required fields per schema # Required fields per schema