Skip to content
Open
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
5 changes: 5 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ newtons, written when set.
- 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)

`AverageBoatSpeed` (ID 8) uses scale 100 (0.01 m/s). When a file declares the
same units and a different scale, the reader uses the scale in the file. Scale
is a UINT8 and 255 is its invalid value, so an older scale of 255 does not
surface; that missing scale is still read as 255 m/s steps.

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
one stroke cycle, not cumulative `distance` and not handle travel (DriveLength).
Expand Down
2 changes: 1 addition & 1 deletion python/src/rowing_data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def _native(
_dev(3, "StrokeRecoveryTime", MessageType.RECORD, BaseType.UINT16, 1, "ms"),
_dev(6, "AverageDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N"),
_dev(7, "PeakDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N"),
_dev(8, "AverageBoatSpeed", MessageType.RECORD, BaseType.UINT16, 255, "m/s"),
_dev(8, "AverageBoatSpeed", MessageType.RECORD, BaseType.UINT16, 100, "m/s"),
_dev(
9,
"WorkoutState",
Expand Down
56 changes: 48 additions & 8 deletions python/src/rowing_data/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path

Expand Down Expand Up @@ -146,9 +147,10 @@ def _our_developer_fields(
) -> tuple[dict[str, FieldDef], list[Issue]]:
"""Map names to registry FieldDefs for the standard application UUID only.

Decode always uses Draft v0.1 scales/units. A file that advertises a
different scale (for example pre-v1.2 DriveLength in metres) is not
converted; a warning is recorded instead.
When the file's units match the registry and only the scale differs, decode
with the scale declared in the file (AverageBoatSpeed 255 and 100 are both
m/s). When the units differ, as with pre-v1.2 DriveLength in metres, keep
the registry units and record a warning. Do not convert those values.
"""
by_name: dict[str, FieldDef] = {}
issues: list[Issue] = []
Expand All @@ -166,11 +168,32 @@ def _our_developer_fields(
registry = field_by_id(int(field_id))
except KeyError:
continue
file_scale = message.get_value("scale")
file_scale = _raw_scale(message)
file_units = message.get_value("units")
# Scale is a UINT8. 255 is its invalid value, so parsers drop it.
# That byte was the old AverageBoatSpeed scale. Units are still m/s.
if (
file_scale not in (None, 0)
and float(file_scale) != float(registry.scale)
file_scale is None
and registry.field_id == 8
and _units_match(file_units, registry.units)
):
file_scale = 255
decode_as = registry
scale_differs = file_scale not in (None, 0) and float(file_scale) != float(
registry.scale
)
if scale_differs and _units_match(file_units, registry.units):
decode_as = replace(registry, scale=float(file_scale))
issues.append(
Issue(
"warning",
"field_scale",
f"{registry.name} (ID {registry.field_id}) has file scale "
f"{file_scale}; decoded with that scale "
f"(registry scale is {registry.scale})",
)
)
elif scale_differs:
issues.append(
Issue(
"warning",
Expand All @@ -180,12 +203,29 @@ def _our_developer_fields(
"decoded with v0.1 units, not converted",
)
)
by_name[registry.name] = registry
by_name[decode_as.name] = decode_as
if name:
by_name[str(name)] = registry
by_name[str(name)] = decode_as
return by_name, issues


def _raw_scale(message) -> object:
"""Scale byte from the field description, including 255.

255 is the UINT8 invalid value, so ``get_value`` drops a scale of 255.
AverageBoatSpeed used that scale before it moved to 100, and the byte is
still meaningful.
"""
field = message.get("scale")
return field.raw_value


def _units_match(file_units: object, registry_units: str) -> bool:
if not isinstance(file_units, str):
return False
return file_units.strip() == registry_units


def _session_developer_int(
message, our_fields: dict[str, FieldDef], name: str
) -> int | None:
Expand Down
2 changes: 1 addition & 1 deletion python/tests/test_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
(1, 450, 450), # StrokeDriveTime ms
(6, 412.3, 4123), # AverageDriveForce, scale 10
(7, 0, 0), # measured zero is a real value
(8, 4.0, 1020), # AverageBoatSpeed, scale 255
(8, 4.0, 400), # AverageBoatSpeed, scale 100
(19, 250, 250), # StrokeWork J
(93, 28.50, 2850), # StrokeRate, scale 100
(10, 1, 1), # RecordingStrategy
Expand Down
2 changes: 1 addition & 1 deletion python/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
(3, "StrokeRecoveryTime", MessageType.RECORD, BaseType.UINT16, 1, "ms", False),
(6, "AverageDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N", False),
(7, "PeakDriveForce", MessageType.RECORD, BaseType.UINT16, 10, "N", False),
(8, "AverageBoatSpeed", MessageType.RECORD, BaseType.UINT16, 255, "m/s", False),
(8, "AverageBoatSpeed", MessageType.RECORD, BaseType.UINT16, 100, "m/s", False),
(9, "WorkoutState", MessageType.RECORD, BaseType.UINT8, 1, "", False),
(10, "RecordingStrategy", MessageType.SESSION, BaseType.UINT8, 1, "", False),
(94, "SlipThreshold", MessageType.SESSION, BaseType.UINT16, 1, "N", False),
Expand Down
105 changes: 105 additions & 0 deletions python/tests/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,111 @@ 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_same_unit_file_scale_is_used_for_boat_speed(tmp_path: Path) -> None:
"""Older AverageBoatSpeed files use scale 255. Units are still m/s."""
path = tmp_path / "boat_speed_255.fit"
field = field_by_id(8)
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 = field.field_id
desc.fit_base_type_id = BaseType.UINT16.value
desc.field_name = field.name
desc.scale = 255
desc.offset = 0
desc.units = "m/s"
builder.add(desc)

session = SessionMessage()
session.start_time = _ts_ms()
session.timestamp = _ts_ms()
builder.add(session)

speed = DeveloperField(
developer_data_index=0,
field_id=8,
size=2,
name=field.name,
base_type=BaseType.UINT16,
scale=255,
offset=0,
units="m/s",
)
speed.set_value(0, 4.0) # raw 1020 at scale 255
rec = RecordMessage(developer_fields=[speed])
rec.timestamp = _ts_ms()
builder.add(rec)
builder.build().to_file(str(path))

loaded = read_fit(path)
assert loaded.records[0].average_boat_speed_mps == 4.0
assert any(issue.code == "field_scale" for issue in loaded.read_issues)


def test_declared_boat_speed_scale_is_used(tmp_path: Path) -> None:
"""A readable scale other than the registry scale still yields m/s."""
path = tmp_path / "boat_speed_10.fit"
field = field_by_id(8)
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 = field.field_id
desc.fit_base_type_id = BaseType.UINT16.value
desc.field_name = field.name
desc.scale = 10
desc.offset = 0
desc.units = "m/s"
builder.add(desc)

session = SessionMessage()
session.start_time = _ts_ms()
session.timestamp = _ts_ms()
builder.add(session)

speed = DeveloperField(
developer_data_index=0,
field_id=8,
size=2,
name=field.name,
base_type=BaseType.UINT16,
scale=10,
offset=0,
units="m/s",
)
speed.set_value(0, 4.0) # raw 40 at scale 10; registry scale 100 would yield 0.4
rec = RecordMessage(developer_fields=[speed])
rec.timestamp = _ts_ms()
builder.add(rec)
builder.build().to_file(str(path))

loaded = read_fit(path)
assert loaded.records[0].average_boat_speed_mps == 4.0


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
Loading