swim-fit-parse 3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swim-fit-parse
|
|
3
|
+
Version: 3.2
|
|
4
|
+
Summary: Deterministic Garmin lap-swimming .FIT parser — session/lap/block metrics with length-level anomaly detection
|
|
5
|
+
Project-URL: Homepage, https://github.com/ssim-baa/swim-fit-parse
|
|
6
|
+
Project-URL: Source, https://github.com/ssim-baa/swim-fit-parse
|
|
7
|
+
Author: ssim-baa
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: fit,fitparse,garmin,lap-swimming,swimming
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: fitparse>=1.2
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# swim-fit-parse
|
|
20
|
+
|
|
21
|
+
Garmin 랩수영 `.FIT` 파일에서 결정론적으로 지표를 추출하는 파서.
|
|
22
|
+
|
|
23
|
+
`.FIT`은 바이너리라 LLM 컨텍스트에 직접 넣을 수 없다. 이 스크립트가
|
|
24
|
+
1세션을 약 450토큰의 텍스트로 축약해 방출한다(record 1Hz 시계열은
|
|
25
|
+
hrr_60 산출에만 소비하고 출력하지 않는다).
|
|
26
|
+
|
|
27
|
+
## 설치
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install fitparse
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 실행
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
python fit_parse.py <FIT 경로...>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
여러 파일을 넘기면 시작 시각 인접성으로 하나의 세션으로 조립한다
|
|
40
|
+
(워치가 한 세션을 전/후로 나눠 저장한 경우).
|
|
41
|
+
|
|
42
|
+
## 출력
|
|
43
|
+
|
|
44
|
+
1. 세션 헤더 — 프로그램명·풀 길이·거리·시간·스트로크·HR·수온
|
|
45
|
+
2. 랩표 — 랩별 거리/시간/페이스/SWOLF/스트로크/영법/HR
|
|
46
|
+
3. `block_metrics` JSON — 블록 단위 집계
|
|
47
|
+
4. 검출 플래그 — F1~F6
|
|
48
|
+
|
|
49
|
+
첫 줄에 `schema_version` / `parser_tag` 스탬프가 찍힌다.
|
|
50
|
+
|
|
51
|
+
## 검출 플래그
|
|
52
|
+
|
|
53
|
+
거리는 추정치가 아니라 **length 카운트**다(각 active length = 정확히
|
|
54
|
+
`pool_length`). 따라서 실패 모드는 거리 오추정이 아니라 턴 미검출로 인한
|
|
55
|
+
length 병합·오생성이다.
|
|
56
|
+
|
|
57
|
+
1차 지표는 **스트로크**다. 제자리 정지 후 재출발하면 시간은 오염되지만
|
|
58
|
+
스트로크는 오염되지 않으므로, 시간 기반 판정이 실패하는 지점에서
|
|
59
|
+
스트로크 기반이 작동한다. 시간 비율은 보조 지표로 병기한다.
|
|
60
|
+
|
|
61
|
+
| 플래그 | 조건 | 의미 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| F1 | strokes < 중앙값 × 0.65 | 분할 아티팩트 — 거리 과대 기록 |
|
|
64
|
+
| F2 | strokes ≥1.6× **그리고** duration ≥1.6× | 턴 미검출 — 거리 과소 기록 |
|
|
65
|
+
| F3 | duration ≥1.4×, strokes 0.75~1.25 | 시간 오염 — **거리는 정확, 병합 금지** |
|
|
66
|
+
| F4 | length 영법 ≠ 설계 영법 | 영법 오검출 |
|
|
67
|
+
| F5 | 실측 본수 ≠ `repeat_value` | 구조 불일치 |
|
|
68
|
+
| F6 | `wkt_step_index` 없음 | 루틴 외 추가 (정보) |
|
|
69
|
+
|
|
70
|
+
정규화 기준은 **동일 파일 내 동일 `wkt_step_index` 그룹의 중앙값**이다.
|
|
71
|
+
`wkt_step_index`는 파일 내에서만 유일하므로 다파일 세션에서 세션 단위로
|
|
72
|
+
풀링하면 무관한 블록이 한 그룹이 된다. 그룹 n<5는 중앙값이 모집단
|
|
73
|
+
통계로 성립하지 않아 "평가 불가"로 출력한다(침묵하지 않는다).
|
|
74
|
+
|
|
75
|
+
### F1 확정 테스트
|
|
76
|
+
|
|
77
|
+
| 테스트 | 조건 | 커버 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| C1 설계 대조 | 초과 length 수 E = 랩 후보 수 | 조각이 **추가**된 경우 |
|
|
80
|
+
| C2 합산 복원 | 인접 후보 스트로크 합이 0.75~1.25 복원 | 하나가 **쪼개진** 경우 |
|
|
81
|
+
| C3 미확정 | 둘 다 미성립 | 제안 없음 — 문진 대상 |
|
|
82
|
+
|
|
83
|
+
C1·C2는 상보적이다. C1이 확정되면 C1을 우선 적용한다.
|
|
84
|
+
|
|
85
|
+
## 교정 적용
|
|
86
|
+
|
|
87
|
+
플래그는 검출과 제안까지만 한다. **자동 적용하지 않는다.**
|
|
88
|
+
|
|
89
|
+
확정된 F1에 대해 파서가 제안 인자를 출력한다:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
python fit_parse.py <FIT> --drop-lengths 36,37
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
거리는 `잔여 active length 수 × pool_length`로 자동 산출된다 — 사람이
|
|
96
|
+
거리 숫자를 입력하지 않으므로 입력 오류 클래스가 소멸한다. 제거된
|
|
97
|
+
length의 시간·스트로크는 앞 생존 length로 흡수된다(버리지 않는다 —
|
|
98
|
+
실제로 헤엄친 물이므로).
|
|
99
|
+
|
|
100
|
+
`--merge <lap>+<lap>`은 폴백이다. length 교정으로 해소되지 않는
|
|
101
|
+
경우에만 쓴다.
|
|
102
|
+
|
|
103
|
+
## 설계 원칙
|
|
104
|
+
|
|
105
|
+
파서는 결정론적 추출만 한다. 판정하지 않고, 개인 상수(연령·체중·기준
|
|
106
|
+
기록·게이트 임계)를 반입하지 않는다.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# swim-fit-parse
|
|
2
|
+
|
|
3
|
+
Garmin 랩수영 `.FIT` 파일에서 결정론적으로 지표를 추출하는 파서.
|
|
4
|
+
|
|
5
|
+
`.FIT`은 바이너리라 LLM 컨텍스트에 직접 넣을 수 없다. 이 스크립트가
|
|
6
|
+
1세션을 약 450토큰의 텍스트로 축약해 방출한다(record 1Hz 시계열은
|
|
7
|
+
hrr_60 산출에만 소비하고 출력하지 않는다).
|
|
8
|
+
|
|
9
|
+
## 설치
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install fitparse
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 실행
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
python fit_parse.py <FIT 경로...>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
여러 파일을 넘기면 시작 시각 인접성으로 하나의 세션으로 조립한다
|
|
22
|
+
(워치가 한 세션을 전/후로 나눠 저장한 경우).
|
|
23
|
+
|
|
24
|
+
## 출력
|
|
25
|
+
|
|
26
|
+
1. 세션 헤더 — 프로그램명·풀 길이·거리·시간·스트로크·HR·수온
|
|
27
|
+
2. 랩표 — 랩별 거리/시간/페이스/SWOLF/스트로크/영법/HR
|
|
28
|
+
3. `block_metrics` JSON — 블록 단위 집계
|
|
29
|
+
4. 검출 플래그 — F1~F6
|
|
30
|
+
|
|
31
|
+
첫 줄에 `schema_version` / `parser_tag` 스탬프가 찍힌다.
|
|
32
|
+
|
|
33
|
+
## 검출 플래그
|
|
34
|
+
|
|
35
|
+
거리는 추정치가 아니라 **length 카운트**다(각 active length = 정확히
|
|
36
|
+
`pool_length`). 따라서 실패 모드는 거리 오추정이 아니라 턴 미검출로 인한
|
|
37
|
+
length 병합·오생성이다.
|
|
38
|
+
|
|
39
|
+
1차 지표는 **스트로크**다. 제자리 정지 후 재출발하면 시간은 오염되지만
|
|
40
|
+
스트로크는 오염되지 않으므로, 시간 기반 판정이 실패하는 지점에서
|
|
41
|
+
스트로크 기반이 작동한다. 시간 비율은 보조 지표로 병기한다.
|
|
42
|
+
|
|
43
|
+
| 플래그 | 조건 | 의미 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| F1 | strokes < 중앙값 × 0.65 | 분할 아티팩트 — 거리 과대 기록 |
|
|
46
|
+
| F2 | strokes ≥1.6× **그리고** duration ≥1.6× | 턴 미검출 — 거리 과소 기록 |
|
|
47
|
+
| F3 | duration ≥1.4×, strokes 0.75~1.25 | 시간 오염 — **거리는 정확, 병합 금지** |
|
|
48
|
+
| F4 | length 영법 ≠ 설계 영법 | 영법 오검출 |
|
|
49
|
+
| F5 | 실측 본수 ≠ `repeat_value` | 구조 불일치 |
|
|
50
|
+
| F6 | `wkt_step_index` 없음 | 루틴 외 추가 (정보) |
|
|
51
|
+
|
|
52
|
+
정규화 기준은 **동일 파일 내 동일 `wkt_step_index` 그룹의 중앙값**이다.
|
|
53
|
+
`wkt_step_index`는 파일 내에서만 유일하므로 다파일 세션에서 세션 단위로
|
|
54
|
+
풀링하면 무관한 블록이 한 그룹이 된다. 그룹 n<5는 중앙값이 모집단
|
|
55
|
+
통계로 성립하지 않아 "평가 불가"로 출력한다(침묵하지 않는다).
|
|
56
|
+
|
|
57
|
+
### F1 확정 테스트
|
|
58
|
+
|
|
59
|
+
| 테스트 | 조건 | 커버 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| C1 설계 대조 | 초과 length 수 E = 랩 후보 수 | 조각이 **추가**된 경우 |
|
|
62
|
+
| C2 합산 복원 | 인접 후보 스트로크 합이 0.75~1.25 복원 | 하나가 **쪼개진** 경우 |
|
|
63
|
+
| C3 미확정 | 둘 다 미성립 | 제안 없음 — 문진 대상 |
|
|
64
|
+
|
|
65
|
+
C1·C2는 상보적이다. C1이 확정되면 C1을 우선 적용한다.
|
|
66
|
+
|
|
67
|
+
## 교정 적용
|
|
68
|
+
|
|
69
|
+
플래그는 검출과 제안까지만 한다. **자동 적용하지 않는다.**
|
|
70
|
+
|
|
71
|
+
확정된 F1에 대해 파서가 제안 인자를 출력한다:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
python fit_parse.py <FIT> --drop-lengths 36,37
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
거리는 `잔여 active length 수 × pool_length`로 자동 산출된다 — 사람이
|
|
78
|
+
거리 숫자를 입력하지 않으므로 입력 오류 클래스가 소멸한다. 제거된
|
|
79
|
+
length의 시간·스트로크는 앞 생존 length로 흡수된다(버리지 않는다 —
|
|
80
|
+
실제로 헤엄친 물이므로).
|
|
81
|
+
|
|
82
|
+
`--merge <lap>+<lap>`은 폴백이다. length 교정으로 해소되지 않는
|
|
83
|
+
경우에만 쓴다.
|
|
84
|
+
|
|
85
|
+
## 설계 원칙
|
|
86
|
+
|
|
87
|
+
파서는 결정론적 추출만 한다. 판정하지 않고, 개인 상수(연령·체중·기준
|
|
88
|
+
기록·게이트 임계)를 반입하지 않는다.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "swim-fit-parse"
|
|
7
|
+
version = "3.2"
|
|
8
|
+
description = "Deterministic Garmin lap-swimming .FIT parser — session/lap/block metrics with length-level anomaly detection"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "ssim-baa" }]
|
|
13
|
+
keywords = ["garmin", "fit", "swimming", "lap-swimming", "fitparse"]
|
|
14
|
+
dependencies = ["fitparse>=1.2"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/ssim-baa/swim-fit-parse"
|
|
25
|
+
Source = "https://github.com/ssim-baa/swim-fit-parse"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
swim-fit-parse = "swim_fit_parse:_cli"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/swim_fit_parse"]
|
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fit_parse.py — deterministic extractor for Garmin lap-swimming .FIT files.
|
|
3
|
+
|
|
4
|
+
Emits a compact text report for LLM session context:
|
|
5
|
+
1. session header
|
|
6
|
+
2. lap table
|
|
7
|
+
3. block aggregation as `block_metrics` JSON (Workout_L3_Swim_Sessions field)
|
|
8
|
+
|
|
9
|
+
Design constraints (Workout Schema Document v3.0, section A):
|
|
10
|
+
- Deterministic extraction only. No judgement, no grading, no personal
|
|
11
|
+
constants (age, weight, base times, gate thresholds). Verdicts live in
|
|
12
|
+
Notion L2 `rule` records.
|
|
13
|
+
- `record` (1Hz) messages are consumed for hrr_60 only and never printed
|
|
14
|
+
(operating rule 11: ~2,700 samples/session would flood context).
|
|
15
|
+
- No pool-length normalisation. Course-dependent metrics are reported raw;
|
|
16
|
+
splitting by course is the consumer's job (operating rule 14).
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
pip install swim-fit-parse
|
|
20
|
+
python -m swim_fit_parse <FIT path...>
|
|
21
|
+
|
|
22
|
+
Multiple paths are assembled into one session (Stage 0) when they are
|
|
23
|
+
adjacent in time; otherwise each group is reported separately.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import bisect
|
|
27
|
+
import json
|
|
28
|
+
import statistics
|
|
29
|
+
import sys
|
|
30
|
+
from collections import Counter
|
|
31
|
+
from datetime import timedelta
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from fitparse import FitFile
|
|
35
|
+
except ImportError: # pragma: no cover
|
|
36
|
+
sys.exit("fitparse is required: pip install fitparse")
|
|
37
|
+
|
|
38
|
+
SCHEMA_VERSION = "3.2"
|
|
39
|
+
PARSER_TAG = "v3.2"
|
|
40
|
+
|
|
41
|
+
# block_metrics payload marker — see the emit site for why this is mandatory.
|
|
42
|
+
BM_PREFIX = "bm1|"
|
|
43
|
+
|
|
44
|
+
KST = timedelta(hours=9)
|
|
45
|
+
|
|
46
|
+
# Stage 0: files whose start times fall within this window belong to one session.
|
|
47
|
+
SESSION_GAP_LIMIT_S = 3 * 3600
|
|
48
|
+
|
|
49
|
+
# Detection thresholds (Schema v3.0 검출 규칙, 2026-07-31 정정).
|
|
50
|
+
#
|
|
51
|
+
# The PRIMARY index is total_strokes, not time: pausing mid-length corrupts
|
|
52
|
+
# duration but never stroke count, and that is exactly where a time-based
|
|
53
|
+
# test fails. Time ratio is retained as a secondary/diagnostic figure.
|
|
54
|
+
F1_STROKE_LOW = 0.65 # strokes below this fraction of group median -> candidate
|
|
55
|
+
F1_RESTORE_LOW = 0.75 # adjacent candidates summed must land in this band
|
|
56
|
+
F1_RESTORE_HIGH = 1.25 # to be confirmed as a split artifact
|
|
57
|
+
|
|
58
|
+
F2_STROKE_HIGH = 1.6 # missed turn: strokes AND duration both >= 1.6x
|
|
59
|
+
F2_DURATION_HIGH = 1.6
|
|
60
|
+
|
|
61
|
+
F3_DURATION_HIGH = 1.4 # time contamination: duration >= 1.4x ...
|
|
62
|
+
F3_STROKE_LOW = 0.75 # ... while strokes stay normal (0.75~1.25)
|
|
63
|
+
F3_STROKE_HIGH = 1.25 # -> distance is CORRECT, do not merge
|
|
64
|
+
|
|
65
|
+
# Group key = (file, wkt_step_index); free sessions use (file, swim_stroke).
|
|
66
|
+
# wkt_step_index is unique only within a file, so a multi-file session must not
|
|
67
|
+
# pool across sources. Below the minimum size the median is not a population
|
|
68
|
+
# statistic — report "평가 불가" rather than staying silent.
|
|
69
|
+
F_MIN_GROUP = 5
|
|
70
|
+
|
|
71
|
+
SWOLF_TOLERANCE = 1.0 # |garmin - derived| > 1 -> cross-check flag
|
|
72
|
+
HRR_MIN_REST_S = 60.0 # operating rule 13
|
|
73
|
+
|
|
74
|
+
# A lap crediting distance with zero recorded strokes in under this many
|
|
75
|
+
# seconds is a watch artifact, excluded from aggregates rather than silently
|
|
76
|
+
# averaged in.
|
|
77
|
+
PHANTOM_LAP_MAX_S = 10.0
|
|
78
|
+
|
|
79
|
+
STROKE_KO = {
|
|
80
|
+
"freestyle": "자유",
|
|
81
|
+
"backstroke": "배영",
|
|
82
|
+
"breaststroke": "평영",
|
|
83
|
+
"butterfly": "접영",
|
|
84
|
+
"drill": "드릴",
|
|
85
|
+
"im": "혼영",
|
|
86
|
+
"mixed": "혼합",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# extraction
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _fields(msg):
|
|
95
|
+
return {f.name: f.value for f in msg}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_fit(path):
|
|
99
|
+
"""Pull the messages we need out of one .FIT file."""
|
|
100
|
+
ff = FitFile(path)
|
|
101
|
+
out = {
|
|
102
|
+
"path": path,
|
|
103
|
+
"session": {},
|
|
104
|
+
"laps": [],
|
|
105
|
+
"lengths": [],
|
|
106
|
+
"steps": {},
|
|
107
|
+
"wkt_name": None,
|
|
108
|
+
"hr_series": [], # (datetime, hr) — consumed by hrr_60, never printed
|
|
109
|
+
}
|
|
110
|
+
for msg in ff.get_messages(("session", "lap", "length", "workout",
|
|
111
|
+
"workout_step", "record")):
|
|
112
|
+
d = _fields(msg)
|
|
113
|
+
if msg.name == "session":
|
|
114
|
+
out["session"] = d
|
|
115
|
+
elif msg.name == "lap":
|
|
116
|
+
out["laps"].append(d)
|
|
117
|
+
elif msg.name == "length":
|
|
118
|
+
out["lengths"].append(d)
|
|
119
|
+
elif msg.name == "workout":
|
|
120
|
+
out["wkt_name"] = d.get("wkt_name")
|
|
121
|
+
elif msg.name == "workout_step":
|
|
122
|
+
out["steps"][d.get("message_index")] = d
|
|
123
|
+
elif msg.name == "record":
|
|
124
|
+
hr, ts = d.get("heart_rate"), d.get("timestamp")
|
|
125
|
+
if hr is not None and ts is not None:
|
|
126
|
+
out["hr_series"].append((ts, hr))
|
|
127
|
+
out["laps"].sort(key=lambda l: (l.get("start_time") or 0))
|
|
128
|
+
out["lengths"].sort(key=lambda l: (l.get("message_index") or 0))
|
|
129
|
+
out["hr_series"].sort()
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def group_sessions(files):
|
|
134
|
+
"""Stage 0 — assemble multi-file recordings into single sessions."""
|
|
135
|
+
files = [f for f in files if f["session"].get("start_time")]
|
|
136
|
+
files.sort(key=lambda f: f["session"]["start_time"])
|
|
137
|
+
groups, cur = [], []
|
|
138
|
+
for f in files:
|
|
139
|
+
if not cur:
|
|
140
|
+
cur = [f]
|
|
141
|
+
continue
|
|
142
|
+
prev = cur[-1]["session"]
|
|
143
|
+
prev_end = prev["start_time"] + timedelta(
|
|
144
|
+
seconds=prev.get("total_elapsed_time") or 0)
|
|
145
|
+
if (f["session"]["start_time"] - prev_end).total_seconds() <= SESSION_GAP_LIMIT_S:
|
|
146
|
+
cur.append(f)
|
|
147
|
+
else:
|
|
148
|
+
groups.append(cur)
|
|
149
|
+
cur = [f]
|
|
150
|
+
if cur:
|
|
151
|
+
groups.append(cur)
|
|
152
|
+
return groups
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# derived metrics — formulas are canonical per Schema v3.0 delta (1)
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def pace_per_100m(seconds, distance_m):
|
|
160
|
+
if not distance_m:
|
|
161
|
+
return None
|
|
162
|
+
return seconds * 100.0 / distance_m
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def derived_swolf(seconds, cycles, lengths):
|
|
166
|
+
"""SWOLF definition: time/length + cycles/length."""
|
|
167
|
+
if not lengths:
|
|
168
|
+
return None
|
|
169
|
+
return seconds / lengths + cycles / lengths
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def hr_efficiency(pace100, avg_hr):
|
|
173
|
+
"""Higher is better. Unchanged from v2.9 (course-independent of pool length
|
|
174
|
+
in form, though the value itself is course-dependent)."""
|
|
175
|
+
if not pace100 or not avg_hr:
|
|
176
|
+
return None
|
|
177
|
+
return round(600000.0 / (pace100 * avg_hr) * 10) / 10
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def coefficient_of_variation(values):
|
|
181
|
+
if len(values) < 2:
|
|
182
|
+
return None
|
|
183
|
+
mean = statistics.fmean(values)
|
|
184
|
+
if not mean:
|
|
185
|
+
return None
|
|
186
|
+
return round(statistics.stdev(values) / mean * 100, 1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def fade_pct(values):
|
|
190
|
+
"""Second-half mean vs first-half mean, percent. Positive = slowed down.
|
|
191
|
+
|
|
192
|
+
Uses every rep, not just the endpoints: a first/last comparison discards
|
|
193
|
+
the interior (n=6 -> 4 values ignored) and lets a single fast opener or
|
|
194
|
+
slow closer dominate. With an odd n the middle rep is excluded from both
|
|
195
|
+
halves. Below n=4 there is no half to average, so fade is null.
|
|
196
|
+
"""
|
|
197
|
+
n = len(values)
|
|
198
|
+
if n < 4:
|
|
199
|
+
return None
|
|
200
|
+
half = n // 2
|
|
201
|
+
first = statistics.fmean(values[:half])
|
|
202
|
+
second = statistics.fmean(values[-half:])
|
|
203
|
+
if not first:
|
|
204
|
+
return None
|
|
205
|
+
return round((second / first - 1) * 100, 1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------
|
|
209
|
+
# lap assembly
|
|
210
|
+
# --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def build_laps(group):
|
|
213
|
+
"""Flatten laps across the assembled files, attaching length-derived data."""
|
|
214
|
+
laps = []
|
|
215
|
+
for f in group:
|
|
216
|
+
lengths = f["lengths"]
|
|
217
|
+
for d in f["laps"]:
|
|
218
|
+
dist = d.get("total_distance") or 0.0
|
|
219
|
+
secs = d.get("total_timer_time") or d.get("total_elapsed_time") or 0.0
|
|
220
|
+
active = d.get("num_active_lengths") or 0
|
|
221
|
+
cycles = d.get("total_cycles") or 0
|
|
222
|
+
|
|
223
|
+
# the length messages this lap is composed of — F1 operates on
|
|
224
|
+
# these, since a lap is a sum that dilutes a single bad length
|
|
225
|
+
first = d.get("first_length_index")
|
|
226
|
+
n_len = d.get("num_lengths")
|
|
227
|
+
window = []
|
|
228
|
+
if first is not None and n_len:
|
|
229
|
+
window = [x for x in lengths
|
|
230
|
+
if first <= (x.get("message_index") or -1) < first + n_len]
|
|
231
|
+
lap_lengths = [{
|
|
232
|
+
"idx": x.get("message_index"),
|
|
233
|
+
"secs": x.get("total_timer_time") or x.get("total_elapsed_time"),
|
|
234
|
+
"strokes": x.get("total_strokes"),
|
|
235
|
+
"stroke": x.get("swim_stroke"),
|
|
236
|
+
"type": x.get("length_type"),
|
|
237
|
+
"step_idx": d.get("wkt_step_index"),
|
|
238
|
+
# wkt_step_index is only unique WITHIN a file. Two assembled
|
|
239
|
+
# files each carry their own step 2, so the group key must be
|
|
240
|
+
# scoped by source or the medians pool unrelated efforts.
|
|
241
|
+
"src": f["path"],
|
|
242
|
+
"lap_n": None,
|
|
243
|
+
} for x in window]
|
|
244
|
+
|
|
245
|
+
# stroke: lap field, else majority vote over its active lengths
|
|
246
|
+
stroke = d.get("swim_stroke")
|
|
247
|
+
if stroke is None:
|
|
248
|
+
votes = Counter(x["stroke"] for x in lap_lengths
|
|
249
|
+
if x["type"] == "active" and x["stroke"])
|
|
250
|
+
if votes:
|
|
251
|
+
stroke = votes.most_common(1)[0][0]
|
|
252
|
+
|
|
253
|
+
laps.append({
|
|
254
|
+
"lengths": lap_lengths,
|
|
255
|
+
"src": f["path"],
|
|
256
|
+
"start": d.get("start_time"),
|
|
257
|
+
"step_idx": d.get("wkt_step_index"),
|
|
258
|
+
"dist": dist,
|
|
259
|
+
"secs": secs,
|
|
260
|
+
"active_lengths": active,
|
|
261
|
+
"cycles": cycles,
|
|
262
|
+
"avg_hr": d.get("avg_heart_rate"),
|
|
263
|
+
"max_hr": d.get("max_heart_rate"),
|
|
264
|
+
"stroke": stroke,
|
|
265
|
+
"garmin_swolf": d.get("avg_swolf"),
|
|
266
|
+
"is_active": dist > 0,
|
|
267
|
+
})
|
|
268
|
+
laps.sort(key=lambda l: (l["start"] or 0))
|
|
269
|
+
for i, lap in enumerate(laps, 1):
|
|
270
|
+
lap["n"] = i
|
|
271
|
+
for ln in lap["lengths"]:
|
|
272
|
+
ln["lap_n"] = i
|
|
273
|
+
# Phantom: distance credited with no strokes in near-zero time. Garmin
|
|
274
|
+
# emits these when the watch mis-detects a wall touch. Keeping them
|
|
275
|
+
# would corrupt every block aggregate they land in.
|
|
276
|
+
lap["phantom"] = bool(
|
|
277
|
+
lap["is_active"] and lap["cycles"] == 0
|
|
278
|
+
and lap["secs"] < PHANTOM_LAP_MAX_S)
|
|
279
|
+
lap["pace100"] = pace_per_100m(lap["secs"], lap["dist"])
|
|
280
|
+
lap["dswolf"] = derived_swolf(lap["secs"], lap["cycles"],
|
|
281
|
+
lap["active_lengths"])
|
|
282
|
+
lap["dps"] = (lap["dist"] / lap["cycles"]) if lap["cycles"] else None
|
|
283
|
+
return laps
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def rest_after(laps, idx):
|
|
287
|
+
"""Seconds of non-active time between active lap idx and the next active lap."""
|
|
288
|
+
total = 0.0
|
|
289
|
+
for lap in laps[idx + 1:]:
|
|
290
|
+
if lap["is_active"]:
|
|
291
|
+
return total
|
|
292
|
+
total += lap["secs"]
|
|
293
|
+
return total if total else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --------------------------------------------------------------------------
|
|
297
|
+
# blocks
|
|
298
|
+
# --------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def signature(dist_m, stroke, n_reps, rest_s):
|
|
301
|
+
"""Normal form per Schema v3.0 delta (9): [dist][stroke] xN @r[rest].
|
|
302
|
+
Stroke is mandatory — the string is a comparison key, so drift splits
|
|
303
|
+
the population. Distance is rep-distance, hence course-independent."""
|
|
304
|
+
ko = STROKE_KO.get(stroke, stroke or "?")
|
|
305
|
+
sig = f"{int(round(dist_m))}{ko}"
|
|
306
|
+
if n_reps > 1:
|
|
307
|
+
sig += f"×{n_reps}"
|
|
308
|
+
if rest_s:
|
|
309
|
+
sig += f"@r{int(rest_s // 60)}:{int(rest_s % 60):02d}"
|
|
310
|
+
return sig
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def group_blocks(laps, has_workout):
|
|
314
|
+
"""Map laps to blocks. wkt_step_index is deterministic when a workout is
|
|
315
|
+
present (operating rule 8); free sessions fall back to run-length grouping
|
|
316
|
+
of (distance, stroke)."""
|
|
317
|
+
active = [(i, l) for i, l in enumerate(laps)
|
|
318
|
+
if l["is_active"] and not l["phantom"]]
|
|
319
|
+
if not active:
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
# Key includes rep distance and stroke, not just step index: one workout
|
|
323
|
+
# step can be reused by the watch for different content (observed 07-25,
|
|
324
|
+
# where step 2 covers both a drill and a freestyle rep), and merging those
|
|
325
|
+
# would pool physically different efforts under one signature.
|
|
326
|
+
merged, order = {}, []
|
|
327
|
+
for i, lap in active:
|
|
328
|
+
kind = "step" if (has_workout and lap["step_idx"] is not None) else (
|
|
329
|
+
"extra" if has_workout else "free")
|
|
330
|
+
key = (kind, lap["step_idx"] if kind == "step" else None,
|
|
331
|
+
round(lap["dist"]), lap["stroke"])
|
|
332
|
+
if key not in merged:
|
|
333
|
+
merged[key] = []
|
|
334
|
+
order.append(key)
|
|
335
|
+
merged[key].append((i, lap))
|
|
336
|
+
# order is first-appearance chronological; repeats rejoin their own block
|
|
337
|
+
return [(k, merged[k]) for k in order]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def hrr_60(laps, idx, hr_series):
|
|
341
|
+
"""Operating rule 13: only when the following rest is >=60s AND no active
|
|
342
|
+
lap starts within 60s. Otherwise null — never 0.
|
|
343
|
+
|
|
344
|
+
Applies to the BLOCK's final rep (see build_block_metrics): evaluating
|
|
345
|
+
every rep and taking the max lets one qualifying rep fill a block whose
|
|
346
|
+
recovery window never existed.
|
|
347
|
+
"""
|
|
348
|
+
# The last active lap of the session has no following rest window — the
|
|
349
|
+
# trailing idle is session teardown, not recovery between efforts.
|
|
350
|
+
if not any(l["is_active"] for l in laps[idx + 1:]):
|
|
351
|
+
return None
|
|
352
|
+
rest = rest_after(laps, idx)
|
|
353
|
+
if rest is None or rest < HRR_MIN_REST_S:
|
|
354
|
+
return None
|
|
355
|
+
lap = laps[idx]
|
|
356
|
+
end = (lap["start"] or None)
|
|
357
|
+
if end is None or not hr_series:
|
|
358
|
+
return None
|
|
359
|
+
end = end + timedelta(seconds=lap["secs"])
|
|
360
|
+
times = [t for t, _ in hr_series]
|
|
361
|
+
|
|
362
|
+
def hr_at(when):
|
|
363
|
+
pos = bisect.bisect_left(times, when)
|
|
364
|
+
best, gap = None, None
|
|
365
|
+
for p in (pos - 1, pos):
|
|
366
|
+
if 0 <= p < len(hr_series):
|
|
367
|
+
g = abs((hr_series[p][0] - when).total_seconds())
|
|
368
|
+
if gap is None or g < gap:
|
|
369
|
+
best, gap = hr_series[p][1], g
|
|
370
|
+
return best if (gap is not None and gap <= 5) else None
|
|
371
|
+
|
|
372
|
+
start_hr = hr_at(end)
|
|
373
|
+
end_hr = hr_at(end + timedelta(seconds=60))
|
|
374
|
+
if start_hr is None or end_hr is None:
|
|
375
|
+
return None
|
|
376
|
+
return start_hr - end_hr
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def build_block_metrics(laps, has_workout, hr_series, steps):
|
|
380
|
+
out = []
|
|
381
|
+
for key, entries in group_blocks(laps, has_workout):
|
|
382
|
+
reps = [lap for _, lap in entries]
|
|
383
|
+
idxs = [i for i, _ in entries]
|
|
384
|
+
paces = [l["pace100"] for l in reps if l["pace100"]]
|
|
385
|
+
dists = [l["dist"] for l in reps]
|
|
386
|
+
hrs = [l["avg_hr"] for l in reps if l["avg_hr"]]
|
|
387
|
+
cycles = sum(l["cycles"] for l in reps)
|
|
388
|
+
act_len = sum(l["active_lengths"] for l in reps)
|
|
389
|
+
dist_total = sum(dists)
|
|
390
|
+
|
|
391
|
+
rests = [r for r in (rest_after(laps, i) for i in idxs[:-1]) if r]
|
|
392
|
+
rest_med = statistics.median(rests) if rests else None
|
|
393
|
+
# signature rest is quantised to 5s to keep the key stable
|
|
394
|
+
sig_rest = round(rest_med / 5) * 5 if rest_med else None
|
|
395
|
+
|
|
396
|
+
step_idx = key[1] if key[0] == "step" else None
|
|
397
|
+
# Operating rule 13 is a BLOCK condition: evaluate the recovery window
|
|
398
|
+
# that follows the block's final rep, not every rep with a max().
|
|
399
|
+
block_hrr = hrr_60(laps, idxs[-1], hr_series)
|
|
400
|
+
# The window the gate actually evaluated — distinct from
|
|
401
|
+
# rest_median_s (which is rest BETWEEN reps). Emitting it makes an
|
|
402
|
+
# hrr_60 value auditable instead of having to trust the gate.
|
|
403
|
+
hrr_rest = rest_after(laps, idxs[-1])
|
|
404
|
+
if not any(l["is_active"] for l in laps[idxs[-1] + 1:]):
|
|
405
|
+
hrr_rest = None
|
|
406
|
+
|
|
407
|
+
# Garmin does not count strokes on drill lengths, so total_cycles==0
|
|
408
|
+
# means NOT MEASURED, not "zero strokes". Emitting 0 (or a swolf that
|
|
409
|
+
# is really just lap time) would poison downstream averages.
|
|
410
|
+
measured = cycles > 0
|
|
411
|
+
swolfs = [l["dswolf"] for l in reps if l["dswolf"]] if measured else []
|
|
412
|
+
|
|
413
|
+
out.append({
|
|
414
|
+
"signature": signature(statistics.median(dists), reps[0]["stroke"],
|
|
415
|
+
len(reps), sig_rest),
|
|
416
|
+
"wkt_step_index": step_idx,
|
|
417
|
+
"source": key[0],
|
|
418
|
+
"n": len(reps),
|
|
419
|
+
"laps": [l["n"] for l in reps],
|
|
420
|
+
"distance_m": round(dist_total),
|
|
421
|
+
"pace_median": round(statistics.median(paces), 1) if paces else None,
|
|
422
|
+
"cv": coefficient_of_variation(paces),
|
|
423
|
+
"fade": fade_pct(paces),
|
|
424
|
+
"dps_m": round(dist_total / cycles, 2) if measured else None,
|
|
425
|
+
"cycles_per_length": (round(cycles / act_len, 2)
|
|
426
|
+
if measured and act_len else None),
|
|
427
|
+
"swolf": round(statistics.median(swolfs), 1) if swolfs else None,
|
|
428
|
+
"spi": round(dist_total / cycles / (statistics.median(paces) / 100), 3)
|
|
429
|
+
if measured and paces and statistics.median(paces) else None,
|
|
430
|
+
"rest_median_s": round(rest_med) if rest_med else None,
|
|
431
|
+
"avg_hr": round(statistics.fmean(hrs)) if hrs else None,
|
|
432
|
+
"peak_hr": max((l["max_hr"] for l in reps if l["max_hr"]), default=None),
|
|
433
|
+
"hrr_60": block_hrr,
|
|
434
|
+
"hrr_rest_s": round(hrr_rest) if hrr_rest else None,
|
|
435
|
+
})
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# --------------------------------------------------------------------------
|
|
440
|
+
# flags
|
|
441
|
+
# --------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
def collect_flags(laps, lengths, steps, blocks, pool_length=None):
|
|
444
|
+
"""v3.0 detection rules F1~F4 (Schema: 병합 서브루틴 절).
|
|
445
|
+
|
|
446
|
+
Detection descends from lap to length. In FIT each active length is exactly
|
|
447
|
+
`pool_length`, so distance is a COUNT, not an estimate — the only failure
|
|
448
|
+
mode is a missed turn merging or mis-creating lengths. Lap-level pace
|
|
449
|
+
heuristics are prohibited: a lap is a sum of lengths, which dilutes the
|
|
450
|
+
signal.
|
|
451
|
+
|
|
452
|
+
Detection only. Cause attribution belongs to P1 문진; merging happens via
|
|
453
|
+
--merge after USER GATE.
|
|
454
|
+
"""
|
|
455
|
+
flags = []
|
|
456
|
+
|
|
457
|
+
# --- group the active lengths -----------------------------------------
|
|
458
|
+
# Key = (file, wkt_step_index), or (file, swim_stroke) for free sessions.
|
|
459
|
+
groups = {}
|
|
460
|
+
for ln in lengths:
|
|
461
|
+
if ln["type"] != "active":
|
|
462
|
+
continue
|
|
463
|
+
key = ("step", ln["src"], ln["step_idx"]) if ln["step_idx"] is not None \
|
|
464
|
+
else ("stroke", ln["src"], ln["stroke"])
|
|
465
|
+
groups.setdefault(key, []).append(ln)
|
|
466
|
+
|
|
467
|
+
for key, group in sorted(groups.items(), key=lambda kv: str(kv[0])):
|
|
468
|
+
label = (f"wkt_step_index={key[2]}" if key[0] == "step"
|
|
469
|
+
else f"영법={STROKE_KO.get(key[2], key[2])}")
|
|
470
|
+
strokes = [l["strokes"] for l in group if l["strokes"]]
|
|
471
|
+
times = [l["secs"] for l in group if l["secs"]]
|
|
472
|
+
|
|
473
|
+
# Undersized groups are reported, not silently skipped: silence would
|
|
474
|
+
# read as "checked and clean".
|
|
475
|
+
if len(strokes) < F_MIN_GROUP or len(times) < F_MIN_GROUP:
|
|
476
|
+
flags.append({
|
|
477
|
+
"type": "--",
|
|
478
|
+
"title": "평가 불가",
|
|
479
|
+
"length_idx": None,
|
|
480
|
+
"lap_n": None,
|
|
481
|
+
"detail": (f"그룹 {label} n={len(group)} "
|
|
482
|
+
f"(<{F_MIN_GROUP}) — F1~F3 평가 제외"),
|
|
483
|
+
"suggest": "모집단 부족으로 중앙값 기준 성립 불가",
|
|
484
|
+
})
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
s_med = statistics.median(strokes)
|
|
488
|
+
t_med = statistics.median(times)
|
|
489
|
+
if not s_med or not t_med:
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
# per-length ratios; strokes primary, duration secondary
|
|
493
|
+
for ln in group:
|
|
494
|
+
ln["_sr"] = (ln["strokes"] / s_med) if ln["strokes"] else None
|
|
495
|
+
ln["_tr"] = (ln["secs"] / t_med) if ln["secs"] else None
|
|
496
|
+
|
|
497
|
+
# --- F1 split artifact: strokes far below median ------------------
|
|
498
|
+
# Confirmed when adjacent candidates' strokes sum back into 0.75~1.25:
|
|
499
|
+
# the watch fabricated extra lengths out of one real length.
|
|
500
|
+
candidates = [ln for ln in group
|
|
501
|
+
if ln["_sr"] is not None and ln["_sr"] < F1_STROKE_LOW]
|
|
502
|
+
cand_idx = {ln["idx"] for ln in candidates}
|
|
503
|
+
consumed = set()
|
|
504
|
+
|
|
505
|
+
# --- C1: design comparison (takes precedence over C2) --------------
|
|
506
|
+
# E = observed active lengths - designed lengths. When a lap carries
|
|
507
|
+
# exactly E surplus candidates, those candidates ARE the surplus and
|
|
508
|
+
# the whole set can be dropped. This covers the single-fragment case
|
|
509
|
+
# that C2 (adjacent-sum restoration) structurally cannot: a fragment
|
|
510
|
+
# that was ADDED was never split, so nothing sums back.
|
|
511
|
+
c1_laps = {}
|
|
512
|
+
if key[0] == "step" and pool_length:
|
|
513
|
+
step_c1 = steps.get(key[2])
|
|
514
|
+
dd_c1 = step_c1.get("duration_distance") if step_c1 else None
|
|
515
|
+
if dd_c1:
|
|
516
|
+
designed = dd_c1 / pool_length
|
|
517
|
+
per_lap = {}
|
|
518
|
+
for ln in candidates:
|
|
519
|
+
per_lap.setdefault(ln["lap_n"], []).append(ln)
|
|
520
|
+
for lap_n, cands in per_lap.items():
|
|
521
|
+
owner = next((l for l in laps if l["n"] == lap_n), None)
|
|
522
|
+
if not owner:
|
|
523
|
+
continue
|
|
524
|
+
excess = owner["active_lengths"] - designed
|
|
525
|
+
if excess > 0 and len(cands) == excess:
|
|
526
|
+
c1_laps[lap_n] = {
|
|
527
|
+
"excess": int(excess),
|
|
528
|
+
"designed": int(designed),
|
|
529
|
+
"observed": owner["active_lengths"],
|
|
530
|
+
"cands": sorted(x["idx"] for x in cands),
|
|
531
|
+
"dd": dd_c1,
|
|
532
|
+
}
|
|
533
|
+
for ln in candidates:
|
|
534
|
+
if ln["idx"] in consumed:
|
|
535
|
+
continue
|
|
536
|
+
# greedily absorb following adjacent candidates
|
|
537
|
+
run = [ln]
|
|
538
|
+
nxt = ln["idx"] + 1
|
|
539
|
+
while nxt in cand_idx:
|
|
540
|
+
run.append(next(x for x in candidates if x["idx"] == nxt))
|
|
541
|
+
nxt += 1
|
|
542
|
+
total = sum(x["strokes"] for x in run)
|
|
543
|
+
restored = total / s_med
|
|
544
|
+
c1 = c1_laps.get(run[0]["lap_n"])
|
|
545
|
+
# C1 wins when it covers this run: it is the superset test.
|
|
546
|
+
c1_covers = bool(c1 and set(x["idx"] for x in run) <= set(c1["cands"]))
|
|
547
|
+
c2_ok = (len(run) > 1
|
|
548
|
+
and F1_RESTORE_LOW <= restored <= F1_RESTORE_HIGH)
|
|
549
|
+
confirmed = c1_covers or c2_ok
|
|
550
|
+
test_used = "C1" if c1_covers else ("C2" if c2_ok else "C3")
|
|
551
|
+
for x in run:
|
|
552
|
+
consumed.add(x["idx"])
|
|
553
|
+
idxs = "+".join("#%s" % x["idx"] for x in run)
|
|
554
|
+
laps_involved = sorted({x["lap_n"] for x in run if x["lap_n"]})
|
|
555
|
+
stroke_terms = "+".join(str(x["strokes"]) for x in run)
|
|
556
|
+
indiv = ", ".join("%.2f" % x["_sr"] for x in run)
|
|
557
|
+
time_terms = ", ".join(
|
|
558
|
+
("%.2f" % x["_tr"]) if x["_tr"] else "-" for x in run)
|
|
559
|
+
lap_list = "+".join(str(n) for n in laps_involved)
|
|
560
|
+
|
|
561
|
+
# Proposal: drop all but one of the fabricated run. The survivor
|
|
562
|
+
# carries the real length; the rest are the watch's invention.
|
|
563
|
+
proposal = None
|
|
564
|
+
design = None
|
|
565
|
+
if c1_covers:
|
|
566
|
+
# the surplus candidates themselves are what to drop
|
|
567
|
+
drop_idx = list(c1["cands"])
|
|
568
|
+
proposal = "--drop-lengths " + ",".join(
|
|
569
|
+
str(d) for d in drop_idx)
|
|
570
|
+
design = (
|
|
571
|
+
f"C1 설계 대조 — 실측 {c1['observed']} length vs 설계 "
|
|
572
|
+
f"{c1['dd']:.0f}m ÷ {pool_length:.0f}m = {c1['designed']} "
|
|
573
|
+
f"length, 초과 E={c1['excess']} = 후보 수 "
|
|
574
|
+
f"{len(c1['cands'])} 일치 ✅")
|
|
575
|
+
elif c2_ok:
|
|
576
|
+
drop_idx = [x["idx"] for x in run[1:]]
|
|
577
|
+
proposal = "--drop-lengths " + ",".join(
|
|
578
|
+
str(d) for d in drop_idx)
|
|
579
|
+
# Second evidence line: does the corrected lap distance match
|
|
580
|
+
# the programmed step distance? Deterministic when present.
|
|
581
|
+
owner = next((l for l in laps if l["n"] == run[0]["lap_n"]), None)
|
|
582
|
+
step = steps.get(key[2]) if key[0] == "step" else None
|
|
583
|
+
dd = step.get("duration_distance") if step else None
|
|
584
|
+
if owner and dd and pool_length:
|
|
585
|
+
remaining = owner["active_lengths"] - len(drop_idx)
|
|
586
|
+
corrected = remaining * pool_length
|
|
587
|
+
design = (
|
|
588
|
+
f"설계값 {dd:.0f}m vs 교정 후 {remaining} length × "
|
|
589
|
+
f"{pool_length:.0f}m = {corrected:.0f}m — "
|
|
590
|
+
+ ("일치 ✅" if abs(corrected - dd) < 1
|
|
591
|
+
else f"불일치 ⚠ (차 {corrected - dd:+.0f}m)"))
|
|
592
|
+
elif key[0] != "step":
|
|
593
|
+
design = ("설계값 없음(free 세션) — 스트로크 복원 단독 "
|
|
594
|
+
"근거이므로 근거 약함, 육안 확인 권장")
|
|
595
|
+
else:
|
|
596
|
+
design = "설계값 미보유 — 스트로크 복원 단독 근거"
|
|
597
|
+
else:
|
|
598
|
+
# C3 — neither test fires. Data cannot settle it; asking the
|
|
599
|
+
# user is P1 문진's job, not a heuristic's.
|
|
600
|
+
if key[0] != "step":
|
|
601
|
+
design = ("C3 미확정 — free 세션이라 설계값 부재, "
|
|
602
|
+
"인접 합산 복원도 미성립. **문진 대상**")
|
|
603
|
+
else:
|
|
604
|
+
design = ("C3 미확정 — 설계 대조·합산 복원 모두 미성립. "
|
|
605
|
+
"**문진 대상**")
|
|
606
|
+
flags.append({
|
|
607
|
+
"type": "F1",
|
|
608
|
+
"title": ("분할 아티팩트" + (f" [{test_used} 확정]" if confirmed
|
|
609
|
+
else " 후보 [C3 미확정]")),
|
|
610
|
+
"length_idx": run[0]["idx"],
|
|
611
|
+
"lap_n": run[0]["lap_n"],
|
|
612
|
+
"detail": (
|
|
613
|
+
f"length {idxs} strokes {stroke_terms}={total} / "
|
|
614
|
+
f"그룹 중앙값 {s_med:.0f} = "
|
|
615
|
+
f"{'합산 ' if len(run) > 1 else ''}비율 {restored:.2f}"
|
|
616
|
+
+ (f" (개별 {indiv})" if len(run) > 1 else "")
|
|
617
|
+
+ f" | 시간 비율 {time_terms} (보조지표) "
|
|
618
|
+
f"| 그룹 {label}, n={len(strokes)}"),
|
|
619
|
+
"suggest": (
|
|
620
|
+
f"분할 확정 — 랩 {lap_list} 거리 과대 기록 상태"
|
|
621
|
+
if confirmed else
|
|
622
|
+
"스트로크 과소 — 합산 복원 미확인. 단독 이상 여부 확인 필요"),
|
|
623
|
+
"proposal": proposal,
|
|
624
|
+
"design": design,
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
# --- F2 missed turn: strokes AND duration both high ---------------
|
|
628
|
+
for ln in group:
|
|
629
|
+
if ln["_sr"] is None or ln["_tr"] is None:
|
|
630
|
+
continue
|
|
631
|
+
if ln["_sr"] >= F2_STROKE_HIGH and ln["_tr"] >= F2_DURATION_HIGH:
|
|
632
|
+
flags.append({
|
|
633
|
+
"type": "F2",
|
|
634
|
+
"title": "턴 미검출",
|
|
635
|
+
"length_idx": ln["idx"],
|
|
636
|
+
"lap_n": ln["lap_n"],
|
|
637
|
+
"detail": (f"length#{ln['idx']} strokes {ln['strokes']} "
|
|
638
|
+
f"= 비율 {ln['_sr']:.2f} (≥{F2_STROKE_HIGH}) | "
|
|
639
|
+
f"시간 {ln['secs']:.1f}초 = 비율 {ln['_tr']:.2f} "
|
|
640
|
+
f"(≥{F2_DURATION_HIGH}) | 그룹 {label}, "
|
|
641
|
+
f"n={len(strokes)}"),
|
|
642
|
+
"suggest": "length 2개가 1개로 기록 — 거리 과소 기록 상태. 분할 검토",
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
# --- F3 time contamination: duration high, strokes normal ---------
|
|
646
|
+
# Distance is CORRECT here. Never a merge candidate — exclude from
|
|
647
|
+
# pace/SWOLF only.
|
|
648
|
+
for ln in group:
|
|
649
|
+
if ln["_sr"] is None or ln["_tr"] is None:
|
|
650
|
+
continue
|
|
651
|
+
if (ln["_tr"] >= F3_DURATION_HIGH
|
|
652
|
+
and F3_STROKE_LOW <= ln["_sr"] <= F3_STROKE_HIGH):
|
|
653
|
+
flags.append({
|
|
654
|
+
"type": "F3",
|
|
655
|
+
"title": "시간 오염·거리 정상",
|
|
656
|
+
"length_idx": ln["idx"],
|
|
657
|
+
"lap_n": ln["lap_n"],
|
|
658
|
+
"detail": (f"length#{ln['idx']} 시간 {ln['secs']:.1f}초 "
|
|
659
|
+
f"= 비율 {ln['_tr']:.2f} (≥{F3_DURATION_HIGH}) "
|
|
660
|
+
f"이나 strokes {ln['strokes']} = 비율 "
|
|
661
|
+
f"{ln['_sr']:.2f} (정상 {F3_STROKE_LOW}~"
|
|
662
|
+
f"{F3_STROKE_HIGH}) | 그룹 {label}, "
|
|
663
|
+
f"n={len(strokes)}"),
|
|
664
|
+
"suggest": ("**병합 금지 — 거리 정확**. 제자리 정지 추정. "
|
|
665
|
+
"페이스·SWOLF 산출에서만 제외"),
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
# --- F4: stroke mismatch vs declared workout step ----------------------
|
|
669
|
+
for ln in lengths:
|
|
670
|
+
if ln["type"] != "active" or ln["step_idx"] is None:
|
|
671
|
+
continue
|
|
672
|
+
step = steps.get(ln["step_idx"])
|
|
673
|
+
if not step:
|
|
674
|
+
continue
|
|
675
|
+
# Garmin stores the declared stroke in target_stroke_type, not
|
|
676
|
+
# target_value (confirmed against 07-18 workout_step dump).
|
|
677
|
+
declared = (step.get("target_stroke_type")
|
|
678
|
+
if step.get("target_type") == "swim_stroke" else None)
|
|
679
|
+
if declared and ln["stroke"] and declared != ln["stroke"]:
|
|
680
|
+
flags.append({
|
|
681
|
+
"type": "F4",
|
|
682
|
+
"title": "영법 불일치",
|
|
683
|
+
"length_idx": ln["idx"],
|
|
684
|
+
"lap_n": ln["lap_n"],
|
|
685
|
+
"detail": (f"length#{ln['idx']} 실측 "
|
|
686
|
+
f"{STROKE_KO.get(ln['stroke'], ln['stroke'])} ≠ "
|
|
687
|
+
f"step {ln['step_idx']} 선언 "
|
|
688
|
+
f"{STROKE_KO.get(declared, declared)}"),
|
|
689
|
+
"suggest": "영법 오검출 또는 실행 이탈 — 확인 필요",
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
# --- F5: structural mismatch vs workout_step repeat count -------------
|
|
693
|
+
counts = {}
|
|
694
|
+
for lap in laps:
|
|
695
|
+
if lap["is_active"] and lap["step_idx"] is not None:
|
|
696
|
+
counts[lap["step_idx"]] = counts.get(lap["step_idx"], 0) + 1
|
|
697
|
+
for step_idx, actual in sorted(counts.items()):
|
|
698
|
+
step = steps.get(step_idx)
|
|
699
|
+
if not step:
|
|
700
|
+
continue
|
|
701
|
+
expected = step.get("repeat_value")
|
|
702
|
+
if isinstance(expected, int) and expected and actual != expected:
|
|
703
|
+
flags.append({
|
|
704
|
+
"type": "F5",
|
|
705
|
+
"title": "구조 불일치",
|
|
706
|
+
"length_idx": None,
|
|
707
|
+
"lap_n": None,
|
|
708
|
+
"detail": (f"step {step_idx} 실측 {actual}본 ≠ "
|
|
709
|
+
f"workout_step.repeat_value {expected}본"),
|
|
710
|
+
"suggest": "본수 차이 — 실행 이탈 또는 랩 분할 확인",
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
# --- F6: unassigned laps (informational, not an error) ----------------
|
|
714
|
+
unassigned = [l["n"] for l in laps if l["is_active"] and l["step_idx"] is None]
|
|
715
|
+
if unassigned:
|
|
716
|
+
flags.append({
|
|
717
|
+
"type": "F6",
|
|
718
|
+
"title": "미할당 랩",
|
|
719
|
+
"length_idx": None,
|
|
720
|
+
"lap_n": None,
|
|
721
|
+
"detail": (f"wkt_step_index=None 랩 {len(unassigned)}건 "
|
|
722
|
+
f"(랩 {', '.join(str(n) for n in unassigned)})"),
|
|
723
|
+
"suggest": "루틴 외 추가로 분류 — 오류 아님",
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
order = {"F1": 0, "F2": 1, "F3": 2, "F4": 3, "F5": 4, "F6": 5, "--": 6}
|
|
727
|
+
flags.sort(key=lambda f: order.get(f["type"], 9))
|
|
728
|
+
return flags
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# --------------------------------------------------------------------------
|
|
732
|
+
# rendering
|
|
733
|
+
# --------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
def fmt_pace(seconds):
|
|
736
|
+
if not seconds:
|
|
737
|
+
return "-"
|
|
738
|
+
return f"{int(seconds // 60)}:{int(round(seconds % 60)):02d}"
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def fmt_dur(seconds):
|
|
742
|
+
if not seconds:
|
|
743
|
+
return "-"
|
|
744
|
+
seconds = int(round(seconds))
|
|
745
|
+
return f"{seconds // 60}:{seconds % 60:02d}"
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def render(group, merges=None, drops=None):
|
|
749
|
+
sess = {}
|
|
750
|
+
for f in group:
|
|
751
|
+
for k, v in f["session"].items():
|
|
752
|
+
if v is not None:
|
|
753
|
+
sess.setdefault(k, v)
|
|
754
|
+
# totals must sum across assembled files, not take the first
|
|
755
|
+
total_dist = sum(f["session"].get("total_distance") or 0 for f in group)
|
|
756
|
+
total_time = sum(f["session"].get("total_timer_time")
|
|
757
|
+
or f["session"].get("total_elapsed_time") or 0
|
|
758
|
+
for f in group)
|
|
759
|
+
total_cycles = sum(f["session"].get("total_cycles") or 0 for f in group)
|
|
760
|
+
act_lengths = sum(f["session"].get("num_active_lengths") or 0 for f in group)
|
|
761
|
+
kcal = sum(f["session"].get("total_calories") or 0 for f in group)
|
|
762
|
+
|
|
763
|
+
# Assembled files can each carry a different program (observed 07-25:
|
|
764
|
+
# warmup + speed). Report all of them — collapsing to the first would
|
|
765
|
+
# silently misattribute the routine mapping (operating rule 8).
|
|
766
|
+
wkt_names = []
|
|
767
|
+
for f in group:
|
|
768
|
+
if f["wkt_name"] and f["wkt_name"] not in wkt_names:
|
|
769
|
+
wkt_names.append(f["wkt_name"])
|
|
770
|
+
wkt_name = wkt_names[0] if len(wkt_names) == 1 else None
|
|
771
|
+
has_workout = any(f["steps"] for f in group)
|
|
772
|
+
steps = {}
|
|
773
|
+
for f in group:
|
|
774
|
+
steps.update(f["steps"])
|
|
775
|
+
|
|
776
|
+
hr_series = []
|
|
777
|
+
for f in group:
|
|
778
|
+
hr_series.extend(f["hr_series"])
|
|
779
|
+
hr_series.sort()
|
|
780
|
+
|
|
781
|
+
laps = build_laps(group)
|
|
782
|
+
merge_note = None
|
|
783
|
+
drop_note = None
|
|
784
|
+
if drops:
|
|
785
|
+
# v3.1 primary path — runs before merges so the fallback operates on
|
|
786
|
+
# already-corrected lengths.
|
|
787
|
+
garmin_dist_pre = sum(l["dist"] for l in laps if l["is_active"])
|
|
788
|
+
laps = apply_drops(laps, drops, sess.get("pool_length"))
|
|
789
|
+
drop_note = ",".join(str(d) for d in sorted(drops))
|
|
790
|
+
if merges:
|
|
791
|
+
laps = apply_merges(laps, merges)
|
|
792
|
+
merge_note = ", ".join("+".join(str(m) for m in p) for p in merges)
|
|
793
|
+
blocks = build_block_metrics(laps, has_workout, hr_series, steps)
|
|
794
|
+
|
|
795
|
+
# Garmin's session totals include phantom laps and any dropped lengths.
|
|
796
|
+
# Recompute from clean laps so the header agrees with block_metrics;
|
|
797
|
+
# report the delta rather than silently diverging from Garmin Connect.
|
|
798
|
+
phantoms = [l for l in laps if l["phantom"]]
|
|
799
|
+
if phantoms or drops or merges:
|
|
800
|
+
clean = [l for l in laps if l["is_active"] and not l["phantom"]]
|
|
801
|
+
total_dist = sum(l["dist"] for l in clean)
|
|
802
|
+
total_cycles = sum(l["cycles"] for l in clean)
|
|
803
|
+
act_lengths = sum(l["active_lengths"] for l in clean)
|
|
804
|
+
|
|
805
|
+
start = sess.get("start_time")
|
|
806
|
+
start_kst = (start + KST) if start else None
|
|
807
|
+
pool = sess.get("pool_length")
|
|
808
|
+
course = "LCM" if pool and pool >= 50 else "SCM"
|
|
809
|
+
avg_hrs = [f["session"].get("avg_heart_rate") for f in group
|
|
810
|
+
if f["session"].get("avg_heart_rate")]
|
|
811
|
+
avg_hr = round(statistics.fmean(avg_hrs)) if avg_hrs else None
|
|
812
|
+
peak_hr = max((f["session"].get("max_heart_rate") or 0 for f in group),
|
|
813
|
+
default=None) or None
|
|
814
|
+
temps = [f["session"].get("avg_temperature") for f in group
|
|
815
|
+
if f["session"].get("avg_temperature") is not None]
|
|
816
|
+
temp = round(statistics.fmean(temps), 1) if temps else None
|
|
817
|
+
|
|
818
|
+
pace100 = pace_per_100m(total_time, total_dist)
|
|
819
|
+
|
|
820
|
+
lines = []
|
|
821
|
+
lines.append(f"schema_version: {SCHEMA_VERSION} | parser_tag: {PARSER_TAG}")
|
|
822
|
+
lines.append("")
|
|
823
|
+
lines.append(f"## Swim {start_kst:%Y-%m-%d}" if start_kst else "## Swim")
|
|
824
|
+
lines.append(f"- files: {len(group)}" +
|
|
825
|
+
(" (Stage 0 assembled)" if len(group) > 1 else ""))
|
|
826
|
+
if drop_note:
|
|
827
|
+
lines.append(f"- merge_applied: true (--drop-lengths {drop_note} — "
|
|
828
|
+
f"USER GATE 승인분, 거리 = 잔여 active length × "
|
|
829
|
+
f"{int(sess.get('pool_length') or 0)}m 자동 산출)")
|
|
830
|
+
if merge_note:
|
|
831
|
+
lines.append(f"- merge_applied: true (--merge {merge_note} — "
|
|
832
|
+
f"USER GATE 승인분, 폴백 경로)")
|
|
833
|
+
lines.append(f"- start_kst: {start_kst:%Y-%m-%d %H:%M}" if start_kst else "")
|
|
834
|
+
if len(wkt_names) > 1:
|
|
835
|
+
lines.append(f"- wkt_name: {', '.join(repr(w) for w in wkt_names)}"
|
|
836
|
+
f" ⚠ 다중 프로그램 — routine 매핑은 사용자 확인 필요")
|
|
837
|
+
elif wkt_name:
|
|
838
|
+
lines.append(f"- wkt_name: {wkt_name!r}")
|
|
839
|
+
else:
|
|
840
|
+
lines.append("- wkt_name: None (free — workout_step 부재)")
|
|
841
|
+
lines.append(f"- pool_length_m: {int(pool) if pool else '?'} (course={course})")
|
|
842
|
+
if phantoms:
|
|
843
|
+
garmin_dist = sum(f["session"].get("total_distance") or 0 for f in group)
|
|
844
|
+
lines.append(f"- total_distance_m: {int(total_dist)}"
|
|
845
|
+
f" (Garmin 표시 {int(garmin_dist)} − 유령랩 "
|
|
846
|
+
f"{len(phantoms)}건 {int(garmin_dist - total_dist)}m)")
|
|
847
|
+
else:
|
|
848
|
+
lines.append(f"- total_distance_m: {int(total_dist)}")
|
|
849
|
+
lines.append(f"- duration_min: {round(total_time / 60, 1)}")
|
|
850
|
+
lines.append(f"- avg_pace_per_100m: {round(pace100)} ({fmt_pace(pace100)})"
|
|
851
|
+
if pace100 else "- avg_pace_per_100m: -")
|
|
852
|
+
lines.append(f"- total_cycles: {total_cycles} | num_active_lengths: {act_lengths}")
|
|
853
|
+
lines.append(f"- cycles_per_length: "
|
|
854
|
+
f"{round(total_cycles / act_lengths, 2) if act_lengths else '-'}")
|
|
855
|
+
lines.append(f"- dps_m: {round(total_dist / total_cycles, 2) if total_cycles else '-'}")
|
|
856
|
+
lines.append(f"- avg_hr: {avg_hr} | peak_hr: {peak_hr}")
|
|
857
|
+
lines.append(f"- hr_efficiency: {hr_efficiency(pace100, avg_hr)}")
|
|
858
|
+
lines.append(f"- water_temp_c: {temp}")
|
|
859
|
+
lines.append(f"- total_calories: {int(kcal) if kcal else '-'}")
|
|
860
|
+
|
|
861
|
+
lines.append("")
|
|
862
|
+
lines.append("### laps")
|
|
863
|
+
lines.append("| lap | step | dist | time | pace100 | swolf | cyc | dps | stroke | hr |")
|
|
864
|
+
lines.append("|----:|-----:|-----:|-----:|--------:|------:|----:|----:|--------|---:|")
|
|
865
|
+
for lap in laps:
|
|
866
|
+
if not lap["is_active"]:
|
|
867
|
+
continue
|
|
868
|
+
if lap["phantom"]:
|
|
869
|
+
lines.append(
|
|
870
|
+
f"| {lap['n']} | "
|
|
871
|
+
f"{lap['step_idx'] if lap['step_idx'] is not None else '-'} "
|
|
872
|
+
f"| {int(lap['dist'])} | {lap['secs']:.1f}s | 유령랩 — 집계 제외 "
|
|
873
|
+
f"| - | 0 | - "
|
|
874
|
+
f"| {STROKE_KO.get(lap['stroke'], lap['stroke'] or '?')} "
|
|
875
|
+
f"| {lap['avg_hr'] or '-'} |")
|
|
876
|
+
continue
|
|
877
|
+
lines.append(
|
|
878
|
+
f"| {lap['n']} | {lap['step_idx'] if lap['step_idx'] is not None else '-'} "
|
|
879
|
+
f"| {int(lap['dist'])} | {fmt_dur(lap['secs'])} "
|
|
880
|
+
f"| {fmt_pace(lap['pace100'])} "
|
|
881
|
+
f"| {round(lap['dswolf'], 1) if lap['dswolf'] else '-'} "
|
|
882
|
+
f"| {lap['cycles']} "
|
|
883
|
+
f"| {round(lap['dps'], 2) if lap['dps'] else '-'} "
|
|
884
|
+
f"| {STROKE_KO.get(lap['stroke'], lap['stroke'] or '?')} "
|
|
885
|
+
f"| {lap['avg_hr'] or '-'} |")
|
|
886
|
+
|
|
887
|
+
lines.append("")
|
|
888
|
+
lines.append("### blocks")
|
|
889
|
+
lines.append("- " + ", ".join(b["signature"] for b in blocks)
|
|
890
|
+
if blocks else "- (none)")
|
|
891
|
+
|
|
892
|
+
all_lengths = [ln for lap in laps for ln in lap["lengths"]]
|
|
893
|
+
flags = collect_flags(laps, all_lengths, steps, blocks,
|
|
894
|
+
sess.get("pool_length"))
|
|
895
|
+
lines.append("")
|
|
896
|
+
lines.append("### 검출 플래그")
|
|
897
|
+
if not flags:
|
|
898
|
+
lines.append("- (없음) — F1~F5 무발화")
|
|
899
|
+
for fl in flags:
|
|
900
|
+
loc = []
|
|
901
|
+
if fl["length_idx"] is not None:
|
|
902
|
+
loc.append(f"length#{fl['length_idx']}")
|
|
903
|
+
if fl["lap_n"] is not None:
|
|
904
|
+
loc.append(f"랩{fl['lap_n']}")
|
|
905
|
+
loc = f" [{' / '.join(loc)}]" if loc else ""
|
|
906
|
+
lines.append(f"- **{fl['type']} {fl['title']}**{loc} — {fl['detail']}")
|
|
907
|
+
lines.append(f" → {fl['suggest']}")
|
|
908
|
+
if fl.get("design"):
|
|
909
|
+
lines.append(f" → 근거2: {fl['design']}")
|
|
910
|
+
if fl.get("proposal"):
|
|
911
|
+
lines.append(f" → **제안: `{fl['proposal']}`** "
|
|
912
|
+
f"(USER GATE 승인 시 재실행 — 거리는 자동 산출)")
|
|
913
|
+
if any(f.get("proposal") for f in flags):
|
|
914
|
+
lines.append(" ※ 자동 적용 금지 — 사용자가 제안을 승인해 "
|
|
915
|
+
"명시적으로 인자를 넘길 때만 동작한다")
|
|
916
|
+
|
|
917
|
+
lines.append("")
|
|
918
|
+
lines.append("### block_metrics (L3 적재용 JSON)")
|
|
919
|
+
# One block per line, compact separators: readable enough to eyeball while
|
|
920
|
+
# keeping the payload near the section A context budget (~450 tokens).
|
|
921
|
+
# `bm1|` prefix is REQUIRED, not cosmetic: the Notion MCP connector
|
|
922
|
+
# pre-parses text property values as JSON and rejects a top-level array
|
|
923
|
+
# outright. The marker breaks that parse so the string is stored verbatim.
|
|
924
|
+
# Consumer: value.split("|", 1)[1] then json.loads.
|
|
925
|
+
# bm1 = payload schema version; bump to bm2 if the block shape changes.
|
|
926
|
+
# (JSONL was rejected: a single-block session yields one line that is
|
|
927
|
+
# itself a valid JSON object, hitting the same refusal.)
|
|
928
|
+
lines.append("```")
|
|
929
|
+
lines.append(BM_PREFIX + "[")
|
|
930
|
+
for i, b in enumerate(blocks):
|
|
931
|
+
comma = "," if i < len(blocks) - 1 else ""
|
|
932
|
+
lines.append(json.dumps(b, ensure_ascii=False,
|
|
933
|
+
separators=(",", ":")) + comma)
|
|
934
|
+
lines.append("]")
|
|
935
|
+
lines.append("```")
|
|
936
|
+
return "\n".join(l for l in lines if l is not None)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def parse_drop_arg(spec):
|
|
940
|
+
"""`--drop-lengths 35,36,37` -> {35, 36, 37} (length message indices)."""
|
|
941
|
+
out = set()
|
|
942
|
+
for chunk in spec.replace(" ", "").split(","):
|
|
943
|
+
if not chunk:
|
|
944
|
+
continue
|
|
945
|
+
try:
|
|
946
|
+
out.add(int(chunk))
|
|
947
|
+
except ValueError:
|
|
948
|
+
sys.exit(f"--drop-lengths: '{chunk}' 파싱 불가 (형식: 35,36,37)")
|
|
949
|
+
return out
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def apply_drops(laps, drop, pool_length):
|
|
953
|
+
"""v3.1 주 경로: fold fabricated lengths back into the real one.
|
|
954
|
+
|
|
955
|
+
A dropped length's time and strokes are ABSORBED into the nearest
|
|
956
|
+
preceding surviving length, not discarded: the swimmer really did swim
|
|
957
|
+
that water, the watch merely split one length into several. Discarding
|
|
958
|
+
would leave the survivor as a fragment and re-trip F1.
|
|
959
|
+
|
|
960
|
+
Distance is a COUNT — it recomputes as (remaining active lengths ×
|
|
961
|
+
pool_length), so no human-entered figure enters the pipeline. Lap
|
|
962
|
+
boundaries are preserved: a lap loses lengths, it does not merge.
|
|
963
|
+
"""
|
|
964
|
+
if not pool_length:
|
|
965
|
+
sys.exit("--drop-lengths: pool_length 미상으로 거리 재산출 불가")
|
|
966
|
+
seen = set()
|
|
967
|
+
for lap in laps:
|
|
968
|
+
keep = [ln for ln in lap["lengths"] if ln["idx"] not in drop]
|
|
969
|
+
removed = [ln for ln in lap["lengths"] if ln["idx"] in drop]
|
|
970
|
+
if not removed:
|
|
971
|
+
continue
|
|
972
|
+
seen.update(ln["idx"] for ln in removed)
|
|
973
|
+
# absorb each removed length into the closest earlier survivor
|
|
974
|
+
# (falling back to the first survivor when it led the run)
|
|
975
|
+
for ln in removed:
|
|
976
|
+
host = None
|
|
977
|
+
for cand in keep:
|
|
978
|
+
if cand["idx"] < ln["idx"] and cand["type"] == "active":
|
|
979
|
+
host = cand if host is None or cand["idx"] > host["idx"] else host
|
|
980
|
+
if host is None:
|
|
981
|
+
host = next((c for c in keep if c["type"] == "active"), None)
|
|
982
|
+
if host is None:
|
|
983
|
+
continue
|
|
984
|
+
host["secs"] = (host["secs"] or 0) + (ln["secs"] or 0)
|
|
985
|
+
host["strokes"] = (host["strokes"] or 0) + (ln["strokes"] or 0)
|
|
986
|
+
act = [ln for ln in keep if ln["type"] == "active"]
|
|
987
|
+
lap["lengths"] = keep
|
|
988
|
+
lap["active_lengths"] = len(act)
|
|
989
|
+
lap["dist"] = len(act) * pool_length
|
|
990
|
+
lap["secs"] = sum(ln["secs"] or 0 for ln in keep)
|
|
991
|
+
lap["cycles"] = sum(ln["strokes"] or 0 for ln in act)
|
|
992
|
+
lap["dropped"] = sorted(ln["idx"] for ln in removed)
|
|
993
|
+
lap["is_active"] = lap["dist"] > 0
|
|
994
|
+
lap["phantom"] = False
|
|
995
|
+
lap["pace100"] = pace_per_100m(lap["secs"], lap["dist"])
|
|
996
|
+
lap["dswolf"] = derived_swolf(lap["secs"], lap["cycles"],
|
|
997
|
+
lap["active_lengths"])
|
|
998
|
+
lap["dps"] = (lap["dist"] / lap["cycles"]) if lap["cycles"] else None
|
|
999
|
+
missing = drop - seen
|
|
1000
|
+
if missing:
|
|
1001
|
+
sys.exit(f"--drop-lengths: length {sorted(missing)} 없음")
|
|
1002
|
+
return [l for l in laps if l["is_active"] or not l.get("dropped")]
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def parse_merge_arg(spec):
|
|
1006
|
+
"""`--merge 14+15,20+21` -> [[14,15],[20,21]] (lap numbers as printed)."""
|
|
1007
|
+
pairs = []
|
|
1008
|
+
for chunk in spec.split(","):
|
|
1009
|
+
chunk = chunk.strip()
|
|
1010
|
+
if not chunk:
|
|
1011
|
+
continue
|
|
1012
|
+
try:
|
|
1013
|
+
members = [int(x) for x in chunk.split("+")]
|
|
1014
|
+
except ValueError:
|
|
1015
|
+
sys.exit(f"--merge: '{chunk}' 파싱 불가 (형식: 14+15,20+21)")
|
|
1016
|
+
if len(members) < 2:
|
|
1017
|
+
sys.exit(f"--merge: '{chunk}'는 2개 이상의 랩이 필요하다")
|
|
1018
|
+
pairs.append(members)
|
|
1019
|
+
return pairs
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def apply_merges(laps, pairs):
|
|
1023
|
+
"""Merge approved laps per the Schema 처리 규칙:
|
|
1024
|
+
time/strokes summed, HR time-weighted, SWOLF recomputed from merged
|
|
1025
|
+
totals. Distance is the summed count — a corrected real distance must be
|
|
1026
|
+
supplied by the user downstream, since the parser does not guess.
|
|
1027
|
+
"""
|
|
1028
|
+
by_n = {l["n"]: l for l in laps}
|
|
1029
|
+
dropped = set()
|
|
1030
|
+
for members in pairs:
|
|
1031
|
+
targets = [by_n.get(n) for n in members]
|
|
1032
|
+
missing = [n for n, t in zip(members, targets) if t is None]
|
|
1033
|
+
if missing:
|
|
1034
|
+
sys.exit(f"--merge: 랩 {missing} 없음")
|
|
1035
|
+
head = targets[0]
|
|
1036
|
+
secs = sum(t["secs"] for t in targets)
|
|
1037
|
+
cycles = sum(t["cycles"] for t in targets)
|
|
1038
|
+
dist = sum(t["dist"] for t in targets)
|
|
1039
|
+
act = sum(t["active_lengths"] for t in targets)
|
|
1040
|
+
weighted = [(t["avg_hr"], t["secs"]) for t in targets if t["avg_hr"]]
|
|
1041
|
+
hr = (round(sum(h * s for h, s in weighted) / sum(s for _, s in weighted))
|
|
1042
|
+
if weighted and sum(s for _, s in weighted) else None)
|
|
1043
|
+
head.update({
|
|
1044
|
+
"secs": secs, "cycles": cycles, "dist": dist,
|
|
1045
|
+
"active_lengths": act, "avg_hr": hr,
|
|
1046
|
+
"max_hr": max((t["max_hr"] for t in targets if t["max_hr"]),
|
|
1047
|
+
default=None),
|
|
1048
|
+
"lengths": [ln for t in targets for ln in t["lengths"]],
|
|
1049
|
+
"merged_from": members,
|
|
1050
|
+
})
|
|
1051
|
+
head["pace100"] = pace_per_100m(secs, dist)
|
|
1052
|
+
head["dswolf"] = derived_swolf(secs, cycles, act)
|
|
1053
|
+
head["dps"] = (dist / cycles) if cycles else None
|
|
1054
|
+
head["phantom"] = False
|
|
1055
|
+
dropped.update(members[1:])
|
|
1056
|
+
return [l for l in laps if l["n"] not in dropped]
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def main(argv):
|
|
1060
|
+
# Output carries Korean text and typographic marks; a Windows console
|
|
1061
|
+
# defaults to cp949 and dies on them. Force UTF-8 rather than mangling.
|
|
1062
|
+
try:
|
|
1063
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
1064
|
+
except (AttributeError, ValueError):
|
|
1065
|
+
pass
|
|
1066
|
+
|
|
1067
|
+
merge_spec = None
|
|
1068
|
+
drop_spec = None
|
|
1069
|
+
paths = []
|
|
1070
|
+
i = 0
|
|
1071
|
+
while i < len(argv):
|
|
1072
|
+
a = argv[i]
|
|
1073
|
+
if a in ("--merge", "--drop-lengths"):
|
|
1074
|
+
if i + 1 >= len(argv):
|
|
1075
|
+
sys.exit(f"{a}: 인자 필요")
|
|
1076
|
+
if a == "--merge":
|
|
1077
|
+
merge_spec = argv[i + 1]
|
|
1078
|
+
else:
|
|
1079
|
+
drop_spec = argv[i + 1]
|
|
1080
|
+
i += 2
|
|
1081
|
+
elif a.startswith("--merge="):
|
|
1082
|
+
merge_spec = a.split("=", 1)[1]
|
|
1083
|
+
i += 1
|
|
1084
|
+
elif a.startswith("--drop-lengths="):
|
|
1085
|
+
drop_spec = a.split("=", 1)[1]
|
|
1086
|
+
i += 1
|
|
1087
|
+
else:
|
|
1088
|
+
paths.append(a)
|
|
1089
|
+
i += 1
|
|
1090
|
+
if not paths:
|
|
1091
|
+
sys.exit(__doc__)
|
|
1092
|
+
|
|
1093
|
+
merges = parse_merge_arg(merge_spec) if merge_spec else None
|
|
1094
|
+
drops = parse_drop_arg(drop_spec) if drop_spec else None
|
|
1095
|
+
files = [read_fit(p) for p in paths]
|
|
1096
|
+
groups = group_sessions(files)
|
|
1097
|
+
if (merges or drops) and len(groups) > 1:
|
|
1098
|
+
sys.exit("--merge/--drop-lengths는 단일 세션에만 적용 가능하다 "
|
|
1099
|
+
f"(현재 {len(groups)}개 세션 검출)")
|
|
1100
|
+
for i, group in enumerate(groups):
|
|
1101
|
+
if i:
|
|
1102
|
+
print("\n" + "=" * 72 + "\n")
|
|
1103
|
+
print(render(group, merges, drops))
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _cli():
|
|
1107
|
+
"""console_scripts entry point."""
|
|
1108
|
+
main(sys.argv[1:])
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
if __name__ == "__main__":
|
|
1112
|
+
main(sys.argv[1:])
|