diff --git a/python/README.md b/python/README.md index 9f2d44b..5f9a265 100644 --- a/python/README.md +++ b/python/README.md @@ -34,7 +34,14 @@ 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. Every lap +written here sets native `intensity`. A session with no laps is written as one +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/__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 a2914a2..e87508a 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -51,3 +51,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 313e330..7ca0fea 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 fa7e972..5d11f23 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,20 @@ def resolved_stroke_rate(self) -> float | None: cadence=self.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 + total_timer_s: float | None = None @dataclass(frozen=True, slots=True) @@ -71,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 da95286..58ee41b 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -14,6 +14,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 @@ -65,21 +66,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( @@ -236,8 +239,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 @@ -247,16 +251,52 @@ def _laps(messages: list) -> list[Lap]: 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: + issues.append(issue) laps.append( 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, ) ) 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: @@ -273,7 +313,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") @@ -314,7 +354,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 07bc064..cb483b8 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 @@ -71,6 +71,37 @@ 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 ( + 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 @@ -122,3 +153,28 @@ 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] + + +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 cf1d9f5..75f5c61 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, @@ -34,6 +35,7 @@ CYCLE_LENGTH16_SCALE, PROTOCOL_VERSION, RecordingStrategy, + WorkoutState, ) from .fields import BaseType, FieldDef, field_by_id, field_by_name from .model import ( @@ -131,9 +133,12 @@ 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 + lap_msg.intensity = _fit_intensity(lap.intensity) builder.add(lap_msg) else: lap_msg = LapMessage() @@ -144,6 +149,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: @@ -158,6 +164,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 3d6231b..2b31db5 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_model.py b/python/tests/test_model.py index 7fe0668..0709a93 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,7 +31,71 @@ 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"): 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_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 254d147..ce20f6d 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 @@ -29,6 +29,58 @@ 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=( + 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_slip_and_wash_thresholds(tmp_path: Path) -> None: original = RowingSession( records=(Record(timestamp=datetime(2026, 1, 1, tzinfo=UTC)),), @@ -172,6 +224,28 @@ def _assert_record_roundtrip(got: Record, want: Record) -> None: ) +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 + + def test_stroke_rate_precedence_matches_between_writer_and_validator( tmp_path: Path, ) -> None: diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py index 1796570..5917169 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, PROTOCOL_VERSION, 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: @@ -110,6 +110,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(