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
9 changes: 8 additions & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -7,6 +7,7 @@
PROTOCOL_VERSION,
STANDARD_VERSION,
StrokeState,
WorkoutState,
)
from .fields import (
DEVELOPER_FIELDS,
Expand Down Expand Up @@ -34,6 +35,7 @@
"RowingSession",
"STANDARD_VERSION",
"StrokeState",
"WorkoutState",
"decode",
"encode",
"field_by_id",
Expand Down
12 changes: 12 additions & 0 deletions python/src/rowing_data/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion python/src/rowing_data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
28 changes: 26 additions & 2 deletions 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, StrokeState
from .constants import RecordingStrategy, StrokeState, WorkoutState
from .strokes import resolve_stroke_rate, stroke_counts


Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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])

Expand Down
68 changes: 57 additions & 11 deletions python/src/rowing_data/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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]:
Expand Down
58 changes: 57 additions & 1 deletion python/src/rowing_data/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
)
12 changes: 12 additions & 0 deletions python/src/rowing_data/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
Event,
EventType,
FileType,
Intensity,
Manufacturer,
Sport,
SubSport,
Expand All @@ -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 (
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions python/tests/sample_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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