diff --git a/python/README.md b/python/README.md index d8bdc86..d0ffa4d 100644 --- a/python/README.md +++ b/python/README.md @@ -33,7 +33,10 @@ newtons, written when set. - Oarlock summary fields 11–18 - Dual-oarlock 200–211 - 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. 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/__init__.py b/python/src/rowing_data/__init__.py index e37f188..65ec0bb 100644 --- a/python/src/rowing_data/__init__.py +++ b/python/src/rowing_data/__init__.py @@ -7,6 +7,7 @@ PROTOCOL_VERSION, STANDARD_VERSION, StrokeState, + WorkoutState, ) from .fields import ( DEVELOPER_FIELDS, @@ -34,6 +35,7 @@ "RowingSession", "STANDARD_VERSION", "StrokeState", + "WorkoutState", "decode", "encode", "field_by_id", diff --git a/python/src/rowing_data/constants.py b/python/src/rowing_data/constants.py index 8251245..0a20501 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -49,3 +49,15 @@ class StrokeState(IntEnum): DRIVE = 2 DWELL = 3 RECOVERY = 4 + + +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 955da4d..5608aa6 100644 --- a/python/src/rowing_data/fields.py +++ b/python/src/rowing_data/fields.py @@ -116,7 +116,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(94, "SlipThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N"), diff --git a/python/src/rowing_data/model.py b/python/src/rowing_data/model.py index b1ca486..d05a003 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, StrokeState +from .constants import RecordingStrategy, StrokeState, WorkoutState from .strokes import resolve_stroke_rate, stroke_counts @@ -32,7 +32,7 @@ class Record: average_drive_force: float | None = None peak_drive_force: 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 stroke_state: StrokeState | None = None @@ -45,12 +45,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 6b6f87b..dbbb7fc 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -13,6 +13,7 @@ PROTOCOL_VERSION, RecordingStrategy, StrokeState, + WorkoutState, ) from .fields import FieldDef, field_by_id, field_by_name from .model import ATTR_BY_FIELD_ID, Lap, Record, RowingSession @@ -63,21 +64,23 @@ def read_fit(path: str | Path) -> RowingSession: wash_threshold_n = _session_developer_int(message, our_fields, "WashThreshold") break - laps = _laps(messages) + 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) + record, record_issues = _record(message, our_fields, laps) records_list.append(record) - if state_issue is not None: - issues.append( + if record_issues: + issues.extend( Issue( - state_issue.level, - state_issue.code, - state_issue.message, + item.level, + item.code, + item.message, len(records_list) - 1, ) + for item in record_issues ) records = tuple(records_list) return RowingSession( @@ -234,8 +237,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 @@ -246,15 +250,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: @@ -271,7 +309,7 @@ def _lap_index(timestamp: datetime, laps: list[Lap]) -> int | None: def _record( message, our_fields: dict[str, FieldDef], laps: list[Lap] -) -> tuple[Record, Issue | None]: +) -> tuple[Record, list[Issue]]: timestamp = _as_datetime(message.get_value("timestamp")) if timestamp is None: raise ValueError("record message is missing timestamp") @@ -312,7 +350,13 @@ def _record( continue kwargs[attr] = decoded - return Record(**kwargs), stroke_issue # type: ignore[arg-type] + record_issues = [stroke_issue] if stroke_issue is not None else [] + if kwargs.get("workout_state") is not None: + parsed, state_issue = _as_workout_state(kwargs["workout_state"]) + kwargs["workout_state"] = parsed + if state_issue is not None: + record_issues.append(state_issue) + return Record(**kwargs), record_issues # type: ignore[arg-type] def _as_stroke_state(value: object) -> tuple[StrokeState | None, Issue | None]: diff --git a/python/src/rowing_data/validation.py b/python/src/rowing_data/validation.py index 9017d96..74f24e7 100644 --- a/python/src/rowing_data/validation.py +++ b/python/src/rowing_data/validation.py @@ -71,6 +71,22 @@ def _record_issues(record: Record, index: int, session: RowingSession) -> list[I 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, + ) + ) + if ( record.stroke_state is not None and session.recording_strategy is not RecordingStrategy.TIME_SAMPLED @@ -111,3 +127,11 @@ def _record_issues(record: Record, index: int, session: RowingSession) -> list[I ) ) 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/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index b9c78df..a965f0a 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -22,6 +22,7 @@ Event, EventType, FileType, + Intensity, Manufacturer, Sport, SubSport, @@ -33,6 +34,7 @@ CYCLE_LENGTH16_SCALE, PROTOCOL_VERSION, RecordingStrategy, + WorkoutState, ) from .fields import BaseType, FieldDef, field_by_id, field_by_name from .model import ( @@ -132,6 +134,9 @@ def write_fit(session: RowingSession, path: str | Path) -> None: lap_msg.total_timer_time = lap.total_elapsed_s if lap.total_distance_m is not None: lap_msg.total_distance = lap.total_distance_m + lap_msg.intensity = Intensity( + WorkoutState.ACTIVE if lap.intensity is None else lap.intensity + ) lap_msg.sport = Sport.ROWING builder.add(lap_msg) else: @@ -142,6 +147,7 @@ def write_fit(session: RowingSession, path: str | Path) -> None: lap_msg.total_elapsed_time = elapsed_s lap_msg.total_timer_time = elapsed_s lap_msg.total_distance = total_distance + lap_msg.intensity = Intensity.ACTIVE lap_msg.sport = Sport.ROWING builder.add(lap_msg) 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 8507ca7..15e1b2c 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, StrokeState +from rowing_data.constants import RecordingStrategy, StrokeState, WorkoutState from rowing_data.fields import field_by_id @@ -240,6 +241,66 @@ def test_unknown_stroke_state_is_unknown(tmp_path: Path) -> None: assert any(issue.code == "stroke_state" for issue in loaded.read_issues) + + +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 4aeca3e..c1c8201 100644 --- a/python/tests/test_roundtrip.py +++ b/python/tests/test_roundtrip.py @@ -8,8 +8,8 @@ import pytest from rowing_data import read_fit, validate, write_fit -from rowing_data.constants import RecordingStrategy, StrokeState -from rowing_data.model import Record, RowingSession +from rowing_data.constants import RecordingStrategy, StrokeState, WorkoutState +from rowing_data.model import Lap, Record, RowingSession from sample_sessions import gps_update_session, stroke_boundary_session @@ -86,6 +86,49 @@ def test_stroke_state_on_stroke_boundary_warns_and_is_omitted(tmp_path: Path) -> assert loaded.records[0].stroke_state is None +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_lap_intensity(tmp_path: Path) -> None: + start = datetime(2026, 1, 1, tzinfo=UTC) + original = RowingSession( + records=(Record(timestamp=start),), + laps=( + Lap(start_time=start, intensity=WorkoutState.WARMUP), + Lap(start_time=start), + ), + ) + path = tmp_path / "lap.fit" + write_fit(original, path) + loaded = read_fit(path) + assert [lap.intensity for lap in loaded.laps] == [ + WorkoutState.WARMUP, + WorkoutState.ACTIVE, + ] + + +def test_synthetic_lap_intensity_is_active(tmp_path: Path) -> None: + original = RowingSession( + records=(Record(timestamp=datetime(2026, 1, 1, tzinfo=UTC)),) + ) + path = tmp_path / "synthetic.fit" + write_fit(original, path) + loaded = read_fit(path) + assert [lap.intensity for lap in loaded.laps] == [WorkoutState.ACTIVE] + + def test_roundtrip_gps_update(tmp_path: Path) -> None: original = gps_update_session() path = tmp_path / "gps.fit"