diff --git a/python/README.md b/python/README.md index d8bdc86..9f2d44b 100644 --- a/python/README.md +++ b/python/README.md @@ -10,14 +10,15 @@ 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`. Producers write protocol version 1 in `DeveloperDataId.application_version`. A file that omits it is still read. -§5.1 still requires native `cadence` when rate is known; this library writes -and reads both `cadence` and 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`. `RecordingStrategy` includes TimeSampled (3). `StrokeState` (ID 96) is written only for that strategy: 0 Unknown, 1 Waiting, 2 Drive, 3 Dwell, 4 Recovery. diff --git a/python/src/rowing_data/constants.py b/python/src/rowing_data/constants.py index 8251245..a2914a2 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -29,7 +29,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 955da4d..313e330 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, ) @@ -122,7 +122,6 @@ def _native( _dev(94, "SlipThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N"), _dev(95, "WashThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N"), _dev(19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J"), - _dev(93, "StrokeRate", MessageType.RECORD, BaseType.UINT16, 100, "spm"), _dev(96, "StrokeState", MessageType.RECORD, BaseType.UINT8, 1, ""), ) @@ -137,11 +136,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 b1ca486..fa7e972 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 @@ -40,9 +40,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, ) @@ -86,7 +86,6 @@ def stroke_counts(self) -> list[int]: (8, "average_boat_speed_mps"), (9, "workout_state"), (19, "stroke_work_j"), - (93, "stroke_rate"), (96, "stroke_state"), ) @@ -102,7 +101,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 6b6f87b..da95286 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -10,6 +10,7 @@ from .codec import decode from .constants import ( APPLICATION_ID, + CADENCE256_FIELD_ID, PROTOCOL_VERSION, RecordingStrategy, StrokeState, @@ -26,7 +27,8 @@ "timestamp", "distance", "cadence", - "fractional_cadence", + "cadence256", + f"unknown_{CADENCE256_FIELD_ID}", "heart_rate", "power", "enhanced_speed", @@ -280,7 +282,7 @@ def _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"), @@ -357,6 +359,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/validation.py b/python/src/rowing_data/validation.py index 9017d96..671ef1e 100644 --- a/python/src/rowing_data/validation.py +++ b/python/src/rowing_data/validation.py @@ -18,8 +18,8 @@ "average_drive_force": (0.0, 2000.0), "peak_drive_force": (0.0, 2000.0), "drive_length_mm": (300.0, 1500.0), - "stroke_rate": (10.0, 100.0), } +_STROKE_RATE_RANGE = (10.0, 100.0) @dataclass(frozen=True, slots=True) @@ -95,6 +95,17 @@ def _record_issues(record: Record, index: int, session: RowingSession) -> list[I ) rate = record.resolved_stroke_rate() + if rate is not None: + lo, hi = _STROKE_RATE_RANGE + if rate < lo or rate > hi: + issues.append( + Issue( + "warning", + "typical_range", + f"stroke_rate={rate!r} is outside typical [{lo}, {hi}]", + index, + ) + ) drive = record.stroke_drive_time_ms recovery = record.stroke_recovery_time_ms if rate and rate > 0 and drive is not None and recovery is not None: diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index b9c78df..cf1d9f5 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -30,6 +30,7 @@ from .codec import encode from .constants import ( APPLICATION_ID, + CADENCE256_SCALE, CYCLE_LENGTH16_SCALE, PROTOCOL_VERSION, RecordingStrategy, @@ -259,13 +260,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 @@ -320,10 +321,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.cadence256 if record.cadence256 is not None else record.stroke_rate + 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 c6a4ff8..a755619 100644 Binary files a/python/tests/fixtures/gps-update.fit and b/python/tests/fixtures/gps-update.fit differ diff --git a/python/tests/fixtures/stroke-boundary.fit b/python/tests/fixtures/stroke-boundary.fit index 3d4f973..4d231e0 100644 Binary files a/python/tests/fixtures/stroke-boundary.fit and b/python/tests/fixtures/stroke-boundary.fit differ diff --git a/python/tests/test_codec.py b/python/tests/test_codec.py index ff3ba0d..adc1533 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 ], ) @@ -46,9 +45,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 b7cfbd7..d7e03f6 100644 --- a/python/tests/test_fields.py +++ b/python/tests/test_fields.py @@ -31,7 +31,6 @@ (94, "SlipThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N", False), (95, "WashThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N", False), (19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J", False), - (93, "StrokeRate", MessageType.RECORD, BaseType.UINT16, 100, "spm", False), (96, "StrokeState", MessageType.RECORD, BaseType.UINT8, 1, "", False), ) @@ -39,7 +38,7 @@ "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"), @@ -91,7 +90,8 @@ def test_developer_field_ids_stop_at_254() -> 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_roundtrip.py b/python/tests/test_roundtrip.py index 4aeca3e..254d147 100644 --- a/python/tests/test_roundtrip.py +++ b/python/tests/test_roundtrip.py @@ -170,3 +170,42 @@ def _assert_record_roundtrip(got: Record, want: Record) -> None: assert got.enhanced_speed_mps == pytest.approx( want.enhanced_speed_mps, abs=0.001 ) + + +def test_stroke_rate_precedence_matches_between_writer_and_validator( + tmp_path: Path, +) -> None: + original = RowingSession( + records=( + Record( + timestamp=datetime(2026, 1, 1, tzinfo=UTC), + stroke_rate=30.0, + cadence256=28.5, + ), + ) + ) + path = tmp_path / "precedence.fit" + write_fit(original, path) + loaded = read_fit(path) + assert loaded.records[0].cadence256 == pytest.approx(28.5) + assert original.records[0].resolved_stroke_rate() == pytest.approx(28.5) + + +@pytest.mark.parametrize("rate", [5.0, 120.0]) +def test_validate_warns_on_stroke_rate_outside_typical_range(rate: float) -> None: + session = RowingSession( + records=(Record(timestamp=datetime(2026, 1, 1, tzinfo=UTC), stroke_rate=rate),), + ) + issues = validate(session) + assert any( + i.code == "typical_range" and "stroke_rate" in i.message for i in issues + ) + + +def test_validate_accepts_typical_stroke_rate() -> None: + session = RowingSession( + records=( + Record(timestamp=datetime(2026, 1, 1, tzinfo=UTC), cadence256=24.0), + ), + ) + assert validate(session) == [] 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 5dbec55..1796570 100644 --- a/python/tests/test_writer.py +++ b/python/tests/test_writer.py @@ -74,7 +74,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] == "AverageDriveForce" @@ -89,12 +89,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")