fix(train): the wheel is learnt from rides GPS covered, whatever its size (ADR 0030, D56)

The measure was held to GPS making 90 % of the wheel's way at the
circumference taken, 2105 mm to start: a real wheel under some 1.9 m fell
short of it on every ride and was never learnt. A ride now measures where
precise fixes followed it for 95 % of its moving time, its quotient between
1 and 3 m as one typed may be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 16:01:40 +01:00
commit 9b9b8d08ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 127 additions and 19 deletions

File diff suppressed because one or more lines are too long

View file

@ -168,9 +168,10 @@ int? _wheelTurns(Recording recording) {
/// The wheel's circumference, in millimetres, [recording] measured by GPS /// The wheel's circumference, in millimetres, [recording] measured by GPS
/// (D49): its track's length over the wheel's revolutions, where GPS /// (D49): its track's length over the wheel's revolutions, where GPS
/// covered the ride (its distance is GPS's: a tunnel's stretch would read /// covered the ride's moving time (`_gpsCovered`; a tunnel's straight line
/// as a smaller wheel), it went 2 km at least by both and they make a wheel /// would read as another wheel), it went 2 km at least and they make a
/// (1.5 to 2.5 m); else null. /// wheel (1 to 3 m, as one typed may be); else null. Held to time, not to
/// the wheel taken: a smaller wheel's ride falls short of it.
int? measuredWheelMm(Recording recording) { int? measuredWheelMm(Recording recording) {
final gps = _metres(recording); final gps = _metres(recording);
final turns = _wheelTurns(recording); final turns = _wheelTurns(recording);
@ -178,11 +179,79 @@ int? measuredWheelMm(Recording recording) {
turns == null || turns == null ||
turns == 0 || turns == 0 ||
gps < 2000 || gps < 2000 ||
usesWheel(gps, wheelMetresOf(recording))) { !_gpsCovered(recording)) {
return null; return null;
} }
final mm = (gps * 1000 / turns).round(); final mm = (gps * 1000 / turns).round();
return mm >= 1500 && mm <= 2500 ? mm : null; return mm >= 1000 && mm <= 3000 ? mm : null;
}
/// The longest GPS may go without a precise fix while moving, its track
/// still the way it went.
const _longestGap = Duration(seconds: 30);
/// The share of the moving time GPS must cover for its track to measure
/// the wheel.
const _coveredShare = 0.95;
/// Whether precise fixes followed [recording] for [_coveredShare] of its
/// moving time, from its first wheel reading to its last: the time between
/// two fixes counts where it is at most [_longestGap] and no pause falls
/// between them.
bool _gpsCovered(Recording recording) {
final wheel = recording.wheel;
if (wheel.length < 2) {
return false;
}
final from = wheel.first.time;
final to = wheel.last.time;
final pauses = recording.pauses;
var next = 0;
var paused = false;
Fix? last;
var covered = Duration.zero;
for (final fix in recording.fixes) {
if (fix.time.isBefore(from) || fix.time.isAfter(to)) {
continue;
}
while (next < pauses.length && !pauses[next].time.isAfter(fix.time)) {
paused = pauses[next++].paused;
last = null;
}
if (paused || fix.accuracyCm > maxAccuracyCm) {
continue;
}
if (last != null) {
final gap = fix.time.difference(last.time);
if (gap <= _longestGap) {
covered += gap;
}
}
last = fix;
}
final moving = _movingBetween(recording, from, to);
return moving > Duration.zero &&
covered.inMilliseconds >= moving.inMilliseconds * _coveredShare;
}
/// How long [recording] moved between [from] and [to], its pauses left out.
Duration _movingBetween(Recording recording, DateTime from, DateTime to) {
DateTime clamp(DateTime time) =>
time.isBefore(from) ? from : (time.isAfter(to) ? to : time);
var moving = to.difference(from);
DateTime? pausedAt;
for (final mark in recording.pauses) {
if (mark.paused) {
pausedAt ??= clamp(mark.time);
} else if (pausedAt != null) {
moving -= clamp(mark.time).difference(pausedAt);
pausedAt = null;
}
}
if (pausedAt != null) {
moving -= to.difference(pausedAt);
}
return moving;
} }
/// The metres the rower counted while not paused: each frame's growth over /// The metres the rower counted while not paused: each frame's growth over

View file

