Metadata-Version: 2.4
Name: swim-fit-parse
Version: 3.3
Summary: Deterministic Garmin lap-swimming .FIT parser — session/lap/block metrics with length-level anomaly detection
Project-URL: Homepage, https://github.com/ssim-baa/swim-fit-parse
Project-URL: Source, https://github.com/ssim-baa/swim-fit-parse
Author: ssim-baa
License: MIT
Keywords: fit,fitparse,garmin,lap-swimming,swimming
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.9
Requires-Dist: fitparse>=1.2
Description-Content-Type: text/markdown

# swim-fit-parse

Garmin 랩수영 `.FIT` 파일에서 결정론적으로 지표를 추출하는 파서.

`.FIT`은 바이너리라 LLM 컨텍스트에 직접 넣을 수 없다. 이 스크립트가
1세션을 약 450토큰의 텍스트로 축약해 방출한다(record 1Hz 시계열은
hrr_60 산출에만 소비하고 출력하지 않는다).

## 설치

```
pip install swim-fit-parse
```

## 실행

```
python -m swim_fit_parse <FIT 경로...>
```

`swim-fit-parse <FIT 경로...>` 명령도 동일하게 동작한다.

여러 파일을 넘기면 시작 시각 인접성으로 하나의 세션으로 조립한다
(워치가 한 세션을 전/후로 나눠 저장한 경우).

## 출력

1. 세션 헤더 — 프로그램명·풀 길이·거리·시간·스트로크·HR·수온
2. 랩표 — 랩별 거리/시간/페이스/SWOLF/스트로크/영법/HR
3. `block_metrics` — 블록 단위 집계
4. 검출 플래그 — F1~F6

첫 줄에 `schema_version` / `parser_tag` 스탬프가 찍힌다.

### block_metrics 포맷

`bm1|` 접두가 붙은 뒤 JSON 배열이 온다.

```
bm1|[{...},{...}]
```

접두는 장식이 아니라 **필수**다. Notion MCP 커넥터가 텍스트 속성 값을
JSON으로 선파싱하는데 최상위가 배열이면 타입을 거부한다. 마커가 붙으면
파싱이 깨져 원문 그대로 저장된다.

소비 측은 `value.split("|", 1)[1]`을 `json.loads`에 넘긴다. `bm1`은
페이로드 스키마 버전이며 구조 변경 시 `bm2`로 올린다.

**미측정 값은 `null`이다.** Garmin은 `swim_stroke='drill'` length에서
스트로크를 세지 않으므로, `total_cycles == 0`인 블록은
`cycles_per_length` · `swolf` · `dps_m` · `spi`를 전부 `null`로 방출한다.
`0`은 "0회 측정"이고 `null`은 "미측정"이다.

## 검출 플래그

거리는 추정치가 아니라 **length 카운트**다(각 active length = 정확히
`pool_length`). 따라서 실패 모드는 거리 오추정이 아니라 턴 미검출로 인한
length 병합·오생성이다.

1차 지표는 **스트로크**다. 제자리 정지 후 재출발하면 시간은 오염되지만
스트로크는 오염되지 않으므로, 시간 기반 판정이 실패하는 지점에서
스트로크 기반이 작동한다. 시간 비율은 보조 지표로 병기한다.

| 플래그 | 조건 | 의미 |
|---|---|---|
| F1 | strokes < 중앙값 × 0.65 | 분할 아티팩트 — 거리 과대 기록 |
| F2 | strokes ≥1.6× **그리고** duration ≥1.6× | 턴 미검출 — 거리 과소 기록 |
| F3 | duration ≥1.4×, strokes 0.75~1.25 | 시간 오염 — **거리는 정확, 병합 금지** |
| F4 | length 영법 ≠ 설계 영법 | 영법 오검출 |
| F5 | 실측 본수 ≠ `repeat_value` | 구조 불일치 |
| F6 | `wkt_step_index` 없음 | 루틴 외 추가 (정보) |

정규화 기준은 **동일 파일 내 동일 `wkt_step_index` 그룹의 중앙값**이다.
`wkt_step_index`는 파일 내에서만 유일하므로 다파일 세션에서 세션 단위로
풀링하면 무관한 블록이 한 그룹이 된다. 그룹 n<5는 중앙값이 모집단
통계로 성립하지 않아 "평가 불가"로 출력한다(침묵하지 않는다).

### F1 확정 테스트

| 테스트 | 조건 | 커버 |
|---|---|---|
| C1 설계 대조 | 초과 length 수 E = 랩 후보 수 | 조각이 **추가**된 경우 |
| C2 합산 복원 | 인접 후보 스트로크 합이 0.75~1.25 복원 | 하나가 **쪼개진** 경우 |
| C3 미확정 | 둘 다 미성립 | 제안 없음 — 문진 대상 |

C1·C2는 상보적이다. C1이 확정되면 C1을 우선 적용한다.

## 교정 적용

플래그는 검출과 제안까지만 한다. **자동 적용하지 않는다.**

확정된 F1에 대해 파서가 제안 인자를 출력한다:

```
python fit_parse.py <FIT> --drop-lengths 36,37
```

거리는 `잔여 active length 수 × pool_length`로 자동 산출된다 — 사람이
거리 숫자를 입력하지 않으므로 입력 오류 클래스가 소멸한다. 제거된
length의 시간·스트로크는 앞 생존 length로 흡수된다(버리지 않는다 —
실제로 헤엄친 물이므로).

`--merge <lap>+<lap>`은 폴백이다. length 교정으로 해소되지 않는
경우에만 쓴다.

## 블록 지표 규격

- **`fade`** = 후반부 평균 / 전반부 평균 − 1. 양수면 후반에 느려진 것이다.
  양 끝점(첫 rep vs 마지막 rep) 비교는 중간을 전부 버리고 이상치 하나에
  좌우되므로 쓰지 않는다. n이 홀수면 중앙 rep은 양쪽에서 제외하고,
  n<4면 반쪽 평균이 성립하지 않으므로 `null`이다.
- **`hrr_60`** = 블록 종료 시점 HR − 60초 후 HR. 후속 휴식이 **60초 이상이고**
  그 안에 다음 활성 랩이 시작되지 않는 블록만 산출하며, 미충족은 `null`이다
  (`0`은 금지 — 오염된 값을 회복 저하로 오독하게 된다). 세션 마지막 활성
  랩은 뒤따르는 idle이 세션 종료지 회복 구간이 아니므로 제외한다.
  게이트가 평가한 실제 휴식 길이는 `hrr_rest_s`로 함께 방출되어 감사
  가능하다 — rep **사이** 휴식인 `rest_median_s`와는 다른 값이다.

## 설계 원칙

파서는 결정론적 추출만 한다. 판정하지 않고, 개인 상수(연령·체중·기준
기록·게이트 임계)를 반입하지 않는다.
