Skip to content

Read Garmin FIT activities into the geospatial formats (#451) - #530

Merged
KnifeLemon merged 4 commits into
mainfrom
feature/fit-reader
Oct 26, 2026
Merged

KnifeLemon merged 4 commits into
mainfrom
feature/fit-reader

Conversation

@SeoJCalive

@SeoJCalive SeoJCalive commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

What does this change?

Closes #451. Garmin FIT activities (.fit, from a watch or bike computer) can now be converted to GPX, KML/KMZ, GeoJSON, Shapefile and CSV (and through CSV to XLSX and JSON), as planned in the issue.

  • The recorded track becomes one line whose points keep their time and height. A value the watch didn't record is left empty, never made up.
  • Filee reads FIT with its own reader (no SDK code or files). FIT is read only.
  • Damaged files, FIT files that aren't activities, and activities without positions (for the map formats) are refused with their own message.

Opened as a draft for the questions below.

Questions for you

  1. Field numbers: the protocol page leaves field numbers to the SDK, so I took the numbers, scales and names from the FIT Profile (Profile.xlsx) and added no SDK code or files. Is that fine for "written from the published protocol description"?
  2. KML with some heights missing: you said "gx:Track with a <when> per point". When some points have no height, I write a LineString with the times in coordTimes instead, so as not to make up an altitude for gx:coord. Or should it stay a gx:Track and write those points' gx:coord with two values?
  3. The GPX notice: a GPX track has no start time field, so when the start is lost, the existing GPX notice shows ("…other attributes were left out and polygons became tracks"). On real watch files that is almost every time (screenshot below). Should I add a FIT-specific notice (for example "The activity's start time was left out: a GPX track has no place for it"), or drop the notice in this case?

Also decided this way (tell me if you'd prefer otherwise):

  • The sport and start time become the feature's type and time, the names GPX uses.
  • A Shapefile with some heights missing drops all heights with a notice, instead of writing 0 for the missing ones.

Screenshots

Linux under Xvfb with no window manager: the donut background is white and the toast sits top-left instead of bottom-right.

FIT file on the donut GPX notice in the toast (8 rides) Indoor activity to GPX
image image image

A check, not a Filee feature: Filee's GPX and CSV output for three real files, read back and plotted (track from <trkpt> / <time>, height from <ele>, heart rate from the CSV):

image

How was it tested?

  • dotnet format --verify-no-changes; Release build adds no warnings.
  • dotnet test on main (e492ec2): Core 609/609, CLI 29/29, Engines 1708 passed / 7 failed. The failures are not from this change (details below). 38 new tests; mutation checks make them fail.
  • CLI with four real watch files (Garmin, Wahoo, Coros) into every format, outputs parsed and counted, plus damaged and non-activity files refused.
  • In the app (Linux): the donut, the toast notice and the "no positions" refusal, as in the screenshots.
Details: what each format keeps
  • Track: the positions of the record messages, in file order. A single position becomes a point. Laps and pauses don't split the track.
  • Feature attributes: the sport (from session) as type; the start (the earliest session start, else the first record time) as time.
  • GPX: a track with <time> and <ele> on the points that have them, and the sport as <type>. The track has no start time field, so when the start differs from the first point's time (or the first point has none), the existing GPX notice shows.
  • KML / KMZ: a gx:Track (a <when> and a gx:coord per point) when every point has a time and a height; otherwise a LineString with the point times in a coordTimes Data entry.
  • GeoJSON: a LineString with a coordTimes property (null where a point has no time).
  • Shapefile: PolyLineZ when every point has a height; when only some do, all heights are dropped with a notice. Point times are left out; type and time are .dbf columns.
  • CSV: one row per record that holds a time, a position or a sensor value; nine columns: time (UTC), longitude, latitude, elevation (m), heart_rate (bpm), cadence (rpm), power (W), speed (m/s), distance (m). Records without a position are kept, so an indoor ride converts to CSV.
  • Refused (9 new strings, all 15 language files): not a FIT file; damaged or cut short (header or file CRC, truncated data); several FIT files joined into one; too large (over 64 MiB or 2,000,000 records); no file_id; not an activity (courses, settings, monitoring…); no positions (map formats only, pointing to CSV/XLSX/JSON); no records.
  • Existing inputs don't change: point times and the "don't fill missing heights" rule are only set by the FIT reader; GeoJSON, KML, GPX and Shapefile inputs write exactly as before.
Details: implementation
  • Changed files: Geo/FitFormat.cs (new: reader, dataset, CSV); GeoModel.cs (an optional time list on GeoLineString, kept through coordinate mapping, and GeoDataset.SparseHeights); the GPX, KML, GeoJSON and Shapefile writers; GeoConverter.cs (FIT as a read-only source, the notices); FormatRegistry.cs; UserDataStore.cs / AppSettings.cs; the 15 language files; docs/ENGINES.md and the README format lines.
  • Reader: 12- or 14-byte header (header CRC 0x0000 means "not computed"), file CRC, normal and compressed-timestamp record headers (5-bit rollover), definitions in both byte orders, developer fields skipped, invalid values, scale and offset. enhanced_altitude / enhanced_speed win over the 16-bit fields. Timestamps below 0x10000000 are relative (clock not set yet) and never become an absolute time.
  • Profile: 11 record fields, file_id.type, session.start_time and sport, and the sport and file names, checked against the Profile in the FIT JavaScript SDK 21.218.
  • Settings: schema 47 adds .fit to the geospatial donut of existing libraries unless the user already put it elsewhere. feat(pdf): add PDF editing tools to the donut #428 took 46 meanwhile; it may need another bump if something else lands first.
  • Tests (38 new): FitReaderTests (12: decoder against bytes built by a test FitBuilder, the published header CRC vector, byte order, compressed timestamps, invalid values, developer fields, every refusal, limits, fixed-seed random input, relative timestamps); FitFormatTests (16: through the real converter into every format, XLSX and JSON compared row by row with the CSV); GeoPointTimesTests (5: writers with and without point times, existing inputs unchanged); FitFormatRegistrationTests (4); one CLI test. Four existing UserDataStoreTests list every geospatial extension, so fit is added there.
Details: test results
  • Engines failures: six LibreOffice tests (PackageRepackerTests, SpreadsheetFormatTests.LibreOffice_reads_our_ods_…) fail on main too, because of the LibreOffice on my Linux. EngineProbeTests.A_program_that_never_exits_is_given_up_on_and_killed failed once while another run loaded the machine and passed three runs on its own. The FIT and geo tests passed five runs in a row.
  • App: on my Linux the full App run doesn't finish on main either (the test host doesn't exit), so I ran the App tests this change can affect (localization, format requests, extension editing): 88/88.
  • Mutation checks: dropping the times in coordinate mapping, ignoring the sparse-height rule, removing the header CRC 0x0000 case, removing the timestamp rollover, and removing the FIT → GPX edge each make the matching tests fail.
  • Real files (python-fitparse test files, MIT, not added to the repo): Garmin Fenix 2 run (2,809 points), Wahoo ELEMNT ride (13,348), Coros Pace 2 ride (10,305, 13 without a height), Fenix 2 multisport (24,235). GPX: a time on every point. KML: as many <when> as gx:coord (Coros: LineString + coordTimes). GeoJSON: coordTimes as long as the coordinates. Shapefile: PolyLineZ (Coros: PolyLine with the notice). The Fenix 2's jump from 55 m to 142.4 m and back in the first seconds is in the file itself (checked with fitdecode).
  • Refused, with no output: Garmin settings and monitoring files, a bad CRC, a cut-short file, a corrupt header, two FIT files joined, and a FITS image named .fit. An activity without positions is refused for the map formats and converts to CSV.
  • Speed: the 1 MB multisport file (24,235 points) converts in about 0.7 s through the CLI, against 0.85 s for the same track as a 3.2 MB GPX to KML.
  • In the app: Linux, Xvfb, --convert as the Explorer menu does, XTest clicks. A donut conversion that succeeds shows no notice at all (the donut hands a job to the toast only when it fails or closes mid-conversion; that is how every notice works today), so the toast screenshot closes the donut mid-conversion.
Details: sample output (Fenix 2 run)

GPX:

<trk>
  <type>running</type>
  <trkseg>
    <trkpt lat="58.9591828175" lon="5.7288390398">
      <ele>55</ele>
      <time>2015-08-15T14:45:08Z</time>
    </trkpt>
    …

KML (2,809 <when> and 2,809 <gx:coord>):

<ExtendedData>
  <Data name="type"><value>running</value></Data>
  <Data name="time"><value>2015-08-15T14:45:08Z</value></Data>
</ExtendedData>
<gx:Track xmlns:gx="http://www.google.com/kml/ext/2.2">
  <when>2015-08-15T14:45:08Z</when>
  …
  <gx:coord>5.7288390398 58.9591828175 55</gx:coord>
  …

KML when some heights are missing (Coros):

<Data name="coordTimes">
  <value>["2020-10-25T11:10:50Z","2020-10-25T11:10:51Z",…]</value>
</Data>
…
<LineString>
  <coordinates>-73.9711807854,4.7232181113,2740 -73.9711537119,4.7232190333,2740 …</coordinates>
</LineString>

GeoJSON:

{"type":"Feature","properties":{"type":"running","time":"2015-08-15T14:45:08Z","coordTimes":["2015-08-15T14:45:08Z",…]},
 "geometry":{"type":"LineString","coordinates":[[5.7288390398,58.9591828175,55],…]}}

CSV:

time,longitude,latitude,elevation,heart_rate,cadence,power,speed,distance
2015-08-15T14:45:08Z,5.7288390398,58.9591828175,55,69,56,,5.89,0
2015-08-15T14:45:10Z,5.7287856471,58.9591738489,142.4,69,56,,5.89,3.38
Details: follow-ups and an existing behaviour
  • Not in this PR: TCX, writing FIT, splitting at laps and pauses, chained FIT files, heart rate sent in its own messages, sensor values as GPX TrackPointExtension, recovering files with a bad CRC.
  • Existing behaviour I noticed: a line feature's time attribute (from GeoJSON, KML…) is dropped on GPX tracks without a notice. This PR doesn't change it.

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none)