@ -122,13 +122,20 @@ void main() {
}); });
group("the wheel's distance (D49)", () { group("the wheel's distance (D49)", () {
/// A ride of [seconds] with GPS [gpsMetres] a second and the wheel /// A ride of [seconds] with GPS [gpsMetres] a second, no fix between
/// turning twice a second, its 2.1 m round. /// the seconds [lostFrom] and [lostTo], and the wheel turning twice a
Recording ride({required int seconds, required num gpsMetres}) => Recording( /// second, taken for 2.1 m round.
Recording ride({
required int seconds,
required num gpsMetres,
int? lostFrom,
int? lostTo,
}) => Recording(
activity: Activity.cycling, activity: Activity.cycling,
startedAt: start, startedAt: start,
fixes: [ fixes: [
for (var s = 0; s <= seconds; s += 10) for (var s = 0; s <= seconds; s += 10)
if (lostFrom == null || s <= lostFrom || s >= lostTo!)
north(s, (s * gpsMetres).round()), north(s, (s * gpsMetres).round()),
], ],
wheel: [ wheel: [
@ -162,15 +169,39 @@ void main() {
() { () {
expect(measuredWheelMm(ride(seconds: 200, gpsMetres: 4.2)), isNull); expect(measuredWheelMm(ride(seconds: 200, gpsMetres: 4.2)), isNull);
expect(measuredWheelMm(ride(seconds: 600, gpsMetres: 8)), isNull); expect(measuredWheelMm(ride(seconds: 600, gpsMetres: 8)), isNull);
expect(measuredWheelMm(ride(seconds: 1200, gpsMetres: 1.6)), isNull);
}, },
); );
test("one whose GPS fell short of the wheel's way (a tunnel) measures " test('a smaller wheel than the one taken is measured all the same: '
'none: its quotient would read as a smaller wheel', () { "GPS's track is held to time, not to the wheel taken", () {
// 3.6 m a second by GPS, 4.2 by the wheel: 86 %, a 1.8 m wheel. // 3 m a second by GPS, two turns: a 20" wheel's 1.5 m.
final tunnel = ride(seconds: 1500, gpsMetres: 3.6); final small = ride(seconds: 900, gpsMetres: 3);
expect(rodeByWheel(tunnel), isTrue); expect(measuredWheelMm(small), closeTo(1500, 5));
// A child's 16" wheel, 1.25 m.
expect(
measuredWheelMm(ride(seconds: 900, gpsMetres: 2.5)),
closeTo(1250, 5),
);
});
test('one whose GPS was lost for a while (a tunnel) measures none: the '
'straight line across would read as another wheel', () {
final tunnel = ride(
seconds: 1500,
gpsMetres: 4.2,
lostFrom: 600,
lostTo: 720,
);
expect(measuredWheelMm(tunnel), isNull); expect(measuredWheelMm(tunnel), isNull);
// A fix lost now and then, as on any ride, still measures.
final patchy = ride(
seconds: 1500,
gpsMetres: 4.2,
lostFrom: 600,
lostTo: 620,
);
expect(measuredWheelMm(patchy), closeTo(2100, 5));
}); });
}); });

View file

@ -1187,10 +1187,10 @@ Strava. What was found, 27 Oct:
progress and live speed are the wheel's where GPS made less than 90 % of the wheel's progress and live speed are the wheel's where GPS made less than 90 % of the wheel's
way (a turbo trainer, a tunnel, no fix), else GPS's (`usesWheel`, `rodeByWheel`). way (a turbo trainer, a tunnel, no fix), else GPS's (`usesWheel`, `rodeByWheel`).
The circumference starts at 2105 mm (700×25C) and is learnt from rides with at least The circumference starts at 2105 mm (700×25C) and is learnt from rides with at least
2 km of GPS whose quotient falls between 1.5 and 2.5 m: the first sets it, each later 2 km of GPS covering the ride (D56) whose quotient falls between 1 and 3 m: the
one moves it a quarter of the way (`measuredWheelMm`); one typed on the Record sheet first sets it, each later one moves it a quarter of the way (`measuredWheelMm`); one
(1000 to 3000 mm) stays until cleared, which measures again. Auto-pause still follows typed on the Record sheet (1000 to 3000 mm) stays until cleared, which measures
GPS alone on a trainer. again. Auto-pause still follows GPS alone on a trainer.
- **D50 The instance's own map and searching for places** (recommended, the developer's - **D50 The instance's own map and searching for places** (recommended, the developer's
but the search's region; 29 Oct; step 12, what D22, D23 and D44 left for later): but the search's region; 29 Oct; step 12, what D22, D23 and D44 left for later):
- **Tiles** (12a): Protomaps' basemap (schema v4, zooms 0 to 15, ODbL, OpenStreetMap's - **Tiles** (12a): Protomaps' basemap (schema v4, zooms 0 to 15, ODbL, OpenStreetMap's
@ -1416,6 +1416,14 @@ Strava. What was found, 27 Oct:
written, and the other version is offered instead (`LoopFailed('another update')`). written, and the other version is offered instead (`LoopFailed('another update')`).
The relay's calls to Polar have timeouts, caps on their size and https on every The relay's calls to Polar have timeouts, caps on their size and https on every
hop, one fetch per file at a time, and a limit per person. hop, one fetch per file at a time, and a limit per person.
- **D56 The wheel learnt from GPS that covered the ride** (recommended, the developer's;
30 Oct; revises D49's measure): a ride measures the wheel where precise fixes followed
it for 95 % of its moving time (gaps of 30 s at most count, a pause cuts), not where
GPS made 90 % of the wheel's way at the circumference taken: that held every wheel to
the one taken, so a real wheel under some 1.9 m (20", 24", a child's 16") was never
learnt from the 2105 mm a sensor starts with. Its quotient may fall between 1 and 3 m,
as one typed may (`measuredWheelMm`, `_gpsCovered`). A tunnel's straight line still
measures nothing. The distance still follows `usesWheel`, against the wheel as learnt.
## Consequences ## Consequences