From 74d8b89f09e37134cb2df2020ee80e4f41309868 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 7 Oct 2026 09:31:30 +0200 Subject: [PATCH 1/3] Python: define WorkoutState with the FIT intensity values. Unknown values are read as Other. When a record disagrees with its lap intensity, the lap wins. Co-authored-by: Cursor --- python/README.md | 5 +- python/src/rowing_data/__init__.py | 8 ++- python/src/rowing_data/constants.py | 12 +++++ python/src/rowing_data/fields.py | 5 +- python/src/rowing_data/model.py | 11 ++++- python/src/rowing_data/reader.py | 74 +++++++++++++++++++++++----- python/src/rowing_data/validation.py | 28 ++++++++++- python/tests/test_model.py | 28 ++++++++++- python/tests/test_reader.py | 63 ++++++++++++++++++++++- python/tests/test_roundtrip.py | 17 ++++++- 10 files changed, 229 insertions(+), 22 deletions(-) diff --git a/python/README.md b/python/README.md index 2d4bfaa..41e566b 100644 --- a/python/README.md +++ b/python/README.md @@ -23,7 +23,10 @@ so this library writes and reads ID 93. - Oarlock summary fields 11โ€“18 - Dual-oarlock 200โ€“211 (210/211 have a metres vs millimetres conflict in the draft) - 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) + +`WorkoutState` (ID 9) uses the FIT intensity values: 0 Active, 1 Rest, 2 Warmup, +3 Cooldown, 4 Recovery, 5 Interval, 6 Other. An unknown value is read as Other. +When a lap's `intensity` disagrees with a record, the lap wins. **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 diff --git a/python/src/rowing_data/__init__.py b/python/src/rowing_data/__init__.py index 8bf6e76..24d494a 100644 --- a/python/src/rowing_data/__init__.py +++ b/python/src/rowing_data/__init__.py @@ -1,7 +1,12 @@ """Draft v0.1 Python implementation of the Rowing Data Standard.""" from .codec import CodecError, decode, encode -from .constants import APPLICATION_ID, APPLICATION_UUID, STANDARD_VERSION +from .constants import ( + APPLICATION_ID, + APPLICATION_UUID, + STANDARD_VERSION, + WorkoutState, +) from .fields import ( DEVELOPER_FIELDS, NATIVE_FIELDS, @@ -26,6 +31,7 @@ "Record", "RowingSession", "STANDARD_VERSION", + "WorkoutState", "decode", "encode", "field_by_id", diff --git a/python/src/rowing_data/constants.py b/python/src/rowing_data/constants.py index c2132d8..ace44df 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -33,3 +33,15 @@ class RecordingStrategy(IntEnum): UNKNOWN = 0 STROKE_BOUNDARY = 1 GPS_UPDATE = 2 + + +class WorkoutState(IntEnum): + """Record developer field ID 9, the native FIT intensity values.""" + + ACTIVE = 0 + REST = 1 + WARMUP = 2 + COOLDOWN = 3 + RECOVERY = 4 + INTERVAL = 5 + OTHER = 6 diff --git a/python/src/rowing_data/fields.py b/python/src/rowing_data/fields.py index f8d2720..4a347dc 100644 --- a/python/src/rowing_data/fields.py +++ b/python/src/rowing_data/fields.py @@ -136,7 +136,10 @@ def _native( BaseType.UINT8, 1, "", - notes="Opaque UINT8; the draft has no enum table.", + notes=( + "FIT intensity enum: 0 Active, 1 Rest, 2 Warmup, 3 Cooldown, " + "4 Recovery, 5 Interval, 6 Other." + ), ), _dev(10, "RecordingStrategy", MessageType.SESSION, BaseType.UINT8, 1, ""), _dev(19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J"), diff --git a/python/src/rowing_data/model.py b/python/src/rowing_data/model.py index 81fb6dc..0af0cc6 100644 --- a/python/src/rowing_data/model.py +++ b/python/src/rowing_data/model.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from datetime import datetime -from .constants import RecordingStrategy +from .constants import RecordingStrategy, WorkoutState from .strokes import resolve_stroke_rate, stroke_counts @@ -34,7 +34,7 @@ class Record: average_drive_force_n: float | None = None peak_drive_force_n: float | None = None average_boat_speed_mps: float | None = None - workout_state: int | None = None + workout_state: WorkoutState | None = None stroke_work_j: int | None = None stroke_rate: float | None = None lap_index: int | None = None @@ -46,12 +46,19 @@ def resolved_stroke_rate(self) -> float | None: fractional_cadence=self.fractional_cadence, ) + def resolved_workout_state(self, lap: Lap | None) -> WorkoutState | None: + """Lap intensity wins when the lap carries one.""" + if lap is not None and lap.intensity is not None: + return lap.intensity + return self.workout_state + @dataclass(frozen=True, slots=True) class Lap: start_time: datetime total_elapsed_s: float | None = None total_distance_m: float | None = None + intensity: WorkoutState | None = None @dataclass(frozen=True, slots=True) diff --git a/python/src/rowing_data/reader.py b/python/src/rowing_data/reader.py index bec34d1..2877863 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -8,7 +8,7 @@ from fitparse import FitFile from .codec import decode -from .constants import APPLICATION_ID, RecordingStrategy +from .constants import APPLICATION_ID, RecordingStrategy, WorkoutState from .fields import FieldDef, field_by_id, field_by_name from .model import ATTR_BY_FIELD_ID, Lap, Record, RowingSession from .validation import Issue @@ -53,12 +53,23 @@ def read_fit(path: str | Path) -> RowingSession: issues.append(strategy_issue) break - laps = _laps(messages) - records = tuple( - _record(message, our_fields, laps) - for message in messages - if message.name == "record" - ) + laps, lap_issues = _laps(messages) + issues.extend(lap_issues) + records_list: list[Record] = [] + for message in messages: + if message.name != "record": + continue + record, state_issue = _record(message, our_fields, laps) + records_list.append(record) + if state_issue is not None: + state_issue = Issue( + state_issue.level, + state_issue.code, + state_issue.message, + len(records_list) - 1, + ) + issues.append(state_issue) + records = tuple(records_list) return RowingSession( records=records, recording_strategy=strategy, @@ -169,8 +180,9 @@ def _as_datetime(value: object) -> datetime | None: return None -def _laps(messages: list) -> list[Lap]: +def _laps(messages: list) -> tuple[list[Lap], list[Issue]]: laps: list[Lap] = [] + issues: list[Issue] = [] for message in messages: if message.name != "lap": continue @@ -181,15 +193,49 @@ def _laps(messages: list) -> list[Lap]: continue elapsed = message.get_value("total_elapsed_time") distance = message.get_value("total_distance") + intensity, issue = _as_workout_state(message.get_value("intensity")) + if issue is not None: + issues.append(issue) laps.append( Lap( start_time=start, total_elapsed_s=None if elapsed is None else float(elapsed), total_distance_m=None if distance is None else float(distance), + intensity=intensity, ) ) laps.sort(key=lambda lap: lap.start_time) - return laps + return laps, issues + + +def _as_workout_state(value: object) -> tuple[WorkoutState | None, Issue | None]: + if value is None: + return None, None + if isinstance(value, str): + try: + return WorkoutState[value.strip().upper()], None + except KeyError: + return WorkoutState.OTHER, Issue( + "warning", + "workout_state", + f"WorkoutState {value!r} is unknown; treating as Other", + ) + try: + number = int(value) + except (TypeError, ValueError): + return WorkoutState.OTHER, Issue( + "warning", + "workout_state", + f"WorkoutState {value!r} is unknown; treating as Other", + ) + try: + return WorkoutState(number), None + except ValueError: + return WorkoutState.OTHER, Issue( + "warning", + "workout_state", + f"WorkoutState {number} is above 6; treating as Other", + ) def _lap_index(timestamp: datetime, laps: list[Lap]) -> int | None: @@ -204,7 +250,9 @@ def _lap_index(timestamp: datetime, laps: list[Lap]) -> int | None: return index -def _record(message, our_fields: dict[str, FieldDef], laps: list[Lap]) -> Record: +def _record( + message, our_fields: dict[str, FieldDef], laps: list[Lap] +) -> tuple[Record, Issue | None]: timestamp = _as_datetime(message.get_value("timestamp")) if timestamp is None: raise ValueError("record message is missing timestamp") @@ -238,7 +286,11 @@ def _record(message, our_fields: dict[str, FieldDef], laps: list[Lap]) -> Record continue kwargs[attr] = decode(spec, int(raw)) - return Record(**kwargs) # type: ignore[arg-type] + state_issue = None + if kwargs.get("workout_state") is not None: + parsed, state_issue = _as_workout_state(kwargs["workout_state"]) + kwargs["workout_state"] = parsed + return Record(**kwargs), state_issue # type: ignore[arg-type] def _native_int(message, name: str) -> int | None: diff --git a/python/src/rowing_data/validation.py b/python/src/rowing_data/validation.py index a5201a6..54520f3 100644 --- a/python/src/rowing_data/validation.py +++ b/python/src/rowing_data/validation.py @@ -33,11 +33,11 @@ def validate(session: RowingSession) -> list[Issue]: """Return errors and warnings. Does not modify ``session``.""" issues: list[Issue] = list(session.read_issues) for index, record in enumerate(session.records): - issues.extend(_record_issues(record, index)) + issues.extend(_record_issues(record, index, session)) return issues -def _record_issues(record: Record, index: int) -> list[Issue]: +def _record_issues(record: Record, index: int, session: RowingSession) -> list[Issue]: issues: list[Issue] = [] for field_id, attr in RECORD_DEVELOPER_ATTRS: physical = getattr(record, attr) @@ -70,6 +70,22 @@ def _record_issues(record: Record, index: int) -> list[Issue]: except CodecError as exc: issues.append(Issue("error", "overflow", str(exc), index)) + lap = _lap_for(record, session) + if ( + lap is not None + and lap.intensity is not None + and record.workout_state is not None + and record.workout_state != lap.intensity + ): + issues.append( + Issue( + "warning", + "workout_state", + "WorkoutState disagrees with lap intensity; the lap wins", + index, + ) + ) + rate = record.resolved_stroke_rate() drive = record.stroke_drive_time_ms recovery = record.stroke_recovery_time_ms @@ -87,3 +103,11 @@ def _record_issues(record: Record, index: int) -> list[Issue]: ) ) return issues + + +def _lap_for(record: Record, session: RowingSession): + if record.lap_index is None: + return None + if record.lap_index < 0 or record.lap_index >= len(session.laps): + return None + return session.laps[record.lap_index] diff --git a/python/tests/test_model.py b/python/tests/test_model.py index 7fe0668..c944649 100644 --- a/python/tests/test_model.py +++ b/python/tests/test_model.py @@ -6,7 +6,8 @@ import pytest -from rowing_data.constants import RecordingStrategy +from rowing_data import validate +from rowing_data.constants import RecordingStrategy, WorkoutState from rowing_data.model import Lap, Record, RowingSession @@ -30,6 +31,31 @@ def test_session_start_and_stroke_counts() -> None: assert session.recording_strategy is RecordingStrategy.GPS_UPDATE +def test_workout_state_enum_matches_fit_intensity() -> None: + assert WorkoutState.ACTIVE == 0 + assert WorkoutState.REST == 1 + assert WorkoutState.WARMUP == 2 + assert WorkoutState.COOLDOWN == 3 + assert WorkoutState.RECOVERY == 4 + assert WorkoutState.INTERVAL == 5 + assert WorkoutState.OTHER == 6 + + +def test_lap_intensity_wins_when_workout_state_disagrees() -> None: + record = Record( + timestamp=_ts(0), + workout_state=WorkoutState.ACTIVE, + lap_index=0, + ) + lap = Lap(start_time=_ts(0), intensity=WorkoutState.REST) + session = RowingSession(records=(record,), laps=(lap,)) + assert record.resolved_workout_state(lap) is WorkoutState.REST + issues = validate(session) + assert any( + issue.code == "workout_state" and issue.level == "warning" for issue in issues + ) + + def test_empty_session_without_start_time_raises() -> None: session = RowingSession(records=()) with pytest.raises(ValueError, match="no records"): diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index f795459..2b10a92 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -12,12 +12,13 @@ from fit_tool.profile.messages.developer_data_id_message import DeveloperDataIdMessage from fit_tool.profile.messages.field_description_message import FieldDescriptionMessage from fit_tool.profile.messages.file_id_message import FileIdMessage +from fit_tool.profile.messages.lap_message import LapMessage from fit_tool.profile.messages.record_message import RecordMessage from fit_tool.profile.messages.session_message import SessionMessage -from fit_tool.profile.profile_type import FileType, Manufacturer +from fit_tool.profile.profile_type import FileType, Intensity, Manufacturer from rowing_data import APPLICATION_ID, read_fit, validate -from rowing_data.constants import RecordingStrategy +from rowing_data.constants import RecordingStrategy, WorkoutState from rowing_data.fields import field_by_id @@ -132,6 +133,64 @@ def test_mismatched_file_scale_warns_and_uses_v01_units(tmp_path: Path) -> None: assert any(issue.code == "field_scale" for issue in validate(loaded)) +def test_unknown_workout_state_is_other(tmp_path: Path) -> None: + path = tmp_path / "state.fit" + builder = FitFileBuilder(auto_define=True, min_string_size=64) + + file_id = FileIdMessage() + file_id.type = FileType.ACTIVITY + file_id.manufacturer = Manufacturer.DEVELOPMENT + file_id.time_created = _ts_ms() + builder.add(file_id) + + dev_id = DeveloperDataIdMessage() + dev_id.application_id = APPLICATION_ID + dev_id.developer_data_index = 0 + builder.add(dev_id) + + desc = FieldDescriptionMessage() + desc.developer_data_index = 0 + desc.field_definition_number = 9 + desc.fit_base_type_id = BaseType.UINT8.value + desc.field_name = "WorkoutState" + desc.scale = 1 + desc.offset = 0 + desc.units = "" + builder.add(desc) + + state = DeveloperField( + developer_data_index=0, + field_id=9, + size=1, + name="WorkoutState", + base_type=BaseType.UINT8, + scale=1, + offset=0, + units="", + ) + state.set_value(0, 7) + session = SessionMessage() + session.start_time = _ts_ms() + session.timestamp = _ts_ms() + builder.add(session) + + lap = LapMessage() + lap.start_time = _ts_ms() + lap.timestamp = _ts_ms() + lap.intensity = Intensity.REST + builder.add(lap) + + rec = RecordMessage(developer_fields=[state]) + rec.timestamp = _ts_ms() + builder.add(rec) + builder.build().to_file(str(path)) + + loaded = read_fit(path) + assert loaded.records[0].workout_state is WorkoutState.OTHER + assert loaded.laps[0].intensity is WorkoutState.REST + assert any(issue.code == "workout_state" for issue in loaded.read_issues) + + def test_invalid_recording_strategy_is_unknown_with_warning(tmp_path: Path) -> None: path = tmp_path / "bad_strategy.fit" builder = FitFileBuilder(auto_define=True, min_string_size=64) diff --git a/python/tests/test_roundtrip.py b/python/tests/test_roundtrip.py index 2d334be..3f90ad5 100644 --- a/python/tests/test_roundtrip.py +++ b/python/tests/test_roundtrip.py @@ -8,7 +8,7 @@ import pytest from rowing_data import read_fit, validate, write_fit -from rowing_data.constants import RecordingStrategy +from rowing_data.constants import RecordingStrategy, WorkoutState from rowing_data.model import Record, RowingSession from sample_sessions import gps_update_session, stroke_boundary_session @@ -29,6 +29,21 @@ def test_roundtrip_stroke_boundary(tmp_path: Path) -> None: assert validate(loaded) == [] +def test_roundtrip_workout_state(tmp_path: Path) -> None: + original = RowingSession( + records=( + Record( + timestamp=datetime(2026, 1, 1, tzinfo=UTC), + workout_state=WorkoutState.RECOVERY, + ), + ) + ) + path = tmp_path / "state.fit" + write_fit(original, path) + loaded = read_fit(path) + assert loaded.records[0].workout_state is WorkoutState.RECOVERY + + def test_roundtrip_gps_update(tmp_path: Path) -> None: original = gps_update_session() path = tmp_path / "gps.fit" From a34f0f2aaab319483e2b9e73cd388b404fd68088 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 7 Oct 2026 09:32:52 +0200 Subject: [PATCH 2/3] Python: set native intensity on every lap. Caller laps without intensity are rejected. A session with no laps is written as one active lap. Co-authored-by: Cursor --- python/README.md | 4 +++- python/src/rowing_data/writer.py | 11 ++++++++- python/tests/sample_sessions.py | 9 ++++++-- python/tests/test_roundtrip.py | 39 +++++++++++++++++++++++++++++++- python/tests/test_writer.py | 13 ++++++++++- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/python/README.md b/python/README.md index 41e566b..c4701b0 100644 --- a/python/README.md +++ b/python/README.md @@ -26,7 +26,9 @@ so this library writes and reads ID 93. `WorkoutState` (ID 9) uses the FIT intensity values: 0 Active, 1 Rest, 2 Warmup, 3 Cooldown, 4 Recovery, 5 Interval, 6 Other. An unknown value is read as Other. -When a lap's `intensity` disagrees with a record, the lap wins. +When a lap's `intensity` disagrees with a record, the lap wins. Every lap +written here sets native `intensity`. A session with no laps is written as one +active lap. **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 diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index 3ab34dc..87e21ba 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -22,13 +22,14 @@ Event, EventType, FileType, + Intensity, Manufacturer, Sport, SubSport, ) from .codec import encode -from .constants import APPLICATION_ID, CYCLE_LENGTH16_SCALE +from .constants import APPLICATION_ID, CYCLE_LENGTH16_SCALE, WorkoutState from .fields import BaseType, FieldDef, field_by_id, field_by_name from .model import RECORD_DEVELOPER_ATTRS, Record, RowingSession from .strokes import native_cadence_parts @@ -119,6 +120,7 @@ def write_fit(session: RowingSession, path: str | Path) -> None: if lap.total_distance_m is not None: lap_msg.total_distance = lap.total_distance_m lap_msg.sport = Sport.ROWING + lap_msg.intensity = _fit_intensity(lap.intensity) builder.add(lap_msg) else: lap_msg = LapMessage() @@ -129,6 +131,7 @@ def write_fit(session: RowingSession, path: str | Path) -> None: lap_msg.total_timer_time = elapsed_s lap_msg.total_distance = total_distance lap_msg.sport = Sport.ROWING + lap_msg.intensity = Intensity.ACTIVE builder.add(lap_msg) for record in session.records: @@ -143,6 +146,12 @@ def write_fit(session: RowingSession, path: str | Path) -> None: builder.build().to_file(str(path)) +def _fit_intensity(intensity: WorkoutState | None) -> Intensity: + if intensity is None: + raise ValueError("every lap must set intensity") + return Intensity(int(intensity)) + + def _unix_ms(moment: datetime) -> int: if moment.tzinfo is None: moment = moment.replace(tzinfo=UTC) diff --git a/python/tests/sample_sessions.py b/python/tests/sample_sessions.py index 4b60e60..ddc01f9 100644 --- a/python/tests/sample_sessions.py +++ b/python/tests/sample_sessions.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime -from rowing_data.constants import RecordingStrategy +from rowing_data.constants import RecordingStrategy, WorkoutState from rowing_data.model import Lap, Record, RowingSession @@ -17,7 +17,12 @@ def stroke_boundary_session() -> RowingSession: return RowingSession( recording_strategy=RecordingStrategy.STROKE_BOUNDARY, laps=( - Lap(start_time=_ts(0), total_elapsed_s=4.0, total_distance_m=20.0), + Lap( + start_time=_ts(0), + total_elapsed_s=4.0, + total_distance_m=20.0, + intensity=WorkoutState.ACTIVE, + ), ), records=( Record( diff --git a/python/tests/test_roundtrip.py b/python/tests/test_roundtrip.py index 3f90ad5..56ae759 100644 --- a/python/tests/test_roundtrip.py +++ b/python/tests/test_roundtrip.py @@ -9,7 +9,7 @@ from rowing_data import read_fit, validate, write_fit from rowing_data.constants import RecordingStrategy, WorkoutState -from rowing_data.model import Record, RowingSession +from rowing_data.model import Lap, Record, RowingSession from sample_sessions import gps_update_session, stroke_boundary_session @@ -29,6 +29,43 @@ def test_roundtrip_stroke_boundary(tmp_path: Path) -> None: assert validate(loaded) == [] +def test_roundtrip_work_and_rest_laps(tmp_path: Path) -> None: + start = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + rest = datetime(2026, 1, 1, 12, 1, tzinfo=UTC) + original = RowingSession( + records=( + Record(timestamp=start, workout_state=WorkoutState.INTERVAL), + Record(timestamp=rest, workout_state=WorkoutState.REST), + ), + laps=( + Lap(start_time=start, intensity=WorkoutState.INTERVAL), + Lap(start_time=rest, intensity=WorkoutState.REST), + ), + ) + path = tmp_path / "laps.fit" + write_fit(original, path) + loaded = read_fit(path) + assert [lap.intensity for lap in loaded.laps] == [ + WorkoutState.INTERVAL, + WorkoutState.REST, + ] + assert loaded.records[0].resolved_workout_state(loaded.laps[0]) is ( + WorkoutState.INTERVAL + ) + + +def test_synthetic_lap_is_active(tmp_path: Path) -> None: + path = tmp_path / "one-lap.fit" + write_fit( + RowingSession( + records=(Record(timestamp=datetime(2026, 1, 1, tzinfo=UTC)),), + ), + path, + ) + loaded = read_fit(path) + assert loaded.laps[0].intensity is WorkoutState.ACTIVE + + def test_roundtrip_workout_state(tmp_path: Path) -> None: original = RowingSession( records=( diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py index 2e3bec0..8daac26 100644 --- a/python/tests/test_writer.py +++ b/python/tests/test_writer.py @@ -11,7 +11,7 @@ from rowing_data import APPLICATION_ID, APPLICATION_UUID, write_fit from rowing_data.codec import CodecError from rowing_data.constants import RecordingStrategy -from rowing_data.model import Record, RowingSession +from rowing_data.model import Lap, Record, RowingSession def _ts(second: int) -> datetime: @@ -105,6 +105,17 @@ def test_writer_application_id_and_recording_strategy(tmp_path: Path) -> None: assert second.get_value("distance") == pytest.approx(10.5) +def test_writer_requires_intensity_on_caller_laps(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="intensity"): + write_fit( + RowingSession( + records=(Record(timestamp=_ts(0)),), + laps=(Lap(start_time=_ts(0)),), + ), + tmp_path / "no-intensity.fit", + ) + + def test_writer_rejects_values_that_overflow_fit_types(tmp_path: Path) -> None: with pytest.raises(CodecError): write_fit( From 5579b4821bd02121eb564597c9ec3b260de26aff Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 13 Oct 2026 17:11:34 +0200 Subject: [PATCH 3/3] Python: sum moving time over non-rest laps and flag rowing in rest laps. Implements the Python side of the moving-time rule in #39. Lap gains total_timer_s, which is read and written, and RowingSession.moving_time_s() sums it over every lap except rest. validate warns when a record in a rest lap carries rowing data. Co-Authored-By: Claude Sonnet 5.5 --- python/README.md | 4 ++- python/src/rowing_data/model.py | 17 ++++++++++++ python/src/rowing_data/reader.py | 2 ++ python/src/rowing_data/validation.py | 34 +++++++++++++++++++++++- python/src/rowing_data/writer.py | 2 ++ python/tests/test_model.py | 39 ++++++++++++++++++++++++++++ python/tests/test_roundtrip.py | 22 ++++++++++++++++ 7 files changed, 118 insertions(+), 2 deletions(-) diff --git a/python/README.md b/python/README.md index 280c60c..67169cc 100644 --- a/python/README.md +++ b/python/README.md @@ -38,7 +38,9 @@ newtons, written when set. 3 Cooldown, 4 Recovery, 5 Interval, 6 Other. An unknown value is read as Other. When a lap's `intensity` disagrees with a record, the lap wins. Every lap written here sets native `intensity`. A session with no laps is written as one -active lap. +active lap. `RowingSession.moving_time_s()` sums timer time over every lap except +`Rest`, and `validate` warns about rowing data inside a rest lap (light rowing is +`Recovery`). 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 diff --git a/python/src/rowing_data/model.py b/python/src/rowing_data/model.py index d05a003..2c819f3 100644 --- a/python/src/rowing_data/model.py +++ b/python/src/rowing_data/model.py @@ -58,6 +58,7 @@ class Lap: total_elapsed_s: float | None = None total_distance_m: float | None = None intensity: WorkoutState | None = None + total_timer_s: float | None = None @dataclass(frozen=True, slots=True) @@ -78,6 +79,22 @@ def session_start(self) -> datetime: raise ValueError("RowingSession has no records and no start_time") return self.records[0].timestamp + def moving_time_s(self) -> float: + """Sum of timer time over every lap except rest (spec ยง4.1). + + A lap without ``total_timer_s`` falls back to ``total_elapsed_s``, which + is what the writer stores as its timer time. + """ + total = 0.0 + for lap in self.laps: + if lap.intensity is WorkoutState.REST: + continue + timer = lap.total_timer_s + if timer is None: + timer = lap.total_elapsed_s + total += timer or 0.0 + return total + def stroke_counts(self) -> list[int]: return stroke_counts([record.total_cycles for record in self.records]) diff --git a/python/src/rowing_data/reader.py b/python/src/rowing_data/reader.py index dbbb7fc..5da0178 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -249,6 +249,7 @@ def _laps(messages: list) -> tuple[list[Lap], list[Issue]]: if start is None: continue elapsed = message.get_value("total_elapsed_time") + timer = message.get_value("total_timer_time") distance = message.get_value("total_distance") intensity, issue = _as_workout_state(message.get_value("intensity")) if issue is not None: @@ -257,6 +258,7 @@ def _laps(messages: list) -> tuple[list[Lap], list[Issue]]: Lap( start_time=start, total_elapsed_s=None if elapsed is None else float(elapsed), + total_timer_s=None if timer is None else float(timer), total_distance_m=None if distance is None else float(distance), intensity=intensity, ) diff --git a/python/src/rowing_data/validation.py b/python/src/rowing_data/validation.py index 215f649..80b0ffc 100644 --- a/python/src/rowing_data/validation.py +++ b/python/src/rowing_data/validation.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from .codec import CodecError, encode -from .constants import RecordingStrategy +from .constants import RecordingStrategy, WorkoutState from .fields import field_by_id, field_by_name from .model import NATIVE_RECORD_ATTRS, RECORD_DEVELOPER_ATTRS, Record, RowingSession @@ -87,6 +87,21 @@ def _record_issues(record: Record, index: int, session: RowingSession) -> list[I ) ) + if ( + lap is not None + and lap.intensity is WorkoutState.REST + and _is_rowing(record) + ): + issues.append( + Issue( + "warning", + "rest_lap_rowing", + "record in a rest lap carries rowing data; light rowing is " + "recovery, not rest", + index, + ) + ) + if ( record.stroke_state is not None and session.recording_strategy is not RecordingStrategy.TIME_SAMPLED @@ -135,3 +150,20 @@ def _lap_for(record: Record, session: RowingSession): if record.lap_index < 0 or record.lap_index >= len(session.laps): return None return session.laps[record.lap_index] + + +def _is_rowing(record: Record) -> bool: + rate = record.resolved_stroke_rate() + if (rate or 0) > 0 or (record.power or 0) > 0: + return True + return any( + getattr(record, attr) is not None + for attr in ( + "drive_length_mm", + "stroke_drive_time_ms", + "stroke_recovery_time_ms", + "average_drive_force", + "peak_drive_force", + "stroke_work_j", + ) + ) diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index 28464f4..e918c7c 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -132,6 +132,8 @@ def write_fit(session: RowingSession, path: str | Path) -> None: if lap.total_elapsed_s is not None: lap_msg.total_elapsed_time = lap.total_elapsed_s lap_msg.total_timer_time = lap.total_elapsed_s + if lap.total_timer_s is not None: + lap_msg.total_timer_time = lap.total_timer_s if lap.total_distance_m is not None: lap_msg.total_distance = lap.total_distance_m lap_msg.sport = Sport.ROWING diff --git a/python/tests/test_model.py b/python/tests/test_model.py index c944649..0709a93 100644 --- a/python/tests/test_model.py +++ b/python/tests/test_model.py @@ -60,3 +60,42 @@ def test_empty_session_without_start_time_raises() -> None: session = RowingSession(records=()) with pytest.raises(ValueError, match="no records"): session.session_start() + + +def test_moving_time_sums_every_lap_except_rest() -> None: + session = RowingSession( + records=(), + laps=( + Lap( + start_time=_ts(0), + total_elapsed_s=100.0, + intensity=WorkoutState.WARMUP, + ), + Lap( + start_time=_ts(10), + total_elapsed_s=60.0, + total_timer_s=50.0, + intensity=WorkoutState.INTERVAL, + ), + Lap( + start_time=_ts(20), + total_elapsed_s=90.0, + intensity=WorkoutState.REST, + ), + Lap( + start_time=_ts(30), + total_elapsed_s=30.0, + intensity=WorkoutState.RECOVERY, + ), + ), + ) + assert session.moving_time_s() == 180.0 + + +def test_rowing_in_a_rest_lap_is_flagged() -> None: + laps = (Lap(start_time=_ts(0), intensity=WorkoutState.REST),) + rowing = Record(timestamp=_ts(1), stroke_rate=20.0, lap_index=0) + idle = Record(timestamp=_ts(2), heart_rate=90, lap_index=0) + issues = validate(RowingSession(records=(rowing, idle), laps=laps)) + flagged = [i.record_index for i in issues if i.code == "rest_lap_rowing"] + assert flagged == [0] diff --git a/python/tests/test_roundtrip.py b/python/tests/test_roundtrip.py index 0b38706..ffe5db5 100644 --- a/python/tests/test_roundtrip.py +++ b/python/tests/test_roundtrip.py @@ -222,3 +222,25 @@ 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_roundtrip_timer_time_and_moving_time(tmp_path: Path) -> None: + start = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + rest = datetime(2026, 1, 1, 12, 1, tzinfo=UTC) + original = RowingSession( + records=(Record(timestamp=start), Record(timestamp=rest)), + laps=( + Lap( + start_time=start, + total_elapsed_s=60.0, + total_timer_s=55.0, + intensity=WorkoutState.INTERVAL, + ), + Lap(start_time=rest, total_elapsed_s=30.0, intensity=WorkoutState.REST), + ), + ) + path = tmp_path / "moving.fit" + write_fit(original, path) + loaded = read_fit(path) + assert [lap.total_timer_s for lap in loaded.laps] == [55.0, 30.0] + assert loaded.moving_time_s() == 55.0