From 74d8b89f09e37134cb2df2020ee80e4f41309868 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 7 Oct 2026 09:31:30 +0200 Subject: [PATCH 1/2] 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/2] 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(