镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Pass GPX elevation through to FIT record altitudes - #8

Merged
mshroyer merged 4 commits into
mshroyer:mainfrom
mshroyer-bot:fit-record-altitude
Aug 18, 2026
Merged

mshroyer merged 4 commits into
mshroyer:mainfrom
mshroyer-bot:fit-record-altitude

Conversation

@mshroyer-bot

@mshroyer-bot mshroyer-bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

GPX elevations were parsed into GeoPoint and carried through Course records, but the FIT encoder never wrote them, so converted courses showed no elevation profile on device. This encodes them as the record message's altitude field.

  • altitude (field 2) is a uint16 with scale 5 and offset 500 m — the same field Garmin Connect and Ride with GPS write in their own course files (verified against cptr003_connect.fit and cptr003.fit).
  • Altitude is all or nothing for a course: the field is only added to the record definition when every track or route point in the input has an elevation the field can represent (about -500 m to 12,606 m). The profile's invalid value is never encoded, since an Edge 1030 and a fenix 7 both display it as an absurdly high elevation rather than as missing data.
  • An elevation outside the field's range counts as missing, so one bad value drops the course's altitudes rather than producing a wrong or wrapped-around one. This logs a warning.
  • convert --no-elevation skips the passthrough entirely, backed by a new FitCourseOptions::with_elevation.

Testing

  • Rust unit tests for the scale/offset conversion, rounding, range limits, and the conditional field definition.
  • New integration tests in test_fit_encoding.py driving integration-stub, which now accepts an optional per-record elevation in its course spec: altitudes round-trip through the Garmin SDK, including at the extremes of the field's range, and the field is left undefined for courses with no, partial, or out-of-range elevation.
  • CLI-level tests in test_cli.py: every elevation in cptr003.gpx comes back out of the converted course in order; a new cptr008.gpx with no elevation and a new cptr009.gpx with elevation on only half its trackpoints both convert without an altitude field; and --no-elevation suppresses altitudes for input that does have complete elevation data.
  • cargo test -F cli, the full pytest suite (55 passed), and scripts/lint.sh all pass locally.

🤖 Generated with Claude Code

mshroyer-bot and others added 2 commits August 1, 2026 04:14
The GPX reader already parsed <ele> into GeoPoint, and the course
builder carried it through to records, but the FIT encoder dropped it,
so converted courses had no elevation profile on device.

Encode elevation as the record message's altitude field (uint16, scale
5, offset 500), matching what Garmin Connect and RWGPS write.  The field
is only defined for courses that have some elevation data, so files
converted from GPX without <ele> are byte-for-byte unchanged.  Records
individually missing an elevation within such a course, and elevations
outside the range the profile can represent, are encoded as the
profile's invalid value.

Adds integration tests covering altitude scaling, absent, partial, and
out-of-range elevations via integration-stub, along with a CLI-level
test that every elevation in cptr003.gpx survives the conversion.  The
stub's course spec now accepts an optional elevation per record, and
cptr008.gpx is a new, small GPX track with no elevation at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
garmin-fit-sdk 21.178.0, which is what uv.lock pins, decodes an altitude
set to the profile's invalid value as 12607.0 meters rather than
reporting the field as unset, so the partial and out-of-range altitude
tests failed in CI even though they passed against a newer SDK locally.

Read those altitudes with fitdecode instead, which consistently reports
invalid fields as None, and assert on the record definition message
directly for the case where the field shouldn't be encoded at all.

Also switch the GPX elevation helper to defusedxml, as devtools already
does for parsing GPX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@mshroyer mshroyer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly looks good over all. I'll still need to manually test this with devices before approving for merging.

Comment thread integration/tests/test_fit_encoding.py Outdated
ALTITUDE_ABS_TOLERANCE = 0.11


def write_fit_records(tmpdir, integration_stub, records):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this helper and invoke integration_stub directly from within the new tests, as is done in the existing tests. I prefer keeping control flow as "flat" as possible in tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ac26e05 — the helper is gone and each test invokes integration_stub directly.

Comment thread integration/README.md Outdated
- cptr003_connect.fit: cptr003.gpx imported into Garmin Connect and re-exported as FIT.
- cptr004.gpx: Export of [a Gaia GPS folder](https://www.gaiagps.com/map/?loc=13.6/-122.1094/37.2305&pubLink=b4sitlz7aVCkPFVDFaLm1bSi&folderId=16650ba9-3ecb-45cb-9c21-430e53c7d4c0) folder containing a hiking route and five waypoints, four of them located approximately on the route.
- cptr007.gpx: A GPX export from Gaia GPS containing two routes
- cptr008.gpx: A short, handwritten GPX track whose trackpoints have no elevation, along with one waypoint near the track.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note this as "AI generated" rather than "handwritten".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the README now says "AI generated", and I updated the file's own creator attribute to match.

Comment thread integration/src/integration/__init__.py Outdated
Comment on lines +188 to +190
We read these with fitdecode rather than the Garmin SDK because the SDK's
treatment of the invalid value varies by version: 21.178.0 decodes it as an
altitude of 12607.0 meters instead of reporting the field as unset.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the Garmin SDK dependency to the latest version if that simplifies the implementation (and doesn't break anything else).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — bumped to 21.208.0, which reports fields set to the profile's invalid value as unset (21.178.0 decoded an invalid altitude as a literal 12607.0 m, which is why I'd reached for fitdecode here). That let me delete fitdecode_record_altitudes entirely. The record definition is still read with fitdecode, since the SDK can't distinguish a field that was never defined from one set to the invalid value, and that distinction is the point of the no-elevation test.

Addresses review feedback:

- Drop the write_fit_records helper and invoke integration-stub directly
  from each test, keeping their control flow flat like the surrounding
  tests.
- Update garmin-fit-sdk to 21.208.0, which reports a field set to the
  profile's invalid value as unset.  That removes the need to read those
  altitudes with fitdecode, so fitdecode_record_altitudes goes away; the
  record definition is still read with fitdecode, since that's the only
  way to tell an undefined field from an invalid one.
- Describe cptr008.gpx as AI generated rather than handwritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mshroyer

Copy link
Copy Markdown
Owner

I tested this on my Garmin Edge 1030 (software 31.33) and Fenix 7 (26.09) by exporting a GPX course and manually editing it to create copies with all, half, or none of its <ele>...</ele> tags. I then ran convert on those three using this branch and experimented with the output.

In the file containing only some elevation tags, while fitfileviewer.com correctly interpreted the u16 FFFFh values as invalid, both devices interpreted this as an absurdly high elevation.

Please update this PR to:

  • Only pass through elevation if it's available for every track or route point in the input, and not use FFFFh as a placeholder to mean elevation unknown.
  • Also add a flag to the convert subcommand to disable elevation passthrough, if desired.

Devices don't read an altitude set to the FIT profile's invalid value as
missing data: an Edge 1030 and a fenix 7 both showed it as an absurdly
high elevation.  Write the record altitude field only when the whole
course has elevations the field can represent, so that value never gets
encoded, and treat an out-of-range elevation like a missing one.

Also add a --no-elevation flag to the convert subcommand, backed by a
new FitCourseOptions::with_elevation, for skipping the passthrough even
when the input does have complete elevation data.
@mshroyer-bot

Copy link
Copy Markdown
Contributor Author

Both changes are in 84f8cbb.

  • Altitudes are now all or nothing per course: the altitude field is only defined for the record message when every track or route point in the input has an elevation the field can represent, so FFFFh is never written. An out-of-range elevation counts as missing for this purpose, which means one bad value drops the course's altitudes rather than turning into a wrong one; that case logs a warning.
  • convert --no-elevation disables the passthrough, via a new FitCourseOptions::with_elevation.

Test coverage for the partial case moved with the behavior: the stub-level tests now assert the field is undefined for partial and out-of-range input (plus a new case at the exact range limits), and test_cli.py gets cptr009.gpx, a track with elevation on only its first half, along with a --no-elevation test on cptr003.gpx.

@mshroyer
mshroyer merged commit eec5dbb into mshroyer:main Aug 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants