Files
platform/docs/level-calculator.md

116 lines
5.8 KiB
Markdown

# The Level Calculator
## Introduction
The level calculator turns a player's raw performance on a handful of physical
tests — how much they can squat, how fast they run a mile, how far they can
broad jump — into a single, easy-to-understand number: their **level** for
each attribute, and an overall player level.
The intent is to make performance legible and comparable across people of
different ages, weights, and genders. A 45-year-old squatting 225 lb and a
22-year-old squatting 225 lb are not doing the same thing physiologically, so
raw numbers alone aren't a fair yardstick. Instead, each activity is measured
against **standards**: tables of "what performance corresponds to what level"
for a given age/weight/gender, built from published strength and athletic
performance research. A player's level for an activity is found by comparing
their performance against the standard for players like them; their level for
an attribute (e.g. Strength) is the rounded average of their levels across
that attribute's activities; their overall player level is the rounded average
across all four attributes.
The guiding principle is: **use real external standards as ground truth
wherever they exist, and only ever generate/extrapolate around that ground
truth** — never invent numbers from nothing. Standards data is also fully
config-driven (via `apps/portal`'s calculator config/dataset pages), so it can
be tuned or replaced without a code change.
## Attributes and activities
| Attribute | Activity | Unit |
| --- | --- | --- |
| Strength | Back Squat, Deadlift, Bench Press | kg |
| Power | Broad Jump | cm |
| Endurance | 1 Mile Run | ms |
| Agility | 3 Cone Drill | ms |
## Sources
The raw standards tables (before any generation/extrapolation) come from:
| Activity | Source |
| --- | --- |
| Back Squat, Deadlift, Bench Press | [Lon Kilgore Strength Standard Tables (2023)](http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf) |
| 1 Mile Run | [runninglevel.com — 1 mile times](https://runninglevel.com/running-times/1-mile-times) |
| Broad Jump | [nrpt.co.uk — broad jump power test](https://nrpt.co.uk/training/tests/power/broad.htm) |
| 3 Cone Drill | [nflsavant.com combine data](https://nflsavant.com/combine.php) |
These are also recorded per-activity as a `source` field on each activity's metadata
in the seeded standards dataset
(`apps/api/src/database/seed-data/standards-config.json`), which is what's
actually loaded at runtime — the config is editable from the portal, so that
seed file (and this document) may drift from whatever standards are live.
## How a level is calculated
`LevelCalculator.calculate` (`packages/calculator/src/index.ts`):
1. For each activity performance the player submitted, look up the standard
for that activity at the player's exact age/weight/gender (interpolated —
see below), and find the level whose value is numerically closest to the
player's performance (`findLevel`).
2. Average the levels of all activities belonging to the same attribute,
rounded to the nearest whole level. That's the attribute level.
3. Average all four attribute levels, rounded, for the overall player level.
If any required input is missing (a metric, or a performance of `0` or less),
the calculator returns level `0` rather than guessing.
## How the standards tables are built
The raw source data only covers a handful of discrete levels, ages, and
weights. `Standards` (`packages/calculator/src/index.ts`) expands that into a
continuous table through a fixed pipeline, run once per config:
1. **Stretch** — the raw data defines 5 base levels. To support fewer/more
levels below/above those 5, an exponential-decay curve
(`A·e^(-B·i) + C`) is fit (via Levenberg-Marquardt) to the ratio between
consecutive levels, then used to extrapolate additional levels in either
direction, per `stretch.lower` / `stretch.upper` config.
2. **Expand / compress** — every standard's level count is resampled to a
single configurable `maxLevel`: expansion linearly inserts intermediate
levels, compression proportionally resamples down.
3. **Skew** — each activity has a `difficultyModifier` multiplier applied
uniformly across its levels, to make an activity easier or harder relative
to its source data.
4. **Age generation** — for ages missing from the source data, a standard is
derived from the nearest reference age using a parabolic falloff centered
on a configurable `peakAge` (steepness controlled by `ageModifier`,
clamped to a `[0.2, 10.0]` multiplier), scaled against average bodyweight
for that age (`avg-weights.json`). Real data always takes precedence over
generated data.
5. **Weight generation** — for weights missing from the source data, a
standard is derived from the reference weight using allometric scaling:
`newLevel = refLevel * (weight / refWeight) ^ weightModifier`. Again, real
data always takes precedence.
## Finding a player's standard
Given a player's exact age/weight/gender, `interpolateByAgeAndWeight` performs
bilinear interpolation across the nearest surrounding age and weight entries
in the (by now fully generated) standards table, producing a standard specific
to that player. `findLevel` then maps their submitted performance onto the
nearest level in that standard.
## Where this lives in code
- `packages/calculator/src/index.ts` — `LevelCalculator`, `Standards`
- `packages/calculator/src/models.ts` — schemas/types (`StandardsData`,
`StandardsParams`, etc.)
- `packages/calculator/src/avg-weights.ts` + `data/avg-weights.json` —
average bodyweight by age/gender, used in age generation
- `apps/api/src/services/calculator.ts` — loads the active `StandardsConfig`
from the database and constructs `LevelCalculator`
- `apps/api/src/database/seed-data/standards-config.json` — seeded standards
data/params, including per-activity `source` citations