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.
- garmin_running_data_normalizer/__init__.py +6 -0
- garmin_running_data_normalizer/__main__.py +5 -0
- garmin_running_data_normalizer/common/__init__.py +1 -0
- garmin_running_data_normalizer/common/identity.py +21 -0
- garmin_running_data_normalizer/common/time.py +42 -0
- garmin_running_data_normalizer/common/validation.py +19 -0
- garmin_running_data_normalizer/export/__init__.py +5 -0
- garmin_running_data_normalizer/export/analysis_pack.py +102 -0
- garmin_running_data_normalizer/fit/__init__.py +12 -0
- garmin_running_data_normalizer/fit/hrv.py +333 -0
- garmin_running_data_normalizer/fit/parser.py +480 -0
- garmin_running_data_normalizer/intake/__init__.py +5 -0
- garmin_running_data_normalizer/intake/archive.py +99 -0
- garmin_running_data_normalizer/intake/discovery.py +69 -0
- garmin_running_data_normalizer/normalizers/__init__.py +17 -0
- garmin_running_data_normalizer/normalizers/activities.py +72 -0
- garmin_running_data_normalizer/normalizers/gear.py +62 -0
- garmin_running_data_normalizer/normalizers/health_status.py +286 -0
- garmin_running_data_normalizer/normalizers/hrv.py +144 -0
- garmin_running_data_normalizer/normalizers/personal_records.py +57 -0
- garmin_running_data_normalizer/normalizers/sleep.py +192 -0
- garmin_running_data_normalizer/output_experience.py +1262 -0
- garmin_running_data_normalizer/policies/__init__.py +5 -0
- garmin_running_data_normalizer/policies/datasets.py +123 -0
- garmin_running_data_normalizer/qa.py +36 -0
- garmin_running_data_normalizer/relationships.py +649 -0
- garmin_running_data_normalizer/run_all.py +802 -0
- garmin_running_data_normalizer/runner.py +244 -0
- garmin_running_data_normalizer/standalone.py +175 -0
- garmin_running_data_normalizer-1.1.1.dist-info/METADATA +265 -0
- garmin_running_data_normalizer-1.1.1.dist-info/RECORD +35 -0
- garmin_running_data_normalizer-1.1.1.dist-info/WHEEL +5 -0
- garmin_running_data_normalizer-1.1.1.dist-info/entry_points.txt +2 -0
- garmin_running_data_normalizer-1.1.1.dist-info/licenses/LICENSE +202 -0
- garmin_running_data_normalizer-1.1.1.dist-info/top_level.txt +1 -0
|
@@ -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,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
|