get default hr and power zones from config file

This commit is contained in:
Davide Scaini
2026-03-29 22:06:22 +02:00
parent 3fcc8bc089
commit 4537273de9
11 changed files with 224 additions and 16 deletions
+22
View File
@@ -129,8 +129,30 @@ incremental: true # false = re-process everything
track: track:
rdp_epsilon: 0.0001 # GPS simplification — larger = fewer points rdp_epsilon: 0.0001 # GPS simplification — larger = fewer points
timeseries_hz: 1 # samples/sec in stored JSON (1 = 1 Hz) timeseries_hz: 1 # samples/sec in stored JSON (1 = 1 Hz)
athlete:
max_hr: 182 # used for context; zones below are authoritative
ftp_w: 280 # functional threshold power in watts
hr_zones: # 5-zone Coggan, explicit bpm boundaries [[lo, hi], ...]
- [0, 115] # Z1 recovery
- [115, 137] # Z2 endurance
- [137, 155] # Z3 tempo
- [155, 169] # Z4 threshold
- [169, 999] # Z5 VO2max
power_zones: # 7-zone Coggan, explicit watt boundaries
- [0, 168] # Z1 active recovery (< 55% FTP)
- [168, 224] # Z2 endurance (5575%)
- [224, 266] # Z3 tempo (7590%)
- [266, 308] # Z4 threshold (90105%)
- [308, 364] # Z5 VO2max (105120%)
- [364, 420] # Z6 anaerobic (120150%)
- [420, 9999] # Z7 neuromuscular (> 150%)
``` ```
Zones are written into `index.json` under `owner.athlete` at extract time and
displayed as overlays on HR and Power histograms in the activity detail page.
After changing zones, re-run `uv run bincio extract` to update `index.json`.
--- ---
## Privacy ## Privacy
+48 -1
View File
@@ -67,7 +67,7 @@ site/ Astro project
ActivityFeed.svelte Card grid, sport filter, pagination ActivityFeed.svelte Card grid, sport filter, pagination
ActivityDetail.svelte Map + stats + charts wrapper ActivityDetail.svelte Map + stats + charts wrapper
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot) ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs) ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence/power tabs)
StatsView.svelte Yearly heatmap + totals StatsView.svelte Yearly heatmap + totals
lib/ lib/
types.ts BAS TypeScript types types.ts BAS TypeScript types
@@ -199,6 +199,46 @@ const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88;
detects the dependency change when the sport filter or scale method changes detects the dependency change when the sport filter or scale method changes
(plain function calls with static args don't trigger Svelte re-renders). (plain function calls with static args don't trigger Svelte re-renders).
## ActivityCharts — controls and athlete zones
`ActivityCharts.svelte` renders Observable Plot charts for the activity detail page.
### Chart controls
- **Metric tabs**: Elevation · Speed · Heart Rate · Cadence · Power
- **Chart type toggle** (right-aligned): `↗ Line` | `▭ Hist`
- **X-axis toggle** (line mode only, shown when speed data present): `Time` | `Dist`
- Distance is integrated from `speed_kmh` at 1 Hz — no extra data needed.
- **Histogram controls** (visible only in histogram mode):
- **Dual range slider** — trims the x domain; two overlapping `<input type="range">` with CSS track highlight.
- **Bins slider** — exact bin count using explicit evenly-spaced thresholds (not d3's "nice" count, which ignores narrow ranges).
### Athlete zones
Zones are configured in `extract_config.yaml` under `athlete:` and written into
`index.json` at extract time (`owner.athlete`). The Astro activity page reads them
from the index and passes them down: `[id].astro``ActivityDetail``ActivityCharts`.
When viewing HR or Power in histogram mode, zone boundaries are drawn as dashed
vertical rules with Z1Z5/Z7 labels at the top of the chart.
Labels and rules are clipped to the current trim range automatically.
Zone color palettes:
- HR (5 zones): `#60a5fa #4ade80 #facc15 #fb923c #f87171`
- Power (7 zones): `#60a5fa #34d399 #facc15 #fb923c #f87171 #c084fc #f43f5e`
### Zone calculation reference (Coggan)
| Zone | HR (% max HR) | Power (% FTP) |
|------|--------------|---------------|
| Z1 | < 55% | < 55% |
| Z2 | 5575% | 5575% |
| Z3 | 7587% | 7590% |
| Z4 | 8793% | 90105% |
| Z5 | > 93% | 105120% |
| Z6 | — | 120150% |
| Z7 | — | > 150% |
## Activity sidecar edits — design spec ## Activity sidecar edits — design spec
Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir. Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir.
@@ -348,6 +388,13 @@ are served at `data/activities/images/{id}/{filename}` by the Astro dev server.
- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled - [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled
- [x] Markdown rendering in activity description with image path rewriting - [x] Markdown rendering in activity description with image path rewriting
- [x] `hide_stats` support in activity detail stats panel - [x] `hide_stats` support in activity detail stats panel
- [x] ActivityCharts power tab (elevation/speed/HR/cadence/power)
- [x] Chart type toggle: line ↔ histogram
- [x] X-axis toggle: time ↔ distance (integrated from speed)
- [x] Histogram dual range slider + bins slider (exact thresholds)
- [x] Athlete zones in `extract_config.yaml``index.json` → chart overlays
- [x] StatsView heatmap click-to-pin tooltip (Esc / click-outside to dismiss)
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes - [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
- [ ] Highlight badge in activity feed cards - [ ] Highlight badge in activity feed cards
- [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI) - [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI)
- [ ] HR / power zone defaults from `max_hr` / `ftp_w` when explicit zones not set
+23
View File
@@ -133,8 +133,31 @@ track:
timeseries_hz: 1 # data samples per second stored in JSON timeseries_hz: 1 # data samples per second stored in JSON
incremental: true # skip files whose hash hasn't changed incremental: true # skip files whose hash hasn't changed
# Optional: athlete profile for zone overlays on HR/power charts
athlete:
max_hr: 182
ftp_w: 280
hr_zones: # [[lo, hi], ...] in bpm — 5-zone Coggan
- [0, 115]
- [115, 137]
- [137, 155]
- [155, 169]
- [169, 999]
power_zones: # [[lo, hi], ...] in watts — 7-zone Coggan
- [0, 168]
- [168, 224]
- [224, 266]
- [266, 308]
- [308, 364]
- [364, 420]
- [420, 9999]
``` ```
Zones are written into `index.json` at extract time and displayed as overlays on
HR and Power histograms in the activity detail page. After changing zones, re-run
`uv run bincio extract` to regenerate `index.json`.
### Privacy levels ### Privacy levels
| Level | GPS track | Stats | Appears in index | | Level | GPS track | Stats | Appears in index |
+10
View File
@@ -149,6 +149,16 @@ def extract(
console.print(f"Using [bold]{n_workers}[/bold] worker processes.") console.print(f"Using [bold]{n_workers}[/bold] worker processes.")
owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name} owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name}
if cfg.athlete:
ath = cfg.athlete
owner["athlete"] = {
k: v for k, v in {
"max_hr": ath.max_hr,
"ftp_w": ath.ftp_w,
"hr_zones": ath.hr_zones,
"power_zones": ath.power_zones,
}.items() if v is not None
}
summaries: list[dict] = [] summaries: list[dict] = []
errors: list[tuple[str, str]] = [] errors: list[tuple[str, str]] = []
skipped = 0 skipped = 0
+18
View File
@@ -27,6 +27,14 @@ class ClassifierConfig:
enabled: bool = False # off by default; opt-in enabled: bool = False # off by default; opt-in
@dataclass
class AthleteConfig:
max_hr: int | None = None
ftp_w: int | None = None
hr_zones: list[list[int]] | None = None # [[lo, hi], ...]
power_zones: list[list[int]] | None = None
@dataclass @dataclass
class ExtractConfig: class ExtractConfig:
input_dirs: list[Path] input_dirs: list[Path]
@@ -39,6 +47,7 @@ class ExtractConfig:
incremental: bool = True incremental: bool = True
owner_handle: str = "me" owner_handle: str = "me"
owner_display_name: str = "Me" owner_display_name: str = "Me"
athlete: AthleteConfig | None = None
def load_config(path: Path) -> ExtractConfig: def load_config(path: Path) -> ExtractConfig:
@@ -70,6 +79,14 @@ def load_config(path: Path) -> ExtractConfig:
cls_raw = raw.get("classifier", {}) cls_raw = raw.get("classifier", {})
classifier = ClassifierConfig(enabled=cls_raw.get("enabled", False)) classifier = ClassifierConfig(enabled=cls_raw.get("enabled", False))
ath_raw = raw.get("athlete", {})
athlete = AthleteConfig(
max_hr=ath_raw.get("max_hr"),
ftp_w=ath_raw.get("ftp_w"),
hr_zones=ath_raw.get("hr_zones"),
power_zones=ath_raw.get("power_zones"),
) if ath_raw else None
return ExtractConfig( return ExtractConfig(
input_dirs=dirs, input_dirs=dirs,
output_dir=out, output_dir=out,
@@ -81,6 +98,7 @@ def load_config(path: Path) -> ExtractConfig:
incremental=raw.get("incremental", True), incremental=raw.get("incremental", True),
owner_handle=owner.get("handle", "me"), owner_handle=owner.get("handle", "me"),
owner_display_name=owner.get("display_name", "Me"), owner_display_name=owner.get("display_name", "Me"),
athlete=athlete,
) )
+18
View File
@@ -30,3 +30,21 @@ classifier:
enabled: false # ML activity type classifier (requires scikit-learn extra) enabled: false # ML activity type classifier (requires scikit-learn extra)
incremental: true # skip files whose hash hasn't changed since last run incremental: true # skip files whose hash hasn't changed since last run
# athlete:
# max_hr: 182 # used to derive default HR zone display
# ftp_w: 280 # functional threshold power in watts
# hr_zones: # explicit bpm boundaries [[lo, hi], ...]
# - [0, 115] # Z1 recovery
# - [115, 137] # Z2 endurance
# - [137, 155] # Z3 tempo
# - [155, 169] # Z4 threshold
# - [169, 999] # Z5 VO2max
# power_zones: # explicit watt boundaries
# - [0, 168] # Z1 active recovery (< 55% FTP)
# - [168, 224] # Z2 endurance (5578%)
# - [224, 266] # Z3 tempo (7895%)
# - [266, 308] # Z4 threshold (95109%)
# - [308, 364] # Z5 VO2max (109130%)
# - [364, 420] # Z6 anaerobic (130150%)
# - [420, 9999] # Z7 neuromuscular (> 150%)
+18
View File
@@ -30,3 +30,21 @@ classifier:
enabled: false # ML activity type classifier (requires scikit-learn extra) enabled: false # ML activity type classifier (requires scikit-learn extra)
incremental: true # skip files whose hash hasn't changed since last run incremental: true # skip files whose hash hasn't changed since last run
athlete:
max_hr: 190
ftp_w: 210
hr_zones: # 5-zone Coggan, % of max HR 190 bpm
- [0, 104] # Z1 recovery < 55%
- [104, 142] # Z2 endurance 5575%
- [142, 165] # Z3 tempo 7587%
- [165, 176] # Z4 threshold 8793%
- [176, 999] # Z5 VO2max > 93%
power_zones: # 7-zone Coggan, % of FTP 210 W
- [0, 115] # Z1 active recovery < 55%
- [115, 157] # Z2 endurance 5575%
- [157, 189] # Z3 tempo 7590%
- [189, 220] # Z4 threshold 90105%
- [220, 252] # Z5 VO2max 105120%
- [252, 315] # Z6 anaerobic 120150%
- [315, 9999] # Z7 neuromuscular > 150%
+52 -8
View File
@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import * as Plot from '@observablehq/plot'; import * as Plot from '@observablehq/plot';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Timeseries } from '../lib/types'; import type { Timeseries, AthleteZones } from '../lib/types';
export let timeseries: Timeseries; export let timeseries: Timeseries;
// Linked hover: emit/receive index into timeseries arrays // Linked hover: emit/receive index into timeseries arrays
export let hoveredIdx: number | null = null; export let hoveredIdx: number | null = null;
export let athlete: AthleteZones | null = null;
const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171'];
const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e'];
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power'; type Tab = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
type XMode = 'time' | 'distance'; type XMode = 'time' | 'distance';
@@ -181,18 +185,58 @@
function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) { function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) {
const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`; const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`;
const marks: any[] = [
Plot.rectY(histData, Plot.binX(
{ y: 'count' },
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
)),
Plot.ruleY([0], { stroke: '#52525b' }),
];
const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null;
const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS;
if (rawZones?.length) {
// Boundary vertical lines (interior boundaries only, skip first lo and last hi)
const boundaries = rawZones.slice(0, -1).map((z, i) => ({
x: z[1], // the upper bound of each zone = lower bound of the next
color: zoneColors[i + 1] ?? zoneColors[zoneColors.length - 1],
})).filter(b => b.x > trimMin && b.x < trimMax);
// Zone midpoints for labels
const labels = rawZones.map((z, i) => ({
mid: (Math.max(z[0], trimMin) + Math.min(z[1], trimMax)) / 2,
label: `Z${i + 1}`,
color: zoneColors[i] ?? zoneColors[zoneColors.length - 1],
visible: z[1] > trimMin && z[0] < trimMax,
})).filter(l => l.visible && l.mid >= trimMin && l.mid <= trimMax);
marks.push(
Plot.ruleX(boundaries, {
x: 'x',
stroke: (d: any) => d.color,
strokeWidth: 1,
strokeOpacity: 0.5,
strokeDasharray: '4,3',
}),
Plot.text(labels, {
x: 'mid',
text: 'label',
fill: (d: any) => d.color,
fontSize: 9,
fontWeight: '600',
frameAnchor: 'top',
dy: 6,
}),
);
}
return Plot.plot({ return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32, width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' }, style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] }, x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat }, y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks: [ marks,
Plot.rectY(histData, Plot.binX(
{ y: 'count' },
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
)),
Plot.ruleY([0], { stroke: '#52525b' }),
],
}); });
} }
</script> </script>
+3 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { marked } from 'marked'; import { marked } from 'marked';
import type { ActivitySummary, ActivityDetail } from '../lib/types'; import type { ActivitySummary, ActivityDetail, AthleteZones } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format'; import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
import ActivityMap from './ActivityMap.svelte'; import ActivityMap from './ActivityMap.svelte';
import ActivityCharts from './ActivityCharts.svelte'; import ActivityCharts from './ActivityCharts.svelte';
@@ -9,6 +9,7 @@
export let activity: ActivitySummary; export let activity: ActivitySummary;
export let base: string = '/'; export let base: string = '/';
export let athlete: AthleteZones | null = null;
const editUrl = import.meta.env.PUBLIC_EDIT_URL; const editUrl = import.meta.env.PUBLIC_EDIT_URL;
@@ -237,7 +238,7 @@
<p class="text-red-400 text-sm mt-4">{error}</p> <p class="text-red-400 text-sm mt-4">{error}</p>
{:else if detail?.timeseries && detail.timeseries.t.length > 0} {:else if detail?.timeseries && detail.timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4"> <div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx /> <ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx {athlete} />
</div> </div>
{:else if !detail} {:else if !detail}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse" /> <div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse" />
+8 -1
View File
@@ -28,9 +28,16 @@ export interface ActivitySummary {
preview_coords: [number, number][] | null; preview_coords: [number, number][] | null;
} }
export interface AthleteZones {
max_hr?: number;
ftp_w?: number;
hr_zones?: [number, number][];
power_zones?: [number, number][];
}
export interface BASIndex { export interface BASIndex {
bas_version: string; bas_version: string;
owner: { handle: string; display_name: string; avatar_url: string | null }; owner: { handle: string; display_name: string; avatar_url: string | null; athlete?: AthleteZones };
generated_at: string; generated_at: string;
shards: Array<{ year: number; url: string; count: number }>; shards: Array<{ year: number; url: string; count: number }>;
activities: ActivitySummary[]; activities: ActivitySummary[];
+4 -4
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import ActivityDetail from '../../components/ActivityDetail.svelte'; import ActivityDetail from '../../components/ActivityDetail.svelte';
import type { BASIndex, ActivitySummary } from '../../lib/types'; import type { BASIndex, ActivitySummary, AthleteZones } from '../../lib/types';
export async function getStaticPaths() { export async function getStaticPaths() {
const dataDir = process.env.BINCIO_DATA_DIR const dataDir = process.env.BINCIO_DATA_DIR
@@ -15,13 +15,13 @@ export async function getStaticPaths() {
.filter(a => a.privacy !== 'private' && a.id) .filter(a => a.privacy !== 'private' && a.id)
.map(a => ({ .map(a => ({
params: { id: a.id }, params: { id: a.id },
props: { activity: a }, props: { activity: a, athlete: index.owner.athlete ?? null },
})); }));
} }
const { activity } = Astro.props as { activity: ActivitySummary }; const { activity, athlete } = Astro.props as { activity: ActivitySummary; athlete: AthleteZones | null };
const base = import.meta.env.BASE_URL; const base = import.meta.env.BASE_URL;
--- ---
<Base title={`${activity.title} — BincioActivity`}> <Base title={`${activity.title} — BincioActivity`}>
<ActivityDetail {activity} {base} client:only="svelte" /> <ActivityDetail {activity} {base} {athlete} client:only="svelte" />
</Base> </Base>