@SeoJCalive SeoJCalive added the formats New formats and engines label Oct 24, 2026
@SeoJCalive SeoJCalive closed this Oct 24, 2026
@SeoJCalive SeoJCalive reopened this Oct 24, 2026
@SeoJCalive
SeoJCalive marked this pull request as ready for review October 25, 2026 01:10

@KnifeLemon KnifeLemon 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.

@SeoJCalive 님, 꼼꼼한 PR 감사해요. 리더가 헤더·파일 CRC, 압축 타임스탬프 헤더(롤오버 포함), 개발자 필드 건너뛰기, 무효값, semicircle→도, 1989-12-31 기준 시각을 프로토콜대로 처리하고 있어요. 생성한 테스트 파일과 변형 테스트로 하나하나 확인해 주셔서 리뷰하기 편했어요. 스키마 47도 main의 46 다음 번호가 맞고, 새 문구 9개도 16개 언어 파일에 모두 들어 있어요.

먼저 질문에 답할게요.

  1. 필드 번호: Profile에서 번호·스케일·이름만 가져오고 SDK 코드나 파일을 넣지 않았다면 괜찮아요. "공개된 프로토콜 설명으로 직접 작성"에 해당해요.
  2. KML에서 높이가 일부 없을 때: 지금처럼 LineString에 coordTimes로 써 주세요. gx:coord는 경도·위도·높이 세 값이 정해진 형식이라 두 값으로 쓰면 안 돼요.
  3. GPX 알림: 시작 시간이 첫 점과 다르다는 이유로는 알림을 띄우지 말아 주세요. 점마다 시간이 남아 있어서 실제로 잃는 건 거의 없는데, 실제 시계 파일마다 뜨면 소음이 돼요. 점 시간이 하나도 없는데 세션 시작 시간만 있을 때처럼 시작 시간이 정말 사라지는 경우에만 지금 알림을 남기면 돼요. FIT 전용 문구는 필요 없어요. Gpx_only_reports_a_different_or_unrepresentable_summary_start도 거기에 맞춰 주세요.

