garmin-running-data-normalizer 1.1.1__py3-none-any.whl

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.
Files changed (35) hide show
  1. garmin_running_data_normalizer/__init__.py +6 -0
  2. garmin_running_data_normalizer/__main__.py +5 -0
  3. garmin_running_data_normalizer/common/__init__.py +1 -0
  4. garmin_running_data_normalizer/common/identity.py +21 -0
  5. garmin_running_data_normalizer/common/time.py +42 -0
  6. garmin_running_data_normalizer/common/validation.py +19 -0
  7. garmin_running_data_normalizer/export/__init__.py +5 -0
  8. garmin_running_data_normalizer/export/analysis_pack.py +102 -0
  9. garmin_running_data_normalizer/fit/__init__.py +12 -0
  10. garmin_running_data_normalizer/fit/hrv.py +333 -0
  11. garmin_running_data_normalizer/fit/parser.py +480 -0
  12. garmin_running_data_normalizer/intake/__init__.py +5 -0
  13. garmin_running_data_normalizer/intake/archive.py +99 -0
  14. garmin_running_data_normalizer/intake/discovery.py +69 -0
  15. garmin_running_data_normalizer/normalizers/__init__.py +17 -0
  16. garmin_running_data_normalizer/normalizers/activities.py +72 -0
  17. garmin_running_data_normalizer/normalizers/gear.py +62 -0
  18. garmin_running_data_normalizer/normalizers/health_status.py +286 -0
  19. garmin_running_data_normalizer/normalizers/hrv.py +144 -0
  20. garmin_running_data_normalizer/normalizers/personal_records.py +57 -0
  21. garmin_running_data_normalizer/normalizers/sleep.py +192 -0
  22. garmin_running_data_normalizer/output_experience.py +1262 -0
  23. garmin_running_data_normalizer/policies/__init__.py +5 -0
  24. garmin_running_data_normalizer/policies/datasets.py +123 -0
  25. garmin_running_data_normalizer/qa.py +36 -0
  26. garmin_running_data_normalizer/relationships.py +649 -0
  27. garmin_running_data_normalizer/run_all.py +802 -0
  28. garmin_running_data_normalizer/runner.py +244 -0
  29. garmin_running_data_normalizer/standalone.py +175 -0
  30. garmin_running_data_normalizer-1.1.1.dist-info/METADATA +265 -0
  31. garmin_running_data_normalizer-1.1.1.dist-info/RECORD +35 -0
  32. garmin_running_data_normalizer-1.1.1.dist-info/WHEEL +5 -0
  33. garmin_running_data_normalizer-1.1.1.dist-info/entry_points.txt +2 -0
  34. garmin_running_data_normalizer-1.1.1.dist-info/licenses/LICENSE +202 -0
  35. garmin_running_data_normalizer-1.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """Local-first Garmin Account Export normalization primitives."""
