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
/// (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
/// as a smaller wheel), it went 2 km at least by both and they make a wheel
/// (1.5 to 2.5 m); else null.
/// covered the ride's moving time (`_gpsCovered`; a tunnel's straight line
/// would read as another wheel), it went 2 km at least and they make a
/// 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) {
final gps = _metres(recording);
final turns = _wheelTurns(recording);
@ -178,11 +179,79 @@ int? measuredWheelMm(Recording recording) {
turns == null ||
turns == 0 ||
gps < 2000 ||
usesWheel(gps, wheelMetresOf(recording))) {
!_gpsCovered(recording)) {
return null;
}
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

View file

@ -122,14 +122,21 @@ void main() {
});
group("the wheel's distance (D49)", () {
/// A ride of [seconds] with GPS [gpsMetres] a second and the wheel
/// turning twice a second, its 2.1 m round.
Recording ride({required int seconds, required num gpsMetres}) => Recording(
/// A ride of [seconds] with GPS [gpsMetres] a second, no fix between
/// the seconds [lostFrom] and [lostTo], and the wheel turning twice a
/// second, taken for 2.1 m round.
Recording ride({
required int seconds,
required num gpsMetres,
int? lostFrom,
int? lostTo,
}) => Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [
for (var s = 0; s <= seconds; s += 10)
north(s, (s * gpsMetres).round()),
if (lostFrom == null || s <= lostFrom || s >= lostTo!)
north(s, (s * gpsMetres).round()),
],
wheel: [
for (var s = 0; s <= seconds; s++) Reading(time: at(s), value: s * 2),
@ -162,15 +169,39 @@ void main() {
() {
expect(measuredWheelMm(ride(seconds: 200, gpsMetres: 4.2)), 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 "
'none: its quotient would read as a smaller wheel', () {
// 3.6 m a second by GPS, 4.2 by the wheel: 86 %, a 1.8 m wheel.
final tunnel = ride(seconds: 1500, gpsMetres: 3.6);
expect(rodeByWheel(tunnel), isTrue);
test('a smaller wheel than the one taken is measured all the same: '
"GPS's track is held to time, not to the wheel taken", () {
// 3 m a second by GPS, two turns: a 20" wheel's 1.5 m.
final small = ride(seconds: 900, gpsMetres: 3);
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);
// 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
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
2 km of GPS whose quotient falls between 1.5 and 2.5 m: the first sets it, each later
one moves it a quarter of the way (`measuredWheelMm`); one typed on the Record sheet
(1000 to 3000 mm) stays until cleared, which measures again. Auto-pause still follows
GPS alone on a trainer.
2 km of GPS covering the ride (D56) whose quotient falls between 1 and 3 m: the
first sets it, each later one moves it a quarter of the way (`measuredWheelMm`); one
typed on the Record sheet (1000 to 3000 mm) stays until cleared, which measures
again. Auto-pause still follows GPS alone on a trainer.
- **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):
- **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')`).
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.
- **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