고칠 점이에요.

  1. FitFormat.Read의 session(18) 개수에 제한이 없어요. 레코드는 2,000,000개 제한이 있는데 activity.Sessions는 메시지마다 하나씩 늘어나요. 필드가 0개인 session 정의 뒤에 1바이트짜리 data 메시지만 반복한 파일을 만들어 보니, 16 MiB 파일에서 세션 16,777,216개, 메모리 835 MiB, 12.7초가 걸렸어요. 64 MiB 상한 파일이면 3 GB가 넘어요. ToDataset이 쓰는 건 가장 이른 시작 시간과 종목 목록(최대 256개)뿐이라서, 읽는 동안 이 두 가지만 모아 두면 리스트가 필요 없어요. 아니면 세션에도 개수 제한을 걸어 주세요. 이 경우를 막는 테스트도 하나 넣어 주세요.
  2. ReadNumber가 float32·float64의 NaN·Infinity를 값으로 돌려줘요. lat is < -90 or > 90 범위 검사는 NaN을 거르지 못해요. 그래서 위치 필드를 float로 정의한 깨진 파일은 GPX에 <trkpt lat="NaN" lon="22.5">를 그대로 쓰고, CSV에도 NaN이 들어가요. double.IsFinite가 아닌 값은 무효값(null)으로 처리해 주세요.

