Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ PYTHONPATH=/tmp/pylibs pkgx +python.org -- python3 \

**The reference hardcodes its assumptions, and the JS has since moved past
several of them.** `AS_REFERENCE` in `fixtures.mjs` pins them all back:
`keepStrokeWhenUnsure: false` (it writes the stroke-count verdict even when
the duration disagrees, where the JS now keeps the watch's label),
`lengthsPerLap: 1` (it merges every active length in a lap, full stop),
`strokeSplit: 40` and `durationSplit: 100` (per-length constants fitted at
50 m, where the JS's scaled defaults happen to land on exactly the same
Expand Down Expand Up @@ -187,6 +189,7 @@ assumption is worth a lot.
| swim-05 | **18 m**, recorded as 20 m | short pool, **wrong pool size**, and the first file to break the unit estimator | **open** — watch 64, time 63.7, strokes 63.8 |
| swim-06 | 18 m, recorded correctly | short pool where nearly every recorded length is already a real length, so *any* merging is wrong | **open** — watch 63, time 61.4, strokes 64.5 |
| swim-07 | 50 m | nearly clean: one true phantom split among thirty single-length laps, plus two genuine blocks (3 and 2 lengths) from swimming through the turn — laps end at rests, not button presses, so multi-length laps are normal use and a blanket merge destroys real distance | 17 / 850 m |
| swim-08 | 50 m | the first **missed turn**: lap 12 holds a 151 s length with 51 strokes, twice a normal length in both. Nothing merges it -- the tool cannot split -- so the file comes out one short and the `missed-turn` finding has to account for the difference exactly | 22 / 1100 m, all freestyle (21 written + 1 reported) |

### How the length unit is chosen, and why it changed twice

Expand Down Expand Up @@ -297,20 +300,55 @@ for screenshots and for checking a deploy.
`resolveLapTargets()` produces one target per lap; `analyze()` returns them
as `lapTargets` and `repair()` uses those rather than recomputing. A fixed
number still overrides. Merging is downwards only — a lap with fewer lengths
than its target is untouched and nothing is ever split, so a *missed* turn is
out of reach either way.
- **The unit estimate is `max()` of two estimators on purpose.** Both fail
small, in opposite directions: the recorded-length estimator is useless when
every length was split (swim-01 has no intact length anywhere), and the
lap-total estimator is dragged down by long continuous blocks. Do not
"simplify" it to one of them — `auto-lengths.test.mjs` pins the swimmer's
confirmed count for each fixture.
than its target is untouched. A *missed* turn is out of its reach; that is
what the separate, opt-in split below is for.
- **The unit is whichever of two estimates is more uniform.** One comes from
the recorded lengths, one from the lap totals; `estimateLengthUnit()` takes
the set with the lower coefficient of variation, as described under "How the
length unit is chosen" above. This bullet used to describe the `max()` of the
two, the rule that section explains was discarded after it scored 4-for-6 —
do not bring it back. `auto-lengths.test.mjs` pins the swimmer's confirmed
count for each fixture.
- **The `lap-structure` finding is the guard against a wrong target.** It fires when more
than half the laps hold multiple lengths, or any lap holds four or more —
which means the swimmer did not lap once per length and the merge will delete
real distance. It reports rather than refuses, because the data alone cannot
distinguish "many phantom turns" from "a different lapping habit". Fixture 1
trips it legitimately. Keep it loud in every front end.
- **A missed turn is reported, and repaired only on request.** Two lengths recorded as one
show up as a single length at ~2x the unit *and* ~2x the median stroke count
(`MISSED_TURN_RATIO`, 1.75). Both halves matter: a kick set or a pause at the
wall is long without the strokes, and an 18 m pool reaches 1.61x on duration
alone. The threshold rests on one positive example (swim-08) and seven
negatives -- do not lower it without a second real one. And the stroke of
such a length is left as the watch said: its count covers two lengths, so
measured against a per-length threshold it reads as breaststroke, which is
exactly what used to get written into an all-freestyle swim.
- **A split is a pre-pass, and it is opt-in on purpose.** `splitMissedTurns`
runs `applySplits()` before anything else sees the file, so every other
guarantee -- lap targets, stroke decisions, the working table adding up to
what is written -- holds on the split file unchanged. It is the one place
this tool adds data: a merge only discards, so every number it writes was
measured, but a split has to make up where the turn fell and how the strokes
divide. Keep it off by default, keyed per length (the finding's `key`, the
length's index in the file *as recorded* — not its start time, which two
lengths can share, and not its index in a split file, which shifts), and keep
the made-up lengths marked as such (`swimLaps[].split`) in every front end.
Anything that reports what the *watch* did counts from `recorded` and
`info.lengths`, never from the split file — the Watch column, the
phantom-turn and lap-structure findings, the "was" figures. A fixed
lengths-per-lap can merge the made-up parts straight back; the finding's
`gained` says how many lengths a split really added, and a merged group
containing a made-up length keeps the watch's stroke, or its doubled stroke
count reads as breaststroke all over again. `split.test.mjs` holds it to adding
nothing but a length: total time and strokes are unchanged. Before/after
figures must subtract the made-up lengths, or "before" includes data that
never existed.
- **Stroke decisions are made once, in `analyze()`.** `strokeWrite` maps each
merged group to the stroke to write, or `null` to keep the watch's label, and
`repair()` writes that rather than re-deriving it. It re-derived it once, and
the page promised the watch's label was kept for ambiguous strokes while the
file got the stroke count's verdict regardless.
- **Second vs millisecond resolution.** `length.start_time` is in whole
seconds, `lap.total_elapsed_time` in milliseconds. Lap boundaries are derived
from the *next* lap's `start_time` to stay in whole seconds throughout. A
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,14 @@ scrubbing step that is not optional.
you lapped inconsistently, which no single number can. It is still a
heuristic tuned on seven files — check the before/after numbers, and put a
number in if you disagree.
- **It only ever merges, never splits.** If the watch *missed* a turn and
recorded two lengths as one, nothing here will recover it.
- **It merges on its own, and splits only when you ask.** If the watch *missed*
a turn and recorded two lengths as one, you get a "possible missed turn"
finding: a length that runs as long as two, in both time and strokes, with its
stroke left as the watch recorded it. The file stays short unless you tick
*Split into 2 lengths* on that finding (`--split-missed-turns=LAP` on the
command line). The split lengths are made up — the turn is put halfway and
the strokes are divided with the time — and are marked that way in *Show the
working*. Total time and strokes don't change; only the length count does.
- The freestyle/breaststroke split defaults to `auto`, which scales it from the
pool length — 40 strokes per length at 50 m, proportionally fewer in a short
pool. The constant behind it was still fitted on one swimmer, so a fixed
Expand Down
91 changes: 88 additions & 3 deletions packages/fitfix/src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ Assumptions, calibrated on a Forerunner 265 and scaled to your pool:
--duration-split=N|auto seconds per length, cross-checks --stroke-split
(default ${DEFAULTS.durationSplit}, scaled the same way)
--keep-stroke leave the watch's stroke classification alone
--keep-elapsed leave total elapsed time as recorded`;
--keep-elapsed leave total elapsed time as recorded

--split-missed-turns[=LAPS]
split lengths that look like two the watch
recorded as one -- all of them, or only those in
the listed laps (e.g. =12,20). Off by default: it
invents where the turn fell, so use it for a turn
you actually remember.`;

const args = process.argv.slice(2);
if (!args.length || args.includes('--help') || args.includes('-h')) {
Expand Down Expand Up @@ -67,6 +74,7 @@ const KNOWN = new Set([
'dry-run',
'entry',
'help',
'split-missed-turns',
]);
const unknown = Object.keys(flags).filter((f) => !KNOWN.has(f));
if (unknown.length) {
Expand All @@ -93,6 +101,22 @@ for (const [flag, { key, auto }] of Object.entries(NUMERIC)) {
}
for (const [flag, key] of Object.entries(BOOLEAN)) if (flags[flag]) opts[key] = false;

/*
* Laps are what a swimmer can name from the table, so that is what the flag
* takes; the library wants finding keys, which are resolved once the file is
* read. Validated here, like every other flag, rather than silently ignored.
*/
let splitLaps = null;
if (flags['split-missed-turns'] !== undefined) {
const v = flags['split-missed-turns'];
if (v === true) splitLaps = true;
else if (/^\d+(,\d+)*$/.test(v)) splitLaps = new Set(v.split(',').map(Number));
else {
console.error(`--split-missed-turns takes lap numbers like =12,20, got "${v}"`);
process.exit(2);
}
}

const raw = new Uint8Array(await readFile(src));

/*
Expand Down Expand Up @@ -162,6 +186,28 @@ if (isZip(raw)) {

const dst = pos[1] ?? join(dirname(src), `${label.replace(/\.fit$/i, '')}_fixed.fit`);

if (splitLaps === true) {
opts.splitMissedTurns = true;
// Said, not silently skipped: "I asked for a split and nothing happened"
// should never have to be worked out from the distance alone.
if (
!analyze(u8, { ...opts, splitMissedTurns: false }).findings.some(
(f) => f.type === 'missed-turn',
)
)
console.warn(`${label}: --split-missed-turns: no possible missed turn to split`);
} else if (splitLaps) {
const found = analyze(u8, { ...opts, splitMissedTurns: false }).findings.filter(
(f) => f.type === 'missed-turn',
);
const unknown = [...splitLaps].filter((n) => !found.some((f) => f.lap + 1 === n));
if (unknown.length) {
console.error(`--split-missed-turns: no possible missed turn in lap ${unknown.join(', ')}`);
process.exit(2);
}
opts.splitMissedTurns = found.filter((f) => splitLaps.has(f.lap + 1)).map((f) => f.key);
}

if (flags['dry-run']) {
const info = analyze(u8, opts);
console.log(
Expand Down Expand Up @@ -199,9 +245,14 @@ function printWorking(info) {
console.log(' lap watch fixed the lengths it saw (s) rest laps not shown');
for (const l of info.swimLaps) {
const merged = l.lengths > l.target;
const seen = l.lengthsS.map((s) => (s === null ? '?' : Math.round(s))).join(' + ');
const seen = l.lengthsS
.map((s, i) =>
s === null ? '?' : `${Math.round(s)}${l.missedTurns[i] ? '!' : ''}${l.split[i] ? '*' : ''}`,
)
.join(' + ');
const missed = l.missedTurns.some(Boolean);
console.log(
` ${String(l.lap + 1).padStart(3)} ${String(l.lengths).padStart(5)} -> ${String(l.target).padEnd(5)} ${seen}${merged ? ' merged' : ''}`,
` ${String(l.lap + 1).padStart(3)} ${String(l.recorded).padStart(5)} -> ${String(l.target).padEnd(5)} ${seen}${merged ? ' merged' : ''}${missed ? ' possible missed turn' : ''}${l.split.some(Boolean) ? ' split (* made up)' : ''}`,
);
}
}
Expand All @@ -218,6 +269,40 @@ if (opts.lengthsPerLap === 'auto' && info.lengthUnitS) {
);
}

/*
* Also loud, for the opposite reason: the file comes out short. The tool only
* splits when asked -- a split invents a turn the watch never recorded -- so
* by default the most it does is say so, and how to ask.
*/
for (const f of info.findings.filter((f) => f.type === 'missed-turn')) {
console.warn('');
if (f.split && f.gained === 0) {
console.warn(
` !! lap ${f.lap + 1}: split as asked, but the fixed --lengths-per-lap merges the parts`,
);
console.warn(
' !! straight back -- the distance is unchanged by it. Use auto for it to count.',
);
continue;
}
if (f.split) {
console.warn(
` !! lap ${f.lap + 1}: split a ${Math.round(f.durS)} s length into ${f.looksLike}, as asked.`,
);
console.warn(' !! The turn is put halfway -- those lengths are made up, not recorded.');
continue;
}
console.warn(
` !! lap ${f.lap + 1}: possible missed turn -- one length took ${Math.round(f.durS)} s`,
);
console.warn(
` !! with ${f.strokes} strokes, about ${f.looksLike} lengths' worth (one is ~${Math.round(f.unitS)} s).`,
);
console.warn(
` !! The distance is probably ${f.looksLike - 1} length(s) short. --split-missed-turns=${f.lap + 1} splits it.`,
);
}

// Loud, before anything else: this is the case where the output is garbage.
const structure = info.findings.find((f) => f.type === 'lap-structure');
if (structure) {
Expand Down
Loading
Loading