2
+
3
+ __version__ = "1.1.1"
4
+ IMPLEMENTATION_STATUS = "STABLE_RELEASE_READY"
5
+
6
+ __all__ = ["IMPLEMENTATION_STATUS", "__version__"]
@@ -0,0 +1,5 @@
1
+ from .runner import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Shared deterministic helpers with no external dependencies."""
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Iterable
6
+
7
+
8
+ def stable_hash(parts: Iterable[object], *, prefix: str = "") -> str:
9
+ """Build a deterministic opaque identifier from normalized parts."""
10
+ canonical = json.dumps(list(parts), ensure_ascii=False, separators=(",", ":"), default=str)
11
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
12
+ return f"{prefix}{digest}"
13
+
14
+
15
+ def garmin_activity_key(activity_id: object | None, *fallback_parts: object) -> str:
16
+ """Prefer Garmin's activity identifier, with a deterministic fallback."""
17
+ if activity_id not in (None, ""):
18
+ return f"garmin_activity:{activity_id}"
19
+ if not fallback_parts or all(part in (None, "") for part in fallback_parts):
20
+ raise ValueError("activity_id or at least one fallback part is required")
21
+ return stable_hash(fallback_parts, prefix="garmin_activity_hash:")
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+ from zoneinfo import ZoneInfo
6
+
7
+
8
+ def unix_ms_to_local_datetime(value: int | float | None, timezone_name: str = "Asia/Tokyo") -> str | None:
9
+ if value is None:
10
+ return None
11
+ try:
12
+ return datetime.fromtimestamp(float(value) / 1000.0, timezone.utc).astimezone(
13
+ ZoneInfo(timezone_name)
14
+ ).isoformat()
15
+ except (OverflowError, TypeError, ValueError):
16
+ return None
17
+
18
+
19
+ def unix_ms_to_local_date(value: int | float | None, timezone_name: str = "Asia/Tokyo") -> str | None:
20
+ converted = unix_ms_to_local_datetime(value, timezone_name)
21
+ return converted[:10] if converted else None
22
+
23
+
24
+ def daily_calendar_date(value: Any) -> str | None:
25
+ """Normalize Garmin daily labels without shifting epoch-millisecond dates."""
26
+ if value in (None, ""):
27
+ return None
28
+ if isinstance(value, (int, float)):
29
+ try:
30
+ return datetime.fromtimestamp(float(value) / 1000.0, timezone.utc).date().isoformat()
31
+ except (OverflowError, TypeError, ValueError):
32
+ return None
33
+ text = str(value)
34
+ if len(text) >= 10:
35
+ try:
36
+ return datetime.fromisoformat(text[:10]).date().isoformat()
37
+ except ValueError:
38
+ pass
39
+ try:
40
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).date().isoformat()
41
+ except ValueError:
42
+ return None
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping
4
+
5
+
6
+ def require_fields(record: Mapping[str, object], required: Iterable[str]) -> None:
7
+ missing = sorted(set(required) - set(record))
8
+ if missing:
9
+ raise ValueError(f"Missing required fields: {missing}")
10
+
11
+
12
+ def require_unique(records: Iterable[Mapping[str, object]], fields: Iterable[str]) -> None:
13
+ field_names = tuple(fields)
14
+ seen: set[tuple[object, ...]] = set()
15
+ for record in records:
16
+ key = tuple(record.get(field) for field in field_names)
17
+ if key in seen:
18
+ raise ValueError(f"Duplicate key for fields {field_names}: {key}")
19
+ seen.add(key)
@@ -0,0 +1,5 @@
1
+ """Portable output builders with explicit allowlists."""
2
+
3
+ from .analysis_pack import build_analysis_pack
4
+
5
+ __all__ = ["build_analysis_pack"]
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import zipfile
7
+ from collections.abc import Mapping
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import Any
10
+
11
+ ALLOWED_SUFFIXES = {".csv", ".json", ".md"}
12
+ FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0)
13
+
14
+
15
+ def _sha256(data: bytes) -> str:
16
+ return hashlib.sha256(data).hexdigest()
17
+
18
+
19
+ def _safe_relative(root: Path, candidate: str | Path) -> tuple[Path, str]:
20
+ requested = PurePosixPath(str(candidate).replace("\\", "/"))
21
+ if requested.is_absolute() or ".." in requested.parts or not requested.parts:
22
+ raise ValueError(f"unsafe Analysis Pack path: {candidate}")
23
+ absolute = (root / Path(*requested.parts)).resolve()
24
+ try:
25
+ relative = absolute.relative_to(root).as_posix()
26
+ except ValueError as exc:
27
+ raise ValueError(f"Analysis Pack path escapes root: {candidate}") from exc
28
+ if absolute.is_symlink() or not absolute.is_file():
29
+ raise ValueError(f"Analysis Pack input must be a regular file: {candidate}")
30
+ if absolute.suffix.lower() not in ALLOWED_SUFFIXES:
31
+ raise ValueError(f"Analysis Pack file type is not allowlisted: {candidate}")
32
+ return absolute, relative
33
+
34
+
35
+ def _write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
36
+ info = zipfile.ZipInfo(name, FIXED_ZIP_TIME)
37
+ info.compress_type = zipfile.ZIP_DEFLATED
38
+ info.external_attr = 0o100644 << 16
39
+ archive.writestr(info, data)
40
+
41
+
42
+ def build_analysis_pack_payloads(payloads: Mapping[str, bytes]) -> tuple[bytes, dict[str, Any]]:
43
+ """Build deterministic Analysis Pack bytes from an explicit in-memory allowlist."""
44
+ if not payloads:
45
+ raise ValueError("Analysis Pack payload allowlist must not be empty")
46
+ selected: list[tuple[str, bytes]] = []
47
+ for raw_path in sorted(payloads):
48
+ requested = PurePosixPath(str(raw_path).replace("\\", "/"))
49
+ if requested.is_absolute() or ".." in requested.parts or not requested.parts:
50
+ raise ValueError(f"unsafe Analysis Pack path: {raw_path}")
51
+ relative = requested.as_posix()
52
+ if requested.suffix.lower() not in ALLOWED_SUFFIXES:
53
+ raise ValueError(f"Analysis Pack file type is not allowlisted: {raw_path}")
54
+ data = payloads[raw_path]
55
+ if not isinstance(data, bytes):
56
+ raise TypeError("Analysis Pack payloads must be bytes")
57
+ selected.append((relative, data))
58
+ entries = [
59
+ {"path": path, "bytes": len(data), "sha256": _sha256(data)}
60
+ for path, data in selected
61
+ ]
62
+ manifest = {
63
+ "format": "garmin-running-data-normalizer-analysis-pack-v1",
64
+ "deterministic": True,
65
+ "entries": entries,
66
+ }
67
+ manifest_data = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode(
68
+ "utf-8"
69
+ )
70
+ stream = io.BytesIO()
71
+ with zipfile.ZipFile(stream, "w") as archive:
72
+ _write_entry(archive, "manifest.json", manifest_data)
73
+ for relative, data in selected:
74
+ _write_entry(archive, relative, data)
75
+ pack = stream.getvalue()
76
+ return pack, {**manifest, "pack_sha256": _sha256(pack)}
77
+
78
+
79
+ def build_analysis_pack(
80
+ root: str | Path,
81
+ include: list[str],
82
+ output_path: str | Path,
83
+ ) -> dict[str, Any]:
84
+ """Build a deterministic ZIP from an explicit relative-path allowlist."""
85
+ base = Path(root).resolve()
86
+ if not base.is_dir():
87
+ raise FileNotFoundError(root)
88
+ if not include:
89
+ raise ValueError("Analysis Pack include allowlist must not be empty")
90
+ selected = [_safe_relative(base, value) for value in sorted(set(include))]
91
+ payloads: dict[str, bytes] = {}
92
+ for absolute, relative in selected:
93
+ data = absolute.read_bytes()
94
+ payloads[relative] = data
95
+ pack, result = build_analysis_pack_payloads(payloads)
96
+ destination = Path(output_path)
97
+ destination.parent.mkdir(parents=True, exist_ok=True)
98
+ destination.write_bytes(pack)
99
+ return result
100
+
101
+
102
+ __all__ = ["build_analysis_pack", "build_analysis_pack_payloads"]
@@ -0,0 +1,12 @@
1
+ """Dependency-free FIT session and lap parsing."""
2
+
3
+ from .parser import parse_fit_bytes, parse_fit_export
4
+ from .hrv import dedupe_fit_hrv_daily, parse_fit_hrv_bytes, parse_fit_hrv_export
5
+
6
+ __all__ = [
7
+ "dedupe_fit_hrv_daily",
8
+ "parse_fit_bytes",
9
+ "parse_fit_export",
10
+ "parse_fit_hrv_bytes",
11
+ "parse_fit_hrv_export",
12
+ ]
@@ -0,0 +1,333 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import struct
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from zoneinfo import ZoneInfo
9
+
10
+ from ..intake.discovery import discover_export
11
+ from .parser import Definition, FieldDef, FIT_EPOCH_OFFSET, MAX_FIT_BYTES, _read_value
12
+
13
+ FIT_HRV_MESSAGE_NUM = 370
14
+ FIT_HRV_VALUE_FIELD_NUM = 1
15
+ FIT_TIMESTAMP_FIELD_NUM = 253
16
+ FIT_HRV_DIVISOR = 128.0
17
+ FIT_HRV_INVALID_RAW_VALUE = 65535
18
+ FIT_TIMESTAMP_INVALID_RAW_VALUE = 0xFFFFFFFF
19
+
20
+
21
+ def _fit_datetime(value: Any, timezone_name: str) -> str | None:
22
+ if (
23
+ isinstance(value, bool)
24
+ or not isinstance(value, int)
25
+ or value == FIT_TIMESTAMP_INVALID_RAW_VALUE
26
+ ):
27
+ return None
28
+ try:
29
+ unix_seconds = int(value) + FIT_EPOCH_OFFSET
30
+ return datetime.fromtimestamp(unix_seconds, timezone.utc).astimezone(
31
+ ZoneInfo(timezone_name)
32
+ ).isoformat()
33
+ except (OverflowError, TypeError, ValueError):
34
+ return None
35
+
36
+
37
+ def _empty_result(status: str, file_id: str, source_path: str) -> dict[str, Any]:
38
+ return {
39
+ "status": status,
40
+ "fit_file_id": file_id,
41
+ "source_path": source_path,
42
+ "records": [],
43
+ "message370_record_count": 0,
44
+ "hrv_record_count": 0,
45
+ "unknown_records": 0,
46
+ }
47
+
48
+
49
+ def parse_fit_hrv_bytes(
50
+ data: bytes,
51
+ *,
52
+ file_id: str,
53
+ source_path: str,
54
+ timezone_name: str = "Asia/Tokyo",
55
+ ) -> dict[str, Any]:
56
+ """Extract the bounded HRV PoC signal from FIT message 370 field 1."""
57
+ if len(data) > MAX_FIT_BYTES:
58
+ return _empty_result("too_large", file_id, source_path)
59
+ if len(data) < 12:
60
+ return _empty_result("too_small", file_id, source_path)
61
+ header_size = data[0]
62
+ if header_size not in (12, 14) or len(data) < header_size or data[8:12] != b".FIT":
63
+ return _empty_result("bad_header", file_id, source_path)
64
+ data_size = struct.unpack_from("<I", data, 4)[0]
65
+ if header_size + data_size > len(data):
66
+ return _empty_result("truncated", file_id, source_path)
67
+
68
+ position = header_size
69
+ data_end = header_size + data_size
70
+ definitions: dict[int, Definition] = {}
71
+ records: list[dict[str, Any]] = []
72
+ unknown_records = 0
73
+ message_count = 0
74
+
75
+ while position < data_end:
76
+ record_header = data[position]
77
+ position += 1
78
+ if record_header & 0x80:
79
+ local_message = (record_header >> 5) & 0x03
80
+ is_definition = False
81
+ else:
82
+ local_message = record_header & 0x0F
83
+ is_definition = bool(record_header & 0x40)
84
+
85
+ if is_definition:
86
+ if position + 5 > data_end:
87
+ return _empty_result("truncated", file_id, source_path)
88
+ position += 1
89
+ architecture = data[position]
90
+ position += 1
91
+ endian = ">" if architecture == 1 else "<"
92
+ global_message = struct.unpack_from(endian + "H", data, position)[0]
93
+ position += 2
94
+ field_count = data[position]
95
+ position += 1
96
+ if position + field_count * 3 > data_end:
97
+ return _empty_result("truncated", file_id, source_path)
98
+ fields = tuple(
99
+ FieldDef(
100
+ data[position + index * 3],
101
+ data[position + index * 3 + 1],
102
+ data[position + index * 3 + 2],
103
+ )
104
+ for index in range(field_count)
105
+ )
106
+ position += field_count * 3
107
+ developer_sizes: tuple[int, ...] = ()
108
+ if record_header & 0x20:
109
+ if position >= data_end:
110
+ return _empty_result("truncated", file_id, source_path)
111
+ developer_count = data[position]
112
+ position += 1
113
+ if position + developer_count * 3 > data_end:
114
+ return _empty_result("truncated", file_id, source_path)
115
+ developer_sizes = tuple(
116
+ data[position + index * 3 + 1] for index in range(developer_count)
117
+ )
118
+ position += developer_count * 3
119
+ definitions[local_message] = Definition(
120
+ endian, global_message, fields, developer_sizes
121
+ )
122
+ continue
123
+
124
+ definition = definitions.get(local_message)
125
+ if definition is None:
126
+ unknown_records += 1
127
+ break
128
+ total_size = sum(field.size for field in definition.fields) + sum(
129
+ definition.developer_field_sizes
130
+ )
131
+ if position + total_size > data_end:
132
+ return _empty_result("truncated", file_id, source_path)
133
+
134
+ values: dict[int, Any] = {}
135
+ for field in definition.fields:
136
+ value = _read_value(data, position, field, definition.endian)
137
+ position += field.size
138
+ if definition.global_message == FIT_HRV_MESSAGE_NUM:
139
+ values[field.number] = value
140
+ position += sum(definition.developer_field_sizes)
141
+ if definition.global_message != FIT_HRV_MESSAGE_NUM:
142
+ continue
143
+
144
+ message_count += 1
145
+ raw_value = values.get(FIT_HRV_VALUE_FIELD_NUM)
146
+ timestamp_raw = values.get(FIT_TIMESTAMP_FIELD_NUM)
147
+ scalar_raw = (
148
+ raw_value
149
+ if isinstance(raw_value, int) and not isinstance(raw_value, bool)
150
+ else None
151
+ )
152
+ invalid = scalar_raw == FIT_HRV_INVALID_RAW_VALUE
153
+ hrv_value = None
154
+ if scalar_raw is not None and not invalid:
155
+ candidate = float(scalar_raw) / FIT_HRV_DIVISOR
156
+ hrv_value = candidate if math.isfinite(candidate) else None
157
+ timestamp_safe = (
158
+ timestamp_raw
159
+ if isinstance(timestamp_raw, int)
160
+ and not isinstance(timestamp_raw, bool)
161
+ and timestamp_raw != FIT_TIMESTAMP_INVALID_RAW_VALUE
162
+ else None
163
+ )
164
+ timestamp_local = _fit_datetime(timestamp_safe, timezone_name)
165
+ date = timestamp_local[:10] if timestamp_local else None
166
+ raw_hold = date is None or (hrv_value is None and not invalid)
167
+
168
+ if invalid:
169
+ confidence = "invalid_raw_value"
170
+ needs_inspection = False
171
+ raw_hold = False
172
+ elif raw_hold:
173
+ confidence = "raw_required_hold"
174
+ needs_inspection = False
175
+ else:
176
+ confidence = "needs_inspection"
177
+ needs_inspection = True
178
+
179
+ records.append(
180
+ {
181
+ "date": date,
182
+ "fit_hrv_value": hrv_value,
183
+ "fit_hrv_unit": "ms",
184
+ "fit_hrv_raw_value": scalar_raw,
185
+ "fit_hrv_invalid_raw_value_flag": invalid,
186
+ "fit_message_name_or_num": "message370",
187
+ "fit_field_name_or_num": "field_1",
188
+ "fit_file_id": file_id,
189
+ "source_path": source_path,
190
+ "extraction_method": "fit_message370_field1_raw_div_128",
191
+ "confidence_status": confidence,
192
+ "needs_inspection_flag": needs_inspection,
193
+ "raw_required_hold_flag": raw_hold,
194
+ "record_timestamp_raw": timestamp_safe,
195
+ "record_timestamp_local": timestamp_local,
196
+ "date_semantics_note": "date derived from message370 field253 timestamp converted to local date",
197
+ "date_semantics_confidence": "medium" if date else "needs_inspection",
198
+ "dedupe_method": "not_deduped_record_level",
199
+ }
200
+ )
201
+
202
+ return {
203
+ "status": "parsed_fit_hrv" if records else "no_fit_hrv_records",
204
+ "fit_file_id": file_id,
205
+ "source_path": source_path,
206
+ "records": records,
207
+ "message370_record_count": message_count,
208
+ "hrv_record_count": len(records),
209
+ "unknown_records": unknown_records,
210
+ }
211
+
212
+
213
+ def dedupe_fit_hrv_daily(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
214
+ """Build daily FIT HRV candidates without filling or averaging conflicts."""
215
+ grouped: dict[str, list[dict[str, Any]]] = {}
216
+ hold_rows: list[dict[str, Any]] = []
217
+ for row in rows:
218
+ date = row.get("date")
219
+ if date is None:
220
+ hold_rows.append(row)
221
+ else:
222
+ grouped.setdefault(str(date), []).append(row)
223
+
224
+ daily: list[dict[str, Any]] = []
225
+ for date, items in sorted(grouped.items()):
226
+ invalid_items = [item for item in items if item["fit_hrv_invalid_raw_value_flag"]]
227
+ valid_items = [item for item in items if not item["fit_hrv_invalid_raw_value_flag"]]
228
+ unique_values = sorted(
229
+ {item["fit_hrv_value"] for item in valid_items if item["fit_hrv_value"] is not None}
230
+ )
231
+ base = dict(valid_items[0] if valid_items else items[0])
232
+ base.update(
233
+ {
234
+ "date": date,
235
+ "record_count_for_date": len(items),
236
+ "source_file_count_for_date": len({item["fit_file_id"] for item in items}),
237
+ "invalid_raw_value_count_for_date": len(invalid_items),
238
+ }
239
+ )
240
+ if len(unique_values) == 1:
241
+ base.update(
242
+ {
243
+ "fit_hrv_value": unique_values[0],
244
+ "fit_hrv_invalid_raw_value_flag": False,
245
+ "confidence_status": "needs_inspection",
246
+ "needs_inspection_flag": True,
247
+ "raw_required_hold_flag": False,
248
+ "dedupe_method": "same_date_valid_value_after_invalid_excluded"
249
+ if invalid_items
250
+ else "same_date_same_value_deduped",
251
+ }
252
+ )
253
+ elif len(unique_values) > 1:
254
+ base.update(
255
+ {
256
+ "fit_hrv_value": None,
257
+ "confidence_status": "needs_inspection",
258
+ "needs_inspection_flag": True,
259
+ "raw_required_hold_flag": False,
260
+ "dedupe_method": "same_date_different_valid_values_not_averaged",
261
+ "valid_values_for_review": unique_values,
262
+ }
263
+ )
264
+ elif invalid_items:
265
+ base.update(
266
+ {
267
+ "fit_hrv_value": None,
268
+ "fit_hrv_invalid_raw_value_flag": True,
269
+ "confidence_status": "invalid_raw_value",
270
+ "needs_inspection_flag": False,
271
+ "raw_required_hold_flag": False,
272
+ "dedupe_method": "only_invalid_raw_values_excluded",
273
+ }
274
+ )
275
+ else:
276
+ base.update(
277
+ {
278
+ "fit_hrv_value": None,
279
+ "confidence_status": "raw_required_hold",
280
+ "needs_inspection_flag": False,
281
+ "raw_required_hold_flag": True,
282
+ "dedupe_method": "no_valid_value_for_date",
283
+ }
284
+ )
285
+ daily.append(base)
286
+
287
+ for row in hold_rows:
288
+ hold = dict(row)
289
+ hold.update(
290
+ {
291
+ "record_count_for_date": 1,
292
+ "source_file_count_for_date": 1,
293
+ "invalid_raw_value_count_for_date": int(
294
+ bool(hold["fit_hrv_invalid_raw_value_flag"])
295
+ ),
296
+ "confidence_status": "raw_required_hold",
297
+ "needs_inspection_flag": False,
298
+ "raw_required_hold_flag": True,
299
+ "dedupe_method": "missing_date_raw_required_hold",
300
+ }
301
+ )
302
+ daily.append(hold)
303
+ return sorted(daily, key=lambda row: (str(row.get("date")), row["fit_file_id"]))
304
+
305
+
306
+ def parse_fit_hrv_export(
307
+ root: str | Path, timezone_name: str = "Asia/Tokyo"
308
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
309
+ records: list[dict[str, Any]] = []
310
+ audit: list[dict[str, Any]] = []
311
+ for asset in (item for item in discover_export(root) if item.kind == "fit"):
312
+ file_id = f"fit_file:{asset.sha256[:24]}"
313
+ parsed = parse_fit_hrv_bytes(
314
+ asset.data,
315
+ file_id=file_id,
316
+ source_path=asset.provenance_path,
317
+ timezone_name=timezone_name,
318
+ )
319
+ audit.append(
320
+ {
321
+ "fit_file_id": file_id,
322
+ "source_path": asset.provenance_path,
323
+ "source_sha256": asset.sha256,
324
+ "parse_status": parsed["status"],
325
+ "message370_record_count": parsed["message370_record_count"],
326
+ "hrv_record_count": parsed["hrv_record_count"],
327
+ "unknown_records": parsed["unknown_records"],
328
+ }
329
+ )
330
+ for record in parsed["records"]:
331
+ record["source_sha256"] = asset.sha256
332
+ records.append(record)
333
+ return dedupe_fit_hrv_daily(records), audit