이 두 가지와 GPX 알림만 정리되면 머지할 수 있어요.

SeoJCalive added a commit that referenced this pull request Oct 25, 2026
…tice only when the start is lost

Review on #530:
- Sessions are no longer kept one by one: the reader keeps the earliest
  start and the sports in file order, so a file of empty session
  messages can't grow memory (16 MiB: 95 MB peak, 0.96 s).
- float32/float64 NaN and infinities are invalid values, like the base
  type's invalid value.
- The GPX notice shows only when no point has a time and the activity
  has a start; a start that differs from the first point's time no
  longer raises it.
@SeoJCalive

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 리뷰와 답변 감사해요. 말씀하신 세 가지를 새 커밋(636116e)으로 고쳤고, 그사이 들어온 main(#550) 위로도 올렸어요.

  1. session 개수: 세션을 하나씩 모으지 않고, 읽는 동안 가장 이른 시작 시간과 종목 목록(파일 순서, 중복 없이)만 남기게 했어요. 말씀하신 파일(필드가 0개인 session 정의 뒤에 1바이트 data 메시지 반복)로 재 보니 16 MiB는 최대 메모리 95 MB에 0.96초, 64 MiB는 142 MB에 2.1초예요. 테스트는 Many_empty_sessions_are_summed_up_without_keeping_each_one이에요. 빈 session 50만 개를 읽을 때 할당한 메모리가 파일 크기 + 1 MB를 넘지 않는지 확인해요. 같은 파일의 Huge_declared_data_size… 테스트처럼 GC.GetAllocatedBytesForCurrentThread로 재고, 예전처럼 세션마다 객체를 쌓으면 실패해요.
  2. NaN·Infinity: ReadNumber가 float32·float64 값이 유한하지 않으면 무효값(null)으로 돌려줘요. Non_finite_float_values_are_left_out_like_invalid_ones에서 위도 NaN, 경도 -Infinity, 고도 Infinity인 레코드를 GPX와 CSV로 바꿔, 그 값들이 빈 칸으로 남고 NaN·Infinity가 나오지 않는지 확인해요.
  3. GPX 알림: 어느 점에도 시간이 없고 세션 시작 시간만 있을 때만 지금 알림이 떠요. 시험은 Gpx_reports_the_start_only_when_no_point_keeps_a_time로 이름을 바꾸고, 시작 시간이 첫 점과 다를 때와 첫 점에만 시간이 없을 때는 알림이 없는지 확인하게 했어요. docs/ENGINES.md의 설명도 맞췄어요.

새로 넣은 두 테스트와 바꾼 GPX 테스트는 고친 곳을 하나씩 되돌리면 각각 실패하는 것도 확인했어요. 다시 봐 주세요!

@SeoJCalive

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 리더를 다시 점검하다가 비슷한 문제를 두 가지 더 찾아서 지금 고치고 있어요.

  1. 레코드가 많은 작은 파일의 메모리: 레코드 200만 개 상한은 있지만, 10 MB 파일을 CSV로 바꾸면 메모리를 약 1 GB 써요. CSV를 한 줄씩 쓰고 빈 레코드는 저장하지 않게 바꾸는 중이에요.
  2. float로 정의된 범위 밖 값: 고도나 심박을 float로 정의한 파일은 3e38 같은 값이 그대로 출력돼요. Profile의 정수 폭으로 정의된 필드만 읽게 바꾸는 중이에요.

곧 커밋 하나로 올릴게요. 그때까지 머지는 잠시만 기다려 주세요

SeoJCalive added a commit that referenced this pull request Oct 25, 2026
…file width

Found while checking the reader again after the review on #530:
- A record without a single value is counted toward the record limit
  but not kept, and the FIT CSV rows are made as they are written.
  CsvFormat.WriteRows writes row by row to a file beside the target and
  replaces it only once every row is written; an existing read-only or
  kept file stays as it was. 2M records: CSV peak 1.4 GB -> 415 MB.
- Fields are used only when the file defines them as integers of the
  FIT Profile width, so a float32 altitude of 3e38 or a uint32 heart
  rate no longer reaches the output.
@SeoJCalive

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 리더를 다시 점검하다가 비슷한 문제 두 가지를 더 찾아서 새 커밋(3f3e65d)으로 고쳤어요.

  1. 레코드가 많은 작은 파일의 메모리: 레코드는 2,000,000개 제한이 있지만, 레코드가 작으면 파일 크기에 비해 메모리를 많이 썼어요. CSV를 모두 문자열로 만든 뒤 한 번에 쓰고, 값이 하나도 없는 레코드도 객체로 만들어 두었기 때문이에요. 이제 CSV는 한 줄씩 쓰고, 값이 없는 레코드는 제한에는 세지만 저장하지 않아요. CsvFormat.WriteRows도 한 줄씩 쓰는데, 대상 옆의 임시 파일에 다 쓴 뒤에만 대상을 바꿔요. 그래서 덮어쓰기 설정에서 변환이 중간에 실패하거나 대상이 읽기 전용이면 기존 파일이 그대로 남아요(OfficeTests.Csv_rows_that_fail_part_way_or_meet_a_read_only_file_leave_it_as_it_was). 출력 바이트는 전과 같아요. 레코드 2,000,000개로 만든 파일로 재 보면 이래요.

    파일 대상 전 후
    빈 레코드, 2 MB CSV 364 MB 81 MB
    시간만, 10 MB CSV 1.0 GB 386 MB
    시간과 위치, 26 MB CSV 1.4 GB 415 MB
    시간과 위치, 26 MB GPX / GeoJSON 512 / 509 MB 523 / 502 MB

    GPX와 GeoJSON은 레코드와 좌표 자체의 크기라 레코드 제한 안에서 머물러요. 테스트는 Empty_records_count_toward_the_limit_but_are_not_kept예요. 빈 레코드 50만 개를 읽을 때 할당이 파일 크기 + 1 MB를 넘지 않는지, 제한에는 그대로 세는지 확인해요. 빈 레코드를 저장하지 않게 되어 Relative_timestamps_never_create_an_absolute_time_or_replace_its_base의 기대값에서 빈 레코드 자리를 뺐어요. 상대 시간이 절대 시간으로 잘못 바뀌면 그 레코드가 남아서 여전히 실패해요.

  2. float로 정의된 범위 밖 값: 고도를 float32 3e38로, 심박을 uint32로 정의한 파일은 <ele>60000000109955100000000000000000000000</ele>와 심박 2147483647이 그대로 나왔어요. 이제 리더가 쓰는 필드는 Profile의 정수 폭으로 정의됐을 때만 읽고, 아니면 무효값처럼 건너뛰어요. 테스트는 Fields_not_in_their_profile_integer_width_are_left_out이에요.

실제 기기 파일 세 개(Garmin Fenix 2, Wahoo ELEMNT, Coros)는 CSV·GPX·KML·GeoJSON 출력이 고치기 전과 바이트까지 같아요. 새 테스트 세 개는 고친 곳을 되돌리면 각각 실패하는 것도 확인했어요.

@SeoJCalive
SeoJCalive requested a review from KnifeLemon October 25, 2026 10:11

@KnifeLemon KnifeLemon 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.

@SeoJCalive 님, 새 커밋 3f3e65d5만 다시 봤어요. 값이 하나도 없는 레코드를 세기만 하고 저장하지 않는 것, CSV를 한 줄씩 만들어 쓰는 것, 공용 CsvFormat.WriteRows를 대상 옆 임시 파일에 다 쓴 다음에만 바꾸는 것 모두 좋아요. 다른 호출부(데이터 → CSV, vCard·iCalendar → CSV, GeoCSV)도 출력 바이트(UTF-8 BOM, CRLF, 따옴표)가 그대로이고, 빈 표도 예전처럼 BOM만 있는 파일이 나와요. 임시 파일이 같은 폴더라 교체는 같은 볼륨 안의 이름 바꾸기이고, 감시 폴더는 점으로 시작하는 이름과 .tmp를 건너뛰어서 다시 변환될 일도 없어요. 실패하면 있던 파일이 그대로 남는다는 것도 테스트로 확인돼 있고요. Windows에서 FIT/CSV/Data/Geo 관련 테스트 556개가 통과했어요. 공용 CSV 변경은 작고 FIT 메모리 때문에 필요한 거라 이 PR에 그대로 두셔도 돼요.

작은 것 세 가지만 봐 주세요.

  1. src/Filee.Engines/Office/Sheets/CsvFormat.cs:96: catch 안의 File.Delete(partial)가 실패하면(Windows에서 백신이 막 쓴 파일을 잠깐 잡고 있을 때 등) 원래 예외 대신 삭제 오류가 올라가고 임시 파일도 남아요. try { File.Delete(partial); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }로 감싸 주세요. 81행의 임시 이름도 JobQueue.ReplaceOriginal처럼 .{이름}.{8자리}.filee-tmp로 맞춰 주시면 좋겠어요. 지금 형식은 이름이 38자 늘어서, 아주 긴 출력 이름(구성 요소 255자 한도 근처)은 예전엔 되던 게 실패할 수 있어요.
  2. src/Filee.Engines/Geo/FitFormat.cs:239: 리더는 고도만 있는 레코드를 남기는데, CSV의 HasRow(343행)는 그런 행을 빼요. 그래서 주석("어떤 출력에도 보탬이 없는 레코드는 남기지 않는다")과 달리 고도만 있는 레코드는 여전히 전부 메모리에 남아요. 리더의 조건을 HasRow와 같게 맞추면 WriteCsv의 Any(HasRow)/Where(HasRow)도 Records.Count == 0 확인 하나로 줄일 수 있어요.
  3. src/Filee.Engines/Geo/FitFormat.cs:200: base type의 폭이 프로필 폭과 다르면 값을 버리는데, FIT은 정의 메시지의 base type으로 값을 읽어요. 그래서 다른 기기나 앱이 심박을 uint16으로, 고도를 uint32로 쓴 파일도 올바른 FIT이고, 지금은 그런 파일의 해당 열이 조용히 비어요. 막으려는 건 프로필 범위를 넘는 값이니 폭이 아니라 값으로 걸러 주세요. 예를 들어 프로필 타입의 최댓값(심박 uint8이면 254)을 넘으면 버리는 식이에요. 그러면 3e38 고도나 40억 심박은 똑같이 막고 정상 값은 남길 수 있어요.

#541(독일어)이 방금 머지돼서 이 PR의 새 문자열 키가 de.json에 없는데, 그건 저희가 채워서 올릴게요. 위 세 가지가 반영되고 CI가 통과하면 머지할 수 있어요.

A FIT activity becomes one track whose points keep their time and height:
GPX, KML/KMZ (gx:Track, or LineString + coordTimes when some heights are
missing), GeoJSON (coordTimes), Shapefile, and a nine-column CSV of the
records (XLSX and JSON through it). FIT is read only, by Filee's own
reader; damaged, chained, non-activity and position-less files are
refused with their own messages. Settings schema 47 adds .fit to the
geospatial donut of existing libraries.
…tice only when the start is lost

Review on #530:
- Sessions are no longer kept one by one: the reader keeps the earliest
  start and the sports in file order, so a file of empty session
  messages can't grow memory (16 MiB: 95 MB peak, 0.96 s).
- float32/float64 NaN and infinities are invalid values, like the base
  type's invalid value.
- The GPX notice shows only when no point has a time and the activity
  has a start; a start that differs from the first point's time no
  longer raises it.
…file width

Found while checking the reader again after the review on #530:
- A record without a single value is counted toward the record limit
  but not kept, and the FIT CSV rows are made as they are written.
  CsvFormat.WriteRows writes row by row to a file beside the target and
  replaces it only once every row is written; an existing read-only or
  kept file stays as it was. 2M records: CSV peak 1.4 GB -> 415 MB.
- Fields are used only when the file defines them as integers of the
  FIT Profile width, so a float32 altitude of 3e38 or a uint32 heart
  rate no longer reaches the output.
…t uses

- Read each record, session and file_id field in the base type its definition
  gives and use it only when it is a whole number within its Profile type's
  range (short of the invalid value), so wider, signed or float definitions
  of valid values are kept while out-of-range values, fractions and the sint32
  invalid position written in another type are dropped.
- Keep a record only when a CSV row or a track uses it (a height alone is
  counted toward the limit but not kept); WriteCsv only checks for no records.
- CsvFormat.WriteRows: name the partial file like JobQueue does
  (.{name}.{8 hex}.filee-tmp) and let a failed cleanup never replace the
  original error.
- Add the FIT strings to de.json.
@SeoJCalive

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 짚어 주신 세 가지를 새 커밋(a99f4f1)으로 고쳤어요. 그사이 머지된 독일어(#541) 위로도 올렸고, CI가 통과하도록 de.json에 FIT 문구 9개도 넣었어요. 번역이 어색하면 편하게 고쳐 주세요.

  1. 임시 파일: 실패 뒤 임시 파일을 지우다 IOException·UnauthorizedAccessException이 나면 삼키고 원래 예외를 올려요. 이름도 JobQueue.ReplaceOriginal과 같은 .{이름}.{8자리}.filee-tmp로 맞췄어요. 테스트 Csv_rows_that_fail_keep_their_error_when_the_partial_file_cannot_be_deleted는 삭제가 실패하는 상황(Windows는 공유 삭제 없이 연 핸들, Unix는 쓸 수 없는 폴더)에서 원래 예외와 기존 파일이 그대로인지 확인해요.
  2. 고도만 있는 레코드: 리더가 레코드를 남기는 조건을 CSV의 HasRow와 같게 맞추고, WriteCsv는 Records.Count == 0만 확인해요. Empty_records_count_toward_the_limit_but_are_not_kept에 고도만 있는 레코드를 넣었고, Csv_has_exact_header…도 레코드를 목록에 직접 넣지 않고 리더를 거치게 바꿨어요.
  3. 폭 대신 값으로 거르기: 말씀대로 FIT은 정의 메시지의 base type으로 읽는 거라, 폭이 다르다고 버린 건 제 판단이 틀렸어요. 이제 원래 값이 Profile 타입의 범위 안(무효값 제외: uint8 254, uint16 65534, uint32 4294967294까지, 위치 sint32는 2147483646까지)의 정수일 때만 써요. 그래서 float로 쓴 72.5 심박이 72로 잘리거나, sint32 무효값인 경도 2147483647을 sint64로 써서 약 180도 위치가 되는 일도 막아요. NaN도 이 비교를 통과하지 못해요. 테스트 이름을 Values_past_their_profile_range_are_left_out_whatever_their_base_type로 바꾸고, float32 3e38 고도·uint32 40억 심박·음수 sint8 케이던스·72.5 심박·sint64 무효 경도는 빠지고, uint16 심박 150·uint32 고도·float32 케이던스 90·sint32 파워 300은 남고, 파일 형식 4.5는 activity로 읽히지 않는지 확인해요.

실제 기기 파일 세 개(Garmin Fenix 2, Wahoo ELEMNT, Coros)의 CSV·GPX·KML·GeoJSON 출력은 고치기 전과 바이트까지 같고, 레코드 200만 개 파일의 메모리도 지난번과 같은 수준이에요(CSV 82 / 385 / 414 MB). 임시 이름은 JobQueue와 같아서 대상 이름보다 20자 길어요. 그래서 255자 한도에 아주 가까운 이름은 여전히 실패할 수 있는데, 이건 JobQueue와 같은 한계라 그대로 두었어요. 새로 넣거나 바꾼 테스트는 고친 곳을 하나씩 되돌리면 각각 실패하는 것도 확인했어요.

@SeoJCalive
SeoJCalive requested a review from KnifeLemon October 25, 2026 14:43

@KnifeLemon KnifeLemon 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.

@SeoJCalive 님, a99f4f1 확인했어요. 세 가지 모두 말씀드린 대로예요. 임시 파일을 지우지 못해도 원래 예외가 그대로 올라가고 이름도 JobQueue와 같은 형식이 됐어요. 리더가 레코드를 남기는 조건도 HasRow와 같아져서 고도만 있는 레코드는 더 남지 않아요. 값 범위로 거르는 방식은 uint16 심박이나 uint32 고도는 살리고 3e38 고도, 40억 심박, 72.5 심박은 막아서 좋아요. de.json에 FIT 문구까지 넣어 주셔서 감사해요. Windows에서 FIT·CSV·Office·Geo 테스트와 Core 테스트가 모두 통과했어요. CI의 Windows build는 러너 문제(첫 실행은 시간 제한, 다시 실행은 .NET 설치 다운로드 오류)로 끝나지 못해서 저희가 다시 돌리고 있어요. CI가 통과하면 머지할게요.

@KnifeLemon
KnifeLemon merged commit e2cbc02 into main Oct 26, 2026
28 of 30 checks passed
@SeoJCalive
SeoJCalive deleted the feature/fit-reader branch October 26, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

formats New formats and engines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Format request: .fit

2 participants