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
4 changes: 4 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ so this library writes and reads ID 93.
- In-stroke axis metadata 90–92, curve arrays, curve summaries, `.instroke.json`
- WorkoutState enumeration (the draft has no value table; ID 9 is an opaque UINT8)

Heart rate is the native `heart_rate` field. When no sensor is connected, omit
it rather than write 0. `cycle_length16` is the distance the boat travels in
one stroke cycle, not cumulative `distance` and not handle travel (DriveLength).

**Pre-v1.2 files:** DriveLength used scale 100 / metres. This reader decodes
with **v0.1 units (mm)** and records a `field_scale` warning. It does not
silently convert old files into millimetres.
Expand Down
11 changes: 10 additions & 1 deletion python/src/rowing_data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,16 @@ def _native(
_native("position_lat", BaseType.SINT32, 1, "semicircles"),
_native("position_long", BaseType.SINT32, 1, "semicircles"),
_native("total_cycles", BaseType.UINT32, 1, ""),
_native("cycle_length16", BaseType.UINT16, CYCLE_LENGTH16_SCALE, "m"),
_native(
"cycle_length16",
BaseType.UINT16,
CYCLE_LENGTH16_SCALE,
"m",
notes=(
"Distance the boat travels during one stroke cycle. "
"Not cumulative distance, and not handle travel (DriveLength)."
),
),
)

_BY_ID: dict[int, FieldDef] = {
Expand Down
10 changes: 10 additions & 0 deletions python/src/rowing_data/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ def _record_issues(record: Record, index: int) -> list[Issue]:
except CodecError as exc:
issues.append(Issue("error", "overflow", str(exc), index))

if record.heart_rate == 0:
issues.append(
Issue(
"warning",
"heart_rate",
"heart_rate 0 is omitted; leave the field out when there is no sensor",
index,
)
)

rate = record.resolved_stroke_rate()
drive = record.stroke_drive_time_ms
recovery = record.stroke_recovery_time_ms
Expand Down
3 changes: 2 additions & 1 deletion python/src/rowing_data/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ def _set_native_fields(rec: RecordMessage, record: Record) -> None:
if record.distance_m is not None:
encode(field_by_name("distance"), record.distance_m)
rec.distance = float(record.distance_m)
if record.heart_rate is not None:
# 0 means "no sensor" in some devices. Omit the field instead of writing it.
if record.heart_rate not in (None, 0):
encode(field_by_name("heart_rate"), record.heart_rate)
rec.heart_rate = record.heart_rate
if record.power is not None:
Expand Down
6 changes: 6 additions & 0 deletions python/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ def test_native_field_scales_match_spec_section_4() -> None:
assert field.units == units


def test_cycle_length16_is_boat_travel_per_stroke() -> None:
notes = field_by_name("cycle_length16").notes
assert "Not cumulative distance" in notes
assert "DriveLength" in notes


def test_recording_strategy_enum() -> None:
assert RecordingStrategy.UNKNOWN == 0
assert RecordingStrategy.STROKE_BOUNDARY == 1
Expand Down
15 changes: 15 additions & 0 deletions python/tests/test_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ def test_validate_warns_on_typical_range_and_timing() -> None:
assert all(issue.level == "warning" for issue in issues)


def test_validate_warns_when_heart_rate_is_zero() -> None:
session = RowingSession(
records=(
Record(
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
heart_rate=0,
),
)
)
issues = validate(session)
assert any(
issue.level == "warning" and issue.code == "heart_rate" for issue in issues
)


def test_validate_overflow_is_error() -> None:
session = RowingSession(
records=(
Expand Down
11 changes: 11 additions & 0 deletions python/tests/test_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ def test_writer_rejects_values_that_overflow_fit_types(tmp_path: Path) -> None:
)


def test_writer_omits_heart_rate_zero(tmp_path: Path) -> None:
path = tmp_path / "hr.fit"
write_fit(
_session(Record(timestamp=_ts(0), heart_rate=0, distance_m=1.0)),
path,
)
record = next(FitFile(str(path), check_crc=True).get_messages("record"))
assert record.get_value("heart_rate") is None
assert record.get_value("distance") == pytest.approx(1.0)


def test_writer_rejects_native_values_that_overflow(tmp_path: Path) -> None:
with pytest.raises(CodecError):
write_fit(
Expand Down
Loading