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.
- package/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- 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)
|