From adde2be22cffd1ef55a28e2748504826ca15ea0f Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 7 Oct 2026 09:29:21 +0200 Subject: [PATCH] Python: write stroke rate as cadence256 and rounded cadence. Developer field 93 and fractional_cadence are no longer written. 28.5 spm rounds half up to cadence 29. Co-authored-by: Cursor --- python/README.md | 10 +++---- python/src/rowing_data/constants.py | 4 ++- python/src/rowing_data/fields.py | 11 ++++---- python/src/rowing_data/model.py | 7 ++--- python/src/rowing_data/reader.py | 20 +++++++++++-- python/src/rowing_data/strokes.py | 33 +++++++++++++++------- python/src/rowing_data/writer.py | 23 +++++++-------- python/tests/fixtures/gps-update.fit | Bin 673 -> 532 bytes python/tests/fixtures/stroke-boundary.fit | Bin 1531 -> 1445 bytes python/tests/test_codec.py | 7 ++--- python/tests/test_fields.py | 6 ++-- python/tests/test_strokes.py | 27 ++++++++++-------- python/tests/test_writer.py | 14 ++++----- 13 files changed, 96 insertions(+), 66 deletions(-) diff --git a/python/README.md b/python/README.md index 2d4bfaa..4cf45d3 100644 --- a/python/README.md +++ b/python/README.md @@ -10,13 +10,13 @@ shipping products. ## Scope -Implements Draft v0.1 **§4 native FIT fields**, **§5.1 core metrics** (IDs 0–9, -19, and **StrokeRate 93**), and Session **RecordingStrategy** (ID 10), under +Implements Draft v0.1 **§4 native FIT fields**, **§5.1 core metrics** (IDs 0–9 +and 19), and Session **RecordingStrategy** (ID 10), under application UUID `89e86158-6d47-5c98-9d46-7d29437f27b9`. -That is conformance **Levels 1–2** as in §7, plus StrokeRate (93). §7’s Level 2 -bullet list omits 93; §5.1 still requires native `cadence` when rate is known, -so this library writes and reads ID 93. +Stroke rate is native `cadence256` (scale 256) plus `cadence`, rounded to the +nearest integer. This library does not write developer field `StrokeRate` (93) +or native `fractional_cadence`. **Not implemented** (Level 3–4, or blocked on draft defects): diff --git a/python/src/rowing_data/constants.py b/python/src/rowing_data/constants.py index c2132d8..888e436 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -24,7 +24,9 @@ DISTANCE_SCALE = 100 # metres ENHANCED_SPEED_SCALE = 1000 # m/s CYCLE_LENGTH16_SCALE = 100 # metres -FRACTIONAL_CADENCE_SCALE = 128 # fraction of 1 spm +# Native record field 52. physical spm = raw / 256. +CADENCE256_SCALE = 256 +CADENCE256_FIELD_ID = 52 class RecordingStrategy(IntEnum): diff --git a/python/src/rowing_data/fields.py b/python/src/rowing_data/fields.py index f8d2720..11c7b8a 100644 --- a/python/src/rowing_data/fields.py +++ b/python/src/rowing_data/fields.py @@ -11,10 +11,10 @@ from enum import StrEnum from .constants import ( + CADENCE256_SCALE, CYCLE_LENGTH16_SCALE, DISTANCE_SCALE, ENHANCED_SPEED_SCALE, - FRACTIONAL_CADENCE_SCALE, ) @@ -140,7 +140,6 @@ def _native( ), _dev(10, "RecordingStrategy", MessageType.SESSION, BaseType.UINT8, 1, ""), _dev(19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J"), - _dev(93, "StrokeRate", MessageType.RECORD, BaseType.UINT16, 100, "spm"), ) NATIVE_FIELDS: tuple[FieldDef, ...] = ( @@ -154,11 +153,11 @@ def _native( _native("distance", BaseType.UINT32, DISTANCE_SCALE, "m"), _native("cadence", BaseType.UINT8, 1, "spm"), _native( - "fractional_cadence", - BaseType.UINT8, - FRACTIONAL_CADENCE_SCALE, + "cadence256", + BaseType.UINT16, + CADENCE256_SCALE, "spm", - notes="Fractional part of cadence; physical = raw / 128.", + notes="Fractional stroke rate; physical = raw / 256. Native record field 52.", ), _native("heart_rate", BaseType.UINT8, 1, "bpm"), _native("power", BaseType.UINT16, 1, "W"), diff --git a/python/src/rowing_data/model.py b/python/src/rowing_data/model.py index 81fb6dc..e5c0a6b 100644 --- a/python/src/rowing_data/model.py +++ b/python/src/rowing_data/model.py @@ -17,7 +17,7 @@ class Record: timestamp: datetime distance_m: float | None = None cadence: int | None = None - fractional_cadence: float | None = None + cadence256: float | None = None # physical spm, native field 52 heart_rate: int | None = None power: int | None = None enhanced_speed_mps: float | None = None @@ -41,9 +41,9 @@ class Record: def resolved_stroke_rate(self) -> float | None: return resolve_stroke_rate( + cadence256=self.cadence256, stroke_rate=self.stroke_rate, cadence=self.cadence, - fractional_cadence=self.fractional_cadence, ) @@ -87,7 +87,6 @@ def stroke_counts(self) -> list[int]: (8, "average_boat_speed_mps"), (9, "workout_state"), (19, "stroke_work_j"), - (93, "stroke_rate"), ) ATTR_BY_FIELD_ID = dict(RECORD_DEVELOPER_ATTRS) @@ -96,7 +95,7 @@ def stroke_counts(self) -> list[int]: NATIVE_RECORD_ATTRS: Sequence[tuple[str, str]] = ( ("distance_m", "distance"), ("cadence", "cadence"), - ("fractional_cadence", "fractional_cadence"), + ("cadence256", "cadence256"), ("heart_rate", "heart_rate"), ("power", "power"), ("enhanced_speed_mps", "enhanced_speed"), diff --git a/python/src/rowing_data/reader.py b/python/src/rowing_data/reader.py index bec34d1..399ac67 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -8,7 +8,7 @@ from fitparse import FitFile from .codec import decode -from .constants import APPLICATION_ID, RecordingStrategy +from .constants import APPLICATION_ID, CADENCE256_FIELD_ID, RecordingStrategy from .fields import FieldDef, field_by_id, field_by_name from .model import ATTR_BY_FIELD_ID, Lap, Record, RowingSession from .validation import Issue @@ -21,7 +21,8 @@ "timestamp", "distance", "cadence", - "fractional_cadence", + "cadence256", + f"unknown_{CADENCE256_FIELD_ID}", "heart_rate", "power", "enhanced_speed", @@ -213,7 +214,7 @@ def _record(message, our_fields: dict[str, FieldDef], laps: list[Lap]) -> Record "timestamp": timestamp, "distance_m": _native_float(message, "distance"), "cadence": _native_int(message, "cadence"), - "fractional_cadence": _native_float(message, "fractional_cadence"), + "cadence256": _cadence256_spm(message), "heart_rate": _native_int(message, "heart_rate"), "power": _native_int(message, "power"), "enhanced_speed_mps": _native_float(message, "enhanced_speed"), @@ -262,6 +263,19 @@ def _native_raw(message, name: str) -> int | None: return int(field.raw_value) +def _cadence256_spm(message) -> float | None: + spec = field_by_name("cadence256") + for field in message: + if field.name not in ("cadence256", f"unknown_{CADENCE256_FIELD_ID}"): + continue + raw = field.raw_value + if raw is None or isinstance(raw, list | tuple): + return None + decoded = decode(spec, int(raw)) + return None if decoded is None else float(decoded) + return None + + def _cycle_length_m(message) -> float | None: spec = field_by_name("cycle_length16") for field in message: diff --git a/python/src/rowing_data/strokes.py b/python/src/rowing_data/strokes.py index f678e9b..9cc7ba6 100644 --- a/python/src/rowing_data/strokes.py +++ b/python/src/rowing_data/strokes.py @@ -1,14 +1,17 @@ """Consumer helpers required by spec §3.1. Strokes are inferred from native ``total_cycles``, never from Record count. -Stroke rate prefers developer field 93, then cadence plus fractional cadence, -then integer cadence alone. +Stroke rate prefers native ``cadence256``, then the caller's ``stroke_rate``, +then integer ``cadence``. """ from __future__ import annotations +import math from collections.abc import Sequence +from .constants import CADENCE256_SCALE + def strokes_between(previous_total: int | None, current_total: int | None) -> int: """How many strokes occurred after the previous record. @@ -35,20 +38,30 @@ def stroke_counts(total_cycles: Sequence[int | None]) -> list[int]: def resolve_stroke_rate( *, + cadence256: float | None = None, stroke_rate: float | None = None, cadence: int | None = None, - fractional_cadence: float | None = None, ) -> float | None: - """Stroke rate in spm, following spec §3.1 item 5.""" + """Stroke rate in spm, following spec §3.1 item 5. + + ``cadence256`` is the value read from the file. ``stroke_rate`` is the + physical rate a caller asked to write, used when the file value is absent. + """ + if cadence256 is not None: + return float(cadence256) if stroke_rate is not None: return float(stroke_rate) if cadence is None: return None - fraction = 0.0 if fractional_cadence is None else float(fractional_cadence) - return float(cadence) + fraction + return float(cadence) -def native_cadence_parts(stroke_rate_spm: float) -> tuple[int, float]: - """Split a rate into native integer cadence and fractional part in [0, 1).""" - integer = int(stroke_rate_spm) - return integer, float(stroke_rate_spm) - integer +def native_cadence_parts(stroke_rate_spm: float) -> tuple[int, int]: + """Rounded integer cadence and ``cadence256`` raw value, half up. + + 28.5 spm becomes cadence 29 and raw 7296. + """ + rate = float(stroke_rate_spm) + integer = math.floor(rate + 0.5) + raw = math.floor(rate * CADENCE256_SCALE + 0.5) + return int(integer), int(raw) diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index 3ab34dc..dbce557 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -28,7 +28,7 @@ ) from .codec import encode -from .constants import APPLICATION_ID, CYCLE_LENGTH16_SCALE +from .constants import APPLICATION_ID, CADENCE256_SCALE, CYCLE_LENGTH16_SCALE from .fields import BaseType, FieldDef, field_by_id, field_by_name from .model import RECORD_DEVELOPER_ATTRS, Record, RowingSession from .strokes import native_cadence_parts @@ -225,13 +225,13 @@ def _record_message( rec = RecordMessage(developer_fields=dev_fields) if dev_fields else RecordMessage() rec.timestamp = _unix_ms(record.timestamp) _set_native_fields(rec, record) - cadence, fraction = _native_cadence(record) + cadence, cadence256 = _native_cadence(record) if cadence is not None: encode(field_by_name("cadence"), cadence) rec.cadence = cadence - if fraction is not None: - encode(field_by_name("fractional_cadence"), fraction) - rec.fractional_cadence = fraction + if cadence256 is not None: + encode(field_by_name("cadence256"), cadence256) + rec.cadence256 = cadence256 return rec @@ -285,10 +285,11 @@ def _attr_for_field_id(field_id: int | None) -> str: def _native_cadence(record: Record) -> tuple[int | None, float | None]: - """Integer cadence plus fractional part when a rate is known.""" - if record.stroke_rate is not None: - integer, fraction = native_cadence_parts(record.stroke_rate) - return integer, fraction - if record.cadence is None and record.fractional_cadence is None: + """Rounded integer cadence and physical cadence256 when a rate is known.""" + rate = record.stroke_rate if record.stroke_rate is not None else record.cadence256 + if rate is not None: + integer, raw = native_cadence_parts(rate) + return integer, raw / CADENCE256_SCALE + if record.cadence is None: return None, None - return record.cadence, record.fractional_cadence + return record.cadence, None diff --git a/python/tests/fixtures/gps-update.fit b/python/tests/fixtures/gps-update.fit index c6a4ff8c663633d901b6f5946ea879b130a92067..a755619783d77f3d98cefc21a3a012e666dc0f54 100644 GIT binary patch delta 106 zcmZ3;I)#OmN4bE5Z6m8Hkp9KLHe%0g8(UF*7jm0EHXG92gix8JPdFv@tL;FalMBlrgZZ$_D^g C6Bw-k delta 180 zcmbQjvXGUPN4bDw@##kl>h6AEIof(S1s5J1i{uN^Y0xl3S zgM;B9P$C5=;RlkKz=)7I1e7=dl#l^Q1Tix(@BlS9oZ@g`U=U?s2HC{Ozz9?iQpdo| HvNs6;A)PHh diff --git a/python/tests/fixtures/stroke-boundary.fit b/python/tests/fixtures/stroke-boundary.fit index 3d4f9733f43d830b719b49e95afa6ec42c30b899..4d231e0411008c7d35b299a9ca22557386650332 100644 GIT binary patch delta 247 zcmey(y_B1kN4bDw`bJhymdRIGO6ocPva~TXGO;i+v9h$WGqnh_w3#rqc(SyGGqtcW zFflMPF)%YRurV>PGcgD=F)$nu-RayT%K!wI7#M+MgUmw#28JN!9@ayQMQqXn6>`5A z5*Qdn7&veljACjE)YwBnWBX)ZFf%Y*2U-dQvIc?-3{9*%7~|L$@Nba)z`$?>sH9i+ y0y_i46$YR>pw0h)Hn+1LXUt%`#4jTEmBE35L6m_RXfM!Zj6lbMbTF`7J_i7=A2Or> delta 226 zcmZ3={hOPWN4bFG?M7Bl7Cr`s*p}duqWtXCpv02Yi66~aQW%O0CKoc=P4;6cspt61 z(#Fil#KOqL%F@Qp)FRB%X3EIq31l-cF)%VQFf%c*F)^?+F$gm;#4<5391z{<+#|yP z1eX{XfuzGj0S1O1)vFtFU#0|2_AGv)vQ diff --git a/python/tests/test_codec.py b/python/tests/test_codec.py index 230315c..87da1a5 100644 --- a/python/tests/test_codec.py +++ b/python/tests/test_codec.py @@ -17,7 +17,6 @@ (7, 0, 0), # measured zero is a real value (8, 4.0, 1020), # AverageBoatSpeed, scale 255 (19, 250, 250), # StrokeWork J - (93, 28.50, 2850), # StrokeRate, scale 100 (10, 1, 1), # RecordingStrategy (4, 90.5, 905), # deprecated pounds still encode ], @@ -47,9 +46,9 @@ def test_native_distance_and_speed_scales() -> None: assert encode(speed, 4.321) == 4321 assert decode(speed, 4321) == pytest.approx(4.321) - frac = field_by_name("fractional_cadence") - assert encode(frac, 0.5) == 64 - assert decode(frac, 64) == pytest.approx(0.5) + rate = field_by_name("cadence256") + assert encode(rate, 28.5) == 7296 + assert decode(rate, 7296) == pytest.approx(28.5) cycle = field_by_name("cycle_length16") assert encode(cycle, 8.50) == 850 diff --git a/python/tests/test_fields.py b/python/tests/test_fields.py index 658f8b0..36087f7 100644 --- a/python/tests/test_fields.py +++ b/python/tests/test_fields.py @@ -31,14 +31,13 @@ (9, "WorkoutState", MessageType.RECORD, BaseType.UINT8, 1, "", False), (10, "RecordingStrategy", MessageType.SESSION, BaseType.UINT8, 1, "", False), (19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J", False), - (93, "StrokeRate", MessageType.RECORD, BaseType.UINT16, 100, "spm", False), ) EXPECTED_NATIVE_SCALES = { "timestamp": (BaseType.UINT32, 1, "s"), "distance": (BaseType.UINT32, 100, "m"), "cadence": (BaseType.UINT8, 1, "spm"), - "fractional_cadence": (BaseType.UINT8, 128, "spm"), + "cadence256": (BaseType.UINT16, 256, "spm"), "heart_rate": (BaseType.UINT8, 1, "bpm"), "power": (BaseType.UINT16, 1, "W"), "enhanced_speed": (BaseType.UINT32, 1000, "m/s"), @@ -83,7 +82,8 @@ def test_developer_field_ids_are_unique() -> None: def test_field_lookup() -> None: - assert field_by_id(93).name == "StrokeRate" + with pytest.raises(KeyError): + field_by_id(93) assert field_by_name("DriveLength").field_id == 0 assert field_by_name("cadence").native is True with pytest.raises(KeyError): diff --git a/python/tests/test_strokes.py b/python/tests/test_strokes.py index a553561..e3b30e8 100644 --- a/python/tests/test_strokes.py +++ b/python/tests/test_strokes.py @@ -22,21 +22,24 @@ def test_stroke_counts_across_a_sequence() -> None: def test_resolve_stroke_rate_precedence() -> None: - assert ( - resolve_stroke_rate(stroke_rate=28.5, cadence=18, fractional_cadence=0.25) - == 28.5 - ) - assert resolve_stroke_rate(cadence=18, fractional_cadence=0.25) == 18.25 + assert resolve_stroke_rate(cadence256=28.5, stroke_rate=22.0, cadence=18) == 28.5 + assert resolve_stroke_rate(stroke_rate=28.5, cadence=18) == 28.5 assert resolve_stroke_rate(cadence=18) == 18.0 assert resolve_stroke_rate() is None - assert resolve_stroke_rate(fractional_cadence=0.5) is None + + +def test_native_cadence_parts_rounds_half_up() -> None: + from rowing_data.strokes import native_cadence_parts + + assert native_cadence_parts(28.5) == (29, 7296) + assert native_cadence_parts(28.4) == (28, 7270) def test_record_resolved_stroke_rate() -> None: ts = datetime(2026, 1, 1, tzinfo=UTC) - with_dev = Record( - timestamp=ts, stroke_rate=22.1, cadence=22, fractional_cadence=0.0 - ) - assert with_dev.resolved_stroke_rate() == 22.1 - native_only = Record(timestamp=ts, cadence=19, fractional_cadence=0.4) - assert native_only.resolved_stroke_rate() == 19.4 + from_caller = Record(timestamp=ts, stroke_rate=22.1, cadence=22) + assert from_caller.resolved_stroke_rate() == 22.1 + from_file = Record(timestamp=ts, cadence256=19.4, cadence=19) + assert from_file.resolved_stroke_rate() == 19.4 + native_only = Record(timestamp=ts, cadence=19) + assert native_only.resolved_stroke_rate() == 19.0 diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py index 2e3bec0..0d6ff5a 100644 --- a/python/tests/test_writer.py +++ b/python/tests/test_writer.py @@ -71,7 +71,7 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None: for msg in fit.get_messages("field_description") } assert descriptions[10] == "RecordingStrategy" - assert descriptions[93] == "StrokeRate" + assert 93 not in descriptions assert descriptions[0] == "DriveLength" assert descriptions[19] == "StrokeWork" assert descriptions[6] == "AverageDriveForceN" @@ -84,12 +84,12 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None: records = list(fit.get_messages("record")) assert len(records) == 2 first = records[0] - assert first.get_value("cadence") == 28 - frac = first.get("fractional_cadence") - assert frac is not None - assert frac.raw_value == 64 - stroke_rate = next(field for field in first if field.name == "StrokeRate") - assert stroke_rate.raw_value == 2850 + assert first.get_value("cadence") == 29 + fractional = first.get("fractional_cadence") + assert fractional is None or fractional.raw_value is None + cadence256 = first.get("cadence256") + assert cadence256 is not None + assert cadence256.raw_value == 7296 drive = next(field for field in first if field.name == "DriveLength") assert drive.raw_value == 1420 work = next(field for field in first if field.name == "StrokeWork")