From 84c9b374c12464be1b6aaf2e52c23b59e1536da6 Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 1 Oct 2026 01:07:03 +0800 Subject: [PATCH 1/2] Normalize additional FIT lap bounds and grade-adjusted speeds --- README.md | 1 + src/activity_parser/fit_fields.py | 6 ++++++ tests/test_fit_fields.py | 26 ++++++++++++++++++++++++++ tests/test_parse_fit.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/README.md b/README.md index 0600536..f0a29c0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ time A few notes on column handling: - Column names and units are standardized across source formats for known field types. +- With `include_all_columns=True`, FIT lap bounding coordinates (`nec_lat`, `nec_long`, `swc_lat`, `swc_long`) are normalized to degrees, and `avg_grade_adjusted_speed` is normalized to km/h. Raw FIT parsing preserves native units. - Unknown fields are omitted by default; customize columns via `record_columns`, `lap_columns`, and `include_all_columns` on `ActivityParser` — see its docstring for details. - Not every column appears in every file: `parse()` only includes columns actually present. diff --git a/src/activity_parser/fit_fields.py b/src/activity_parser/fit_fields.py index 8ee8f3a..660fdcb 100644 --- a/src/activity_parser/fit_fields.py +++ b/src/activity_parser/fit_fields.py @@ -32,6 +32,11 @@ } LAP_UNITS: dict[str, float] = { + "nec_lat": SEMICIRCLES_TO_DEGREES, + "nec_long": SEMICIRCLES_TO_DEGREES, + "swc_lat": SEMICIRCLES_TO_DEGREES, + "swc_long": SEMICIRCLES_TO_DEGREES, + "avg_grade_adjusted_speed": MPS_TO_KMH, "end_position_lat": SEMICIRCLES_TO_DEGREES, "end_position_long": SEMICIRCLES_TO_DEGREES, "start_position_lat": SEMICIRCLES_TO_DEGREES, @@ -45,6 +50,7 @@ } SESSION_UNITS: dict[str, float] = { + "avg_grade_adjusted_speed": MPS_TO_KMH, "end_position_lat": SEMICIRCLES_TO_DEGREES, "end_position_long": SEMICIRCLES_TO_DEGREES, "nec_lat": SEMICIRCLES_TO_DEGREES, diff --git a/tests/test_fit_fields.py b/tests/test_fit_fields.py index bcbda64..0e24090 100644 --- a/tests/test_fit_fields.py +++ b/tests/test_fit_fields.py @@ -150,3 +150,29 @@ def test_convert_units_mapping_ignores_absent_and_non_numeric_values(): out = convert_units_mapping(row, SESSION_UNITS) assert out == row assert "total_distance" not in out + + +@pytest.mark.parametrize( + ("field", "raw", "expected"), + [ + ("nec_lat", 2**29, 45.0), + ("nec_long", 2**30, 90.0), + ("swc_lat", -(2**29), -45.0), + ("swc_long", -(2**30), -90.0), + ("avg_grade_adjusted_speed", 10.0, 36.0), + ], +) +def test_convert_units_normalizes_additional_lap_fields(field, raw, expected): + source = pd.DataFrame({field: [raw, None]}) + result = convert_units(source, LAP_UNITS) + assert result[field].iloc[0] == pytest.approx(expected) + assert pd.isna(result[field].iloc[1]) + assert source[field].iloc[0] == raw + + +def test_convert_units_mapping_normalizes_grade_adjusted_session_speed(): + source = {"avg_grade_adjusted_speed": 10.0, "avg_vam": 1.5} + result = convert_units_mapping(source, SESSION_UNITS) + assert result["avg_grade_adjusted_speed"] == pytest.approx(36.0) + assert result["avg_vam"] == 1.5 + assert source["avg_grade_adjusted_speed"] == 10.0 diff --git a/tests/test_parse_fit.py b/tests/test_parse_fit.py index f1cd014..a656af0 100644 --- a/tests/test_parse_fit.py +++ b/tests/test_parse_fit.py @@ -765,3 +765,34 @@ def test_parse_fit_leaves_developer_fields_unconverted(): records, _, _ = parse_fit(DEVELOPER_DATA) assert records["doughnuts_earned"].iloc[0] == 1 assert records["speed"].iloc[0] == pytest.approx(170.9568) + + +def test_fit_lap_bounds_and_grade_adjusted_speed_are_normalized(): + payload = synthetic_fit.encode( + [ + { + "mesg_num": synthetic_fit.LAP_MESG_NUM, + "timestamp": synthetic_fit.at(60), + "start_time": synthetic_fit.T0, + "nec_lat": 2**29, + "nec_long": 2**30, + "swc_lat": -(2**29), + "swc_long": -(2**30), + "avg_grade_adjusted_speed": 10.0, + } + ] + ) + _, laps, _ = ActivityParser(include_all_columns=True).parse(io.BytesIO(payload), "fit") + for field, expected in { + "nec_lat": 45.0, + "nec_long": 90.0, + "swc_lat": -45.0, + "swc_long": -90.0, + "avg_grade_adjusted_speed": 36.0, + }.items(): + assert laps[field].iloc[0] == pytest.approx(expected) + + # Raw parsing intentionally keeps native FIT units. + raw = parse_fit_raw(io.BytesIO(payload))["lap"] + assert raw["nec_lat"].iloc[0] == 2**29 + assert raw["avg_grade_adjusted_speed"].iloc[0] == 10.0 From ff690914c4164c7860289ac0259693e55e735b7a Mon Sep 17 00:00:00 2001 From: Tabish Mustufa Date: Fri, 2 Oct 2026 18:59:20 -0700 Subject: [PATCH 2/2] Sort new unit table entries, update SDK version, drop redundant tests test_table_covers_installed_profile already pins these entries against the installed profile, and existing tests cover the lap and raw conversion paths. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 - src/activity_parser/fit_fields.py | 14 +++++++------- tests/test_fit_fields.py | 26 -------------------------- tests/test_parse_fit.py | 31 ------------------------------- 4 files changed, 7 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index f0a29c0..0600536 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ time A few notes on column handling: - Column names and units are standardized across source formats for known field types. -- With `include_all_columns=True`, FIT lap bounding coordinates (`nec_lat`, `nec_long`, `swc_lat`, `swc_long`) are normalized to degrees, and `avg_grade_adjusted_speed` is normalized to km/h. Raw FIT parsing preserves native units. - Unknown fields are omitted by default; customize columns via `record_columns`, `lap_columns`, and `include_all_columns` on `ActivityParser` — see its docstring for details. - Not every column appears in every file: `parse()` only includes columns actually present. diff --git a/src/activity_parser/fit_fields.py b/src/activity_parser/fit_fields.py index 660fdcb..a5a21f6 100644 --- a/src/activity_parser/fit_fields.py +++ b/src/activity_parser/fit_fields.py @@ -4,7 +4,7 @@ package normalizes those to degrees, km and km/h. Vertical rates (``avg_vam``, ``vertical_speed``, ...) are left in m/s. -The tables are transcribed from the FIT profile in garmin-fit-sdk 21.212.0, covering +The tables are transcribed from the FIT profile in garmin-fit-sdk 21.217.0, covering the ``record``, ``lap`` and ``session`` message types. ``tests/test_fit_fields.py`` checks that the installed profile's fields are covered. """ @@ -32,17 +32,17 @@ } LAP_UNITS: dict[str, float] = { - "nec_lat": SEMICIRCLES_TO_DEGREES, - "nec_long": SEMICIRCLES_TO_DEGREES, - "swc_lat": SEMICIRCLES_TO_DEGREES, - "swc_long": SEMICIRCLES_TO_DEGREES, - "avg_grade_adjusted_speed": MPS_TO_KMH, "end_position_lat": SEMICIRCLES_TO_DEGREES, "end_position_long": SEMICIRCLES_TO_DEGREES, + "nec_lat": SEMICIRCLES_TO_DEGREES, + "nec_long": SEMICIRCLES_TO_DEGREES, "start_position_lat": SEMICIRCLES_TO_DEGREES, "start_position_long": SEMICIRCLES_TO_DEGREES, + "swc_lat": SEMICIRCLES_TO_DEGREES, + "swc_long": SEMICIRCLES_TO_DEGREES, "avg_stroke_distance": M_TO_KM, "total_distance": M_TO_KM, + "avg_grade_adjusted_speed": MPS_TO_KMH, "avg_speed": MPS_TO_KMH, "enhanced_avg_speed": MPS_TO_KMH, "enhanced_max_speed": MPS_TO_KMH, @@ -50,7 +50,6 @@ } SESSION_UNITS: dict[str, float] = { - "avg_grade_adjusted_speed": MPS_TO_KMH, "end_position_lat": SEMICIRCLES_TO_DEGREES, "end_position_long": SEMICIRCLES_TO_DEGREES, "nec_lat": SEMICIRCLES_TO_DEGREES, @@ -62,6 +61,7 @@ "avg_stroke_distance": M_TO_KM, "total_distance": M_TO_KM, "avg_ball_speed": MPS_TO_KMH, + "avg_grade_adjusted_speed": MPS_TO_KMH, "avg_speed": MPS_TO_KMH, "enhanced_avg_speed": MPS_TO_KMH, "enhanced_max_speed": MPS_TO_KMH, diff --git a/tests/test_fit_fields.py b/tests/test_fit_fields.py index 0e24090..bcbda64 100644 --- a/tests/test_fit_fields.py +++ b/tests/test_fit_fields.py @@ -150,29 +150,3 @@ def test_convert_units_mapping_ignores_absent_and_non_numeric_values(): out = convert_units_mapping(row, SESSION_UNITS) assert out == row assert "total_distance" not in out - - -@pytest.mark.parametrize( - ("field", "raw", "expected"), - [ - ("nec_lat", 2**29, 45.0), - ("nec_long", 2**30, 90.0), - ("swc_lat", -(2**29), -45.0), - ("swc_long", -(2**30), -90.0), - ("avg_grade_adjusted_speed", 10.0, 36.0), - ], -) -def test_convert_units_normalizes_additional_lap_fields(field, raw, expected): - source = pd.DataFrame({field: [raw, None]}) - result = convert_units(source, LAP_UNITS) - assert result[field].iloc[0] == pytest.approx(expected) - assert pd.isna(result[field].iloc[1]) - assert source[field].iloc[0] == raw - - -def test_convert_units_mapping_normalizes_grade_adjusted_session_speed(): - source = {"avg_grade_adjusted_speed": 10.0, "avg_vam": 1.5} - result = convert_units_mapping(source, SESSION_UNITS) - assert result["avg_grade_adjusted_speed"] == pytest.approx(36.0) - assert result["avg_vam"] == 1.5 - assert source["avg_grade_adjusted_speed"] == 10.0 diff --git a/tests/test_parse_fit.py b/tests/test_parse_fit.py index a656af0..f1cd014 100644 --- a/tests/test_parse_fit.py +++ b/tests/test_parse_fit.py @@ -765,34 +765,3 @@ def test_parse_fit_leaves_developer_fields_unconverted(): records, _, _ = parse_fit(DEVELOPER_DATA) assert records["doughnuts_earned"].iloc[0] == 1 assert records["speed"].iloc[0] == pytest.approx(170.9568) - - -def test_fit_lap_bounds_and_grade_adjusted_speed_are_normalized(): - payload = synthetic_fit.encode( - [ - { - "mesg_num": synthetic_fit.LAP_MESG_NUM, - "timestamp": synthetic_fit.at(60), - "start_time": synthetic_fit.T0, - "nec_lat": 2**29, - "nec_long": 2**30, - "swc_lat": -(2**29), - "swc_long": -(2**30), - "avg_grade_adjusted_speed": 10.0, - } - ] - ) - _, laps, _ = ActivityParser(include_all_columns=True).parse(io.BytesIO(payload), "fit") - for field, expected in { - "nec_lat": 45.0, - "nec_long": 90.0, - "swc_lat": -45.0, - "swc_long": -90.0, - "avg_grade_adjusted_speed": 36.0, - }.items(): - assert laps[field].iloc[0] == pytest.approx(expected) - - # Raw parsing intentionally keeps native FIT units. - raw = parse_fit_raw(io.BytesIO(payload))["lap"] - assert raw["nec_lat"].iloc[0] == 2**29 - assert raw["avg_grade_adjusted_speed"].iloc[0] == 10.0