API reference¶
Everything below is importable straight from acwr.
Checking the log¶
acwr.check_load ¶
check_load(load: Series, *, acute: int = 7, chronic: int = 28, break_days: int = 10, outlier_factor: float = 3.0, unit_shift_factor: float = 50.0) -> LoadQuality
Report what is wrong, or merely surprising, about a daily load series.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
acute
|
int
|
The windows the series is about to be used with. Only used to judge whether the history is long enough to support them. |
7
|
chronic
|
int
|
The windows the series is about to be used with. Only used to judge whether the history is long enough to support them. |
7
|
break_days
|
int
|
A run of this many consecutive zero-load days is reported as a break. Default 10. |
10
|
outlier_factor
|
float
|
A day is reported as an outlier when its load exceeds this multiple of the 95th percentile of training days. Default 3.0. The comparison is against the upper tail rather than the median deliberately. Training load is strongly right-skewed: a long run or a race is several times a typical day and is not an error. Measured on gamma-distributed loads, the largest of 400 days routinely sits five times the median and under 2.3 times the 95th percentile, so a rule written against the median would flag every athlete's longest run and be ignored within a week. The watch-left-running record that motivated this check sat around four times the 95th percentile. |
3.0
|
unit_shift_factor
|
float
|
A suspected change of unit is reported when the median training load of one half of the series differs from the other by more than this factor. Default 50.0, chosen to sit well above any plausible change in fitness and well below the thousandfold step of kilometres against metres. |
50.0
|
Returns:
| Type | Description |
|---|---|
LoadQuality
|
Carrying every finding, with |
Notes
Severities mean specific things. An error makes the computation meaningless: no training at all, or a history shorter than the chronic window. A warning changes the answer without invalidating it: a suspected unit change, a long break, an extreme dynamic range. A note is something to be aware of when reading the output, such as a series that is mostly rest.
Nothing here is corrected automatically. Whether a thirty day gap was a planned break or a month of unsynced training is not a question a library can answer, and guessing would propagate silently into every number that follows.
Examples:
>>> import pandas as pd
>>> idx = pd.date_range("2026-01-01", periods=40, freq="D")
>>> load = pd.Series([10.0] * 40, index=idx)
>>> report = check_load(load)
>>> report.ok
True
A series shorter than the chronic window cannot produce a ratio at all,
and saying so is more useful than returning a column of NaN:
>>> short = pd.Series([10.0] * 20, index=pd.date_range("2026-01-01", periods=20))
>>> report = check_load(short)
>>> report.ok
False
>>> print(report.errors[0].message)
20 days of history is shorter than the 28 day chronic window
Source code in src/acwr/quality.py
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | |
acwr.LoadQuality
dataclass
¶
What :func:check_load found, and whether anything is disqualifying.
Source code in src/acwr/quality.py
errors
property
¶
Findings that make a computed ratio meaningless rather than suspect.
warnings
property
¶
Findings that change the answer without invalidating it.
ok
property
¶
True when nothing disqualifying was found.
A series can be ok and still carry warnings. The distinction is
between a series that cannot support the computation at all and one
that supports it with caveats the user should know about.
acwr.Finding
dataclass
¶
One thing worth knowing about a load series before using it.
Source code in src/acwr/quality.py
Building the daily series¶
acwr.daily_load ¶
daily_load(activities: DataFrame, date_col: str = 'date', load_col: str = 'load', missing: Literal['zero', 'na'] = 'zero', start: str | Timestamp | None = None, end: str | Timestamp | None = None) -> pd.Series
Aggregate activities to one load value per calendar day.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
activities
|
DataFrame
|
One row per activity. Several activities on the same day are summed. |
required |
date_col
|
str
|
Name of the date column. Parsed with :func: |
'date'
|
load_col
|
str
|
Name of the column holding the load value. Any numeric quantity works:
session RPE, duration in minutes, distance, training stress score. The
ratio is scale free, so the unit only has to be consistent. A value
that is present but cannot be read as a number raises, because under
|
'load'
|
missing
|
Literal['zero', 'na']
|
|
'zero'
|
start
|
str | Timestamp | None
|
Optional bounds for the returned series. Defaults to the first and last
dates present in |
None
|
end
|
str | Timestamp | None
|
Optional bounds for the returned series. Defaults to the first and last
dates present in |
None
|
Returns:
| Type | Description |
|---|---|
Series
|
Load indexed by a complete daily :class: |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
ValueError
|
If |
Examples:
>>> import pandas as pd
>>> acts = pd.DataFrame(
... {"date": ["2026-01-01", "2026-01-01", "2026-01-04"],
... "load": [30.0, 20.0, 60.0]}
... )
>>> daily_load(acts)
2026-01-01 50.0
2026-01-02 0.0
2026-01-03 0.0
2026-01-04 60.0
Freq: D, Name: load, dtype: float64
Two activities on 1 January are summed, and the two days with no training are filled with zero rather than skipped.
Source code in src/acwr/load.py
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | |
The ratio¶
acwr.acwr ¶
acwr(load: Series, acute: int = 7, chronic: int = 28, method: Method = 'rac', smooth: int | None = None, min_periods: int | None = None, return_components: bool = False, decay: Decay = 'span', smooth_center: bool = False) -> pd.Series | pd.DataFrame
Compute the acute:chronic workload ratio.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
acute
|
int
|
Length of the acute window in days. Default 7. |
7
|
chronic
|
int
|
Length of the chronic window in days. Default 28. Must exceed |
28
|
method
|
Method
|
|
'rac'
|
smooth
|
int | None
|
If given, apply a trailing rolling mean of this many days to the ratio itself. Off by default, because smoothing the ratio after the fact makes it harder to say what any single value represents. |
None
|
decay
|
Decay
|
Only used when |
'span'
|
smooth_center
|
bool
|
Whether the smoothing window is centred. |
False
|
min_periods
|
int | None
|
Minimum observations required before a value is produced. Defaults to
a full window, so the first |
None
|
return_components
|
bool
|
If |
False
|
Returns:
| Type | Description |
|---|---|
Series or DataFrame
|
The ratio, named |
Notes
Days on which chronic load is zero give inf where acute load is
positive and NaN where both are zero. Dividing by a zero chronic load is
not meaningful, so these are left as they are rather than being filled with
a number that would look like a result.
The coupled form has a ceiling that the uncoupled form does not. Written
with sums instead of means, RAC = (chronic / acute) * (S_acute /
S_chronic), and the acute window sits inside the chronic one, so
S_acute <= S_chronic and the ratio cannot exceed chronic / acute.
At the usual 7 and 28 day settings that ceiling is exactly 4.0. A reading
of 4.0 therefore does not mean four times the usual load, it means every
unit of load in the last month was accumulated in the last week.
Coupling also compresses the ratio toward 1.0 from either side. RAC
reads lower than RAU on any day the ratio is above 1 and higher on any
day it is below 1. Since every published threshold sits above 1.0, in the
region that gets monitored the coupled form can only understate.
Examples:
>>> import pandas as pd
>>> load = pd.Series(
... [50.0] * 28 + [100.0] * 7,
... index=pd.date_range("2026-01-01", periods=35, freq="D"),
... )
>>> r = acwr(load, method="rac")
>>> round(float(r.iloc[-1]), 4)
1.6
After four steady weeks at 50 and then one week at 100, the coupled ratio reads 1.6 rather than 2.0, because the hard week sits inside the chronic window too and has pulled the denominator up from 50 to 62.5. The uncoupled form does not do that:
Source code in src/acwr/ratio.py
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 | |
acwr.acute_load ¶
acute_load(load: Series, acute: int = 7, method: Method = 'rac', min_periods: int | None = None, decay: Decay = 'span') -> pd.Series
Short term load average.
For "rac" and "rau" this is the rolling mean over the last acute
days inclusive of the current day. For "ewma" it is an exponentially
weighted mean whose smoothing constant comes from :func:_alpha.
Source code in src/acwr/ratio.py
acwr.chronic_load ¶
chronic_load(load: Series, acute: int = 7, chronic: int = 28, method: Method = 'rac', min_periods: int | None = None, decay: Decay = 'span') -> pd.Series
Long term load average.
The window depends on the method. "rac" averages the last chronic
days inclusive of today, so it overlaps the acute window. "rau" averages
the chronic - acute days that end the day before the acute window
begins, so there is no overlap. "ewma" uses an exponentially weighted
mean whose smoothing constant comes from :func:_alpha.
Source code in src/acwr/ratio.py
Foster's companion metrics¶
acwr.srpe_load ¶
srpe_load(duration_min: Series | DataFrame, rpe: Series | None = None, *, duration_col: str = 'duration_min', rpe_col: str = 'rpe') -> pd.Series
Session RPE load: duration in minutes multiplied by rated exertion.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
duration_min
|
Series | DataFrame
|
Session durations in minutes, or a frame holding both columns. |
required |
rpe
|
Series | None
|
Rated perceived exertion for each session. Required when
|
None
|
duration_col
|
str
|
Column names to use when a frame is passed. |
'duration_min'
|
rpe_col
|
str
|
Column names to use when a frame is passed. |
'duration_min'
|
Returns:
| Type | Description |
|---|---|
Series
|
Load in arbitrary units, named |
Notes
The RPE scale is not checked against a fixed range. Foster's work uses the 0 to 10 category ratio scale, but the 6 to 20 Borg scale is also reported, and a scale conversion applied silently would be worse than none. Negative durations or ratings are rejected, since neither is meaningful.
Examples:
>>> import pandas as pd
>>> srpe_load(pd.Series([60.0, 45.0]), pd.Series([6.0, 8.0])).tolist()
[360.0, 360.0]
The example is deliberate: an hour at RPE 6 and three quarters of an hour at RPE 8 produce the same number, which is the central assumption of the method and the main thing people dispute about it.
Source code in src/acwr/foster.py
acwr.monotony ¶
monotony(load: Series, window: int = 7, *, ddof: int = 1, min_periods: int | None = None) -> pd.Series
Training monotony: mean daily load over a window divided by its spread.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
window
|
int
|
Length of the trailing window in days. Default 7. |
7
|
ddof
|
int
|
Delta degrees of freedom for the standard deviation. |
1
|
min_periods
|
int | None
|
Minimum observations required before a value is produced. Defaults to a full window. |
None
|
Returns:
| Type | Description |
|---|---|
Series
|
Monotony, named |
Notes
A window in which every day carries the same load has zero spread, so
monotony is inf when that load is positive and NaN when the window
is entirely rest. Both are left as they are. The inf is not a defect in
the data: a week of seven identical sessions really is the maximum of what
this statistic measures, and replacing it with a large finite number would
hide that.
Applied work often quotes 2.0 as the level above which monotony is a concern. That threshold rests on the same body of evidence as the ACWR thresholds, so this function reports the number and does not band it.
Examples:
>>> import pandas as pd
>>> idx = pd.date_range("2026-01-01", periods=7, freq="D")
>>> steady = pd.Series([50.0] * 7, index=idx)
>>> float(monotony(steady).iloc[-1])
inf
A week with a rest day and a hard day is far less monotonous than one that repeats the same session, even at an identical weekly total:
>>> varied = pd.Series([0.0, 80.0, 40.0, 0.0, 90.0, 60.0, 80.0], index=idx)
>>> round(float(monotony(varied).iloc[-1]), 3)
1.321
Source code in src/acwr/foster.py
acwr.strain ¶
strain(load: Series, window: int = 7, *, ddof: int = 1, min_periods: int | None = None) -> pd.Series
Training strain: total load over a window scaled by its monotony.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
window
|
int
|
Length of the trailing window in days. Default 7. |
7
|
ddof
|
int
|
Passed to :func: |
1
|
min_periods
|
int | None
|
Minimum observations required before a value is produced. |
None
|
Returns:
| Type | Description |
|---|---|
Series
|
Strain, named |
Notes
Strain carries the units of load multiplied by a dimensionless ratio, so its scale depends entirely on the load unit chosen. Strain computed from session RPE load is not comparable with strain computed from kilometres, and neither is comparable across athletes using different RPE scales. The quantity is useful within one athlete's own history and misleading between athletes.
Because strain multiplies by monotony, it inherits the inf of a
perfectly even window.
Examples:
>>> import pandas as pd
>>> idx = pd.date_range("2026-01-01", periods=7, freq="D")
>>> varied = pd.Series([0.0, 80.0, 40.0, 0.0, 90.0, 60.0, 80.0], index=idx)
>>> round(float(strain(varied).iloc[-1]), 1)
462.2
Source code in src/acwr/foster.py
acwr.weekly_blocks ¶
Monotony and strain per calendar week, which is Foster's original form.
The rolling versions in :func:monotony and :func:strain answer on every
day. This answers once per week, over blocks that do not overlap, which is
how the quantities were originally reported and what to use when comparing
against a published table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
ddof
|
int
|
Delta degrees of freedom for the standard deviation, as in
:func: |
1
|
week_ends
|
str
|
Pandas resampling rule naming the day a week ends on. The default
|
'W-SUN'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per week, indexed by the week ending date, with columns
|
Notes
A week in which any covered day is unobserved reports NaN throughout,
which matches what the rolling :func:monotony does on the same week.
Only :func:acwr.daily_load with missing="na" produces unobserved
days; under the default "zero" every day is a real observation and no
week is ever suppressed on this ground.
A partial week at either end of the series covers fewer than seven days
and is reported as it stands, since every day it does cover was observed.
n_days says how many days the row is built from, so check it before
comparing the ends against full weeks.
Examples:
>>> import pandas as pd
>>> idx = pd.date_range("2026-01-05", periods=14, freq="D") # a Monday
>>> load = pd.Series([10.0, 0.0, 20.0, 10.0, 0.0, 30.0, 10.0] * 2, index=idx)
>>> weekly_blocks(load)[["n_days", "total", "monotony"]].round(3)
n_days total monotony
2026-01-11 7 80.0 1.069
2026-01-18 7 80.0 1.069
Source code in src/acwr/foster.py
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | |
Week-to-week change¶
acwr.week_to_week_change ¶
week_to_week_change(load: Series, window: int = 7, *, as_percent: bool = True, min_periods: int | None = None) -> pd.Series
Change in trailing-window load against the window before it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
window
|
int
|
Length of each block in days. Default 7, so the comparison is the last seven days against the seven before them. |
7
|
as_percent
|
bool
|
|
True
|
min_periods
|
int | None
|
Minimum observations required in each window. Defaults to a full
window, so the first |
None
|
Returns:
| Type | Description |
|---|---|
Series
|
Named |
Notes
A week following complete rest gives inf, since any positive load is an
infinite increase on zero. That is arithmetically correct and practically
the single most important case in the whole metric, which is a fair summary
of the problem with it: the number is least informative exactly where the
risk is most discussed.
Two consecutive weeks of complete rest give NaN.
This is a trailing comparison computed every day, not a weekly report. For
the calendar-week form, use :func:block_change.
Examples:
>>> import pandas as pd
>>> idx = pd.date_range("2026-01-01", periods=14, freq="D")
>>> load = pd.Series([10.0] * 7 + [12.0] * 7, index=idx)
>>> round(float(week_to_week_change(load).iloc[-1]), 1)
20.0
Seventy units became eighty-four, which is a rise of twenty percent. Coming back from a week off, the same function has nothing useful to say:
>>> back = pd.Series([0.0] * 7 + [10.0] * 7, index=idx)
>>> float(week_to_week_change(back).iloc[-1])
inf
Source code in src/acwr/progression.py
acwr.block_change ¶
Calendar-week totals and the change between consecutive weeks.
The form most training logs are read in: one row per week, each compared with the week above it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
as_percent
|
bool
|
As in :func: |
True
|
week_ends
|
str
|
Pandas resampling rule naming the day a week ends on. Changing it moves the boundary, and a long run sitting either side of that boundary moves with it. |
'W-SUN'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Indexed by week ending date, with |
Notes
A week in which any covered day is unobserved reports NaN for its
total and its change, matching :func:acwr.weekly_blocks. The first and
last rows may cover fewer than seven days, which n_days reports, so
check it before reading the ends as weekly changes.
Examples:
>>> import pandas as pd
>>> idx = pd.date_range("2026-01-05", periods=21, freq="D") # a Monday
>>> load = pd.Series([10.0] * 7 + [11.0] * 7 + [13.0] * 7, index=idx)
>>> block_change(load)[["total", "change"]].round(2)
total change
2026-01-11 70.0 NaN
2026-01-18 77.0 10.00
2026-01-25 91.0 18.18
Source code in src/acwr/progression.py
A squad rather than one athlete¶
acwr.by_athlete ¶
by_athlete(activities: DataFrame, func: Callable[..., Series | DataFrame], *, athlete_col: str = 'athlete', date_col: str = 'date', load_col: str = 'load', missing: str = 'zero', common_calendar: bool = False, **kwargs: Any) -> pd.DataFrame
Apply a per-athlete computation to a frame holding several athletes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
activities
|
DataFrame
|
Long frame with one row per activity, carrying an athlete identifier, a date and a load. |
required |
func
|
Callable[..., Series | DataFrame]
|
Any function taking a daily load Series and returning a Series or a
DataFrame indexed the same way. :func: |
required |
athlete_col
|
str
|
Column names in |
'athlete'
|
date_col
|
str
|
Column names in |
'athlete'
|
load_col
|
str
|
Column names in |
'athlete'
|
missing
|
str
|
Passed to :func: |
'zero'
|
common_calendar
|
bool
|
If |
False
|
**kwargs
|
Any
|
Forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Long frame with the athlete column, the date column, and whatever
columns |
Raises:
| Type | Description |
|---|---|
KeyError
|
If any named column is absent. |
ValueError
|
If the frame is empty, or if an athlete identifier is missing. |
Notes
Athletes are processed independently and in sorted order, so no athlete's
training can leak into another's window. An athlete whose history is
shorter than the chronic window yields all-NaN rows rather than being
dropped, which keeps the roster visible in the output.
Source code in src/acwr/panel.py
Checking whether the ratio means anything¶
acwr.rescaling_check ¶
rescaling_check(load: Series, acute: int = 7, chronic: int = 28, method: Method = 'rac', draws: int = 2000, seed: int = 0) -> RescalingResult
Correlate the ratio against the acute load on its own.
The ratio is acute load divided by chronic load. When chronic load varies little relative to acute load, the division barely changes the ordering of days, and the ratio becomes a rescaled copy of its numerator. That is the core of the Impellizzeri critique and it can be checked without any injury data.
Returns:
| Type | Description |
|---|---|
RescalingResult
|
Spearman and Pearson correlations between the ratio and the acute load, with a bootstrap interval on the Spearman. |
Examples:
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(1)
>>> load = pd.Series(
... rng.gamma(2.0, 25.0, 400),
... index=pd.date_range("2026-01-01", periods=400, freq="D"),
... )
>>> res = rescaling_check(load)
>>> res.spearman > 0.8
True
On noisy day to day training the ratio tracks the acute load closely, which is exactly the point the critique makes.
Source code in src/acwr/diagnostics.py
acwr.compare_to_null ¶
compare_to_null(load: Series, outcome: Series, acute: int = 7, chronic: int = 28, method: Method = 'rac', kind: NullKind = 'shuffle', n_null_draws: int = 200, boot_draws: int = 2000, seed: int = 0) -> NullComparison
Run the Impellizzeri test on your own data.
Computes the association between outcome and the real ratio, then repeats
it n_null_draws times against ratios whose chronic denominator has been
replaced by a contrivance, and reports both.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load series, as from :func: |
required |
outcome
|
Series
|
Aligned to |
required |
kind
|
NullKind
|
Which contrivance to use for the denominator. See
:func: |
'shuffle'
|
n_null_draws
|
int
|
How many null series to generate. The spread across these draws is what the null interval describes. |
200
|
Returns:
| Type | Description |
|---|---|
NullComparison
|
Both statistics with bootstrap intervals, and a
:attr: |
Notes
This is a descriptive comparison, not a significance test, and it says nothing about causation. A real ratio inside its own null interval means the chronic denominator added nothing on this dataset. It does not establish that the ratio is useless everywhere, and a result outside the interval does not establish that the ratio predicts injury.
Source code in src/acwr/diagnostics.py
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | |
acwr.null_chronic_acwr ¶
null_chronic_acwr(load: Series, acute: int = 7, chronic: int = 28, method: Method = 'rac', kind: NullKind = 'shuffle', seed: int = 0) -> pd.Series
Build an ACWR whose denominator carries no real information.
The numerator is the genuine acute load. The denominator is replaced, following Impellizzeri et al. (2021), by one of three contrivances.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
NullKind
|
|
'shuffle'
|
seed
|
int
|
Seeds the draw so a given null series is reproducible. |
0
|
Returns:
| Type | Description |
|---|---|
Series
|
A ratio series named |
Source code in src/acwr/diagnostics.py
acwr.window_sensitivity ¶
window_sensitivity(load: Series, acute_options: Sequence[int] = (5, 7, 10, 14), chronic_options: Sequence[int] = (21, 28, 35, 42), *, threshold: float = 1.5, method: Method = 'rac', reference: tuple[int, int] = (7, 28), decay: Decay = 'span') -> WindowSensitivity
Ask how much the answer depends on the windows nobody justifies.
Seven and twenty-eight days are conventional. They are not derived from
anything, and papers that use them rarely say why. This sweeps a grid of
window pairs, flags the days each one puts above threshold, and reports
how far those sets of days agree with the pair you would have used anyway.
Agreement is the Jaccard index: the number of days both pairs flag divided by the number either flags. A value of 1.0 means the choice of windows made no difference to which days got flagged. A value of 0.5 means half the flagged days came from the window choice rather than from the training.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
acute_options
|
Sequence[int]
|
Window lengths to sweep. Pairs where the chronic window does not exceed the acute one are skipped. |
(5, 7, 10, 14)
|
chronic_options
|
Sequence[int]
|
Window lengths to sweep. Pairs where the chronic window does not exceed the acute one are skipped. |
(5, 7, 10, 14)
|
threshold
|
float
|
The value above which a day counts as flagged. Default 1.5, which is the figure applied practice quotes most often. |
1.5
|
method
|
Method
|
Which ratio to compute, as in :func: |
'rac'
|
reference
|
tuple[int, int]
|
The pair everything is compared against. Default |
(7, 28)
|
decay
|
Decay
|
Passed through when |
'span'
|
Returns:
| Type | Description |
|---|---|
WindowSensitivity
|
Holding |
Notes
Days are compared only where both the pair and the reference produce a
finite ratio, so a long chronic window is not penalised for the extra
warm-up days it needs. A pair sharing no comparable day with the
reference, which happens when its chronic window is longer than the
series, reports an agreement of NaN rather than 1.0. The reference
pair must be inside the grid.
This is descriptive. A low agreement does not prove the metric is worthless, and a high one does not validate it. What it does is put a number on a choice that usually goes unexamined.
Source code in src/acwr/diagnostics.py
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 | |
acwr.threshold_from_history ¶
threshold_from_history(load: Series, percentiles: Sequence[float] = (50, 75, 90, 95, 99), *, acute: int = 7, chronic: int = 28, method: Method = 'rac', reference: float = 1.5, decay: Decay = 'span') -> ThresholdReport
Put a universal threshold next to the athlete it is being applied to.
The figure quoted in applied practice is 1.5, the same number for a marathoner in a base block and a sprinter in competition. This reports what that number actually picks out of one athlete's own history, and what cut would be needed to pick out a given share of their hardest days instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
percentiles
|
Sequence[float]
|
Which percentiles of this athlete's ratio distribution to report. |
(50, 75, 90, 95, 99)
|
acute
|
int
|
Passed to :func: |
7
|
chronic
|
int
|
Passed to :func: |
7
|
method
|
int
|
Passed to :func: |
7
|
decay
|
int
|
Passed to :func: |
7
|
reference
|
float
|
The universal threshold to compare against. Default 1.5. |
1.5
|
Returns:
| Type | Description |
|---|---|
ThresholdReport
|
|
Notes
Infinite ratios, which a return from complete rest produces under the uncoupled and exponentially weighted methods, are excluded from the percentiles and counted as flagged. Leaving them in would make every percentile above the first infinite one meaningless.
This is descriptive and is not a recommendation to use a personalised threshold. A threshold chosen from an athlete's own distribution flags a fixed share of their days by construction, whatever their training was like, which is a different failure from the one it fixes. The point of the function is to make the arbitrariness of the universal number visible rather than to replace it with a different arbitrary number.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> load = pd.Series(
... rng.gamma(2.0, 30.0, 400),
... index=pd.date_range("2025-01-01", periods=400, freq="D"),
... )
>>> report = threshold_from_history(load)
>>> report.reference_flagged_fraction < 0.05
True
Source code in src/acwr/diagnostics.py
597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 | |
Result objects¶
acwr.RescalingResult
dataclass
¶
How much of the ratio is explained by its own numerator.
Source code in src/acwr/diagnostics.py
acwr.NullComparison
dataclass
¶
Association with an outcome for the real ratio and for a null ratio.
Source code in src/acwr/diagnostics.py
real_beats_null
property
¶
True only if the real statistic exceeds the null interval.
Deliberately one sided. A real ratio that lands below its null is not
beating anything, and an earlier version of this property returned
True in that case, which read as a pass when it was the opposite.
Use :attr:real_outside_null for the two sided question.
This is descriptive rather than a hypothesis test.
real_outside_null
property
¶
True if the real statistic falls outside the null interval either way.
Being outside it below is itself informative: it means the real chronic denominator was actively costing you signal that a random one left alone. With the coupled method that happens when the outcome responds to acute load, because the shared window damps the spikes.
acwr.WindowSensitivity
dataclass
¶
How much the flagged days depend on the window lengths you chose.
Source code in src/acwr/diagnostics.py
agreement_range
property
¶
Lowest and highest agreement with the reference pair.
acwr.ThresholdReport
dataclass
¶
What a universal threshold means for one athlete's own distribution.
Source code in src/acwr/diagnostics.py
Plots¶
Both need matplotlib, an optional dependency: pip install 'acwr[plot]'.
acwr.plot.plot_acwr ¶
plot_acwr(load: Series, acute: int = 7, chronic: int = 28, *, methods: tuple[Method, ...] = ('rac',), decay: Decay = 'span', axes: tuple[Axes, Axes] | None = None, figsize: tuple[float, float] = (11.0, 6.0)) -> tuple[Figure, tuple[Axes, Axes]]
Daily load above, the ratio below, one scale each.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
load
|
Series
|
Daily load indexed by a complete :class: |
required |
acute
|
int
|
Window lengths, passed to :func: |
7
|
chronic
|
int
|
Window lengths, passed to :func: |
7
|
methods
|
tuple[Method, ...]
|
Which methods to draw. One by default. Up to three may be given, and they are drawn in the order passed with a legend. |
('rac',)
|
decay
|
Decay
|
Passed through for the exponentially weighted method. |
'span'
|
axes
|
tuple[Axes, Axes] | None
|
An existing pair of axes to draw on. A new figure is made when this is omitted. |
None
|
figsize
|
tuple[float, float]
|
Size of the new figure, ignored when |
(11.0, 6.0)
|
Returns:
| Type | Description |
|---|---|
(Figure, (Axes, Axes))
|
The figure and the two axes, load first. |
Raises:
| Type | Description |
|---|---|
ImportError
|
If matplotlib is not installed. |
ValueError
|
If more than three methods are given, or none. |
Notes
Infinite ratios are dropped rather than clipped to the top of the axis, because a line touching the top of a chart reads as a large finite value and an infinite ratio is a different statement.
No threshold band is drawn. The horizontal line at 1.0 is there because a ratio of 1.0 means this week matched the recent average, which is a fact about division rather than a claim about injury.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> load = pd.Series(
... rng.gamma(2.0, 30.0, 120),
... index=pd.date_range("2026-01-01", periods=120, freq="D"),
... )
>>> fig, (top, bottom) = plot_acwr(load)
>>> bottom.get_ylabel()
'acute : chronic'
Source code in src/acwr/plot.py
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | |
acwr.plot.plot_window_sensitivity ¶
plot_window_sensitivity(result: WindowSensitivity, *, value: str = 'agreement', ax: Axes | None = None, figsize: tuple[float, float] = (7.0, 5.0)) -> tuple[Figure, Axes]
Draw the window sweep as a grid, acute across and chronic down.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
WindowSensitivity
|
A :class: |
required |
value
|
str
|
Which column to shade: |
'agreement'
|
ax
|
Axes | None
|
An existing axes to draw on. |
None
|
figsize
|
tuple[float, float]
|
Size of the new figure, ignored when |
(7.0, 5.0)
|
Returns:
| Type | Description |
|---|---|
(Figure, Axes)
|
|
Raises:
| Type | Description |
|---|---|
ImportError
|
If matplotlib is not installed. |
KeyError
|
If |
Notes
Magnitude gets one hue running light to dark, never a rainbow, so the ordering of the colours matches the ordering of the numbers. Every cell is labelled with its value as well as shaded, which is what lets the figure be read without relying on colour. Cells with no comparable day are left blank rather than shaded at zero.
The reference pair is outlined, because the figure's point is how far the other pairs sit from it.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import acwr
>>> rng = np.random.default_rng(0)
>>> load = pd.Series(
... rng.gamma(2.0, 30.0, 400),
... index=pd.date_range("2025-01-01", periods=400, freq="D"),
... )
>>> fig, ax = plot_window_sensitivity(acwr.window_sensitivity(load))
>>> ax.get_xlabel()
'acute window (days)'
Source code in src/acwr/plot.py
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | |