Methodology
Every number in Koncur comes from a formula you can read. This page lists them — the constants, the boundaries, the sources — and the places the app does less than you might assume.
On 27 July 2026 every claim below was checked line-by-line against the code that is actually deployed. Where the shipped behaviour was narrower than the ambition, the claim was cut or narrowed rather than dressed up. Things we intend to build but have not built sit in On the roadmap, in the future tense, and nowhere else.
A methodology page is only worth anything if it is boring and correct. If you find a discrepancy between this page and what the app does, that is a bug in one of the two and we want to hear about it.
01 — Benchmarks & zones
Where your zones come from
Koncur does not use generic beginner/intermediate/advanced pace bands. Every intensity target is derived from one of your own benchmarks: a 5K time, an FTP, a 2K row, a max heart rate.
Running pace — Daniels VDOT from your 5K
Running zones are computed from a single input: your current 5K time in seconds. We estimate VDOT with the Daniels–Gilbert relations, then invert the oxygen-cost equation at a fixed fraction of VDOT per zone to get a velocity, and convert that to a pace.
v = (5000 / t_sec) × 60 // m/min
vo2 = −4.60 + 0.182258·v + 0.000104·v²
pctMax = 0.8 + 0.1894393·e^(−0.012778·t_min)
+ 0.2989558·e^(−0.1932605·t_min)
VDOT = vo2 / pctMax
// zone velocity = inverse of the vo2 quadratic at:
Z1 Recovery 0.59 × VDOT
Z2 Easy / aerobic base 0.67 × VDOT
Z3 Tempo / threshold 0.84 × VDOT
Z4 VO₂max intervals 0.97 × VDOT
Z5 Anaerobic / sprint 1.05 × VDOT
generate-programme/zones.ts:5–38 · mobile/src/utils/clientZones.ts:47–73 — numerically identical, verified
10K estimate — Riegel, exponent 1.06
If you have given us a 5K but not a 10K, we estimate the 10K rather than leave the endurance benchmark blank. The estimate is tagged at the point of writing so it can be told apart from a time you actually ran.
T₂ = T₁ × (D₂ / D₁)^1.06 // applied as: ten_k = round(five_k × (10/5)^1.06) // tagged as: ten_k_source = 'riegel' // provenance ladder — higher never overwritten by lower 'user' manually entered — never overwritten 'healthkit' imported 9.5–10.5 km run 'riegel' estimated from your 5K null not set
mobile/src/utils/predictRunTime.ts:11–18 · mobile/src/store/profileStore.ts:100–103, 154–208
The Riegel exponent is Riegel's own 1.06, unchanged. The same exponent is used in reverse (10K → 5K) when only a 10K is on file and a pace band is needed.
The riegel tag is honoured in the app's own display — an estimated 10K is labelled as a Day-1 estimate on your Endurance axis. It is not read by the server when the server computes your profile, so a Riegel-derived 10K and a 10K you actually raced currently carry equal weight in programme decisions. Per-benchmark provenance on the server side is on the roadmap.
Cycling power — Coggan zones from FTP
| Zone | Name | % FTP |
|---|---|---|
| Z1 | Recovery | 0 – 55% |
| Z2 | Endurance | 56 – 75% |
| Z3 | Tempo / sweet spot | 76 – 90% |
| Z4 | Threshold | 91 – 105% |
| Z5 | VO₂max | 106 – 120% |
| Z6 | Anaerobic | 121 – 150% |
generate-programme/zones.ts:47–63 · mobile/src/utils/clientZones.ts:22–36
FTP is a number you enter. Koncur does not currently run an FTP test — there is no ramp test and no 20-minute protocol you can complete in the app, and we do not estimate FTP from ride data. Whatever you type is what the zones are built on.
The session picker uses a separate, coarser set of single-watt targets per intensity tier rather than the six-zone table: recover 55%, base 68%, threshold 98%, vo2 113% of FTP. These are midpoints for prescribing one number on a screen, not a second zone model.
design-session/logic.ts:239–244
Heart-rate zones — percentage of max HR
Heart-rate zones are a straight percentage of maximum heart rate. They are not Karvonen heart-rate-reserve zones. (Heart-rate reserve is used, but only inside the load model — see below. Do not conflate the two.)
Z1 Recovery 50–60% HRmax Z2 Aerobic 60–70% HRmax Z3 Tempo 70–80% HRmax Z4 Threshold 80–90% HRmax Z5 Max 90–100% HRmax HRmax = entered value, else 220 − age (floor 120, default 190)
mobile/src/constants/hrZones.ts:4–23 · aerobicTarget.ts:35–37 · liveAerobicBlocks.ts:236 — five copies, all identical
We use 220 − age rather than the Tanaka regression. Tanaka is the better estimator and we know it; the code carries 220 − age for compatibility with the stored profile fields. If you know your true max HR, entering it removes the estimate entirely and is the single highest-value number you can give us.
Erg — Concept2 watts and split
Two separate conversions, for two separate jobs.
// 1. Power ↔ split, the exact Concept2 relation watts = 2.80 / (sec_per_metre)³ split_500m = 500 × ∛(2.80 / watts) // 2. Session targets, relative to your own benchmark ref_split = row: 2K_time / 4 | ski: 1K_time / 2 recover ref + 26 s base ref + 20 s threshold ref + 7 s vo2 ref + 2 s
mobile/src/utils/pickerV3.ts:415–417 · design-session/logic.ts:199–244
There is no swim model. Koncur has no critical-swim-speed calculation and no swim pace zones — the modalities with a real zone model are run, bike, row and ski.
02 — Training load
Two channels, deliberately not fused
Heart rate describes cardiovascular cost. It describes mechanical cost badly — a heavy triple spikes HR far less than its tissue cost warrants, and a long easy ride spikes it far more. So Koncur computes two separate load channels and does not add them into one "strain" number.
Cardiac load — TRIMP
The training-impulse figure saved with every session, in the Banister family: minutes weighted by an exponential function of heart-rate reserve, with a sex-specific exponent.
HRr = (avgHR − restHR) / (HRmax − restHR)
TRIMP = minutes × HRr × e^(b · HRr)
b = 1.92 male
1.67 female
// as actually executed on session save:
restHR = 60 ← a constant, not your resting HR
mobile/src/store/sessionLogStore.ts:256–271, call site :887–901
Resting heart rate is hardcoded to 60 bpm in the path that computes the TRIMP actually stored against your session. Your measured resting HR is not used. If your true resting HR is 45, your heart-rate reserve — and therefore your TRIMP — is systematically understated; if it is 70, overstated.
Our exponent coefficients are Morton/Banister; the leading sex constant is not applied. Published Banister TRIMP carries a leading 0.64 (male) / 0.86 (female) scale factor. Ours does not. Koncur's TRIMP values are internally consistent and all thresholds on this page are calibrated on this scale, but they are not directly comparable to Banister TRIMP figures quoted elsewhere.
The strength-session cardiac cap described below applies to the budget side only. The TRIMP saved against a completed strength session is uncapped.
On the prescription side, an expected TRIMP is computed per session from zone midpoints — Z1 0.55 · Z2 0.65 · Z3 0.75 · Z4 0.85 · Z5 0.92 HRr — and a strength session's expected cardiac cost is capped at 25% of its theoretical ceiling, because the mechanical channel carries the real cost of lifting.
mobile/src/engine/load/prescribedLoad.ts:19–25, 163–172 · constants.ts:27
Mechanical work — kilojoules
For barbell lifts and bodyweight gymnastics, we compute actual mechanical work: mass moved against gravity through a range of motion, with an eccentric multiplier. This is the channel that makes a strength session legible as load rather than as a heart-rate non-event.
mass_kg = bwFraction × bodyweight + external_load
work_J = mass_kg × 9.81 × ROM_m × k_ecc × reps
kJ = round(Σ work_J / 1000, 1)
k_ecc = 1.3 default, per-movement overrides
(e.g. box jump 1.0 — no eccentric phase)
mobile/src/engine/load/movementLibrary.ts:291–305 · constants.ts:14
| Movement | bwFraction | ROM (m) |
|---|---|---|
| Back squat | 0.85 | 0.55 |
| Deadlift | 1.00 | 0.55 |
| Bench press | 0.00 + bar | 0.40 |
| Pull-up | 1.00 | 0.55 |
| Toe-to-bar | 0.33 | 1.05 |
| Box jump | 1.00 | 0.60 |
mobile/src/engine/load/movementLibrary.ts:30–70 — whole-body lifted-mass fractions after Humphrey & McGill 2007; partial-body segmental masses after Plagenhoef 1983 / Winter 2009
Movements where mechanical work in this sense is not a clean fit opt out explicitly and are marked cardiacOnly: plank, echo bike, assault bike, rower, ski erg, run, bike. Their cost lives in the TRIMP channel only. An unknown movement also falls back to cardiacOnly and the session card flags partial coverage rather than inventing a number.
Mechanical kJ is computed correctly, stored, and shown to you. No server-side system reads it. ACWR, the strain budget, recovery scoring and programme adaptation are all driven by cardiac TRIMP alone. In practice that means a strength-only athlete generates almost no load signal for those systems — the number on your session card is real, but nothing downstream currently acts on it.
Acute:chronic workload ratio
acute = ewma(daily TRIMP, 7d, α = 2/(7+1) = 0.250) chronic = ewma(daily TRIMP, 28d, α = 2/(28+1) ≈ 0.069) acwr = acute / max(chronic, 1) acwr < 0.8 undertraining 0.8 ≤ acwr < 1.3 sweet spot 1.3 ≤ acwr < 1.5 high acwr ≥ 1.5 overreaching // suppressed entirely below 14 distinct training days acwrReliable = distinctTrainingDays ≥ 14
recompute-training-load/index.ts:64–88, 259–261
We use the exponentially-weighted variant rather than rolling averages, and we return no value at all rather than a misleading one while the chronic baseline is still forming. The 0.8–1.3 band is Gabbett's.
Strain budget
strain_budget = round(readiness × phase_multiplier)
phase_multiplier = { Base 0.70, Build 1.00,
Peak 0.85, Taper 0.50,
Deload 0.50 }
strain_used = round(TRIMP_today / 4)
strain_left = max(0, budget − used)
recompute-training-load/index.ts:133, 186–190
Strain is a linear rescale of cardiac TRIMP, not an independent model. Base-phase budgets are deliberately below Build: aerobic base is built with volume and recovery, not maximal daily output.
Intensity distribution — measured, not prescribed
Koncur classifies how your last 28 days of time-in-zone actually split. This is a description of what you did, presented so you can act on it. It is not a target the generator enforces.
easy = Z1+Z2 moderate = Z3 hard = Z4+Z5 easy ≥ 75 ∧ hard ≥ 15 ∧ mod < 15 → polarized easy ≥ 70 ∧ mod ≥ 15 ∧ hard < 10 → pyramidal mod > 30 → threshold trap otherwise → unbalanced
recompute-training-load/index.ts:108–119
The classifier is real and the thresholds above are the shipped ones. The programme generator does not prescribe an 80/20 polarised distribution. There is no controller anywhere in the engine that targets an easy/hard ratio, and measured hard-session share across our own test personas ranges from roughly 42% down to 0% depending on training days and mode. If you have read an 80/20 claim about Koncur elsewhere, including in older versions of this page, it was aspirational. See On the roadmap.
03 — Recovery & readiness
Readiness
A 0–100 daily score from four independent signals. Components you cannot supply are excluded and the remaining weights renormalised — never zero-padded, which would silently punish you for not owning a device.
readiness = 0.30·sleep + 0.30·hrv
+ 0.20·rhr + 0.20·strain_debt
// present components only, renormalised to sum to 1
w_i = present_i ? weight_i / Σ(present weights) : 0
band = readiness ≥ 75 → high
| readiness ≥ 55 → moderate
| else → low
recompute-recovery/index.ts:99, 120–138
Sleep
duration = clamp(minutes / target × 100, 0, 100)
efficiency = clamp(asleep / in_bed × 100, 0, 100) (90 if no in-bed)
consistency = clamp(100 − waketime_stddev_min / 60 × 100, 0, 100)
restfulness = clamp(100 − wake_count×4 − awake_min/2, 0, 100)
sleep = 0.40·duration + 0.25·efficiency
+ 0.20·consistency + 0.15·restfulness
target = 450 min (7.5 h) default, configurable
if minutes < 240 → sleep capped at 50
recompute-recovery/index.ts:62, 245–268
Morning HRV
baseline = mean(RMSSD over trailing 28 d, excluding today) delta_pct = (today − baseline) / baseline × 100 hrv = clamp(70 + delta_pct × 1.5, 0, 100) // with no prior samples at all: seed baseline = 50 ms, flat
recompute-recovery/index.ts:286–301
Baseline is your 28-day mean, not a population norm — a "low" HRV for one athlete is unremarkable for another. The one exception is a brand-new account with no history, which falls back to a single flat 50 ms seed. That seed is not adjusted for age or sex.
Resting heart rate
baseline = median(RHR over trailing 28 d) needs ≥ 7 samples band = [ p25 , p75 ] of the same window rhr = clamp(70 − (today − baseline) × 5, 0, 100) drift, counting back from today, unbroken: 0 days above p75 → normal 1–2 days above p75 → drifting ≥ 3 days above p75 → elevated
recompute-recovery/index.ts:321–369
Three consecutive mornings above your own upper quartile is a signal worth acting on — illness, alcohol, or accumulated load. We surface it as a state, not a diagnosis, and we do not attach a probability to it.
Strain debt
acute = Σ TRIMP, days 1–7
chronic = Σ TRIMP, days 8–28, ÷ 3 (21 d → weekly equivalent)
ratio = min(3.0, acute / max(chronic, 1))
penalty = max(0, (ratio − 1) × 50)
+ (days_since_rest > 6 ? 30 : 0)
score = clamp(100 − penalty, 0, 100)
// fallbacks
no training in 28 d, or no TRIMP data → 80
fewer than 3 training days in the 21 d
chronic window ("baseline forming") → 70
recompute-recovery/index.ts:399–457
What readiness actually changes
Readiness is read by the aerobic session picker before it recommends a session. It must be no more than one day old and not flagged low-confidence, otherwise it is treated as unknown.
- Low readiness makes
recoverthe recommended tier: locked to Zone 1, fixed at 20–22 minutes, not scaled by training phase. Harder tiers stay visible with a check-in caution attached. - Moderate or unknown recommends
baseand cautions the VO₂ tier only.
design-session/index.ts:118–122 · design-session/logic.ts:159–179, 617–677
Nothing is taken away from you. A low readiness score changes what Koncur recommends and attaches a prompt to be honest about how you feel — it does not delete sets, force a rest day, or block a hard session you have decided to do. That is a deliberate design position, recorded in the code itself: agency over lockout.
Recovery tracking defaults off for new accounts. While it is off, recompute-recovery never runs — no readiness score, no strain budget, no ACWR, and no adaptive adjustment to your programme. Even with it on, the chain is triggered by opening and refreshing the Health tab, not by finishing a session. If you never turn recovery tracking on, you will train your full block on the programme exactly as it was generated.
Menstrual cycle phase is context, not a coefficient. Koncur computes and displays your current cycle phase with guidance, but it is not read by the readiness composite, any component, or the session picker. We would rather show you the context and let you interpret a low number than bake an untested multiplier into a score.
mobile/src/utils/menstrualPhase.ts — no reference in recompute-recovery or design-session
04 — Programme architecture
How a block is built
A programme is 8, 12 or 16 weeks, split into mesocycles of up to six weeks: five loading weeks and a deload.
| Length | Structure |
|---|---|
| 8 weeks | Base 1–5 · Deload 6 · Build 7–8 |
| 12 weeks | Base 1–5 · Deload 6 · Build 7–10 · Peak 11 · Deload 12 |
| 16 weeks | Base 1–5 · Deload 6 · Build 7–11 · Deload 12 · Peak 13–16 |
generate-programme/periodisation.ts:16–66
There are exactly four phases in the model — base, build, peak, deload. A taper is a transform layered on the final week when you have set a priority-A event (strength sets ×0.5, aerobic duration ×0.55, floored at 20 min). A realisation week replaces the terminal deload for advanced athletes on blocks of 12+ weeks with no event, converting primaries to a single AMRAP top set.
generate-programme/engine.ts:314–349, 849–914
Intensity by phase
Week-1 prescriptions, before the weekly ramp. Percentages are of your one-rep max; RIR is reps-in-reserve.
| Phase | Beginner | Intermediate | Advanced |
|---|---|---|---|
| Base | 3×10 @ 63% · RIR 3 | 4×8 @ 68% · RIR 3 | 4×8 @ 70% · RIR 2 |
| Build | 3×6 @ 73% · RIR 3 | 4×5 @ 78% · RIR 2 | 5×4 @ 81% · RIR 2 |
| Peak | 3×4 @ 80% · RIR 2 | 4×3 @ 85% · RIR 2 | 5×2 @ 88% · RIR 1 |
| Deload | 3×5 @ 65% · RIR 5 | 3×5 @ 65% · RIR 5 | 3×5 @ 65% · RIR 5 |
generate-programme/periodisation.ts:115–136, 163–165
// weekly ramp within a loading mesocycle pct1rm = base + (weekInMeso − 1) × 0.025 // hard ceiling per phase base ≤ 80% build ≤ 85% peak ≤ 92% // week 5 (test week): sets − 1, floor 3 // RIR tightens by 1 every 2 weeks, floor 0
generate-programme/periodisation.ts:167–190
The phase ceilings are set to keep prescribed set volume inside Prilepin's classical rep ranges for each intensity band — the reason peak tops out at 92% rather than climbing to a true max is that Prilepin's tables stop being descriptive above it.
Age and sex adjustments
- Age ≥ 65 — load −5%, rest +60 s, sets −1 (floor 2)
- Age ≥ 55 — load −2.5%, rest +30 s
- Female, age ≥ 45, lower-body, non-deload — load +2.5% (capped at 92%), for bone-density stimulus
generate-programme/periodisation.ts:220–236
Rest
| Phase | Beginner | Intermediate | Advanced |
|---|---|---|---|
| Base | 120 s | 150 s | 180 s |
| Build | 180 s | 210 s | 240 s |
| Peak | 210 s | 270 s | 300 s |
| Deload | 180 s | 180 s | 180 s |
Secondary lifts rest for 80% of the primary's prescription. Accessories are fixed at 90 s, or 60 s for timed and bodyweight-timed work, and are not age-adjusted.
generate-programme/periodisation.ts:117–134 · assembler.ts:943, 1095, 1203
Deload
Week 6 of every mesocycle (except the final short mesocycle of a 16-week block) is a real deload, not a token one. Concretely:
- Primaries and secondaries drop to a fixed 3×5 @ 65%, RIR 5 — no ramp, no RIR tightening
- Accessory working sets are halved (×0.5, floor 1)
- Aerobic quality sessions go to zero — one long session, everything else easy
- Aerobic main-block duration drops to 60%, and the long session is additionally capped at 45 minutes so a duration-clamped long ride deloads proportionally rather than nominally
generate-programme/periodisation.ts:163–165 · engine.ts:839–846 · meso-plan.ts:106–113 · assembler.ts:1447–1458
Measured across our persona test grid, that lands at roughly −33% aerobic minutes and −49% strength tonnage against the mesocycle's heaviest loading week.
Volume landmarks
Per-muscle weekly set landmarks are checked against the generated week and reported. Sets/week, after Pelland 2025:
| Muscle | Level | MV | MEV | MAV | MRV |
|---|---|---|---|---|---|
| Quads | Beginner | 3 | 6 | 8–12 | 16 |
| Quads | Intermediate | 4 | 8 | 12–16 | 20 |
| Quads | Advanced | 6 | 10 | 14–20 | 24 |
| Lats | Advanced | 6 | 10 | 16–22 | 26 |
| Hamstrings | Intermediate | 4 | 8 | 10–14 | 18 |
| Triceps | Beginner | 2 | 4 | 6–8 | 12 |
generate-programme/volume.ts:5–42, 92
All ten muscle groups have a full table across three levels. To be precise about what this does: the landmarks drive a volume report that checks the generated week — they are not a week-to-week ramp. Progressive overload in Koncur is delivered through the intensity ramp above, not through climbing set counts. A per-muscle volume ramp was built, tested, and reverted in May 2026 for overshooting MRV on some muscle groups; that code remains in the tree, unused.
05 — Progressive overload
What your loads are anchored to
Prescribed weights are a percentage of a stored one-rep max, snapshotted into the programme at the moment it is generated.
prescribed_kg = pct1rm × profiles.squat_1rm (bench / deadlift / press)
// frozen at generation into:
programme.strength_maxes = { squat, bench, deadlift, press }
generate-programme/index.ts:128–135 · engine.ts:1006–1011 · assembler.ts:277–297
The programme generator does not estimate a 1RM from your logged sets. It reads the 1RM stored on your profile and multiplies. Hitting a new PR in a session does not automatically raise the baseline your next block is built on — you have to update the number yourself. Automatic carry-forward of achieved loads is on the roadmap.
Estimated 1RM — Brzycki, for PRs
e1RM = weight / (1.0278 − 0.0278 × reps) reps = 1 → returns the weight unchanged reps > 10 → rejected; the formula stops being valid
recompute-strength-prs/index.ts:33–37 · sessionLogStore.ts:224
PR detection and the PR card use Brzycki, capped at 10 reps. Estimates are aggregated per canonical movement — "Barbell Back Squat", "Back Squat" and "Paused Squat" all roll into one lift — so you see one record per movement, not one per name variant. Brzycki is also used in reverse inside the periodisation model to derive the reps-in-reserve implied by a given percentage.
generate-programme/periodisation.ts:94–98
RPE and RIR
Base-phase work is prescribed as a percentage with an RIR label. Build and peak primaries are additionally anchored with an RPE target, mapped straight off the prescribed RIR and clamped to a usable band.
RPE = 10 − RIR, clamped to [7, 9.5]
generate-programme/assembler.ts:196–211
Autoregulation from logged RPE
There is a closed loop, and it lives outside the generator: a separate adaptation pass compares the RPE you logged against the RPE that was prescribed over the trailing seven days, and patches the next occurrence of that exercise.
drift = mean(actual RPE) − target RPE drift > +1.5 → load_modifier = −min(0.15, drift × 0.05) drift < −2.0 → load_modifier = +min(0.10, |drift| × 0.03)
adapt-programme/index.ts:32–33, 409–485
Adjustments are written as a layer on top of the generated programme; the underlying percentage curve is not rewritten. Bounds are deliberately tight — a maximum 15% cut or 10% bump on a single exercise occurrence.
Adaptation is chained off the training-load recomputation, which only runs when recovery tracking is enabled and the Health tab is refreshed. It does not run when you save a session, and it does not run at all for an athlete who has never turned recovery tracking on. If that describes you, your programme is exactly what was generated, unmodified, for the whole block.
Automatic deloads triggered by that same pass cut strength sets by 35% while holding intensity, and aerobic duration by 25%, with a limit of one auto-deload per 28 days.
adapt-programme/index.ts:29–31, 374, 390
An auto-deload writes an intensity-downgrade flag alongside the duration cut. Nothing in the app reads that flag. In practice an aerobic auto-deload shortens the session and leaves the prescribed zone untouched. This is a known defect, not a design choice, and it is on the roadmap to fix.
06 — Concurrent training
Rules that are enforced, and rules that only warn
Training strength and endurance in the same block is the harder problem, and the interference effect is dose- and modality-dependent: running blunts squat development more than cycling does, because of the eccentric load on the same motor units. Koncur encodes this in scheduling. Here is exactly what is binding.
Enforced in generation
- Heavy lower-body days are never adjacent. The scheduler guarantees no two heavy-lower sessions land on adjacent training-day indices.
- Aerobic sessions adjacent to a heavy-lower day are forced easy — the day before or the day after — unless the modality is cycling.
- A running PM session on a double day is forced easy.
generate-programme/selection.ts:481–494 · engine.ts:672–677, 726–727
Deliberate carve-outs
- A cycling PM double can still be a quality session. Cycling is concentric-dominant; the interference case for it is materially weaker than for running.
- A PM double following an upper-body AM session is also exempt — the legs are fresh.
generate-programme/engine.ts:704–725, 744–753
Advisory only
A post-generation validator re-checks the same rules and logs warnings. It mutates nothing. If you see us describe rule-checking, this is what it is: a safety net that tells us when generation produced something questionable, not a gate that blocks it.
generate-programme/engine.ts:64–119, 940
Maintenance doses
Both directions are supported as an explicit opt-in. A strength-led block can carry 1–2 easy cardio sessions per week; an aerobic-led block can carry a short heavy full-body lift — compound work, low reps, sized to keep tissue resilient rather than to drive hypertrophy.
generate-programme/selection.ts:642–664 · assembler.ts:851, 1814–1927
It isn't two programmes glued together. Bolting a powerlifting plan onto a marathon plan gets you the worst of both. Concurrent done properly is one week structure where the strength and aerobic recovery windows are aware of each other — which is why the rules above live in the scheduler rather than in a warning banner.
07 — Personalisation
The six-axis profile
Your profile is six axes, each derived from one benchmark you have supplied, each expressed as a position within your sex and age cohort.
| Axis | Input |
|---|---|
| Max Strength | Squat and/or deadlift 1RM ÷ bodyweight (averaged if both) |
| Strength Endurance | Reps at 70% of squat 1RM |
| Explosive Power | Peak power ÷ bodyweight (W/kg) |
| Running | 5K time |
| Erg | 2K row time |
| Endurance | FTP W/kg and/or 10K time (averaged if both) |
mobile/src/utils/performanceProfile.ts:213–374, 586–597 · _shared/performance-profile.ts:170–198
Cohorts and interpolation
There are four cohorts: male and female, under 35 and 35-plus. A single age boundary, not decade bands. Each cohort has five anchor points per metric; your value is linearly interpolated between adjacent anchors and clamped at the ends.
// Squat 1RM ÷ bodyweight — anchors at P10 / P30 / P50 / P75 / P95 M <35 0.75 1.25 1.75 2.25 2.75 M ≥35 0.65 1.15 1.65 2.10 2.55 F <35 0.55 0.95 1.30 1.65 2.05 F ≥35 0.45 0.85 1.20 1.55 1.90 // Deadlift 1RM ÷ bodyweight M <35 1.00 1.50 2.00 2.50 3.00 M ≥35 0.90 1.40 1.85 2.30 2.80 F <35 0.75 1.15 1.50 1.90 2.30 F ≥35 0.65 1.05 1.40 1.80 2.15
mobile/src/utils/performanceProfile.ts:94–154 · _shared/performance-profile.ts:92–139 — client and server tables verified identical
Values below the lowest anchor clamp to that percentile rather than extrapolating; the same at the top. Time-based axes (5K, 2K, 10K) use a descending interpolation so that faster is better, which is the correct behaviour and was a real bug once.
These anchors are engineering estimates, not a sampled population. They are hand-set reference points calibrated to published strength standards and our own athlete data. We call the output a percentile because that is what it is used as; we are not claiming a sampling frame behind it.
Weakness targeting
Your weakest axis is the lowest-scoring axis below the 50th percentile. What it changes, precisely:
- Explosive Power — a jump-training primer is added to lower-body days, outside deload and test weeks, subject to injury filters.
- Strength Endurance — the day's optional finisher becomes a farmer's carry.
- Running / Erg / Endurance — the finisher becomes a bike block, and the mesocycle's quality-session intent pool is reordered to lead with the matching intent (threshold for endurance and erg, VO₂max for running). The reorder only ever reprioritises intents already sanctioned for that phase; it never injects a new one.
- Max Strength — see the gate below.
generate-programme/assembler.ts:425–426, 487–524 · meso-plan.ts:87–93
// the strength-specific path requires ALL of: readyAxisCount ≥ 3 measured strength axes (maxStr, strEnd, expPow) ≥ 2 weakest of those < 45th percentile // only then: maxStr → squat becomes the de-facto focus lift, + a 4th back-off set strEnd → secondary lift reshaped to 4×10–12 @ RIR 3 (base/build)
generate-programme/index.ts:249–271 · assembler.ts:373–380, 1050–1064
Two things follow from that gate. First, if you have fewer than two measured strength benchmarks, the strength-specific response never fires — the app may still name a strength axis as your gap while nothing in the programme changes in response to it. Second, the "targeting your gap" card recomputes live from your current benchmarks, while the plan you are holding was built against the gap as it stood at generation. Log a fast 2K mid-block and the card can name a different axis than the one your programme was actually shaped around.
Estimated benchmarks
Onboarding offers an "I don't know my 1RM" path, which estimates squat, bench, deadlift and press from your bodyweight using a sex and experience-level multiplier table, rounded to 2.5 kg. Those estimates are written into the same profile columns as a tested max, with no flag marking them as estimates. Everything downstream — your Max Strength axis, your percentile, your prescribed weights — treats them as measured. Because both the estimate and the cohort table are bodyweight-scaled, the resulting percentile is largely a function of your sex and stated level rather than of anything you lifted. Replace them with real numbers as soon as you have them; it is the single biggest accuracy improvement available to you.
mobile/src/screens/onboarding/OnboardingScreen.tsx:337–368, 725–759
The one estimate the app does label is a Riegel-derived 10K feeding your Endurance axis when no FTP is present — that shows as a Day-1 estimate.
mobile/src/utils/performanceProfile.ts:456–476
08 — Known limits
What this page would be hiding if we let it
Every item here is a live defect or a real boundary, verified on 27 July 2026. They are listed because a methodology page that only lists strengths is marketing.
- TRIMP uses a fixed 60 bpm resting heart rate. Your measured resting HR is collected and displayed but not used in the load calculation.
- Our TRIMP omits Banister's leading sex coefficient (0.64 / 0.86). Values are internally consistent but not comparable to published Banister figures.
- Mechanical kilojoules are computed and displayed but read by no server system. A strength-only athlete produces very little load signal for ACWR, strain or adaptation.
- The app shows more than one estimated-1RM figure. PRs use Brzycki; some in-session and historical displays use Epley or Mayhew. For 100 kg × 5 those give 113, 116.7 and 119.0 kg respectively. Consolidating on one estimator is on the roadmap.
- Achieved loads do not carry forward. Setting a PR does not raise the 1RM your next programme is generated from.
- Aerobic long-session duration resets each mesocycle. The ramp is indexed on the week within the mesocycle, so week 7 of a 12-week block resembles week 1 rather than continuing from week 5.
- Aerobic auto-deloads reduce duration only. The intensity-downgrade flag is written and never read.
- Aerobic quality volume is capped by the weekly template. A three-day concurrent athlete gets one aerobic slot, which is the long session — meaning zero interval or threshold sessions per week under that configuration.
- Session duration labels are an estimate — twelve minutes per exercise plus ten, clamped to your session cap. They track the cap more closely than the prescribed work.
- Age-cohort percentiles for VO₂max, resting HR and HRV are not shipped. The tables exist in the codebase but no screen reads them. Any such comparison is roadmap, not product.
- FTP is self-reported. No test protocol runs in the app, and no estimate is derived from ride data.
- There is no swim model.
- Readiness, ACWR, strain and adaptation all require recovery tracking to be enabled, and are triggered by refreshing the Health tab rather than by saving a session.
Things we deliberately do not do
- No single fused "strain" number across modalities. Cardiac and mechanical load stay separate because adding them requires an exchange rate we cannot defend.
- No "fitness age." The underlying datasets are not precise enough to support fusing several metrics into one headline number.
- No caffeine, alcohol, hydration or mood coefficients. These are things you use to interpret a low score, not things we can measure well enough to weight.
- No menstrual-phase multiplier. Surfaced as context, never baked into a score.
- No lockouts. A low readiness score changes what we recommend, never what you are permitted to do.
09 — On the roadmap
Not yet shipped
Everything in this section describes work we intend to do. None of it is in the app today. It is stated in the future tense on purpose — if you find any of it written elsewhere in the present tense, that copy is wrong.
- A prescribed intensity distribution. We will add a controller that targets a polarised easy/hard split in the generated week, rather than only classifying the split after the fact.
- Cross-mesocycle aerobic progression, so long-session duration continues climbing from block to block instead of restarting.
- Automatic 1RM carry-forward from logged sessions, with a sanity clamp, so achieved loads feed the next generation.
- Per-benchmark provenance on the server, so an estimated 10K or an estimated 1RM is weighted differently from a tested one when the engine makes decisions.
- A single estimated-1RM formula across every surface in the app.
- Intensity reduction on aerobic deloads, wiring up the downgrade flag that is currently written and ignored.
- Mechanical load as a server-side signal, so strength work contributes to load, strain and adaptation.
- Load recomputation on session save, decoupled from the recovery toggle and from opening the Health tab.
- Age-cohort percentiles for VO₂max, resting HR and HRV, surfaced against published reference data.
- An in-app FTP test protocol, once live power capture is supported.
10 — References
Sources
Each entry names the thing in the code it is actually load-bearing for. References that decorated a claim we do not implement have been removed from this page.
- Riegel Riegel, P.S. (1981). Athletic records and human endurance. American Scientist 69(3). — the 1.06 exponent, used verbatim for 5K↔10K estimation.
- VDOT Daniels, J. & Gilbert, J. (1979). Oxygen Power; Daniels, J. Daniels' Running Formula, 3rd ed. — the VO₂/velocity and %-of-max relations, and the per-zone VDOT fractions.
- FTP zones Allen, H. & Coggan, A. (2010). Training and Racing with a Power Meter, 2nd ed. — the six %-FTP cycling zone boundaries.
- TRIMP Banister, E.W. (1991). Modeling elite athletic performance, in Physiological Testing of the High-Performance Athlete, Human Kinetics; sex-specific exponents per Morton, Fitz-Clarke & Banister (1990), Modeling human performance in running, J Appl Physiol 69(3). — the exponential HR-reserve weighting and the 1.92 / 1.67 exponents. Our implementation omits the leading sex constant; see Known limits.
- ACWR Gabbett, T.J. (2016). The training—injury prevention paradox. Br J Sports Med 50(5). — the 0.8–1.3 sweet-spot band and the requirement for a ~14-day chronic baseline before the ratio is meaningful.
- Segmental mass Plagenhoef, S., Evans, F.G. & Abdelnour, T. (1983). Anatomical data for analyzing human motion. Res Q Exerc Sport 54(2); Winter, D.A. (2009). Biomechanics and Motor Control of Human Movement, 4th ed. — partial-body segmental mass fractions in the mechanical load model.
- Lifted mass Humphrey, S. & McGill, S. (2007). Spinal loads during squats. Med Sci Sports Exerc. — whole-body lifted-mass fractions for barbell movements.
- Eccentric Hody, S., Croisier, J-L., Bury, T., Rogister, B. & Leprince, P. (2019). Eccentric muscle contractions: risks and benefits. Scand J Med Sci Sports. — the 1.3 default eccentric multiplier.
- Sleep Watson, N.F. et al. (2015). Recommended amount of sleep for a healthy adult. Sleep 38(6). — the 450-minute default sleep target.
- e1RM Brzycki, M. (1993). Strength testing: predicting a one-rep max from reps to fatigue. JOPERD 64(1). — the PR estimator and its ten-rep validity ceiling.
- Intensity caps Prilepin, A.S. — classical set/rep tables per intensity band; the basis for the 80% / 85% / 92% phase ceilings.
- Volume Pelland, J. et al. (2025). Dose-response of resistance-training volume. — the MV/MEV/MAV/MRV landmark tables.
- Interference Hickson, R.C. (1980). Interference of strength development by simultaneously training for strength and endurance. Eur J Appl Physiol 45; Wilson, J.M. et al. (2012). Concurrent training: a meta-analysis. J Strength Cond Res 26(8); Coffey, V.G. & Hawley, J.A. (2017). Concurrent exercise training: do opposites distract? J Physiol 595(9). — the modality-dependent interference effect behind heavy-lower spacing and the easy-aerobic-adjacency rule.
- Cycling carve-out Doma, K., Deakin, G.B. & Bentley, D.J. (2017). Implications of impaired endurance performance following single bouts of resistance training. Sports Med 47(11). — the concentric-dominant rationale for exempting cycling from the PM-double easy rule.
- Maintenance strength Rønnestad, B.R. & Mujika, I. (2014). Optimizing strength training for running and cycling endurance performance. Scand J Med Sci Sports 24(4). — the short heavy full-body maintenance lift offered on aerobic-led blocks.
- Maintenance cardio Murach, K.A. & Bagley, J.R. (2016). Skeletal muscle hypertrophy with concurrent exercise training: contrary evidence for an interference effect. Sports Med 46(8). — the 1–2×/week easy cardio dose on strength-led blocks.
- Taper Mujika, I. (2010). Intense training: the key to optimal performance before and during the taper. Scand J Med Sci Sports. — the volume-cut, intensity-preserved taper transform on priority-A event weeks.
- Distribution Seiler, S. (2010). What is best practice for training intensity and duration distribution in endurance athletes? Int J Sports Physiol Perform 5(3); Stöggl, T. & Sperlich, B. (2014). Polarized training has greater impact on key endurance variables than threshold. Front Physiol. — the thresholds behind the polarized / pyramidal / threshold-trap classifier. Note this is a descriptive classifier only; see Training load.
Found an error, or a claim here that does not match what the app does? hello@koncur.app. We would rather be corrected than believed.