diff --git a/python/README.md b/python/README.md index 3968f6f..d8bdc86 100644 --- a/python/README.md +++ b/python/README.md @@ -19,6 +19,9 @@ it is still read. ยง5.1 still requires native `cadence` when rate is known; this library writes and reads both `cadence` and ID 93. +`RecordingStrategy` includes TimeSampled (3). `StrokeState` (ID 96) is written +only for that strategy: 0 Unknown, 1 Waiting, 2 Drive, 3 Dwell, 4 Recovery. + Force is `AverageDriveForce` (6) and `PeakDriveForce` (7), in newtons. Fields 4 and 5 are not part of this sample. diff --git a/python/src/rowing_data/__init__.py b/python/src/rowing_data/__init__.py index c794f8e..e37f188 100644 --- a/python/src/rowing_data/__init__.py +++ b/python/src/rowing_data/__init__.py @@ -6,6 +6,7 @@ APPLICATION_UUID, PROTOCOL_VERSION, STANDARD_VERSION, + StrokeState, ) from .fields import ( DEVELOPER_FIELDS, @@ -32,6 +33,7 @@ "Record", "RowingSession", "STANDARD_VERSION", + "StrokeState", "decode", "encode", "field_by_id", diff --git a/python/src/rowing_data/constants.py b/python/src/rowing_data/constants.py index 9a0db92..8251245 100644 --- a/python/src/rowing_data/constants.py +++ b/python/src/rowing_data/constants.py @@ -38,3 +38,14 @@ class RecordingStrategy(IntEnum): UNKNOWN = 0 STROKE_BOUNDARY = 1 GPS_UPDATE = 2 + TIME_SAMPLED = 3 + + +class StrokeState(IntEnum): + """Record developer field ID 96. Meaningful for TimeSampled files.""" + + UNKNOWN = 0 + WAITING = 1 + DRIVE = 2 + DWELL = 3 + RECOVERY = 4 diff --git a/python/src/rowing_data/fields.py b/python/src/rowing_data/fields.py index dc1237d..955da4d 100644 --- a/python/src/rowing_data/fields.py +++ b/python/src/rowing_data/fields.py @@ -123,6 +123,7 @@ def _native( _dev(95, "WashThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N"), _dev(19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J"), _dev(93, "StrokeRate", MessageType.RECORD, BaseType.UINT16, 100, "spm"), + _dev(96, "StrokeState", MessageType.RECORD, BaseType.UINT8, 1, ""), ) NATIVE_FIELDS: tuple[FieldDef, ...] = ( diff --git a/python/src/rowing_data/model.py b/python/src/rowing_data/model.py index fa60c6d..b1ca486 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, StrokeState from .strokes import resolve_stroke_rate, stroke_counts @@ -35,6 +35,7 @@ class Record: workout_state: int | None = None stroke_work_j: int | None = None stroke_rate: float | None = None + stroke_state: StrokeState | None = None lap_index: int | None = None def resolved_stroke_rate(self) -> float | None: @@ -86,6 +87,7 @@ def stroke_counts(self) -> list[int]: (9, "workout_state"), (19, "stroke_work_j"), (93, "stroke_rate"), + (96, "stroke_state"), ) ATTR_BY_FIELD_ID = dict(RECORD_DEVELOPER_ATTRS) diff --git a/python/src/rowing_data/reader.py b/python/src/rowing_data/reader.py index 3f5f596..6b6f87b 100644 --- a/python/src/rowing_data/reader.py +++ b/python/src/rowing_data/reader.py @@ -8,7 +8,12 @@ from fitparse import FitFile from .codec import decode -from .constants import APPLICATION_ID, PROTOCOL_VERSION, RecordingStrategy +from .constants import ( + APPLICATION_ID, + PROTOCOL_VERSION, + RecordingStrategy, + StrokeState, +) from .fields import FieldDef, field_by_id, field_by_name from .model import ATTR_BY_FIELD_ID, Lap, Record, RowingSession from .validation import Issue @@ -59,11 +64,22 @@ def read_fit(path: str | Path) -> RowingSession: break laps = _laps(messages) - records = tuple( - _record(message, our_fields, laps) - for message in messages - if message.name == "record" - ) + 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: + issues.append( + Issue( + state_issue.level, + state_issue.code, + state_issue.message, + len(records_list) - 1, + ) + ) + records = tuple(records_list) return RowingSession( records=records, recording_strategy=strategy, @@ -204,7 +220,7 @@ def _recording_strategy( return RecordingStrategy.UNKNOWN, Issue( "warning", "recording_strategy", - f"RecordingStrategy {value} is not 0, 1, or 2; treating as Unknown", + f"RecordingStrategy {value} is not a known value; treating as Unknown", ) @@ -253,7 +269,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") @@ -273,6 +291,7 @@ def _record(message, our_fields: dict[str, FieldDef], laps: list[Lap]) -> Record "lap_index": _lap_index(timestamp, laps), } + stroke_issue: Issue | None = None for field in message: if field.name in _NATIVE_NAMES or field.name in {"unknown", None}: continue @@ -285,9 +304,36 @@ def _record(message, our_fields: dict[str, FieldDef], laps: list[Lap]) -> Record raw = field.raw_value if raw is None or isinstance(raw, list | tuple): continue - kwargs[attr] = decode(spec, int(raw)) + decoded = decode(spec, int(raw)) + if spec.field_id == 96: + decoded, state_issue = _as_stroke_state(decoded) + kwargs[attr] = decoded + stroke_issue = state_issue + continue + kwargs[attr] = decoded + + return Record(**kwargs), stroke_issue # type: ignore[arg-type] - return Record(**kwargs) # type: ignore[arg-type] + +def _as_stroke_state(value: object) -> tuple[StrokeState | None, Issue | None]: + if value is None: + return None, None + try: + number = int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return StrokeState.UNKNOWN, Issue( + "warning", + "stroke_state", + f"StrokeState {value!r} is unknown; treating as Unknown", + ) + try: + return StrokeState(number), None + except ValueError: + return StrokeState.UNKNOWN, Issue( + "warning", + "stroke_state", + f"StrokeState {number} is above 4; treating as Unknown", + ) 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 438e469..9017d96 100644 --- a/python/src/rowing_data/validation.py +++ b/python/src/rowing_data/validation.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from .codec import CodecError, encode +from .constants import RecordingStrategy from .fields import field_by_id, field_by_name from .model import NATIVE_RECORD_ATTRS, RECORD_DEVELOPER_ATTRS, Record, RowingSession @@ -33,11 +34,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 +71,19 @@ def _record_issues(record: Record, index: int) -> list[Issue]: except CodecError as exc: issues.append(Issue("error", "overflow", str(exc), index)) + if ( + record.stroke_state is not None + and session.recording_strategy is not RecordingStrategy.TIME_SAMPLED + ): + issues.append( + Issue( + "warning", + "stroke_state", + "StrokeState is only meaningful when RecordingStrategy is TimeSampled", + index, + ) + ) + if record.heart_rate == 0: issues.append( Issue( diff --git a/python/src/rowing_data/writer.py b/python/src/rowing_data/writer.py index 800dffe..b9c78df 100644 --- a/python/src/rowing_data/writer.py +++ b/python/src/rowing_data/writer.py @@ -28,7 +28,12 @@ ) from .codec import encode -from .constants import APPLICATION_ID, CYCLE_LENGTH16_SCALE, PROTOCOL_VERSION +from .constants import ( + APPLICATION_ID, + CYCLE_LENGTH16_SCALE, + PROTOCOL_VERSION, + RecordingStrategy, +) from .fields import BaseType, FieldDef, field_by_id, field_by_name from .model import ( RECORD_DEVELOPER_ATTRS, @@ -168,6 +173,9 @@ def _last_distance(session: RowingSession) -> float: def _record_developer_fields_used(session: RowingSession) -> tuple[FieldDef, ...]: used: list[FieldDef] = [] for field_id, attr in RECORD_DEVELOPER_ATTRS: + time_sampled = session.recording_strategy is RecordingStrategy.TIME_SAMPLED + if field_id == 96 and not time_sampled: + continue if any(getattr(record, attr) is not None for record in session.records): used.append(field_by_id(field_id)) return tuple(used) diff --git a/python/tests/test_fields.py b/python/tests/test_fields.py index 2f826b4..b7cfbd7 100644 --- a/python/tests/test_fields.py +++ b/python/tests/test_fields.py @@ -32,6 +32,7 @@ (95, "WashThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N", False), (19, "StrokeWork", MessageType.RECORD, BaseType.UINT16, 1, "J", False), (93, "StrokeRate", MessageType.RECORD, BaseType.UINT16, 100, "spm", False), + (96, "StrokeState", MessageType.RECORD, BaseType.UINT8, 1, "", False), ) EXPECTED_NATIVE_SCALES = { @@ -122,3 +123,4 @@ def test_recording_strategy_enum() -> None: assert RecordingStrategy.UNKNOWN == 0 assert RecordingStrategy.STROKE_BOUNDARY == 1 assert RecordingStrategy.GPS_UPDATE == 2 + assert RecordingStrategy.TIME_SAMPLED == 3 diff --git a/python/tests/test_interop.py b/python/tests/test_interop.py index 5652c86..3f19d4c 100644 --- a/python/tests/test_interop.py +++ b/python/tests/test_interop.py @@ -56,6 +56,7 @@ def test_rowingdata_golden_file_if_present() -> None: RecordingStrategy.UNKNOWN, RecordingStrategy.STROKE_BOUNDARY, RecordingStrategy.GPS_UPDATE, + RecordingStrategy.TIME_SAMPLED, } assert len(session.records) > 0 # In-stroke curve fields are ignored; core metrics should still parse. diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index 8cafa4f..8507ca7 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -17,7 +17,7 @@ from fit_tool.profile.profile_type import FileType, Manufacturer from rowing_data import APPLICATION_ID, read_fit, validate -from rowing_data.constants import RecordingStrategy +from rowing_data.constants import RecordingStrategy, StrokeState from rowing_data.fields import field_by_id @@ -194,6 +194,52 @@ 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_stroke_state_is_unknown(tmp_path: Path) -> None: + path = tmp_path / "phase.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 = 96 + desc.fit_base_type_id = BaseType.UINT8.value + desc.field_name = "StrokeState" + desc.scale = 1 + desc.offset = 0 + desc.units = "" + builder.add(desc) + phase = DeveloperField( + developer_data_index=0, + field_id=96, + size=1, + name="StrokeState", + base_type=BaseType.UINT8, + scale=1, + offset=0, + units="", + ) + phase.set_value(0, 9) + session = SessionMessage() + session.start_time = _ts_ms() + session.timestamp = _ts_ms() + builder.add(session) + rec = RecordMessage(developer_fields=[phase]) + rec.timestamp = _ts_ms() + builder.add(rec) + builder.build().to_file(str(path)) + + loaded = read_fit(path) + assert loaded.records[0].stroke_state is StrokeState.UNKNOWN + assert any(issue.code == "stroke_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) @@ -229,7 +275,7 @@ def test_invalid_recording_strategy_is_unknown_with_warning(tmp_path: Path) -> N offset=0, units="", ) - strategy.set_value(0, 3) + strategy.set_value(0, 9) session = SessionMessage(developer_fields=[strategy]) session.start_time = _ts_ms() session.timestamp = _ts_ms() diff --git a/python/tests/test_roundtrip.py b/python/tests/test_roundtrip.py index 2d329a0..4aeca3e 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, StrokeState from rowing_data.model import Record, RowingSession from sample_sessions import gps_update_session, stroke_boundary_session @@ -42,6 +42,50 @@ def test_roundtrip_slip_and_wash_thresholds(tmp_path: Path) -> None: assert loaded.wash_threshold_n == 120 +def test_roundtrip_time_sampled_stroke_state(tmp_path: Path) -> None: + original = RowingSession( + recording_strategy=RecordingStrategy.TIME_SAMPLED, + records=( + Record( + timestamp=datetime(2026, 1, 1, tzinfo=UTC), + stroke_state=StrokeState.DRIVE, + total_cycles=1, + ), + Record( + timestamp=datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC), + stroke_state=StrokeState.RECOVERY, + total_cycles=1, + ), + ), + ) + path = tmp_path / "time.fit" + write_fit(original, path) + loaded = read_fit(path) + assert loaded.recording_strategy is RecordingStrategy.TIME_SAMPLED + assert [record.stroke_state for record in loaded.records] == [ + StrokeState.DRIVE, + StrokeState.RECOVERY, + ] + + +def test_stroke_state_on_stroke_boundary_warns_and_is_omitted(tmp_path: Path) -> None: + session = RowingSession( + recording_strategy=RecordingStrategy.STROKE_BOUNDARY, + records=( + Record( + timestamp=datetime(2026, 1, 1, tzinfo=UTC), + stroke_state=StrokeState.DRIVE, + ), + ), + ) + issues = validate(session) + assert any(issue.code == "stroke_state" for issue in issues) + path = tmp_path / "boundary.fit" + write_fit(session, path) + loaded = read_fit(path) + assert loaded.records[0].stroke_state is None + + def test_roundtrip_gps_update(tmp_path: Path) -> None: original = gps_update_session() path = tmp_path / "gps.fit"