Skip to content
Open
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
9 changes: 5 additions & 4 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion python/src/rowing_data/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 5 additions & 6 deletions python/src/rowing_data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from enum import StrEnum

from .constants import (
CADENCE256_SCALE,
CYCLE_LENGTH16_SCALE,
DISTANCE_SCALE,
ENHANCED_SPEED_SCALE,
FRACTIONAL_CADENCE_SCALE,
)


Expand Down Expand Up @@ -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, ""),
)

Expand All @@ -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"),
Expand Down
7 changes: 3 additions & 4 deletions python/src/rowing_data/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)


Expand Down Expand Up @@ -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"),
)

Expand All @@ -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"),
Expand Down
19 changes: 17 additions & 2 deletions python/src/rowing_data/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .codec import decode
from .constants import (
APPLICATION_ID,
CADENCE256_FIELD_ID,
PROTOCOL_VERSION,
RecordingStrategy,
StrokeState,
Expand All @@ -26,7 +27,8 @@
"timestamp",
"distance",
"cadence",
"fractional_cadence",
"cadence256",
f"unknown_{CADENCE256_FIELD_ID}",
"heart_rate",
"power",
"enhanced_speed",
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 23 additions & 10 deletions python/src/rowing_data/strokes.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
13 changes: 12 additions & 1 deletion python/src/rowing_data/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 12 additions & 10 deletions python/src/rowing_data/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .codec import encode
from .constants import (
APPLICATION_ID,
CADENCE256_SCALE,
CYCLE_LENGTH16_SCALE,
PROTOCOL_VERSION,
RecordingStrategy,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Binary file modified python/tests/fixtures/gps-update.fit
Binary file not shown.
Binary file modified python/tests/fixtures/stroke-boundary.fit
Binary file not shown.
7 changes: 3 additions & 4 deletions python/tests/test_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions python/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,14 @@
(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),
)

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"),
Expand Down Expand Up @@ -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):
Expand Down
39 changes: 39 additions & 0 deletions python/tests/test_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == []
Loading
Loading