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
7 changes: 5 additions & 2 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ 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–3,
6–9, and 19, and **StrokeRate 93**), and Session **RecordingStrategy** (ID 10), under
application UUID `89e86158-6d47-5c98-9d46-7d29437f27b9`.

§5.1 still requires native `cadence` when rate is known; this library writes
and reads both `cadence` and ID 93.

Force is `AverageDriveForce` (6) and `PeakDriveForce` (7), in newtons. Fields 4
and 5 are not part of this sample.

`SlipThreshold` (94) and `WashThreshold` (95) are optional session fields, in
newtons, written when set.

Expand Down
24 changes: 2 additions & 22 deletions python/src/rowing_data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,28 +106,8 @@ def _native(
_dev(1, "StrokeDriveTime", MessageType.RECORD, BaseType.UINT16, 1, "ms"),
_dev(2, "DragFactor", MessageType.RECORD, BaseType.UINT16, 1, ""),
_dev(3, "StrokeRecoveryTime", MessageType.RECORD, BaseType.UINT16, 1, "ms"),
_dev(
4,
"AverageDriveForceLbs",
MessageType.RECORD,
BaseType.UINT16,
10,
"lbs",
deprecated=True,
notes="Use AverageDriveForceN (6).",
),
_dev(
5,
"PeakDriveForceLbs",
MessageType.RECORD,
BaseType.UINT16,
10,
"lbs",
deprecated=True,
notes="Use PeakDriveForceN (7).",
),
_dev(6, "AverageDriveForceN", MessageType.RECORD, BaseType.UINT16, 10, "N"),
_dev(7, "PeakDriveForceN", MessageType.RECORD, BaseType.UINT16, 10, "N"),
_dev(6, "AverageDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N"),
_dev(7, "PeakDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N"),
_dev(8, "AverageBoatSpeed", MessageType.RECORD, BaseType.UINT16, 255, "m/s"),
_dev(
9,
Expand Down
12 changes: 4 additions & 8 deletions python/src/rowing_data/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@ class Record:
stroke_drive_time_ms: int | None = None
drag_factor: int | None = None
stroke_recovery_time_ms: int | None = None
average_drive_force_lbs: float | None = None
peak_drive_force_lbs: float | None = None
average_drive_force_n: float | None = None
peak_drive_force_n: float | None = None
average_drive_force: float | None = None
peak_drive_force: float | None = None
average_boat_speed_mps: float | None = None
workout_state: int | None = None
stroke_work_j: int | None = None
Expand Down Expand Up @@ -82,10 +80,8 @@ def stroke_counts(self) -> list[int]:
(1, "stroke_drive_time_ms"),
(2, "drag_factor"),
(3, "stroke_recovery_time_ms"),
(4, "average_drive_force_lbs"),
(5, "peak_drive_force_lbs"),
(6, "average_drive_force_n"),
(7, "peak_drive_force_n"),
(6, "average_drive_force"),
(7, "peak_drive_force"),
(8, "average_boat_speed_mps"),
(9, "workout_state"),
(19, "stroke_work_j"),
Expand Down
4 changes: 2 additions & 2 deletions python/src/rowing_data/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

# Informative typical ranges from spec §8.1 — not encoding limits.
_TYPICAL = {
"average_drive_force_n": (0.0, 2000.0),
"peak_drive_force_n": (0.0, 2000.0),
"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),
}
Expand Down
12 changes: 6 additions & 6 deletions python/tests/sample_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ def stroke_boundary_session() -> RowingSession:
stroke_drive_time_ms=450,
stroke_recovery_time_ms=1650,
cycle_length_m=8.5,
average_drive_force_n=412.3,
peak_drive_force_n=780.0,
average_drive_force=412.3,
peak_drive_force=780.0,
stroke_work_j=250,
heart_rate=140,
power=210,
Expand All @@ -44,8 +44,8 @@ def stroke_boundary_session() -> RowingSession:
drive_length_mm=1410,
stroke_drive_time_ms=440,
stroke_recovery_time_ms=1630,
average_drive_force_n=400.0,
peak_drive_force_n=760.0,
average_drive_force=400.0,
peak_drive_force=760.0,
stroke_work_j=240,
heart_rate=142,
power=215,
Expand All @@ -59,8 +59,8 @@ def stroke_boundary_session() -> RowingSession:
drive_length_mm=1415,
stroke_drive_time_ms=455,
stroke_recovery_time_ms=1640,
average_drive_force_n=405.0,
peak_drive_force_n=770.0,
average_drive_force=405.0,
peak_drive_force=770.0,
stroke_work_j=245,
heart_rate=141,
power=212,
Expand Down
5 changes: 2 additions & 3 deletions python/tests/test_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@
[
(0, 1420, 1420), # DriveLength mm, scale 1
(1, 450, 450), # StrokeDriveTime ms
(6, 412.3, 4123), # AverageDriveForceN, scale 10
(6, 412.3, 4123), # AverageDriveForce, scale 10
(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
],
)
def test_developer_encode_decode(field_id: int, physical: float, raw: int) -> None:
Expand Down Expand Up @@ -66,7 +65,7 @@ def test_sint32_position_roundtrip() -> None:
def test_encode_overflow_raises() -> None:
force = field_by_id(6)
# UINT16 max 65535 / scale 10 => 6553.5 N is in range; 7000 N is not.
with pytest.raises(CodecError, match="AverageDriveForceN"):
with pytest.raises(CodecError, match="AverageDriveForce"):
encode(force, 7000)

cadence = field_by_name("cadence")
Expand Down
6 changes: 2 additions & 4 deletions python/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
(1, "StrokeDriveTime", MessageType.RECORD, BaseType.UINT16, 1, "ms", False),
(2, "DragFactor", MessageType.RECORD, BaseType.UINT16, 1, "", False),
(3, "StrokeRecoveryTime", MessageType.RECORD, BaseType.UINT16, 1, "ms", False),
(4, "AverageDriveForceLbs", MessageType.RECORD, BaseType.UINT16, 10, "lbs", True),
(5, "PeakDriveForceLbs", MessageType.RECORD, BaseType.UINT16, 10, "lbs", True),
(6, "AverageDriveForceN", MessageType.RECORD, BaseType.UINT16, 10, "N", False),
(7, "PeakDriveForceN", MessageType.RECORD, BaseType.UINT16, 10, "N", False),
(6, "AverageDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N", False),
(7, "PeakDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N", False),
(8, "AverageBoatSpeed", MessageType.RECORD, BaseType.UINT16, 255, "m/s", False),
(9, "WorkoutState", MessageType.RECORD, BaseType.UINT8, 1, "", False),
(10, "RecordingStrategy", MessageType.SESSION, BaseType.UINT8, 1, "", False),
Expand Down
10 changes: 5 additions & 5 deletions python/tests/test_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def test_validate_overflow_is_error() -> None:
records=(
Record(
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
average_drive_force_n=7000,
average_drive_force=7000,
),
)
)
Expand All @@ -113,10 +113,10 @@ def _assert_record_roundtrip(got: Record, want: Record) -> None:
assert got.drive_length_mm == want.drive_length_mm
assert got.stroke_drive_time_ms == want.stroke_drive_time_ms
assert got.stroke_recovery_time_ms == want.stroke_recovery_time_ms
if want.average_drive_force_n is not None:
assert got.average_drive_force_n == pytest.approx(want.average_drive_force_n)
if want.peak_drive_force_n is not None:
assert got.peak_drive_force_n == pytest.approx(want.peak_drive_force_n)
if want.average_drive_force is not None:
assert got.average_drive_force == pytest.approx(want.average_drive_force)
if want.peak_drive_force is not None:
assert got.peak_drive_force == pytest.approx(want.peak_drive_force)
assert got.stroke_work_j == want.stroke_work_j
assert got.heart_rate == want.heart_rate
assert got.power == want.power
Expand Down
12 changes: 7 additions & 5 deletions python/tests/test_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None:
stroke_rate=28.5,
drive_length_mm=1420,
stroke_work_j=0,
average_drive_force_n=412.3,
average_drive_force=412.3,
),
Record(
timestamp=_ts(2),
Expand All @@ -74,8 +74,10 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None:
assert descriptions[93] == "StrokeRate"
assert descriptions[0] == "DriveLength"
assert descriptions[19] == "StrokeWork"
assert descriptions[6] == "AverageDriveForceN"
assert 7 not in descriptions # omitted PeakDriveForceN
assert descriptions[6] == "AverageDriveForce"
assert 4 not in descriptions
assert 5 not in descriptions
assert 7 not in descriptions # omitted PeakDriveForce

sessions = list(fit.get_messages("session"))
strategy = next(field for field in sessions[0] if field.name == "RecordingStrategy")
Expand All @@ -94,7 +96,7 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None:
assert drive.raw_value == 1420
work = next(field for field in first if field.name == "StrokeWork")
assert work.raw_value == 0
force = next(field for field in first if field.name == "AverageDriveForceN")
force = next(field for field in first if field.name == "AverageDriveForce")
assert force.raw_value == 4123
assert first.get_value("total_cycles") == 1
assert first.get_value("distance") == pytest.approx(0.0)
Expand All @@ -109,7 +111,7 @@ def test_writer_rejects_values_that_overflow_fit_types(tmp_path: Path) -> None:
with pytest.raises(CodecError):
write_fit(
_session(
Record(timestamp=_ts(0), average_drive_force_n=7000),
Record(timestamp=_ts(0), average_drive_force=7000),
),
tmp_path / "overflow.fit",
)
Expand Down
Loading