Segments Phase 3: detection algorithm, CLI, ingest hook, and efforts API

- 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
This commit is contained in:
Davide Scaini
2026-05-13 00:50:39 +02:00
parent 61db0734d2
commit 4d2df860ce
6 changed files with 673 additions and 0 deletions
+81
View File
@@ -2484,6 +2484,87 @@ async def delete_segment(
return JSONResponse({"ok": True})
@app.get("/api/segments/{segment_id}/efforts")
async def get_segment_efforts(
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Return all efforts on a segment for the logged-in user."""
user = _require_user(bincio_session)
dd = _get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
efforts = _seg_store.load_efforts(dd, user.handle, segment_id)
return JSONResponse([
{
"activity_id": e.activity_id,
"started_at": _seg_store._iso(e.started_at),
"elapsed_s": e.elapsed_s,
"avg_speed_kmh": e.avg_speed_kmh,
"avg_hr_bpm": e.avg_hr_bpm,
"avg_power_w": e.avg_power_w,
"np_power_w": e.np_power_w,
}
for e in efforts
])
@app.post("/api/segments/{segment_id}/detect")
async def trigger_detect(
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Retroactively detect efforts on a segment for the logged-in user."""
user = _require_user(bincio_session)
dd = _get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
from datetime import datetime as _datetime
from bincio.segments.detect import track_from_timeseries_json, detect_one
import json as _json
user_dir = dd / user.handle
acts_dir = user_dir / "activities"
total = 0
for detail_path in sorted(acts_dir.glob("*.json")):
if ".timeseries." in detail_path.name:
continue
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
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
started_raw = detail.get("started_at")
if not started_raw:
continue
try:
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
except Exception:
continue
track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem),
detail.get("sport", "other"), started_at)
if track is None:
continue
efforts = detect_one(track, seg)
for effort in efforts:
_seg_store.add_effort(dd, user.handle, segment_id, effort)
total += len(efforts)
return JSONResponse({"ok": True, "efforts_found": total})
# ── Feedback ──────────────────────────────────────────────────────────────────
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}