V3-01: activity type per ride
Adds Activity (motorcycle/bicycle/scooter/skateboard/running/walking/other) as a column on Trip, and threads it through as real behaviour rather than a label: ActivityProfile (domain/activity_profile.dart) carries a noise floor, accuracy gate, histogram bucket width, and elevation smoothing window/threshold per activity, consumed by sanitizeSpeedKmh, isUsableFix, computeSummary, Accumulator and speedHistogram. The motorcycle profile reproduces the exact constants the app shipped with before this existed, and a test asserts they never drift apart. This is the port's first real migration: schemaVersion 1 -> 2, m.addColumn(trips, trips.activity) with a motorcycle default so every existing row survives unmodified. Proven with a hand-built v1 SQLite file (raw sqlite3, not drift_dev's schema tooling) that a real database with real rides upgrades and keeps every trip, segment and point. No picker in front of Start: a new trip defaults to whichever activity was most recently used, derived live from the trips table rather than duplicated into a Config field. Editable afterwards on trip detail, which recomputes aggregates under the new profile immediately -- a walking pace that reads as noise under a motorcycle's floor reads as real movement once the activity is corrected, and there's a test proving exactly that transition. The widget-test pass for the activity-picker sheet caught a real overflow bug: seven options overflowed a Column-based bottom sheet the same way the record screen once did. Fixed with a scrollable ListView + isScrollControlled, same shape as that earlier fix. 188 tests (171 -> 188): 2 migration, 5 ActivityProfile, 3 profile-threading proofs in computeSummary, 5 repository, 2 widget. Analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
112
lib/src/domain/activity_profile.dart
Normal file
112
lib/src/domain/activity_profile.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
/// Per-activity defaults for the recording and statistics pipeline.
|
||||
///
|
||||
/// The real value of activity type is not the label — it is driving behaviour that was
|
||||
/// previously hardcoded for a motorcycle: the noise floor that separates a real fix from
|
||||
/// GPS jitter, how fine the speed histogram is, how forgiving the accuracy gate is, and
|
||||
/// how much smoothing elevation gets. See V3-01 in `docs/v3/`.
|
||||
///
|
||||
/// These are **starting points, not tuned constants**. V3-13 (real-ride measurement) is
|
||||
/// where they get corrected against real data, once there is real data to correct them
|
||||
/// against — the same discipline the elevation accumulator has always used: do not tune
|
||||
/// blind.
|
||||
library;
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
class ActivityProfile {
|
||||
const ActivityProfile({
|
||||
required this.noiseFloorKmh,
|
||||
required this.histogramBucketKmh,
|
||||
required this.accuracyGateM,
|
||||
required this.elevationWindowSamples,
|
||||
required this.elevationThresholdM,
|
||||
});
|
||||
|
||||
/// Below this, a fix is treated as noise rather than movement. See
|
||||
/// `telemetry.sanitizeSpeedKmh`.
|
||||
final double noiseFloorKmh;
|
||||
|
||||
/// Width of each bar in the speed-distribution chart, in km/h.
|
||||
final int histogramBucketKmh;
|
||||
|
||||
/// Fixes worse than this are dropped rather than recorded. See
|
||||
/// `telemetry.isUsableFix`.
|
||||
final double accuracyGateM;
|
||||
|
||||
/// Samples averaged before an elevation change is judged real rather than GPS wander.
|
||||
final int elevationWindowSamples;
|
||||
|
||||
/// How far the smoothed altitude must move before a climb or descent is banked.
|
||||
final double elevationThresholdM;
|
||||
|
||||
/// Reproduces the constants the app shipped with before activity type existed —
|
||||
/// `speedNoiseFloorKmh` in `telemetry.dart`, and the elevation window/threshold that
|
||||
/// were the defaults in `stats/ride_statistics.dart`. Kept as literals here rather than
|
||||
/// importing those constants, to avoid a dependency cycle between the domain layer and
|
||||
/// the layers built on top of it. If those numbers ever change, this must change with
|
||||
/// them, and vice versa — there is a parity-style test asserting the two stay equal.
|
||||
static const motorcycle = ActivityProfile(
|
||||
noiseFloorKmh: 1.5,
|
||||
histogramBucketKmh: 10,
|
||||
accuracyGateM: 50.0,
|
||||
elevationWindowSamples: 15,
|
||||
elevationThresholdM: 3.0,
|
||||
);
|
||||
|
||||
/// Same shape as a motorcycle: comparable speeds, comparable GPS behaviour.
|
||||
static const scooter = motorcycle;
|
||||
|
||||
/// Bicycles run slower than motorcycles, so the noise floor and histogram bucket both
|
||||
/// need to be finer or real movement gets folded into "not moving".
|
||||
static const bicycle = ActivityProfile(
|
||||
noiseFloorKmh: 1.0,
|
||||
histogramBucketKmh: 5,
|
||||
accuracyGateM: 50.0,
|
||||
elevationWindowSamples: 15,
|
||||
elevationThresholdM: 3.0,
|
||||
);
|
||||
|
||||
/// Similar speed profile to a bicycle; tighter accuracy gate since skateboarding tends
|
||||
/// to happen close to buildings, where multipath makes bad fixes more common.
|
||||
static const skateboard = ActivityProfile(
|
||||
noiseFloorKmh: 1.0,
|
||||
histogramBucketKmh: 5,
|
||||
accuracyGateM: 30.0,
|
||||
elevationWindowSamples: 15,
|
||||
elevationThresholdM: 3.0,
|
||||
);
|
||||
|
||||
/// Real movement lives much closer to GPS noise than a vehicle ever does, so the floor
|
||||
/// drops hard and the histogram wants ~1 km/h bands rather than 10. The wider elevation
|
||||
/// window compensates for covering less ground per sample at running pace.
|
||||
static const running = ActivityProfile(
|
||||
noiseFloorKmh: 0.5,
|
||||
histogramBucketKmh: 1,
|
||||
accuracyGateM: 30.0,
|
||||
elevationWindowSamples: 25,
|
||||
elevationThresholdM: 3.0,
|
||||
);
|
||||
|
||||
/// Slower again than running.
|
||||
static const walking = ActivityProfile(
|
||||
noiseFloorKmh: 0.3,
|
||||
histogramBucketKmh: 1,
|
||||
accuracyGateM: 30.0,
|
||||
elevationWindowSamples: 25,
|
||||
elevationThresholdM: 3.0,
|
||||
);
|
||||
|
||||
/// The safest generic default: the most permissive on accuracy and least likely to
|
||||
/// silently drop real movement, of everything above.
|
||||
static const other = motorcycle;
|
||||
|
||||
factory ActivityProfile.forActivity(Activity activity) => switch (activity) {
|
||||
Activity.motorcycle => motorcycle,
|
||||
Activity.bicycle => bicycle,
|
||||
Activity.scooter => scooter,
|
||||
Activity.skateboard => skateboard,
|
||||
Activity.running => running,
|
||||
Activity.walking => walking,
|
||||
Activity.other => other,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,15 @@ library;
|
||||
/// OS restarts it mid-ride. Hence both.
|
||||
enum TripState { recording, paused, completed }
|
||||
|
||||
/// What a ride was done on.
|
||||
///
|
||||
/// The recording pipeline never cared — it records positions, speeds and altitudes
|
||||
/// regardless of what produced them. This exists so the app can stop assuming
|
||||
/// motorcycle, and so activity can drive per-activity defaults (see
|
||||
/// `domain/activity_profile.dart`) rather than just labelling the row. See V3-01 in
|
||||
/// `docs/v3/`.
|
||||
enum Activity { motorcycle, bicycle, scooter, skateboard, running, walking, other }
|
||||
|
||||
/// One ride, from pressing Start to pressing Stop.
|
||||
///
|
||||
/// The aggregate fields are denormalised on purpose. They are accumulated as points
|
||||
@@ -36,6 +45,7 @@ class Trip {
|
||||
this.endedAt,
|
||||
this.name,
|
||||
this.state = TripState.recording,
|
||||
this.activity = Activity.motorcycle,
|
||||
this.distanceM = 0.0,
|
||||
this.movingMillis = 0,
|
||||
this.maxSpeedKmh = 0.0,
|
||||
@@ -53,6 +63,9 @@ class Trip {
|
||||
final String? name;
|
||||
|
||||
final TripState state;
|
||||
|
||||
/// Defaults to motorcycle, matching every trip recorded before this field existed.
|
||||
final Activity activity;
|
||||
final double distanceM;
|
||||
final int movingMillis;
|
||||
final double maxSpeedKmh;
|
||||
@@ -69,6 +82,7 @@ class Trip {
|
||||
int? endedAt,
|
||||
String? name,
|
||||
TripState? state,
|
||||
Activity? activity,
|
||||
double? distanceM,
|
||||
int? movingMillis,
|
||||
double? maxSpeedKmh,
|
||||
@@ -80,6 +94,7 @@ class Trip {
|
||||
endedAt: endedAt ?? this.endedAt,
|
||||
name: name ?? this.name,
|
||||
state: state ?? this.state,
|
||||
activity: activity ?? this.activity,
|
||||
distanceM: distanceM ?? this.distanceM,
|
||||
movingMillis: movingMillis ?? this.movingMillis,
|
||||
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
||||
|
||||
Reference in New Issue
Block a user