84eff1f3b0
Extract _haversine_m from the inline block in _gps_speed_kmh, add _spatial_downsample (keep one sample per 10 m traveled, GPS haversine primary / speed×Δt fallback, indoor activities unchanged), and wire it into build_timeseries() after the 1 s dedup loop. Add --downsample-timeseries migration flag to bincio render that applies the same downsampling to existing stored timeseries files without re-extracting from original FIT/GPX files.
751 lines
29 KiB
Python
751 lines
29 KiB
Python
"""bincio render — build or serve the Astro static site."""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import click
|
|
from rich.console import Console
|
|
|
|
console = Console()
|
|
|
|
|
|
def _find_site_dir(explicit: Optional[str]) -> Path:
|
|
"""Locate the Astro project directory."""
|
|
if explicit:
|
|
p = Path(explicit).expanduser().resolve()
|
|
if not (p / "package.json").exists():
|
|
raise click.UsageError(f"No package.json found in --site-dir {p}")
|
|
return p
|
|
|
|
# Search upward from cwd: ./site, ../site (for when cwd is bincio_data/)
|
|
for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]:
|
|
if (candidate / "package.json").exists():
|
|
return candidate
|
|
|
|
raise click.UsageError(
|
|
"Could not find the Astro site directory. "
|
|
"Run from the project root or pass --site-dir."
|
|
)
|
|
|
|
|
|
def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
|
|
"""Resolve the BAS data directory."""
|
|
if explicit:
|
|
return Path(explicit).expanduser().resolve()
|
|
|
|
if config_path and Path(config_path).exists():
|
|
import yaml
|
|
raw = yaml.safe_load(Path(config_path).read_text()) or {}
|
|
out = raw.get("output", {}).get("dir")
|
|
if out:
|
|
return Path(out).expanduser().resolve()
|
|
|
|
# Auto-detect: try extract_config.yaml in cwd
|
|
auto_config = Path.cwd() / "extract_config.yaml"
|
|
if auto_config.exists():
|
|
import yaml
|
|
raw = yaml.safe_load(auto_config.read_text()) or {}
|
|
out = raw.get("output", {}).get("dir")
|
|
if out:
|
|
return Path(out).expanduser().resolve()
|
|
|
|
# Default: ./bincio_data next to cwd
|
|
default = Path.cwd() / "bincio_data"
|
|
if default.exists():
|
|
return default
|
|
|
|
raise click.UsageError(
|
|
"Could not find the BAS data directory. "
|
|
"Run `bincio extract` first, or pass --data-dir."
|
|
)
|
|
|
|
|
|
def _ensure_npm(site: Path) -> None:
|
|
"""Run `npm install` if node_modules is missing or stale."""
|
|
if not (site / "node_modules").exists():
|
|
console.print("Running [cyan]npm install[/cyan]…")
|
|
subprocess.run(["npm", "install"], cwd=site, check=True)
|
|
|
|
|
|
def _user_dirs(data: Path) -> list[Path]:
|
|
"""Return all per-user subdirectories (contain an activities/ dir)."""
|
|
return sorted(
|
|
p for p in data.iterdir()
|
|
if p.is_dir() and (p / "activities").exists()
|
|
)
|
|
|
|
|
|
def _merge_edits(data: Path, handle: str | None = None) -> None:
|
|
"""Run the sidecar merge step for one user or all users."""
|
|
from bincio.render.merge import merge_all
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
total = 0
|
|
for user_dir in targets:
|
|
n = merge_all(user_dir)
|
|
total += n
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} sidecar(s) merged")
|
|
if not total:
|
|
console.print("No sidecars found — _merged/ dirs mirror extracted data.")
|
|
|
|
|
|
def _bake_tracks(data: Path, handle: str | None = None) -> None:
|
|
"""Bake tracks.json for one user or all users."""
|
|
from bincio.explore import bake_tracks
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
try:
|
|
n = bake_tracks(user_dir.name, data)
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
|
|
except Exception as exc:
|
|
console.print(f" [yellow]{user_dir.name}[/yellow]: bake_tracks failed: {exc}")
|
|
|
|
|
|
def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None:
|
|
"""Rebuild athlete.json for one user or all users.
|
|
|
|
Reads raw index.json summaries, applies any sidecar edits in-memory (so
|
|
overrides like sub_sport: indoor are respected), then calls write_athlete_json.
|
|
"""
|
|
import json
|
|
from bincio.extract.writer import write_athlete_json
|
|
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
|
|
for user_dir in targets:
|
|
index_path = user_dir / "index.json"
|
|
if not index_path.exists():
|
|
continue
|
|
try:
|
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
summaries = index_data.get("activities", [])
|
|
if not summaries:
|
|
continue
|
|
|
|
# Apply sidecar edits so overrides (e.g. sub_sport: indoor) are visible
|
|
# to write_athlete_json without stripping best_efforts/best_climb_m.
|
|
edits_dir = user_dir / "edits"
|
|
if edits_dir.exists():
|
|
sidecars: dict[str, dict] = {}
|
|
for sc_path in edits_dir.glob("*.md"):
|
|
try:
|
|
fm, _ = parse_sidecar(sc_path)
|
|
sidecars[sc_path.stem] = fm
|
|
except Exception:
|
|
pass
|
|
if sidecars:
|
|
summaries = [
|
|
_apply_sidecar_summary(s, sidecars[s["id"]])
|
|
if s.get("id") in sidecars else s
|
|
for s in summaries
|
|
]
|
|
|
|
athlete_config: dict = {}
|
|
athlete_path = user_dir / "athlete.json"
|
|
if athlete_path.exists():
|
|
try:
|
|
existing = json.loads(athlete_path.read_text(encoding="utf-8"))
|
|
athlete_config = {k: v for k, v in existing.items() if k not in _COMPUTED}
|
|
except Exception:
|
|
pass
|
|
write_athlete_json(summaries, user_dir, athlete_config)
|
|
except Exception as exc:
|
|
console.print(f" [yellow]{user_dir.name}[/yellow]: rebuild_athlete failed: {exc}")
|
|
|
|
|
|
def _recompute_best_climbs(data: Path, handle: str | None = None) -> None:
|
|
"""Recompute best_climb_m for all cycling activities from their stored timeseries.
|
|
|
|
Updates activities/*.json and index.json in-place. Run this once after
|
|
upgrading the climb algorithm to fix values computed by the old code.
|
|
"""
|
|
import json
|
|
from bincio.extract.metrics import _best_climb
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
acts_dir = user_dir / "activities"
|
|
index_path = user_dir / "index.json"
|
|
if not acts_dir.exists() or not index_path.exists():
|
|
continue
|
|
try:
|
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
updated = 0
|
|
for act_path in acts_dir.glob("*.json"):
|
|
if act_path.stem.endswith((".timeseries", ".geojson")):
|
|
continue
|
|
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
|
if not ts_path.exists():
|
|
continue
|
|
try:
|
|
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
|
if detail.get("sport") != "cycling":
|
|
continue
|
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
|
t_vals = ts.get("t", [])
|
|
e_vals = ts.get("elevation_m", [])
|
|
pairs = sorted(
|
|
(t, e) for t, e in zip(t_vals, e_vals) if e is not None
|
|
)
|
|
if len(pairs) < 2:
|
|
continue
|
|
new_val = _best_climb(pairs)
|
|
if new_val == detail.get("best_climb_m"):
|
|
continue
|
|
detail["best_climb_m"] = new_val
|
|
act_path.write_text(
|
|
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
act_id = act_path.stem
|
|
for s in index_data.get("activities", []):
|
|
if s.get("id") == act_id:
|
|
s["best_climb_m"] = new_val
|
|
break
|
|
updated += 1
|
|
except Exception:
|
|
pass
|
|
|
|
if updated:
|
|
index_path.write_text(
|
|
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} climb(s) recomputed")
|
|
|
|
|
|
def _recompute_elevation(data: Path, handle: str | None = None) -> None:
|
|
"""Recompute elevation_gain_m / elevation_loss_m for all activities.
|
|
|
|
Applies the dropout-skip fix (near-zero values mid-recording) so stored
|
|
values computed by older code are corrected. Updates activities/*.json
|
|
and index.json in-place.
|
|
"""
|
|
import json
|
|
from bincio.extract.metrics import _ELEVATION_THRESHOLD
|
|
|
|
def _accumulate(elevations: list[float], altitude_source: str) -> tuple[float, float]:
|
|
if len(elevations) < 2:
|
|
return 0.0, 0.0
|
|
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
|
# Skip leading near-zeros (device acquiring lock)
|
|
start = 0
|
|
if abs(elevations[0]) < 0.5:
|
|
n_leading = sum(1 for e in elevations if abs(e) < 0.5)
|
|
if n_leading > 1:
|
|
for i, e in enumerate(elevations):
|
|
if abs(e) > threshold:
|
|
start = i
|
|
break
|
|
gain = loss = 0.0
|
|
committed = elevations[start]
|
|
for e in elevations[start + 1:]:
|
|
if abs(e) < 1.0 and abs(committed) > threshold:
|
|
continue
|
|
diff = e - committed
|
|
if abs(diff) >= threshold:
|
|
if diff > 0:
|
|
gain += diff
|
|
else:
|
|
loss += diff
|
|
committed = e
|
|
return gain, loss
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
acts_dir = user_dir / "activities"
|
|
index_path = user_dir / "index.json"
|
|
if not acts_dir.exists() or not index_path.exists():
|
|
continue
|
|
try:
|
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
updated = 0
|
|
for act_path in acts_dir.glob("*.json"):
|
|
if act_path.stem.endswith((".timeseries", ".geojson")):
|
|
continue
|
|
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
|
if not ts_path.exists():
|
|
continue
|
|
try:
|
|
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
|
raw = ts.get("elevation_m", [])
|
|
elevations = [e for e in raw if e is not None]
|
|
if len(elevations) < 2:
|
|
continue
|
|
alt_src = detail.get("altitude_source", "unknown")
|
|
new_gain, new_loss = _accumulate(elevations, alt_src)
|
|
new_gain_r = round(new_gain, 1) if new_gain else None
|
|
new_loss_r = round(abs(new_loss), 1) if new_loss else None
|
|
if (new_gain_r == detail.get("elevation_gain_m") and
|
|
new_loss_r == detail.get("elevation_loss_m")):
|
|
continue
|
|
detail["elevation_gain_m"] = new_gain_r
|
|
detail["elevation_loss_m"] = new_loss_r
|
|
act_path.write_text(
|
|
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
act_id = act_path.stem
|
|
for s in index_data.get("activities", []):
|
|
if s.get("id") == act_id:
|
|
s["elevation_gain_m"] = new_gain_r
|
|
s["elevation_loss_m"] = new_loss_r
|
|
break
|
|
updated += 1
|
|
except Exception:
|
|
pass
|
|
|
|
if updated:
|
|
index_path.write_text(
|
|
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} elevation(s) recomputed")
|
|
|
|
|
|
def _write_root_manifest(data: Path) -> None:
|
|
"""Rewrite the root index.json shard manifest from current user dirs."""
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
users = _user_dirs(data)
|
|
# Read existing manifest to preserve instance metadata
|
|
root = data / "index.json"
|
|
existing: dict = {}
|
|
if root.exists():
|
|
try:
|
|
existing = json.loads(root.read_text())
|
|
except Exception:
|
|
pass
|
|
|
|
has_auth = (data / "instance.db").exists()
|
|
existing_instance = existing.get("instance", {"name": "BincioActivity"})
|
|
if not has_auth:
|
|
# Single-user: no auth server, force private off regardless of what was written before.
|
|
existing_instance = {**existing_instance, "private": False}
|
|
elif "private" not in existing_instance:
|
|
# Multi-user first run: default to private.
|
|
existing_instance = {**existing_instance, "private": True}
|
|
manifest = {
|
|
"bas_version": "1.0",
|
|
"instance": existing_instance,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"shards": [
|
|
{
|
|
"handle": u.name,
|
|
"url": f"{u.name}/_merged/index.json"
|
|
if (u / "_merged" / "index.json").exists()
|
|
else f"{u.name}/index.json",
|
|
}
|
|
for u in users
|
|
],
|
|
"activities": [],
|
|
}
|
|
root.write_text(json.dumps(manifest, indent=2))
|
|
console.print(f"Root manifest updated: [cyan]{len(users)}[/cyan] user shard(s)")
|
|
|
|
if len(users) > 1:
|
|
from bincio.render.merge import write_combined_feed
|
|
n = write_combined_feed(data)
|
|
console.print(f"Combined feed: [cyan]{n}[/cyan] activities across all users")
|
|
|
|
|
|
def _link_data(site: Path, data: Path) -> None:
|
|
"""Symlink site/public/data → data root (each user has their own _merged/)."""
|
|
target = data
|
|
public_data = site / "public" / "data"
|
|
public_data.parent.mkdir(parents=True, exist_ok=True)
|
|
if public_data.is_symlink():
|
|
if public_data.resolve() == target.resolve():
|
|
return # already correct
|
|
public_data.unlink()
|
|
elif public_data.exists():
|
|
console.print(
|
|
f"[yellow]Warning:[/yellow] {public_data} exists and is not a symlink — "
|
|
"remove it manually if you want bincio to manage it."
|
|
)
|
|
return
|
|
public_data.symlink_to(target)
|
|
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
|
|
|
|
|
|
def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
|
"""Recompute climbing_vam_mh and climbing_time_s for all activities.
|
|
|
|
Reads the stored timeseries, re-runs the VAM algorithm, and patches both
|
|
activities/*.json and index.json in-place. Run once after adding
|
|
climbing_time_s to the schema so the NerdCorner VAM chart can filter short
|
|
climbs and opacity-encode confidence.
|
|
"""
|
|
import json
|
|
from bincio.extract.metrics import _VAM_SPORTS, _build_ele_1hz, _vam_from_ele_1hz
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
acts_dir = user_dir / "activities"
|
|
index_path = user_dir / "index.json"
|
|
if not acts_dir.exists() or not index_path.exists():
|
|
continue
|
|
try:
|
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
index_by_id = {s["id"]: s for s in index_data.get("activities", [])}
|
|
updated = 0
|
|
|
|
for act_path in sorted(acts_dir.glob("*.json")):
|
|
if act_path.stem.endswith((".timeseries", ".geojson")):
|
|
continue
|
|
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
|
if not ts_path.exists():
|
|
continue
|
|
try:
|
|
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
|
if detail.get("sport") not in _VAM_SPORTS:
|
|
continue
|
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
|
t_vals = ts.get("t", [])
|
|
e_vals = ts.get("elevation_m", [])
|
|
sparse: dict[int, float | None] = {int(t): e for t, e in zip(t_vals, e_vals)}
|
|
ele_1hz = _build_ele_1hz(sparse)
|
|
result = _vam_from_ele_1hz(ele_1hz) if ele_1hz else None
|
|
new_vam, new_climb_t = result if result else (None, None)
|
|
if (new_vam == detail.get("climbing_vam_mh") and
|
|
new_climb_t == detail.get("climbing_time_s")):
|
|
continue
|
|
detail["climbing_vam_mh"] = new_vam
|
|
detail["climbing_time_s"] = new_climb_t
|
|
act_path.write_text(
|
|
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
summary = index_by_id.get(act_path.stem)
|
|
if summary is not None:
|
|
summary["climbing_vam_mh"] = new_vam
|
|
summary["climbing_time_s"] = new_climb_t
|
|
updated += 1
|
|
except Exception:
|
|
pass
|
|
|
|
if updated:
|
|
index_path.write_text(
|
|
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} VAM(s) recomputed")
|
|
|
|
|
|
def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
|
|
"""Copy climbing_vam_mh from detail JSONs into index.json summaries.
|
|
|
|
Needed once after the vam_curve→climbing_vam_mh-in-summary migration.
|
|
"""
|
|
import json
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
acts_dir = user_dir / "activities"
|
|
index_path = user_dir / "index.json"
|
|
if not acts_dir.exists() or not index_path.exists():
|
|
continue
|
|
try:
|
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
updated = 0
|
|
for s in index_data.get("activities", []):
|
|
if "climbing_vam_mh" in s:
|
|
continue # already backfilled
|
|
act_path = acts_dir / f"{s['id']}.json"
|
|
if not act_path.exists():
|
|
continue
|
|
try:
|
|
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
|
vam = detail.get("climbing_vam_mh")
|
|
if vam is not None:
|
|
s["climbing_vam_mh"] = vam
|
|
updated += 1
|
|
except Exception:
|
|
pass
|
|
|
|
if updated:
|
|
index_path.write_text(
|
|
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated")
|
|
|
|
|
|
def _backfill_speed(data: Path, handle: str | None = None) -> None:
|
|
"""Compute GPS-derived speed for timeseries files where speed_kmh is all null.
|
|
|
|
Reads each *.timeseries.json, fills speed_kmh from haversine distances when
|
|
the device did not record per-second speed, and writes the file back.
|
|
"""
|
|
import json
|
|
from bincio.extract.timeseries import _gps_speed_kmh
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
acts_dir = user_dir / "activities"
|
|
if not acts_dir.exists():
|
|
continue
|
|
updated = 0
|
|
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
|
try:
|
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
spd = ts.get("speed_kmh", [])
|
|
if not spd or any(v is not None for v in spd):
|
|
continue # already has speed data
|
|
lat_vals = ts.get("lat") or []
|
|
lon_vals = ts.get("lon") or []
|
|
t_vals = ts.get("t") or []
|
|
if not lat_vals or not lon_vals or not t_vals:
|
|
continue
|
|
ts["speed_kmh"] = _gps_speed_kmh(lat_vals, lon_vals, t_vals)
|
|
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
updated += 1
|
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} timeseries updated with GPS speed")
|
|
|
|
|
|
def _downsample_timeseries(data: Path, handle: str | None = None) -> None:
|
|
"""Apply 10 m spatial downsampling to all stored timeseries files in activities/.
|
|
|
|
Reads the parallel JSON arrays, computes which indices to keep using the
|
|
same distance logic as _spatial_downsample, slices every channel, and
|
|
writes the file back. Run bincio render --no-build afterward so _merge_edits
|
|
regenerates _merged/ from the smaller source files.
|
|
"""
|
|
import json
|
|
from bincio.extract.timeseries import _haversine_m, _SPATIAL_RESOLUTION_M
|
|
|
|
_CHANNELS = ("t", "lat", "lon", "elevation_m", "speed_kmh",
|
|
"hr_bpm", "cadence_rpm", "power_w", "temperature_c")
|
|
|
|
targets = [data / handle] if handle else _user_dirs(data)
|
|
for user_dir in targets:
|
|
acts_dir = user_dir / "activities"
|
|
if not acts_dir.exists():
|
|
continue
|
|
updated = skipped = 0
|
|
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
|
try:
|
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
t_vals = ts.get("t") or []
|
|
lat_vals = ts.get("lat") or []
|
|
lon_vals = ts.get("lon") or []
|
|
spd_vals = ts.get("speed_kmh") or []
|
|
n = len(t_vals)
|
|
if n < 2:
|
|
skipped += 1
|
|
continue
|
|
|
|
has_gps = any(v is not None for v in lat_vals)
|
|
has_speed = any(v is not None for v in spd_vals)
|
|
if not has_gps and not has_speed:
|
|
skipped += 1
|
|
continue
|
|
|
|
kept: list[int] = [0]
|
|
cum_dist = last_kept = prev_speed = 0.0
|
|
|
|
for i in range(1, n):
|
|
dt = t_vals[i] - t_vals[i - 1]
|
|
la0 = lat_vals[i - 1] if lat_vals else None
|
|
lo0 = lon_vals[i - 1] if lon_vals else None
|
|
la1 = lat_vals[i] if lat_vals else None
|
|
lo1 = lon_vals[i] if lon_vals else None
|
|
|
|
if (has_gps and la0 is not None and lo0 is not None
|
|
and la1 is not None and lo1 is not None):
|
|
dist_m = _haversine_m(la0, lo0, la1, lo1)
|
|
else:
|
|
spd = (spd_vals[i] if spd_vals and spd_vals[i] is not None
|
|
else prev_speed)
|
|
dist_m = (spd / 3.6) * max(dt, 0)
|
|
|
|
if spd_vals and spd_vals[i] is not None:
|
|
prev_speed = spd_vals[i]
|
|
|
|
cum_dist += dist_m
|
|
if cum_dist - last_kept >= _SPATIAL_RESOLUTION_M:
|
|
kept.append(i)
|
|
last_kept = cum_dist
|
|
|
|
if kept[-1] != n - 1:
|
|
kept.append(n - 1)
|
|
|
|
if len(kept) >= n:
|
|
skipped += 1
|
|
continue # already sparse (very short / indoor / rest-stop heavy)
|
|
|
|
for key in _CHANNELS:
|
|
ch = ts.get(key)
|
|
if ch:
|
|
ts[key] = [ch[i] for i in kept]
|
|
|
|
ts_path.write_text(
|
|
json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
updated += 1
|
|
|
|
console.print(
|
|
f" [cyan]{user_dir.name}[/cyan]: "
|
|
f"{updated} downsampled, {skipped} skipped (indoor / short / already sparse)"
|
|
)
|
|
|
|
|
|
@click.command()
|
|
@click.option("--config", "config_path", default=None,
|
|
help="Path to extract_config.yaml (reads output.dir from it).")
|
|
@click.option("--data-dir", default=None,
|
|
help="BAS data store directory (output of bincio extract).")
|
|
@click.option("--site-dir", default=None,
|
|
help="Astro project directory (default: ./site).")
|
|
@click.option("--out", "out_dir", default=None,
|
|
help="Build output directory (default: site/dist).")
|
|
@click.option("--serve", is_flag=True,
|
|
help="Start dev server with hot reload instead of building.")
|
|
@click.option("--deploy", default=None, metavar="TARGET",
|
|
help="Deploy after build. Currently supports: github.")
|
|
@click.option("--handle", default=None,
|
|
help="(Multi-user) Incrementally re-merge one user's shard only.")
|
|
@click.option("--no-build", "no_build", is_flag=True,
|
|
help="Skip the Astro build step (just merge sidecars and update manifests).")
|
|
@click.option("--recompute-climbs", "recompute_climbs", is_flag=True,
|
|
help="Recompute best_climb_m for all cycling activities from stored timeseries "
|
|
"(run once after upgrading the climb algorithm).")
|
|
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
|
|
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
|
|
"(run once after upgrading the dropout-skip fix).")
|
|
@click.option("--recompute-vam", "recompute_vam", is_flag=True,
|
|
help="Recompute climbing_vam_mh and climbing_time_s for all activities from stored "
|
|
"timeseries (run once after adding climbing_time_s to the schema).")
|
|
@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True,
|
|
help="Copy climbing_vam_mh from detail JSONs into index.json summaries "
|
|
"(run once after the VAM curve → summary migration).")
|
|
@click.option("--backfill-speed", "backfill_speed", is_flag=True,
|
|
help="Compute GPS-derived speed for timeseries where the device didn't record "
|
|
"per-second speed (run once to enable speed map coloring on older activities).")
|
|
@click.option("--downsample-timeseries", "downsample_timeseries", is_flag=True,
|
|
help="Apply 10 m spatial downsampling to all stored timeseries files "
|
|
"(run once after deploying the downsampling code).")
|
|
def render(
|
|
config_path: Optional[str],
|
|
data_dir: Optional[str],
|
|
site_dir: Optional[str],
|
|
out_dir: Optional[str],
|
|
serve: bool,
|
|
deploy: Optional[str],
|
|
handle: Optional[str],
|
|
no_build: bool,
|
|
recompute_climbs: bool,
|
|
recompute_elevation: bool,
|
|
recompute_vam: bool,
|
|
backfill_vam_summary: bool,
|
|
backfill_speed: bool,
|
|
downsample_timeseries: bool,
|
|
) -> None:
|
|
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
|
|
|
site = _find_site_dir(site_dir)
|
|
data = _find_data_dir(data_dir, config_path)
|
|
|
|
console.print(f"Site: [cyan]{site}[/cyan]")
|
|
console.print(f"Data: [cyan]{data}[/cyan]")
|
|
|
|
if recompute_climbs:
|
|
console.print("Recomputing best climbs from timeseries…")
|
|
_recompute_best_climbs(data, handle=handle)
|
|
|
|
if recompute_elevation:
|
|
console.print("Recomputing elevation gain/loss from timeseries…")
|
|
_recompute_elevation(data, handle=handle)
|
|
|
|
if recompute_vam:
|
|
console.print("Recomputing VAM and climbing time from timeseries…")
|
|
_recompute_vam(data, handle=handle)
|
|
|
|
if backfill_vam_summary:
|
|
console.print("Backfilling climbing_vam_mh into summaries…")
|
|
_backfill_vam_summary(data, handle=handle)
|
|
|
|
if backfill_speed:
|
|
console.print("Backfilling GPS-derived speed into timeseries…")
|
|
_backfill_speed(data, handle=handle)
|
|
|
|
if downsample_timeseries:
|
|
console.print("Applying spatial downsampling to timeseries…")
|
|
_downsample_timeseries(data, handle=handle)
|
|
|
|
_merge_edits(data, handle=handle)
|
|
_rebuild_athlete_json(data, handle=handle)
|
|
_bake_tracks(data, handle=handle)
|
|
_write_root_manifest(data)
|
|
|
|
if no_build:
|
|
console.print("[green]Data updated.[/green] Skipping Astro build (--no-build).")
|
|
return
|
|
|
|
_ensure_npm(site)
|
|
|
|
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
|
|
|
|
if serve:
|
|
# Dev server needs to serve /data/ files at runtime from public/
|
|
_link_data(site, data)
|
|
console.print("Starting [cyan]astro dev[/cyan]…")
|
|
subprocess.run(["npm", "run", "dev"], cwd=site, env=env)
|
|
return
|
|
|
|
# Production build: BINCIO_DATA_DIR is already set so manifest.ts reads
|
|
# data directly; remove any leftover public/data symlink so Astro doesn't
|
|
# copy the full data directory (9+ GB) into dist/.
|
|
public_data = site / "public" / "data"
|
|
if public_data.is_symlink():
|
|
public_data.unlink()
|
|
|
|
# Build
|
|
cmd = ["npm", "run", "build"]
|
|
if out_dir:
|
|
# Pass outDir via Astro CLI flag
|
|
cmd = ["npx", "astro", "build", "--outDir", str(Path(out_dir).resolve())]
|
|
|
|
console.print("Running [cyan]astro build[/cyan]…")
|
|
result = subprocess.run(cmd, cwd=site, env=env)
|
|
if result.returncode != 0:
|
|
console.print("[red]Build failed.[/red]")
|
|
sys.exit(result.returncode)
|
|
|
|
dist = Path(out_dir).resolve() if out_dir else site / "dist"
|
|
console.print(f"\n[green]Build complete.[/green] Output: [cyan]{dist}[/cyan]")
|
|
|
|
if deploy == "github":
|
|
_deploy_github(site, dist)
|
|
|
|
|
|
def _deploy_github(site: Path, dist: Path) -> None:
|
|
"""Push dist/ to the gh-pages branch."""
|
|
console.print("Deploying to [cyan]GitHub Pages[/cyan]…")
|
|
# Requires npx gh-pages or git subtree push
|
|
result = subprocess.run(
|
|
["npx", "gh-pages", "-d", str(dist)],
|
|
cwd=site,
|
|
)
|
|
if result.returncode != 0:
|
|
console.print(
|
|
"[yellow]Tip:[/yellow] install gh-pages with `npm install -g gh-pages`"
|
|
)
|