Metric registry
sportsdataverse/registry/metrics.yaml is the one source for how a published
football metric is displayed: its label, short label, axis label, number
format, polarity, family, qualifier, glossary slug and per-basis variants. Game
on Paper and the web platform generate their TypeScript copies from it, so a
label or a "lower is better" rule changes in one place. Descriptions are not
here; they stay in manual_column_descriptions.yaml.
The file
One entry per base metric (EPAplay, success, havoc, ...), in a
constrained YAML the package reads without PyYAML (PyYAML is a dev-only
dependency, so the wheel cannot use it; the test suite pins the reader against
PyYAML on the real file):
- key: EPAplay
label: EPA/Play
short: EPA/Play
axis: EPA per play
format: num2
polarity: higher
family: efficiency
qualifier: null
glossary_slug: null
variants:
total: TEPA
per_play: EPAplay
per_game: EPAgame
per_drive: EPAdrive
| key | meaning |
|---|---|
key | the base column name, as the producer publishes it |
label | the full title (Game on Paper's SDV_BASE_METRIC_TITLES, verbatim) |
short | the column header; the resolver rewrites it for a phase (EPA/Play → EPA/DB / EPA/Rush, SR% → Pass SR%) |
axis | the chart axis label |
format | num2 / num1 / pct1 / int — Game on Paper's [1,2,2] / [1,2,1] / [100,2,1] / [1,2,0] formatting triples |
polarity | higher or lower, from the offense or player perspective. Transcribed from the producer's rank directions (cfbfastR-cfb-data team_summaries.py): havoc, play_stuffed, third_down_distance and start_position are lower |
family | efficiency, explosiveness, volume, tendency, situational, drive, adjusted. Consumers must not colour tendency (passrate, pass_oe, ...): style, not quality |
qualifier | dropbacks, carries, targets or null — the denominator a phase-bound metric is measured against (EPAdropback, line_yards, catchpct) |
glossary_slug | the web glossary anchor, or null until one exists |
variants | the per-basis siblings of the same quantity ({} when none); every sibling lists the whole group |
A plain - key: list with two-space fields and a four-space variants: block is
the whole grammar; anything else is a loud ValueError from
load_metric_registry(), which also checks the ten keys, the enums, key
uniqueness and that every variant names a registered metric.
The resolver
resolve(column) maps any published column onto its entry:
base [_off | _def | _margin] [_pass | _rush] [_rank | _pct | _n | _pos_pct | _conf_pct]
The exact column is tried before any suffix is peeled, so a registered X_pct
is its own entry rather than X + _pct. Only the adjusted family also
accepts the prefix and infix spellings its columns use (adj_off_epa,
adj_def_epa, net_adj_epa, off_strength_faced); def_pass_rate resolves
to nothing. The resolver returns the entry's ten keys plus side, phase,
suffix (each None when absent) and the effective polarity:
_defflips the base's polarity:third_down_distance_defishigher,havoc_defishigher,adj_def_epaislower._margin(andnet_) is alwayshigher. The producer writes every margin as good-minus-bad (EPAplay_off - EPAplay_def, buthavoc_def - havoc_offandstart_position_def - start_position_off) and ranks them all descending, so a margin never inherits alowerbase.- anything else keeps the base's.
label and short are rewritten for a phase but carry no Off / Def /
Net side prefix; Game on Paper prepends that itself.
from sportsdataverse.registry import load_metric_registry, resolve
resolve("EPAplay_off_pass_rank")
# {'key': 'EPAplay', 'label': 'EPA/Dropback', 'short': 'EPA/DB', ..., 'side': 'off',
# 'phase': 'pass', 'suffix': '_rank', 'polarity': 'higher'}
resolve("not_a_metric") # None
len(load_metric_registry()) # one dict per base metric
The TypeScript module
uv run python -m sportsdataverse.registry --ts --target gop # 4-space indent (astro)
uv run python -m sportsdataverse.registry --ts --target web # 2-space indent (prettier)
Both targets export the same shape — METRICS: Record<string, MetricEntry> and
resolveMetric(column): ResolvedMetric | null, a line-for-line port of
resolve() (the optional side / phase / suffix are omitted rather than
undefined). The output is deterministic and headed by three comment lines:
// generated by sportsdataverse.registry
// sportsdataverse 0.1.4 -- target gop. The sha256 below covers the body (...), not the whole file; ...
// sha256: <hex digest of everything below these three lines>
The digest covers the body -- everything after the three header lines -- not the whole file, so a consumer verifies its copy was regenerated rather than hand-edited by hashing exactly that:
const lines = source.split("\n")
const expected = lines[2].replace("// sha256: ", "")
const body = lines.slice(3).join("\n") // everything after the three header lines
// sha256(body) === expected, else the file was hand-edited
The version line is outside the digest but inside the file, so a consumer that diffs its committed copy against a fresh render (Game on Paper's sync test) goes red on every sdv-py bump. That is by design: bumping the dependency is the moment to regenerate.
Game on Paper keeps astro/src/resources/metricRegistry.ts honest with that
sync test; the web platform regenerates frontend/lib/platform/metricRegistry.ts
from a sibling sdv-py checkout and checks the body digest in its own tests.
Adding or changing a metric
- Edit
sportsdataverse/registry/metrics.yaml(one entry per base metric; a new sibling joins every member'svariants). uv run pytest tests/test_metric_registry.py -q— the reader/PyYAML parity, the enum checks and the Game on Paper title oracle all run offline.- Regenerate each consumer's copy with the command above and commit it there.