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
39 changes: 30 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,21 +300,22 @@ 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, never repaired.** Two lengths recorded as one
- **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
Expand All @@ -323,6 +324,26 @@ for screenshots and for checking a deploy.
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
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +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, the file stays one length short — splitting it
would mean inventing a turn the watch never recorded. It does now *say* so: a
length that runs as long as two, in both time and strokes, gets a "possible
missed turn" finding, and its stroke is left as the watch recorded 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
80 changes: 73 additions & 7 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 @@ -200,11 +246,13 @@ function printWorking(info) {
for (const l of info.swimLaps) {
const merged = l.lengths > l.target;
const seen = l.lengthsS
.map((s, i) => (s === null ? '?' : `${Math.round(s)}${l.missedTurns[i] ? '!' : ''}`))
.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' : ''}${missed ? ' possible missed turn' : ''}`,
` ${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 @@ -222,19 +270,37 @@ if (opts.lengthsPerLap === 'auto' && info.lengthUnitS) {
}

/*
* Also loud, for the opposite reason: the file comes out short and this tool
* cannot fix it. It merges; it never splits -- that would mean inventing a
* turn the watch never recorded -- so the most it can do is say so.
* 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. Not fixed.`);
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.
Expand Down
Loading
Loading