From b6acc040fbac9d537d97a943dde1bec7932b9a8a Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 7 Oct 2026 09:21:17 +0200 Subject: [PATCH] Python: omit heart rate 0 and clarify cycle_length16. A missing sensor is an absent field. cycle_length16 is boat travel per stroke, not cumulative distance or handle travel. Co-authored-by: Cursor --- python/README.md | 4 ++++ python/src/rowing_data/fields.py | 11 ++++++++++- python/src/rowing_data/validation.py | 10 ++++++++++ python/src/rowing_data/writer.py | 3 ++- python/tests/test_fields.py | 6 ++++++ python/tests/test_roundtrip.py | 15 +++++++++++++++ python/tests/test_writer.py | 11 +++++++++++ 7 files changed, 58 insertions(+), 2 deletions(-) diff --git a/python/README.md b/python/README.md index 2d4bfaa..56f48b5 100644 --- a/python/README.md +++ b/python/README.md @@ -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. diff --git a/python/src/rowing_data/fields.py b/python/src/rowing_data/fields.py index f8d2720..5f1330c 100644 --- a/python/src/rowing_data/fields.py +++ b/python/src/rowing_data/fields.py @@ -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] = { diff --git a/python/src/rowing_data/validation.py b/python/src/rowing_data/validation.py index a5201a6..9788190 100644 --- a/python/src/rowing_data/validation.py +++ b/python/src/rowing_data/validation.py @@ -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 diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index 3ab34dc..dba6ac5 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -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: diff --git a/python/tests/test_fields.py b/python/tests/test_fields.py index 658f8b0..2a46b88 100644 --- a/python/tests/test_fields.py +++ b/python/tests/test_fields.py @@ -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 diff --git a/python/tests/test_roundtrip.py b/python/tests/test_roundtrip.py index 2d334be..fb3622b 100644 --- a/python/tests/test_roundtrip.py +++ b/python/tests/test_roundtrip.py @@ -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=( diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py index 2e3bec0..e3ca9ae 100644 --- a/python/tests/test_writer.py +++ b/python/tests/test_writer.py @@ -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(