Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions python/src/rowing_data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
APPLICATION_UUID,
PROTOCOL_VERSION,
STANDARD_VERSION,
StrokeState,
)
from .fields import (
DEVELOPER_FIELDS,
Expand All @@ -32,6 +33,7 @@
"Record",
"RowingSession",
"STANDARD_VERSION",
"StrokeState",
"decode",
"encode",
"field_by_id",
Expand Down
11 changes: 11 additions & 0 deletions python/src/rowing_data/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions python/src/rowing_data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = (
Expand Down
4 changes: 3 additions & 1 deletion python/src/rowing_data/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 56 additions & 10 deletions python/src/rowing_data/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
)


Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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:
Expand Down
18 changes: 16 additions & 2 deletions python/src/rowing_data/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion python/src/rowing_data/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions python/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions python/tests/test_interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 48 additions & 2 deletions python/tests/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading