catence 0.0.0 → 0.1.0

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 (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ from .staging import StagingWriter
11
+ from .worker import GarminStagingWorker
12
+
13
+
14
+ def parse_args() -> argparse.Namespace:
15
+ parser = argparse.ArgumentParser(description="Stage strictly read-only Garmin data for Catence.")
16
+ parser.add_argument("--from", dest="from_date", required=True, type=date.fromisoformat)
17
+ parser.add_argument("--daily-from", dest="daily_from_date", type=date.fromisoformat)
18
+ parser.add_argument("--daily-to", dest="daily_to_date", type=date.fromisoformat)
19
+ parser.add_argument("--activity-from", dest="activity_from_date", type=date.fromisoformat)
20
+ parser.add_argument("--activity-to", dest="activity_to_date", type=date.fromisoformat)
21
+ parser.add_argument("--data-dir", required=True, type=Path)
22
+ parser.add_argument("--output", required=True, type=Path)
23
+ parser.add_argument("--run-id", required=True)
24
+ parser.add_argument("--to", dest="to_date", type=date.fromisoformat)
25
+ parser.add_argument("--known-activities", type=Path)
26
+ parser.add_argument("--historical-only", action="store_true", help="Skip current account and collection endpoints.")
27
+ parser.add_argument("--skip-daily", action="store_true", help="Do not fetch date-scoped daily data.")
28
+ parser.add_argument("--skip-activities", action="store_true", help="Do not fetch date-scoped activities.")
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ from garminconnect import Garmin
35
+
36
+ email = os.environ.get("GARMIN_EMAIL")
37
+ if not email:
38
+ raise SystemExit("GARMIN_EMAIL is required.")
39
+ password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
40
+ writer = StagingWriter(args.data_dir, args.output)
41
+ writer.manifest(args.run_id, args.from_date.isoformat())
42
+ token_store = args.data_dir / "auth" / "garmin_tokens.json"
43
+ token_store.parent.mkdir(parents=True, exist_ok=True)
44
+ client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
45
+ client.login(str(token_store))
46
+ known_hashes: dict[str, str] = {}
47
+ if args.known_activities and args.known_activities.exists():
48
+ loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
49
+ if isinstance(loaded, dict):
50
+ known_hashes = {str(key): str(value) for key, value in loaded.items()}
51
+ GarminStagingWorker(client, writer, args.data_dir, known_hashes).sync(
52
+ daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
53
+ activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
54
+ to_date=args.to_date,
55
+ daily_to_date=args.daily_to_date,
56
+ activity_to_date=args.activity_to_date,
57
+ include_non_historical=not args.historical_only,
58
+ )
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from . import STAGING_SCHEMA_VERSION
8
+
9
+
10
+ class StagingRecord(BaseModel):
11
+ """Shared JSONL envelope; Node performs the final discriminated validation."""
12
+
13
+ model_config = ConfigDict(extra="allow")
14
+ kind: Literal["run_manifest", "raw_object", "source_entity", "stream_manifest", "activity_sync_state", "extraction_error"]
15
+ schemaVersion: Literal[STAGING_SCHEMA_VERSION] = STAGING_SCHEMA_VERSION
16
+ provider: Literal["garmin"] = "garmin"
17
+ payload: dict[str, Any] | None = None
18
+
19
+
20
+ def validate_record(record: dict[str, Any]) -> dict[str, Any]:
21
+ return StagingRecord.model_validate(record).model_dump(exclude_none=False) | {
22
+ key: value for key, value in record.items() if key not in {"kind", "schemaVersion", "provider", "payload"}
23
+ }
@@ -0,0 +1,48 @@
1
+ """Explicit allowlist for Garmin reads; mutation methods are deliberately absent."""
2
+
3
+ GARMIN_READ_METHODS = {
4
+ "profile": ("get_user_profile", "get_userprofile_settings"),
5
+ "devices": ("get_devices", "get_device_last_used", "get_device_alarms"),
6
+ "health_daily": (
7
+ "get_stats", "get_user_summary", "get_steps_data", "get_heart_rates",
8
+ "get_sleep_data", "get_stress_data", "get_all_day_stress", "get_body_battery_events",
9
+ "get_respiration_data", "get_spo2_data", "get_hrv_data", "get_training_readiness",
10
+ "get_morning_training_readiness", "get_training_status", "get_fitnessage_data",
11
+ "get_floors", "get_intensity_minutes_data", "get_all_day_events", "get_endurance_score",
12
+ "get_max_metrics", "get_hydration_data", "get_menstrual_data_for_date",
13
+ "get_nutrition_daily_food_log", "get_nutrition_daily_meals", "get_nutrition_daily_settings",
14
+ ),
15
+ "health_range": (
16
+ "get_daily_steps", "get_body_battery", "get_blood_pressure", "get_weigh_ins",
17
+ "get_progress_summary_between_dates", "get_menstrual_calendar_data",
18
+ ),
19
+ "training_singletons": (
20
+ "get_lactate_threshold", "get_heart_rate_zones", "get_power_zones", "get_cycling_ftp",
21
+ "get_personal_record", "get_earned_badges", "get_available_badges", "get_in_progress_badges",
22
+ "get_training_plans", "get_pregnancy_summary", "get_golf_user_stats", "get_golf_club_stats",
23
+ ),
24
+ "training_range": (
25
+ "get_functional_threshold_power_range", "get_max_metrics_range", "get_hrv_data_range",
26
+ "get_hill_score", "get_running_tolerance", "get_race_predictions",
27
+ ),
28
+ "activities": (
29
+ "get_activities_by_date", "get_activity", "get_activity_details", "get_activity_splits",
30
+ "get_activity_typed_splits", "get_activity_split_summaries", "get_activity_weather",
31
+ "get_activity_hr_in_timezones", "get_activity_power_in_timezones", "get_activity_exercise_sets",
32
+ "get_activity_gear", "download_activity",
33
+ ),
34
+ "workouts": ("get_workouts", "get_scheduled_workouts"),
35
+ "goals": ("get_goals",),
36
+ "gear": ("get_gear",),
37
+ "golf": ("get_golf_summary",),
38
+ }
39
+
40
+ MUTATION_PREFIXES = ("add_", "create_", "delete_", "import_", "set_", "update_", "upload_", "push_", "request_")
41
+
42
+
43
+ def assert_read_only_registry() -> None:
44
+ """Fail closed if a future registry edit accidentally exposes a mutation."""
45
+ for methods in GARMIN_READ_METHODS.values():
46
+ for method in methods:
47
+ if method.startswith(MUTATION_PREFIXES):
48
+ raise RuntimeError(f"Unsafe Garmin method in read registry: {method}")
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import STAGING_SCHEMA_VERSION
12
+ from .contracts import validate_record
13
+
14
+
15
+ def utc_now() -> str:
16
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
17
+
18
+
19
+ def safe_segment(value: str) -> str:
20
+ return "".join(char if char.isalnum() or char in "._-" else "_" for char in value)[:160] or "unknown"
21
+
22
+
23
+ @dataclass
24
+ class StagingWriter:
25
+ data_dir: Path
26
+ output: Path
27
+
28
+ def __post_init__(self) -> None:
29
+ self.output.parent.mkdir(parents=True, exist_ok=True)
30
+ self.output.touch(exist_ok=True)
31
+
32
+ def emit(self, record: dict[str, Any]) -> None:
33
+ record["schemaVersion"] = STAGING_SCHEMA_VERSION
34
+ with self.output.open("a", encoding="utf-8") as file:
35
+ file.write(json.dumps(validate_record(record), separators=(",", ":"), default=str))
36
+ file.write("\n")
37
+
38
+ def manifest(self, run_id: str, from_date: str) -> None:
39
+ self.emit({
40
+ "kind": "run_manifest", "provider": "garmin", "runId": run_id,
41
+ "fromDate": from_date, "createdAt": utc_now(),
42
+ })
43
+
44
+ def archive_json(self, endpoint: str, remote_id: str | None, payload: Any, scope: dict[str, Any] | None = None) -> str:
45
+ contents = (json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n").encode()
46
+ return self._archive(endpoint, remote_id, contents, "json", "application/json", scope)
47
+
48
+ def archive_bytes(self, endpoint: str, remote_id: str | None, contents: bytes, extension: str = "bin", scope: dict[str, Any] | None = None) -> str:
49
+ return self._archive(endpoint, remote_id, contents, extension, "application/octet-stream", scope)
50
+
51
+ def _archive(self, endpoint: str, remote_id: str | None, contents: bytes, extension: str, content_type: str, scope: dict[str, Any] | None) -> str:
52
+ digest = hashlib.sha256(contents).hexdigest()
53
+ relative = Path("raw") / "garmin" / safe_segment(endpoint) / safe_segment(remote_id or "collection") / f"{digest}.{extension}"
54
+ destination = self.data_dir / relative
55
+ destination.parent.mkdir(parents=True, exist_ok=True)
56
+ if not destination.exists():
57
+ temporary = destination.with_suffix(f".{extension}.tmp")
58
+ temporary.write_bytes(contents)
59
+ os.replace(temporary, destination)
60
+ self.emit({
61
+ "kind": "raw_object", "provider": "garmin", "endpoint": endpoint, "remoteId": remote_id,
62
+ "fetchedAt": utc_now(), "contentHash": digest, "contentType": content_type,
63
+ "relativePath": str(relative), "scope": scope or {},
64
+ })
65
+ return digest
66
+
67
+ def source_entity(
68
+ self, entity_type: str, remote_id: str, payload: dict[str, Any], raw_hash: str | None,
69
+ parent_remote_id: str | None = None, occurred_on: str | None = None,
70
+ ) -> None:
71
+ self.emit({
72
+ "kind": "source_entity", "provider": "garmin", "entityType": entity_type,
73
+ "remoteId": str(remote_id), "parentRemoteId": parent_remote_id, "occurredOn": occurred_on,
74
+ "sourceUpdatedAt": None, "rawObjectHash": raw_hash, "payload": payload, "extension": {},
75
+ })
76
+
77
+ def activity_sync_state(self, activity_id: str, summary_hash: str, details_fetched: bool) -> None:
78
+ self.emit({
79
+ "kind": "activity_sync_state", "provider": "garmin", "activityRemoteId": activity_id,
80
+ "summaryHash": summary_hash, "detailsFetched": details_fetched,
81
+ })
82
+
83
+ def error(self, endpoint: str, message: str, remote_id: str | None = None, retryable: bool = True) -> None:
84
+ self.emit({
85
+ "kind": "extraction_error", "provider": "garmin", "endpoint": endpoint,
86
+ "remoteId": remote_id, "message": message, "retryable": retryable,
87
+ })
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import zipfile
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+
10
+ import pyarrow as pa
11
+ import pyarrow.parquet as pq
12
+
13
+
14
+ SAMPLE_COLUMNS = [
15
+ "activity_source_id", "timestamp_utc", "elapsed_s", "distance_m", "latitude", "longitude",
16
+ "altitude_m", "heart_rate_bpm", "power_w", "cadence_rpm", "speed_mps", "temperature_c", "grade_pct",
17
+ "left_right_balance_pct", "left_torque_effectiveness_pct", "right_torque_effectiveness_pct",
18
+ "left_pedal_smoothness_pct", "right_pedal_smoothness_pct",
19
+ "left_power_phase_start_deg", "left_power_phase_end_deg", "right_power_phase_start_deg", "right_power_phase_end_deg",
20
+ "left_power_phase_peak_start_deg", "left_power_phase_peak_end_deg", "right_power_phase_peak_start_deg", "right_power_phase_peak_end_deg",
21
+ "left_platform_center_offset_mm", "right_platform_center_offset_mm", "extras_json",
22
+ ]
23
+
24
+ POWER_BEST_DURATIONS = (1, 5, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200)
25
+
26
+ FIELD_MAP = {
27
+ "directTimestamp": "timestamp_utc", "timestamp": "timestamp_utc", "sumElapsedDuration": "elapsed_s", "elapsedDuration": "elapsed_s",
28
+ "sumDistance": "distance_m", "distance": "distance_m", "latitude": "latitude", "directLatitude": "latitude", "longitude": "longitude", "directLongitude": "longitude",
29
+ "altitude": "altitude_m", "directAltitude": "altitude_m", "directUncorrectedElevation": "altitude_m", "directElevation": "altitude_m",
30
+ "directHeartRate": "heart_rate_bpm", "heartRate": "heart_rate_bpm",
31
+ "directPower": "power_w", "power": "power_w", "directCadence": "cadence_rpm", "directBikeCadence": "cadence_rpm", "cadence": "cadence_rpm",
32
+ "directSpeed": "speed_mps", "speed": "speed_mps", "directTemperature": "temperature_c", "directAirTemperature": "temperature_c", "temperature": "temperature_c",
33
+ "grade": "grade_pct",
34
+ "leftRightBalance": "left_right_balance_pct", "left_right_balance": "left_right_balance_pct",
35
+ "leftTorqueEffectiveness": "left_torque_effectiveness_pct", "left_torque_effectiveness": "left_torque_effectiveness_pct",
36
+ "rightTorqueEffectiveness": "right_torque_effectiveness_pct", "right_torque_effectiveness": "right_torque_effectiveness_pct",
37
+ "leftPedalSmoothness": "left_pedal_smoothness_pct", "left_pedal_smoothness": "left_pedal_smoothness_pct",
38
+ "rightPedalSmoothness": "right_pedal_smoothness_pct", "right_pedal_smoothness": "right_pedal_smoothness_pct",
39
+ "leftPlatformCenterOffset": "left_platform_center_offset_mm", "left_pco": "left_platform_center_offset_mm",
40
+ "rightPlatformCenterOffset": "right_platform_center_offset_mm", "right_pco": "right_platform_center_offset_mm",
41
+ }
42
+
43
+ PAIR_FIELD_MAP = {
44
+ "leftPowerPhase": ("left_power_phase_start_deg", "left_power_phase_end_deg"),
45
+ "left_power_phase": ("left_power_phase_start_deg", "left_power_phase_end_deg"),
46
+ "rightPowerPhase": ("right_power_phase_start_deg", "right_power_phase_end_deg"),
47
+ "right_power_phase": ("right_power_phase_start_deg", "right_power_phase_end_deg"),
48
+ "leftPowerPhasePeak": ("left_power_phase_peak_start_deg", "left_power_phase_peak_end_deg"),
49
+ "left_power_phase_peak": ("left_power_phase_peak_start_deg", "left_power_phase_peak_end_deg"),
50
+ "rightPowerPhasePeak": ("right_power_phase_peak_start_deg", "right_power_phase_peak_end_deg"),
51
+ "right_power_phase_peak": ("right_power_phase_peak_start_deg", "right_power_phase_peak_end_deg"),
52
+ }
53
+
54
+
55
+ def _number(value: Any) -> float | None:
56
+ return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
57
+
58
+
59
+ def _timestamp(value: Any) -> str | None:
60
+ if not isinstance(value, (int, float)):
61
+ return None
62
+ seconds = float(value)
63
+ if seconds > 10_000_000_000:
64
+ seconds /= 1000
65
+ return datetime.fromtimestamp(seconds, timezone.utc).isoformat().replace("+00:00", "Z")
66
+
67
+
68
+ def activity_power_bests(samples: Iterable[dict[str, Any]]) -> dict[int, float]:
69
+ """Return rolling power bests without bridging invalid or long sample gaps."""
70
+ points: list[tuple[float, float]] = []
71
+ for sample in samples:
72
+ raw_time, raw_power = sample.get("timestamp_utc"), _number(sample.get("power_w"))
73
+ if not isinstance(raw_time, str) or raw_power is None:
74
+ continue
75
+ try:
76
+ points.append((datetime.fromisoformat(raw_time.replace("Z", "+00:00")).timestamp(), raw_power))
77
+ except ValueError:
78
+ continue
79
+ points.sort()
80
+ bests: dict[int, float] = {}
81
+ segment: list[tuple[float, float]] = []
82
+
83
+ def evaluate(rows: list[tuple[float, float]]) -> None:
84
+ for duration in POWER_BEST_DURATIONS:
85
+ start = 0
86
+ total = 0.0
87
+ for end, (current_time, current_power) in enumerate(rows):
88
+ total += current_power
89
+ while start < end and current_time - rows[start + 1][0] >= duration:
90
+ total -= rows[start][1]
91
+ start += 1
92
+ if current_time - rows[start][0] >= duration and end >= start:
93
+ mean = total / (end - start + 1)
94
+ bests[duration] = max(bests.get(duration, float("-inf")), mean)
95
+
96
+ for point in points:
97
+ if segment and point[0] - segment[-1][0] > 5:
98
+ evaluate(segment)
99
+ segment = []
100
+ segment.append(point)
101
+ evaluate(segment)
102
+ return bests
103
+
104
+
105
+ def _sample_value(sample: dict[str, Any], extras: dict[str, Any], source_name: str, value: Any) -> None:
106
+ """Map known cycling-dynamics fields without discarding unfamiliar sensors."""
107
+ target = FIELD_MAP.get(source_name)
108
+ if target:
109
+ sample[target] = _number(value)
110
+ extras[source_name] = value
111
+ return
112
+ targets = PAIR_FIELD_MAP.get(source_name)
113
+ if targets and isinstance(value, (list, tuple)) and len(value) >= 2:
114
+ sample[targets[0]], sample[targets[1]] = _number(value[0]), _number(value[1])
115
+ extras[source_name] = value
116
+ return
117
+ extras[source_name] = value
118
+
119
+
120
+ def activity_details_to_samples(activity_source_id: str, details: dict[str, Any]) -> list[dict[str, Any]]:
121
+ """Convert Garmin positional detail metrics into a stable, nullable sample schema."""
122
+ descriptors = details.get("metricDescriptors") or []
123
+ descriptor_names = {
124
+ descriptor.get("metricsIndex"): descriptor.get("key")
125
+ for descriptor in descriptors if isinstance(descriptor, dict) and isinstance(descriptor.get("metricsIndex"), int)
126
+ }
127
+ rows = details.get("activityDetailMetrics") or details.get("metrics") or []
128
+ samples: list[dict[str, Any]] = []
129
+ for row in rows:
130
+ values = row.get("metrics") if isinstance(row, dict) else row
131
+ if not isinstance(values, list):
132
+ continue
133
+ sample: dict[str, Any] = {column: None for column in SAMPLE_COLUMNS}
134
+ sample["activity_source_id"] = activity_source_id
135
+ extras: dict[str, Any] = {}
136
+ for index, value in enumerate(values):
137
+ source_name = descriptor_names.get(index, str(index))
138
+ target = FIELD_MAP.get(source_name)
139
+ if target == "timestamp_utc":
140
+ sample[target] = _timestamp(value)
141
+ else:
142
+ _sample_value(sample, extras, source_name, value)
143
+ sample["extras_json"] = json.dumps(extras, separators=(",", ":"), default=str)
144
+ samples.append(sample)
145
+ return samples
146
+
147
+
148
+ def fit_archive_to_samples(activity_source_id: str, archive: Path) -> list[dict[str, Any]]:
149
+ """Best-effort FIT parsing; malformed/unavailable archives simply yield no samples."""
150
+ try:
151
+ import fitdecode
152
+ with zipfile.ZipFile(archive) as zip_file:
153
+ fit_name = next((name for name in zip_file.namelist() if name.lower().endswith(".fit")), None)
154
+ if not fit_name:
155
+ return []
156
+ with zip_file.open(fit_name) as stream, fitdecode.FitReader(stream) as reader:
157
+ rows: list[dict[str, Any]] = []
158
+ for frame in reader:
159
+ if not isinstance(frame, fitdecode.records.FitDataMessage) or frame.name != "record":
160
+ continue
161
+ fields = {field.name: field.value for field in frame.fields}
162
+ sample = {column: None for column in SAMPLE_COLUMNS}
163
+ sample["activity_source_id"] = activity_source_id
164
+ sample["timestamp_utc"] = fields.get("timestamp").isoformat().replace("+00:00", "Z") if fields.get("timestamp") else None
165
+ sample["distance_m"] = _number(fields.get("distance"))
166
+ sample["altitude_m"] = _number(fields.get("enhanced_altitude") or fields.get("altitude"))
167
+ sample["heart_rate_bpm"] = _number(fields.get("heart_rate"))
168
+ sample["power_w"] = _number(fields.get("power"))
169
+ sample["cadence_rpm"] = _number(fields.get("cadence"))
170
+ sample["speed_mps"] = _number(fields.get("enhanced_speed") or fields.get("speed"))
171
+ sample["temperature_c"] = _number(fields.get("temperature"))
172
+ extras: dict[str, Any] = {}
173
+ for name, value in fields.items():
174
+ if name in {"timestamp", "distance", "enhanced_altitude", "altitude", "heart_rate", "power", "cadence", "enhanced_speed", "speed", "temperature"}:
175
+ continue
176
+ _sample_value(sample, extras, name, value)
177
+ for name, value in extras.items():
178
+ fields[name] = value
179
+ sample["extras_json"] = json.dumps(fields, separators=(",", ":"), default=str)
180
+ rows.append(sample)
181
+ return rows
182
+ except (ImportError, OSError, ValueError, zipfile.BadZipFile):
183
+ return []
184
+
185
+
186
+ def write_parquet(data_dir: Path, activity_id: str, start_date: str, samples: Iterable[dict[str, Any]]) -> tuple[Path, str, int]:
187
+ records = list(samples)
188
+ year, month = (start_date[:4] or "unknown"), (start_date[5:7] or "unknown")
189
+ destination = data_dir / "lake" / "activity_samples" / "provider=garmin" / f"year={year}" / f"month={month}" / f"activity={activity_id}.parquet"
190
+ destination.parent.mkdir(parents=True, exist_ok=True)
191
+ table = pa.Table.from_pylist(records, schema=pa.schema([(column, pa.string() if column in {"activity_source_id", "timestamp_utc", "extras_json"} else pa.float64()) for column in SAMPLE_COLUMNS]))
192
+ temporary = destination.with_suffix(".tmp")
193
+ pq.write_table(table, temporary, compression="zstd")
194
+ temporary.replace(destination)
195
+ return destination, hashlib.sha256(destination.read_bytes()).hexdigest(), len(records)