4d2df860ce
- detect.py: ActivityTrack + detect_one/detect_all (bbox pre-filter →
start/end proximity 25m → path conformance 50m/30% → effort extraction
with avg speed/HR/power and Coggan NP)
- cli.py: `bincio segments detect` for retroactive detection over stored
timeseries JSONs, with optional --activity-id / --segment-id filters
- ingest.py: non-fatal hook at end of ingest_parsed runs detect_all
- server.py: GET /api/segments/{id}/efforts and POST /api/segments/{id}/detect
115 lines
3.7 KiB
Python
115 lines
3.7 KiB
Python
"""bincio segments — segment management CLI commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
|
|
def _dt(s: str) -> datetime:
|
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
|
|
|
|
@click.group("segments")
|
|
def segments_group() -> None:
|
|
"""Manage segments and detect efforts."""
|
|
|
|
|
|
@segments_group.command("detect")
|
|
@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory (e.g. /var/bincio)")
|
|
@click.option("--handle", required=True, help="User handle to run detection for")
|
|
@click.option("--activity-id", default=None, help="Limit to a single activity ID (optional)")
|
|
@click.option("--segment-id", default=None, help="Limit to a single segment ID (optional)")
|
|
def detect_cmd(data_dir: str, handle: str, activity_id: str | None, segment_id: str | None) -> None:
|
|
"""Retroactively detect segment efforts for stored activities.
|
|
|
|
Walks every activity with GPS data, runs the detection algorithm against
|
|
all (or a single) segment, and persists any new efforts found.
|
|
"""
|
|
from bincio.segments.detect import track_from_timeseries_json, detect_one, detect_all
|
|
from bincio.segments import store as _store
|
|
|
|
dd = Path(data_dir).expanduser().resolve()
|
|
user_dir = dd / handle
|
|
acts_dir = user_dir / "activities"
|
|
|
|
if not acts_dir.exists():
|
|
click.echo(f"No activities directory at {acts_dir}", err=True)
|
|
sys.exit(1)
|
|
|
|
# Choose which segments to check.
|
|
if segment_id:
|
|
seg = _store.load_segment(dd, segment_id)
|
|
if seg is None:
|
|
click.echo(f"Segment not found: {segment_id}", err=True)
|
|
sys.exit(1)
|
|
segments = [seg]
|
|
else:
|
|
segments = _store.list_segments(dd)
|
|
|
|
if not segments:
|
|
click.echo("No segments defined.", err=True)
|
|
sys.exit(0)
|
|
|
|
# Choose which activities to process.
|
|
if activity_id:
|
|
detail_files = [acts_dir / f"{activity_id}.json"]
|
|
else:
|
|
detail_files = sorted(acts_dir.glob("*.json"))
|
|
# Exclude timeseries files.
|
|
detail_files = [f for f in detail_files if ".timeseries." not in f.name]
|
|
|
|
total_efforts = 0
|
|
processed = 0
|
|
|
|
for detail_path in detail_files:
|
|
try:
|
|
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
ts_url = detail.get("timeseries_url")
|
|
if not ts_url:
|
|
continue
|
|
|
|
act_id = detail.get("id", detail_path.stem)
|
|
sport = detail.get("sport", "other")
|
|
started = detail.get("started_at")
|
|
if not started:
|
|
continue
|
|
try:
|
|
started_at = _dt(started)
|
|
except Exception:
|
|
continue
|
|
|
|
ts_path = user_dir / ts_url
|
|
if not ts_path.exists():
|
|
continue
|
|
try:
|
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
track = track_from_timeseries_json(ts, act_id, sport, started_at)
|
|
if track is None:
|
|
continue
|
|
|
|
processed += 1
|
|
for seg in segments:
|
|
from bincio.segments.detect import detect_one
|
|
efforts = detect_one(track, seg)
|
|
for effort in efforts:
|
|
_store.add_effort(dd, handle, seg.id, effort)
|
|
if efforts:
|
|
click.echo(
|
|
f" {act_id}: {len(efforts)} effort(s) on '{seg.name}' "
|
|
f"({', '.join(str(e.elapsed_s) + 's' for e in efforts)})"
|
|
)
|
|
total_efforts += len(efforts)
|
|
|
|
click.echo(f"\nProcessed {processed} activities, found {total_efforts} effort(s).")
|