Skip to content

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:~pandas.DatetimeIndex, as returned by :func:acwr.daily_load.

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 ok false when any is an error.

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
def check_load(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`, as
        returned by :func:`acwr.daily_load`.
    acute, chronic
        The windows the series is about to be used with. Only used to judge
        whether the history is long enough to support them.
    break_days
        A run of this many consecutive zero-load days is reported as a break.
        Default 10.
    outlier_factor
        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.
    unit_shift_factor
        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.

    Returns
    -------
    LoadQuality
        Carrying every finding, with ``ok`` false when any is an error.

    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
    """
    load = _as_daily(load)
    findings: list[Finding] = []

    n_days = len(load)
    observed = load.dropna()
    training = observed[observed > 0]
    n_training = len(training)

    # --- things that make the computation meaningless --------------------
    if n_days < chronic:
        findings.append(
            Finding(
                "history",
                "error",
                f"{n_days} days of history is shorter than the "
                f"{chronic} day chronic window",
            )
        )
    if n_training == 0:
        findings.append(
            Finding("no training", "error", "every observed day is zero or missing")
        )

    if (observed < 0).any():
        bad = observed[observed < 0]
        findings.append(
            Finding(
                "negative load",
                "error",
                f"{len(bad)} day(s) carry a negative load",
                tuple(bad.index[:5]),
            )
        )

    # --- things that change the answer ------------------------------------
    missing = int(load.isna().sum())
    if missing:
        findings.append(
            Finding(
                "unobserved days",
                "warning",
                f"{missing} day(s) are NaN rather than zero, so every window "
                "touching them yields NaN",
                tuple(load.index[load.isna()][:5]),
            )
        )

    if n_training >= 10:
        upper = float(np.percentile(training.to_numpy(), 95))
        big = training[training > outlier_factor * upper]
        if len(big):
            findings.append(
                Finding(
                    "outlier days",
                    "warning",
                    f"{len(big)} day(s) exceed {outlier_factor:g} times the 95th "
                    f"percentile training day of {upper:.4g}, the largest being "
                    f"{float(big.max()):.4g}",
                    tuple(big.nlargest(5).index),
                )
            )

        half = len(load) // 2
        first = training[training.index < load.index[half]]
        second = training[training.index >= load.index[half]]
        if len(first) >= 5 and len(second) >= 5:
            a, b = float(first.median()), float(second.median())
            lo, hi = sorted((a, b))
            if lo > 0 and hi / lo > unit_shift_factor:
                findings.append(
                    Finding(
                        "suspected unit change",
                        "warning",
                        f"the median training day is {a:.4g} in the first half of "
                        f"the series and {b:.4g} in the second, a factor of "
                        f"{hi / lo:.0f}. A change of recording unit is far more "
                        "likely than a change of that size in training",
                    )
                )

        smallest = float(training.min())
        largest = float(training.max())
        if smallest > 0 and largest / smallest > 1e9:
            findings.append(
                Finding(
                    "extreme dynamic range",
                    "warning",
                    f"training loads span {largest / smallest:.1e}, which is wide "
                    "enough for the rolling sums to lose precision",
                )
            )

    longest, ends = _longest_zero_run(load.fillna(0.0))
    if longest >= break_days:
        findings.append(
            Finding(
                "break",
                "warning",
                f"the longest run of zero-load days is {longest} days, ending "
                f"{ends.date() if ends is not None else 'unknown'}. The ratio on "
                "the days after it is dominated by the break rather than by the "
                "training",
                (ends,) if ends is not None else (),
            )
        )

    # --- things to keep in mind when reading the output --------------------
    if n_days:
        rest_fraction = 1.0 - (n_training / n_days)
        if rest_fraction > 0.5:
            findings.append(
                Finding(
                    "mostly rest",
                    "note",
                    f"{100 * rest_fraction:.0f}% of days carry no load, so the "
                    "chronic window is mostly zeros and the ratio will be "
                    "volatile",
                )
            )

    if n_days >= chronic:
        usable = n_days - chronic + 1
        if usable < 30:
            findings.append(
                Finding(
                    "few usable days",
                    "note",
                    f"only {usable} day(s) will carry a ratio once the "
                    f"{chronic} day warm-up is taken out",
                )
            )

    if n_training and float(training.min()) == float(training.max()):
        findings.append(
            Finding(
                "constant load",
                "note",
                "every training day carries the same load, so the ratio is 1.0 "
                "wherever it is defined and monotony is infinite",
            )
        )

    return LoadQuality(
        n_days=n_days, n_training_days=n_training, findings=tuple(findings)
    )

acwr.LoadQuality dataclass

What :func:check_load found, and whether anything is disqualifying.

Source code in src/acwr/quality.py
@dataclass(frozen=True)
class LoadQuality:
    """What :func:`check_load` found, and whether anything is disqualifying."""

    n_days: int
    n_training_days: int
    findings: tuple[Finding, ...] = field(default_factory=tuple)

    @property
    def errors(self) -> tuple[Finding, ...]:
        """Findings that make a computed ratio meaningless rather than suspect."""
        return tuple(f for f in self.findings if f.severity == "error")

    @property
    def warnings(self) -> tuple[Finding, ...]:
        """Findings that change the answer without invalidating it."""
        return tuple(f for f in self.findings if f.severity == "warning")

    @property
    def ok(self) -> bool:
        """``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.
        """
        return len(self.errors) == 0

    def __str__(self) -> str:  # pragma: no cover - presentation only
        head = (
            f"Load quality: {self.n_days} days, {self.n_training_days} with training, "
            f"{len(self.errors)} error(s), {len(self.warnings)} warning(s)"
        )
        if not self.findings:
            return head + "\n  nothing to report"
        return "\n".join([head, *(str(f) for f in self.findings)])

errors property

errors: tuple[Finding, ...]

Findings that make a computed ratio meaningless rather than suspect.

warnings property

warnings: tuple[Finding, ...]

Findings that change the answer without invalidating it.

ok property

ok: bool

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
@dataclass(frozen=True)
class Finding:
    """One thing worth knowing about a load series before using it."""

    check: str
    severity: Severity
    message: str
    dates: tuple[pd.Timestamp, ...] = ()

    def __str__(self) -> str:  # pragma: no cover - presentation only
        mark = {"error": "ERROR", "warning": "warn ", "note": "note "}[self.severity]
        return f"  [{mark}] {self.check}: {self.message}"

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:pandas.to_datetime and truncated to calendar days, so a timestamp is fine.

'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 missing="zero" it would otherwise become a rest day and take its load with it.

'load'
missing Literal['zero', 'na']

"zero" fills days with no activity with 0.0, treating them as rest. "na" leaves them as NaN, which propagates through the rolling windows and yields NaN ratios for any window that touches a gap.

'zero'
start str | Timestamp | None

Optional bounds for the returned series. Defaults to the first and last dates present in activities. Both are truncated to calendar days, so a bound carrying a time of day behaves the same as the date alone.

None
end str | Timestamp | None

Optional bounds for the returned series. Defaults to the first and last dates present in activities. Both are truncated to calendar days, so a bound carrying a time of day behaves the same as the date alone.

None

Returns:

Type Description
Series

Load indexed by a complete daily :class:~pandas.DatetimeIndex, named "load".

Raises:

Type Description
KeyError

If date_col or load_col is not present.

ValueError

If activities is empty, if any load value is negative, or if missing is not one of the two accepted values.

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
def daily_load(
    activities: pd.DataFrame,
    date_col: str = "date",
    load_col: str = "load",
    missing: Literal["zero", "na"] = "zero",
    start: str | pd.Timestamp | None = None,
    end: str | pd.Timestamp | None = None,
) -> pd.Series:
    """Aggregate activities to one load value per calendar day.

    Parameters
    ----------
    activities
        One row per activity. Several activities on the same day are summed.
    date_col
        Name of the date column. Parsed with :func:`pandas.to_datetime` and
        truncated to calendar days, so a timestamp is fine.
    load_col
        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
        ``missing="zero"`` it would otherwise become a rest day and take its
        load with it.
    missing
        ``"zero"`` fills days with no activity with 0.0, treating them as rest.
        ``"na"`` leaves them as ``NaN``, which propagates through the rolling
        windows and yields ``NaN`` ratios for any window that touches a gap.
    start, end
        Optional bounds for the returned series. Defaults to the first and last
        dates present in ``activities``. Both are truncated to calendar days,
        so a bound carrying a time of day behaves the same as the date alone.

    Returns
    -------
    pandas.Series
        Load indexed by a complete daily :class:`~pandas.DatetimeIndex`, named
        ``"load"``.

    Raises
    ------
    KeyError
        If ``date_col`` or ``load_col`` is not present.
    ValueError
        If ``activities`` is empty, if any load value is negative, or if
        ``missing`` is not one of the two accepted values.

    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.
    """
    if missing not in ("zero", "na"):
        raise ValueError(f"missing must be 'zero' or 'na', got {missing!r}")
    for col in (date_col, load_col):
        if col not in activities.columns:
            raise KeyError(f"column {col!r} not found in activities")
    if len(activities) == 0:
        raise ValueError("activities is empty")

    dates = pd.to_datetime(activities[date_col]).dt.normalize()
    raw = activities[load_col]
    loads = pd.to_numeric(raw, errors="coerce").astype(float)

    # A value that was present but could not be read is not a rest day, and
    # under missing="zero" it would silently become one. Refuse it instead:
    # "1,200" with a thousands separator costs 1200 units of load and leaves
    # no trace. A value that was already missing in the input is a different
    # thing and is left to the `missing` policy.
    unreadable = loads.isna() & raw.notna()
    if unreadable.any():
        bad = raw[unreadable].astype(str).unique()[:5].tolist()
        raise ValueError(
            f"{int(unreadable.sum())} value(s) in {load_col!r} could not be read "
            f"as numbers, for example {bad}. Clean or drop them rather than "
            "letting them become rest days."
        )

    if (loads < 0).any():
        raise ValueError("load values must be non-negative")

    per_day = (
        pd.DataFrame({"date": dates, "load": loads})
        .groupby("date", sort=True)["load"]
        .sum(min_count=1)
    )

    # Normalise the bounds. The activity dates are already at midnight, so a
    # bound carrying a time of day would build a date_range offset from them,
    # match nothing on reindex, and return a series of rest days.
    lo = pd.Timestamp(start).normalize() if start is not None else per_day.index.min()
    hi = pd.Timestamp(end).normalize() if end is not None else per_day.index.max()
    if lo > hi:
        raise ValueError(f"start {lo.date()} is after end {hi.date()}")

    full = pd.date_range(lo, hi, freq="D")
    series = per_day.reindex(full)
    if missing == "zero":
        series = series.fillna(0.0)

    series.index.name = None
    return series.rename("load")

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:~pandas.DatetimeIndex, as returned by :func:acwr.daily_load.

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 acute.

28
method Method

"rac" rolling average coupled, "rau" rolling average uncoupled, or "ewma" exponentially weighted. Default "rac", which is what the published literature almost always means by ACWR.

'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 method="ewma". "span" sets the smoothing constant to 2 / (N + 1), which is what Williams et al. (2017) specify, and is the default. "half_life" sets it to 1 - exp(-ln 2 / N), which is what the R package Athlytics uses. The same N means different things under the two, so comparing output across packages requires matching this.

'span'
smooth_center bool

Whether the smoothing window is centred. False by default and that default is deliberate: a centred window averages days either side of the one being reported, so today's smoothed value would depend on training that has not happened yet. For monitoring in real time the window has to be trailing. Set it to True only for retrospective plots, where using the future is acceptable because all of it is already known.

False
min_periods int | None

Minimum observations required before a value is produced. Defaults to a full window, so the first chronic - 1 days are NaN rather than being computed from a partial history. The exponentially weighted method has no window to fill, so it takes the same chronic warm-up by default, which keeps the three methods comparable on the same days.

None
return_components bool

If True, return a frame with load, acute, chronic and acwr columns instead of only the ratio. Useful when checking whether a high ratio came from a hard week or from an unusually easy month.

False

Returns:

Type Description
Series or DataFrame

The ratio, named "acwr", or the four column frame.

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:

>>> round(float(acwr(load, method="rau").iloc[-1]), 4)
2.0
Source code in src/acwr/ratio.py
def acwr(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`, as
        returned by :func:`acwr.daily_load`.
    acute
        Length of the acute window in days. Default 7.
    chronic
        Length of the chronic window in days. Default 28. Must exceed ``acute``.
    method
        ``"rac"`` rolling average coupled, ``"rau"`` rolling average uncoupled,
        or ``"ewma"`` exponentially weighted. Default ``"rac"``, which is what
        the published literature almost always means by ACWR.
    smooth
        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.
    decay
        Only used when ``method="ewma"``. ``"span"`` sets the smoothing constant
        to ``2 / (N + 1)``, which is what Williams et al. (2017) specify, and is
        the default. ``"half_life"`` sets it to ``1 - exp(-ln 2 / N)``, which is
        what the R package Athlytics uses. The same ``N`` means different things
        under the two, so comparing output across packages requires matching
        this.
    smooth_center
        Whether the smoothing window is centred. ``False`` by default and that
        default is deliberate: a centred window averages days either side of the
        one being reported, so today's smoothed value would depend on training
        that has not happened yet. For monitoring in real time the window has to
        be trailing. Set it to ``True`` only for retrospective plots, where
        using the future is acceptable because all of it is already known.
    min_periods
        Minimum observations required before a value is produced. Defaults to
        a full window, so the first ``chronic - 1`` days are ``NaN`` rather
        than being computed from a partial history. The exponentially weighted
        method has no window to fill, so it takes the same ``chronic`` warm-up
        by default, which keeps the three methods comparable on the same days.
    return_components
        If ``True``, return a frame with ``load``, ``acute``, ``chronic`` and
        ``acwr`` columns instead of only the ratio. Useful when checking whether
        a high ratio came from a hard week or from an unusually easy month.

    Returns
    -------
    pandas.Series or pandas.DataFrame
        The ratio, named ``"acwr"``, or the four column frame.

    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:

    >>> round(float(acwr(load, method="rau").iloc[-1]), 4)
    2.0
    """
    load = _as_daily(load)
    _check_periods(acute, chronic)
    if method not in METHODS:
        raise ValueError(f"method must be one of {METHODS}, got {method!r}")

    a = acute_load(
        load, acute=acute, method=method, min_periods=min_periods, decay=decay
    )
    c = chronic_load(
        load,
        acute=acute,
        chronic=chronic,
        method=method,
        min_periods=min_periods,
        decay=decay,
    )

    with np.errstate(divide="ignore", invalid="ignore"):
        ratio = a / c
    ratio = ratio.rename("acwr")

    if smooth is not None:
        if smooth < 1:
            raise ValueError("smooth must be at least 1 day")
        # A full window either way. With min_periods=1 a centred smoother
        # emits a value for any day with a single non-NaN neighbour, which
        # back-fills the chronic warm-up with what is really a one-day mean.
        ratio = (
            ratio.rolling(window=smooth, center=smooth_center, min_periods=smooth)
            .mean()
            .rename("acwr")
        )

    if return_components:
        return pd.DataFrame({"load": load, "acute": a, "chronic": c, "acwr": ratio})
    return ratio

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
def acute_load(
    load: pd.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`.
    """
    load = _as_daily(load)
    if acute < 1:
        raise ValueError("acute must be at least 1 day")
    mp = acute if min_periods is None else min_periods
    if method == "ewma":
        return (
            load.ewm(alpha=_alpha(acute, decay), adjust=False, min_periods=mp)
            .mean()
            .rename("acute")
        )
    return load.rolling(window=acute, min_periods=mp).mean().rename("acute")

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
def chronic_load(
    load: pd.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`.
    """
    load = _as_daily(load)
    _check_periods(acute, chronic)

    if method == "ewma":
        # An exponentially weighted mean has no window to fill, so without a
        # warm-up it reports a ratio on day one, where both legs equal that
        # day's load and the ratio is exactly 1.0 by construction. Defaulting
        # to the same `chronic` warm-up as the other two methods keeps the
        # three comparable on the same days, which the diagnostics rely on.
        mp = chronic if min_periods is None else min_periods
        return (
            load.ewm(alpha=_alpha(chronic, decay), adjust=False, min_periods=mp)
            .mean()
            .rename("chronic")
        )

    if method == "rac":
        mp = chronic if min_periods is None else min_periods
        return load.rolling(window=chronic, min_periods=mp).mean().rename("chronic")

    if method == "rau":
        window = chronic - acute
        mp = window if min_periods is None else min_periods
        # Average the `window` days that end `acute` days ago, so the acute
        # window and the chronic window share no days at all.
        return (
            load.shift(acute)
            .rolling(window=window, min_periods=mp)
            .mean()
            .rename("chronic")
        )

    raise ValueError(f"method must be one of {METHODS}, got {method!r}")

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 duration_min is a Series, ignored when it is a frame.

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 "load".

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
def srpe_load(
    duration_min: pd.Series | pd.DataFrame,
    rpe: pd.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
    ----------
    duration_min
        Session durations in minutes, or a frame holding both columns.
    rpe
        Rated perceived exertion for each session. Required when
        ``duration_min`` is a Series, ignored when it is a frame.
    duration_col, rpe_col
        Column names to use when a frame is passed.

    Returns
    -------
    pandas.Series
        Load in arbitrary units, named ``"load"``.

    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.
    """
    if isinstance(duration_min, pd.DataFrame):
        missing = {duration_col, rpe_col} - set(duration_min.columns)
        if missing:
            raise KeyError(f"frame is missing column(s): {sorted(missing)}")
        dur = duration_min[duration_col].astype(float)
        eff = duration_min[rpe_col].astype(float)
    else:
        if rpe is None:
            raise ValueError("rpe is required when duration_min is a Series")
        dur = duration_min.astype(float)
        eff = rpe.astype(float)
        if not dur.index.equals(eff.index):
            raise ValueError("duration and rpe must share an index")

    if (dur.dropna() < 0).any():
        raise ValueError("session duration cannot be negative")
    if (eff.dropna() < 0).any():
        raise ValueError("RPE cannot be negative")
    return (dur * eff).rename("load")

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:~pandas.DatetimeIndex, as returned by :func:acwr.daily_load. Rest days must be present as zeros; see the module docstring for why.

required
window int

Length of the trailing window in days. Default 7.

7
ddof int

Delta degrees of freedom for the standard deviation. 1 is the sample standard deviation and the default; 0 is the population one. For a seven day window ddof=1 gives a value about 7 percent lower than ddof=0, so numbers from different tools are only comparable when this matches.

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 "monotony".

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
def monotony(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`, as
        returned by :func:`acwr.daily_load`. Rest days must be present as
        zeros; see the module docstring for why.
    window
        Length of the trailing window in days. Default 7.
    ddof
        Delta degrees of freedom for the standard deviation. ``1`` is the
        sample standard deviation and the default; ``0`` is the population one.
        For a seven day window ``ddof=1`` gives a value about 7 percent lower
        than ``ddof=0``, so numbers from different tools are only comparable
        when this matches.
    min_periods
        Minimum observations required before a value is produced. Defaults to
        a full window.

    Returns
    -------
    pandas.Series
        Monotony, named ``"monotony"``.

    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
    """
    load = _as_daily(load)
    if window < 2:
        raise ValueError("window must be at least 2 days; a spread needs two points")
    if ddof not in (0, 1):
        raise ValueError(f"ddof must be 0 or 1, got {ddof!r}")
    # No further guard is needed: window is at least 2 and ddof at most 1, so
    # there is always at least one degree of freedom.

    mp = window if min_periods is None else min_periods
    roll = load.rolling(window=window, min_periods=mp)
    mean = roll.mean()
    sd = roll.std(ddof=ddof)
    with np.errstate(divide="ignore", invalid="ignore"):
        out = mean / sd
    return out.rename("monotony")

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:~pandas.DatetimeIndex.

required
window int

Length of the trailing window in days. Default 7.

7
ddof int

Passed to :func:monotony.

1
min_periods int | None

Minimum observations required before a value is produced.

None

Returns:

Type Description
Series

Strain, named "strain".

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
def strain(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    window
        Length of the trailing window in days. Default 7.
    ddof
        Passed to :func:`monotony`.
    min_periods
        Minimum observations required before a value is produced.

    Returns
    -------
    pandas.Series
        Strain, named ``"strain"``.

    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
    """
    load = _as_daily(load)
    mp = window if min_periods is None else min_periods
    total = load.rolling(window=window, min_periods=mp).sum()
    mono = monotony(load, window=window, ddof=ddof, min_periods=min_periods)
    return (total * mono).rename("strain")

acwr.weekly_blocks

weekly_blocks(load: Series, *, ddof: int = 1, week_ends: str = 'W-SUN') -> pd.DataFrame

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:~pandas.DatetimeIndex.

required
ddof int

Delta degrees of freedom for the standard deviation, as in :func:monotony.

1
week_ends str

Pandas resampling rule naming the day a week ends on. The default "W-SUN" ends weeks on Sunday. A training week that runs Monday to Sunday and one that runs Sunday to Saturday will not agree, and the disagreement is larger than it looks, because moving the boundary moves the long run between blocks.

'W-SUN'

Returns:

Type Description
DataFrame

One row per week, indexed by the week ending date, with columns n_days, n_observed, total, mean, sd, monotony and strain.

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
def weekly_blocks(
    load: pd.Series,
    *,
    ddof: int = 1,
    week_ends: str = "W-SUN",
) -> pd.DataFrame:
    """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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    ddof
        Delta degrees of freedom for the standard deviation, as in
        :func:`monotony`.
    week_ends
        Pandas resampling rule naming the day a week ends on. The default
        ``"W-SUN"`` ends weeks on Sunday. A training week that runs Monday to
        Sunday and one that runs Sunday to Saturday will not agree, and the
        disagreement is larger than it looks, because moving the boundary moves
        the long run between blocks.

    Returns
    -------
    pandas.DataFrame
        One row per week, indexed by the week ending date, with columns
        ``n_days``, ``n_observed``, ``total``, ``mean``, ``sd``, ``monotony``
        and ``strain``.

    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
    """
    load = _as_daily(load)
    grouped = load.resample(week_ends)
    # `size` counts the calendar days the block covers; `count` counts the
    # days actually observed. Resample aggregations skip NaN, so without this
    # a week with four recorded days out of seven would report a complete
    # looking monotony computed from four.
    n_days = grouped.size()
    n_observed = grouped.count()
    out = pd.DataFrame(
        {
            "n_days": n_days,
            "n_observed": n_observed,
            "total": grouped.sum(min_count=1),
            "mean": grouped.mean(),
            "sd": grouped.std(ddof=ddof),
        }
    )
    incomplete = out["n_observed"] < out["n_days"]
    out.loc[incomplete, ["total", "mean", "sd"]] = np.nan
    with np.errstate(divide="ignore", invalid="ignore"):
        out["monotony"] = out["mean"] / out["sd"]
    out["strain"] = out["total"] * out["monotony"]
    out.index.name = load.index.name
    return out

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:~pandas.DatetimeIndex.

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 returns a percentage, so 15.0 means a 15 percent rise. False returns a plain ratio, where the same rise is 0.15.

True
min_periods int | None

Minimum observations required in each window. Defaults to a full window, so the first 2 * window - 1 days are NaN.

None

Returns:

Type Description
Series

Named "week_to_week".

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
def week_to_week_change(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    window
        Length of each block in days. Default 7, so the comparison is the last
        seven days against the seven before them.
    as_percent
        ``True`` returns a percentage, so 15.0 means a 15 percent rise.
        ``False`` returns a plain ratio, where the same rise is 0.15.
    min_periods
        Minimum observations required in each window. Defaults to a full
        window, so the first ``2 * window - 1`` days are ``NaN``.

    Returns
    -------
    pandas.Series
        Named ``"week_to_week"``.

    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
    """
    load = _as_daily(load)
    if window < 1:
        raise ValueError("window must be at least 1 day")

    mp = window if min_periods is None else min_periods
    current = load.rolling(window=window, min_periods=mp).sum()
    previous = current.shift(window)

    with np.errstate(divide="ignore", invalid="ignore"):
        change = (current - previous) / previous
    if as_percent:
        change = change * 100.0
    return change.rename("week_to_week")

acwr.block_change

block_change(load: Series, *, as_percent: bool = True, week_ends: str = 'W-SUN') -> pd.DataFrame

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:~pandas.DatetimeIndex.

required
as_percent bool

As in :func:week_to_week_change.

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 n_days, n_observed, total and change.

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
def block_change(
    load: pd.Series,
    *,
    as_percent: bool = True,
    week_ends: str = "W-SUN",
) -> pd.DataFrame:
    """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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    as_percent
        As in :func:`week_to_week_change`.
    week_ends
        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.

    Returns
    -------
    pandas.DataFrame
        Indexed by week ending date, with ``n_days``, ``n_observed``,
        ``total`` and ``change``.

    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
    """
    load = _as_daily(load)
    grouped = load.resample(week_ends)
    out = pd.DataFrame(
        {
            "n_days": grouped.size(),
            "n_observed": grouped.count(),
            "total": grouped.sum(min_count=1),
        }
    )
    out.loc[out["n_observed"] < out["n_days"], "total"] = np.nan

    previous = out["total"].shift(1)
    with np.errstate(divide="ignore", invalid="ignore"):
        change = (out["total"] - previous) / previous
    out["change"] = change * 100.0 if as_percent else change
    out.index.name = load.index.name
    return out

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:acwr.acwr, :func:acwr.monotony and :func:acwr.strain all qualify.

required
athlete_col str

Column names in activities.

'athlete'
date_col str

Column names in activities.

'athlete'
load_col str

Column names in activities.

'athlete'
missing str

Passed to :func:acwr.daily_load. "zero" treats days without an activity as rest, "na" treats them as unrecorded.

'zero'
common_calendar bool

If True, every athlete is padded to the full span of the frame so all of them have the same dates. False by default, so each athlete spans only their own first to last activity.

False
**kwargs Any

Forwarded to func unchanged.

{}

Returns:

Type Description
DataFrame

Long frame with the athlete column, the date column, and whatever columns func produced.

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
def by_athlete(
    activities: pd.DataFrame,
    func: Callable[..., pd.Series | pd.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
    ----------
    activities
        Long frame with one row per activity, carrying an athlete identifier,
        a date and a load.
    func
        Any function taking a daily load Series and returning a Series or a
        DataFrame indexed the same way. :func:`acwr.acwr`,
        :func:`acwr.monotony` and :func:`acwr.strain` all qualify.
    athlete_col, date_col, load_col
        Column names in ``activities``.
    missing
        Passed to :func:`acwr.daily_load`. ``"zero"`` treats days without an
        activity as rest, ``"na"`` treats them as unrecorded.
    common_calendar
        If ``True``, every athlete is padded to the full span of the frame so
        all of them have the same dates. ``False`` by default, so each athlete
        spans only their own first to last activity.
    **kwargs
        Forwarded to ``func`` unchanged.

    Returns
    -------
    pandas.DataFrame
        Long frame with the athlete column, the date column, and whatever
        columns ``func`` produced.

    Raises
    ------
    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.
    """
    for col in (athlete_col, date_col, load_col):
        if col not in activities.columns:
            raise KeyError(f"activities is missing the column {col!r}")
    if activities.empty:
        raise ValueError("activities frame is empty")
    if activities[athlete_col].isna().any():
        raise ValueError(f"{athlete_col!r} contains missing values")

    span: tuple[pd.Timestamp, pd.Timestamp] | None = None
    if common_calendar:
        dates = pd.to_datetime(activities[date_col])
        span = (dates.min().normalize(), dates.max().normalize())

    frames = []
    for name, part in activities.groupby(athlete_col, sort=True):
        series = daily_load(
            part,
            date_col=date_col,
            load_col=load_col,
            missing=missing,  # type: ignore[arg-type]
            start=None if span is None else span[0],
            end=None if span is None else span[1],
        )
        result = func(series, **kwargs)
        out = result.to_frame() if isinstance(result, pd.Series) else result.copy()
        out = out.reset_index(names=date_col)
        out.insert(0, athlete_col, name)
        frames.append(out)

    return pd.concat(frames, ignore_index=True)

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
def rescaling_check(
    load: pd.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
    -------
    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.
    """
    frame = acwr(
        load,
        acute=acute,
        chronic=chronic,
        method=method,
        return_components=True,
    )
    joined = frame[["acute", "acwr"]].replace([np.inf, -np.inf], np.nan).dropna()
    if len(joined) < 3:
        raise ValueError(
            f"only {len(joined)} usable days after dropping incomplete windows; "
            f"need at least 3. A {chronic} day chronic window needs a longer "
            "load series."
        )
    a = joined["acute"].to_numpy()
    r = joined["acwr"].to_numpy()
    return RescalingResult(
        n=len(joined),
        spearman=float(stats.spearmanr(a, r).statistic),
        pearson=float(stats.pearsonr(a, r).statistic),
        spearman_ci95=_spearman_ci(a, r, draws=draws, seed=seed),
    )

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:acwr.daily_load.

required
outcome Series

Aligned to load by index. A column of 0 and 1, or booleans, is treated as a binary event and scored by AUC. Anything else is treated as continuous and scored by Spearman.

required
kind NullKind

Which contrivance to use for the denominator. See :func:null_chronic_acwr.

'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:~NullComparison.real_beats_null flag.

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
def compare_to_null(
    load: pd.Series,
    outcome: pd.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
    ----------
    load
        Daily load series, as from :func:`acwr.daily_load`.
    outcome
        Aligned to ``load`` by index. A column of 0 and 1, or booleans, is
        treated as a binary event and scored by AUC. Anything else is treated as
        continuous and scored by Spearman.
    kind
        Which contrivance to use for the denominator. See
        :func:`null_chronic_acwr`.
    n_null_draws
        How many null series to generate. The spread across these draws is what
        the null interval describes.

    Returns
    -------
    NullComparison
        Both statistics with bootstrap intervals, and a
        :attr:`~NullComparison.real_beats_null` flag.

    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.
    """
    real_ratio = acwr(load, acute=acute, chronic=chronic, method=method)
    frame = pd.DataFrame({"acwr": real_ratio, "outcome": outcome}).replace(
        [np.inf, -np.inf], np.nan
    )
    frame = frame.dropna()
    if len(frame) < 10:
        raise ValueError(
            f"only {len(frame)} aligned, complete observations; need at least 10"
        )

    y = frame["outcome"].to_numpy()
    uniq = np.unique(y)
    binary = bool(set(uniq.tolist()) <= {0, 1}) and len(uniq) == 2

    real_stat, measure = _association(frame["acwr"].to_numpy(), y, binary)

    keep = frame.index
    null_stats = []
    for d in range(n_null_draws):
        nr = null_chronic_acwr(
            load,
            acute=acute,
            chronic=chronic,
            method=method,
            kind=kind,
            seed=seed + d,
        )
        nr = nr.replace([np.inf, -np.inf], np.nan).reindex(keep)
        ok = nr.notna()
        if ok.sum() < 10:
            # A null draw usable on fewer than ten days describes nothing, so
            # it is skipped rather than counted. Reaching this needs a draw
            # that is unusable on more days than the real ratio was, which the
            # test suite has not managed to construct; it is kept as a guard
            # rather than removed, because the alternative is a distribution
            # built from a handful of points.
            continue
        s, _ = _association(nr[ok].to_numpy(), y[ok.to_numpy()], binary)
        if not np.isnan(s):
            null_stats.append(s)

    if len(null_stats) < 10:
        raise ValueError("could not build enough null draws to describe a distribution")
    null_arr = np.asarray(null_stats)

    # Interval on the real statistic: resample observations.
    rng = np.random.default_rng(seed)
    x_real = frame["acwr"].to_numpy()
    n = len(x_real)
    boots = []
    for _ in range(boot_draws):
        idx = rng.integers(0, n, n)
        if binary and len(np.unique(y[idx])) < 2:
            continue
        s, _ = _association(x_real[idx], y[idx], binary)
        if not np.isnan(s):
            boots.append(s)
    real_ci = (
        (float(np.percentile(boots, 2.5)), float(np.percentile(boots, 97.5)))
        if len(boots) >= 10
        else (float("nan"), float("nan"))
    )

    return NullComparison(
        n=n,
        n_events=int(y.sum()) if binary else n,
        measure=measure,
        real=real_stat,
        real_ci95=real_ci,
        null=float(null_arr.mean()),
        null_ci95=(
            float(np.percentile(null_arr, 2.5)),
            float(np.percentile(null_arr, 97.5)),
        ),
        null_kind=kind,
        n_null_draws=len(null_arr),
        null_distribution=null_arr,
    )

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" randomly permutes the real chronic series, so the denominator keeps its exact distribution and loses its timing. "normal" draws from a normal distribution matched to the real chronic mean and standard deviation, truncated at a small positive floor. "constant" uses the mean of the real chronic series, which is the fixed-value version of the test.

'shuffle'
seed int

Seeds the draw so a given null series is reproducible.

0

Returns:

Type Description
Series

A ratio series named "acwr_null", aligned to load.

Source code in src/acwr/diagnostics.py
def null_chronic_acwr(
    load: pd.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
    ----------
    kind
        ``"shuffle"`` randomly permutes the real chronic series, so the
        denominator keeps its exact distribution and loses its timing.
        ``"normal"`` draws from a normal distribution matched to the real
        chronic mean and standard deviation, truncated at a small positive
        floor. ``"constant"`` uses the mean of the real chronic series, which is
        the fixed-value version of the test.
    seed
        Seeds the draw so a given null series is reproducible.

    Returns
    -------
    pandas.Series
        A ratio series named ``"acwr_null"``, aligned to ``load``.
    """
    a = acute_load(load, acute=acute, method=method)
    c = chronic_load(load, acute=acute, chronic=chronic, method=method)

    valid = c.notna() & np.isfinite(c)
    real = c[valid].to_numpy()
    if len(real) < 2:
        raise ValueError("not enough chronic load values to build a null model")

    rng = np.random.default_rng(seed)
    if kind == "shuffle":
        fake = rng.permutation(real)
    elif kind == "normal":
        floor = max(real.mean() * 1e-3, 1e-9)
        fake = np.clip(
            rng.normal(real.mean(), real.std(ddof=1) or 1.0, len(real)), floor, None
        )
    elif kind == "constant":
        fake = np.full(len(real), real.mean())
    else:
        raise ValueError(
            f"kind must be 'shuffle', 'normal' or 'constant', got {kind!r}"
        )

    c_null = pd.Series(np.nan, index=c.index, dtype=float)
    c_null.loc[valid] = fake
    with np.errstate(divide="ignore", invalid="ignore"):
        return (a / c_null).rename("acwr_null")

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:~pandas.DatetimeIndex.

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:acwr.acwr.

'rac'
reference tuple[int, int]

The pair everything is compared against. Default (7, 28).

(7, 28)
decay Decay

Passed through when method="ewma".

'span'

Returns:

Type Description
WindowSensitivity

Holding table, one row per pair with the flagged count, the flagged fraction, and the Jaccard agreement with the reference.

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
def window_sensitivity(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    acute_options, chronic_options
        Window lengths to sweep. Pairs where the chronic window does not exceed
        the acute one are skipped.
    threshold
        The value above which a day counts as flagged. Default 1.5, which is
        the figure applied practice quotes most often.
    method
        Which ratio to compute, as in :func:`acwr.acwr`.
    reference
        The pair everything is compared against. Default ``(7, 28)``.
    decay
        Passed through when ``method="ewma"``.

    Returns
    -------
    WindowSensitivity
        Holding ``table``, one row per pair with the flagged count, the flagged
        fraction, and the Jaccard agreement with the reference.

    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.
    """
    load = _as_daily(load)
    if reference[0] >= reference[1]:
        raise ValueError("reference chronic window must exceed the acute one")
    if reference[0] not in acute_options or reference[1] not in chronic_options:
        raise ValueError(f"reference pair {reference} must be inside the swept grid")

    def flags(a: int, c: int) -> pd.Series:
        r = acwr(load, acute=a, chronic=c, method=method, decay=decay)
        assert isinstance(r, pd.Series)
        return r.replace([np.inf, -np.inf], np.nan).dropna() > threshold

    ref_flags = flags(*reference)
    rows = []
    for a in acute_options:
        for c in chronic_options:
            if c <= a:
                continue
            f = flags(a, c)
            shared = f.index.intersection(ref_flags.index)
            x, y = f.loc[shared], ref_flags.loc[shared]
            if len(shared) == 0:
                # No day is comparable, usually because the chronic window is
                # longer than the series. Reporting 1.0 here would read as
                # perfect agreement from a pair that produced nothing.
                jaccard = float("nan")
            else:
                union = int((x | y).sum())
                jaccard = 1.0 if union == 0 else float((x & y).sum()) / union
            rows.append(
                {
                    "acute": a,
                    "chronic": c,
                    "n_days": len(f),
                    "n_flagged": int(f.sum()),
                    "flagged_fraction": float(f.mean()) if len(f) else float("nan"),
                    "agreement": jaccard,
                    "is_reference": (a, c) == reference,
                }
            )

    table = pd.DataFrame(rows).sort_values(["acute", "chronic"]).reset_index(drop=True)
    return WindowSensitivity(
        threshold=threshold, method=method, reference=reference, table=table
    )

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:~pandas.DatetimeIndex.

required
percentiles Sequence[float]

Which percentiles of this athlete's ratio distribution to report.

(50, 75, 90, 95, 99)
acute int

Passed to :func:acwr.acwr.

7
chronic int

Passed to :func:acwr.acwr.

7
method int

Passed to :func:acwr.acwr.

7
decay int

Passed to :func:acwr.acwr.

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
def threshold_from_history(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    percentiles
        Which percentiles of this athlete's ratio distribution to report.
    acute, chronic, method, decay
        Passed to :func:`acwr.acwr`.
    reference
        The universal threshold to compare against. Default 1.5.

    Returns
    -------
    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
    """
    ratio = acwr(load, acute=acute, chronic=chronic, method=method, decay=decay)
    assert isinstance(ratio, pd.Series)
    usable = ratio.dropna()
    if len(usable) == 0:
        raise ValueError("no usable days; the series is shorter than the warm-up")

    finite = usable.replace([np.inf, -np.inf], np.nan).dropna()
    if len(finite) == 0:
        raise ValueError("every usable day is infinite; percentiles are undefined")

    values = {float(p): float(np.percentile(finite.to_numpy(), p)) for p in percentiles}
    flagged = int((usable > reference).sum())
    return ThresholdReport(
        method=method,
        n=len(usable),
        percentiles=values,
        reference=float(reference),
        reference_flagged=flagged,
    )

Result objects

acwr.RescalingResult dataclass

How much of the ratio is explained by its own numerator.

Source code in src/acwr/diagnostics.py
@dataclass(frozen=True)
class RescalingResult:
    """How much of the ratio is explained by its own numerator."""

    n: int
    spearman: float
    pearson: float
    spearman_ci95: tuple[float, float]

    def __str__(self) -> str:  # pragma: no cover - presentation only
        lo, hi = self.spearman_ci95
        return (
            f"ACWR vs acute load alone, n={self.n}: "
            f"Spearman {self.spearman:.3f} [{lo:.3f}, {hi:.3f}], "
            f"Pearson {self.pearson:.3f}"
        )

acwr.NullComparison dataclass

Association with an outcome for the real ratio and for a null ratio.

Source code in src/acwr/diagnostics.py
@dataclass(frozen=True)
class NullComparison:
    """Association with an outcome for the real ratio and for a null ratio."""

    n: int
    n_events: int
    measure: str
    real: float
    real_ci95: tuple[float, float]
    null: float
    null_ci95: tuple[float, float]
    null_kind: str
    n_null_draws: int
    null_distribution: np.ndarray = field(repr=False)

    @property
    def real_beats_null(self) -> bool:
        """``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.
        """
        _, hi = self.null_ci95
        return bool(self.real > hi)

    @property
    def real_outside_null(self) -> bool:
        """``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.
        """
        lo, hi = self.null_ci95
        return bool(self.real < lo or self.real > hi)

    def __str__(self) -> str:  # pragma: no cover - presentation only
        rlo, rhi = self.real_ci95
        nlo, nhi = self.null_ci95
        if self.real_beats_null:
            verdict = "real ratio is ABOVE the null interval"
        elif self.real < nlo:
            verdict = (
                "real ratio is BELOW the null interval: the real denominator "
                "cost signal that a random one did not"
            )
        else:
            verdict = (
                "real ratio is INSIDE the null interval: the chronic "
                "denominator added nothing here"
            )
        return (
            f"{self.measure}, n={self.n} ({self.n_events} events)\n"
            f"  real ACWR:          {self.real:.3f} [{rlo:.3f}, {rhi:.3f}]\n"
            f"  {self.null_kind:<10} null:    {self.null:.3f} "
            f"[{nlo:.3f}, {nhi:.3f}]  ({self.n_null_draws} draws)\n"
            f"  {verdict}"
        )

real_beats_null property

real_beats_null: bool

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

real_outside_null: bool

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
@dataclass(frozen=True)
class WindowSensitivity:
    """How much the flagged days depend on the window lengths you chose."""

    threshold: float
    method: Method
    reference: tuple[int, int]
    table: pd.DataFrame

    @property
    def agreement_range(self) -> tuple[float, float]:
        """Lowest and highest agreement with the reference pair."""
        other = self.table[~self.table["is_reference"]]["agreement"].dropna()
        if other.empty:
            return (float("nan"), float("nan"))
        return (float(other.min()), float(other.max()))

    def __str__(self) -> str:  # pragma: no cover - presentation only
        lo, hi = self.agreement_range
        ref_rate = float(
            self.table.loc[self.table["is_reference"], "flagged_fraction"].iloc[0]
        )
        return (
            f"Window sensitivity at threshold {self.threshold:g} "
            f"({self.method}, {len(self.table)} window pairs)\n"
            f"  reference {self.reference[0]}/{self.reference[1]} flags "
            f"{100 * ref_rate:.1f}% of days\n"
            f"  flagged fraction across pairs: "
            f"{100 * self.table['flagged_fraction'].min():.1f}% to "
            f"{100 * self.table['flagged_fraction'].max():.1f}%\n"
            f"  agreement with the reference: {lo:.2f} to {hi:.2f} (Jaccard)"
        )

agreement_range property

agreement_range: tuple[float, float]

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
@dataclass(frozen=True)
class ThresholdReport:
    """What a universal threshold means for one athlete's own distribution."""

    method: Method
    n: int
    percentiles: dict[float, float]
    reference: float
    reference_flagged: int

    @property
    def reference_flagged_fraction(self) -> float:
        """Share of this athlete's usable days that the reference flags."""
        return self.reference_flagged / self.n if self.n else float("nan")

    @property
    def reference_percentile(self) -> float:
        """Where the reference threshold sits in this athlete's own history."""
        return 100.0 * (1.0 - self.reference_flagged_fraction)

    def __str__(self) -> str:  # pragma: no cover - presentation only
        lines = [
            f"Thresholds on this athlete's own history "
            f"({self.method}, n={self.n} usable days)",
            f"  {self.reference:g} flags {self.reference_flagged} days "
            f"({100 * self.reference_flagged_fraction:.1f}%), which is this "
            f"athlete's {self.reference_percentile:.1f}th percentile",
        ]
        lines.append("  their own percentiles:")
        for pct, value in sorted(self.percentiles.items()):
            lines.append(f"    {pct:g}th  {value:.2f}")
        return "\n".join(lines)

reference_flagged_fraction property

reference_flagged_fraction: float

Share of this athlete's usable days that the reference flags.

reference_percentile property

reference_percentile: float

Where the reference threshold sits in this athlete's own history.

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:~pandas.DatetimeIndex.

required
acute int

Window lengths, passed to :func:acwr.acwr.

7
chronic int

Window lengths, passed to :func:acwr.acwr.

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 axes is given.

(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
def plot_acwr(
    load: pd.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
    ----------
    load
        Daily load indexed by a complete :class:`~pandas.DatetimeIndex`.
    acute, chronic
        Window lengths, passed to :func:`acwr.acwr`.
    methods
        Which methods to draw. One by default. Up to three may be given, and
        they are drawn in the order passed with a legend.
    decay
        Passed through for the exponentially weighted method.
    axes
        An existing pair of axes to draw on. A new figure is made when this is
        omitted.
    figsize
        Size of the new figure, ignored when ``axes`` is given.

    Returns
    -------
    (matplotlib.figure.Figure, (matplotlib.axes.Axes, matplotlib.axes.Axes))
        The figure and the two axes, load first.

    Raises
    ------
    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'
    """
    plt = _require_matplotlib()
    if not methods:
        raise ValueError("give at least one method to draw")
    if len(methods) > 3:
        raise ValueError(
            f"at most three methods can be drawn legibly, got {len(methods)}"
        )

    if axes is None:
        fig, (top, bottom) = plt.subplots(
            2, 1, figsize=figsize, height_ratios=[1.0, 1.4], sharex=True
        )
    else:
        top, bottom = axes
        fig = top.get_figure()

    for ax in (top, bottom):
        _recede(ax)

    # Vertical lines rather than bars. A multi-year log puts more days on the
    # axis than there are pixels, and touching bars of equal colour antialias
    # into darker seams at the shared edges, which reads as structure that is
    # not in the data.
    top.vlines(
        load.index, 0.0, load.to_numpy(), color=_BAR, linewidth=0.9, antialiased=True
    )
    top.set_ylim(bottom=0.0)
    top.set_ylabel("load per day", color=_INK2, fontsize=9.5)
    top.set_title(
        "Daily load", color=_INK, fontsize=11.5, fontweight="semibold", loc="left"
    )

    for method, colour in zip(methods, _SERIES, strict=False):
        ratio = acwr(load, acute=acute, chronic=chronic, method=method, decay=decay)
        assert isinstance(ratio, pd.Series)
        drawn = ratio.replace([np.inf, -np.inf], np.nan)
        bottom.plot(
            drawn.index,
            drawn.to_numpy(),
            color=colour,
            linewidth=1.6,
            solid_capstyle="round",
            label=method,
        )

    bottom.axhline(1.0, color=_RULE, linewidth=1.0, linestyle=(0, (4, 3)))
    bottom.set_ylabel("acute : chronic", color=_INK2, fontsize=9.5)
    bottom.set_title(
        f"Ratio, {acute} against {chronic} days",
        color=_INK,
        fontsize=11.5,
        fontweight="semibold",
        loc="left",
    )
    if len(methods) > 1:
        bottom.legend(
            frameon=False, fontsize=9.5, labelcolor=_INK2, loc="upper left", ncol=3
        )

    return fig, (top, bottom)

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:~acwr.WindowSensitivity from :func:acwr.window_sensitivity.

required
value str

Which column to shade: "agreement", the Jaccard overlap with the reference pair, or "flagged_fraction".

'agreement'
ax Axes | None

An existing axes to draw on.

None
figsize tuple[float, float]

Size of the new figure, ignored when ax is given.

(7.0, 5.0)

Returns:

Type Description
(Figure, Axes)

Raises:

Type Description
ImportError

If matplotlib is not installed.

KeyError

If value names a column the table does not have.

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
def 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
    ----------
    result
        A :class:`~acwr.WindowSensitivity` from
        :func:`acwr.window_sensitivity`.
    value
        Which column to shade: ``"agreement"``, the Jaccard overlap with the
        reference pair, or ``"flagged_fraction"``.
    ax
        An existing axes to draw on.
    figsize
        Size of the new figure, ignored when ``ax`` is given.

    Returns
    -------
    (matplotlib.figure.Figure, matplotlib.axes.Axes)

    Raises
    ------
    ImportError
        If matplotlib is not installed.
    KeyError
        If ``value`` names a column the table does not have.

    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)'
    """
    plt = _require_matplotlib()
    from matplotlib.colors import LinearSegmentedColormap

    table = result.table
    if value not in table.columns:
        raise KeyError(
            f"{value!r} is not a column of the sweep; "
            f"choose from {sorted(table.columns)}"
        )

    grid = table.pivot(index="chronic", columns="acute", values=value)
    if ax is None:
        fig, ax = plt.subplots(figsize=figsize)
    else:
        fig = ax.get_figure()

    # `with_extremes` rather than `set_bad`, which matplotlib has marked for
    # deprecation. A package that emits warnings from its own code trains its
    # users to ignore warnings.
    cmap = LinearSegmentedColormap.from_list(
        "acwr_sequential", _SEQUENTIAL
    ).with_extremes(bad="#f5f4f1")
    data = np.ma.masked_invalid(grid.to_numpy(dtype=float))
    mesh = ax.imshow(data, cmap=cmap, aspect="auto", origin="upper")

    ax.set_xticks(range(len(grid.columns)), [str(c) for c in grid.columns])
    ax.set_yticks(range(len(grid.index)), [str(i) for i in grid.index])
    ax.set_xlabel("acute window (days)", color=_INK2, fontsize=9.5)
    ax.set_ylabel("chronic window (days)", color=_INK2, fontsize=9.5)
    ax.tick_params(colors=_INK2, labelsize=9, length=0)
    for side in ax.spines.values():
        side.set_visible(False)

    label = {
        "agreement": "Agreement with the reference pair (Jaccard)",
        "flagged_fraction": "Share of days flagged",
    }.get(value, value)
    ax.set_title(
        f"{label}\nthreshold {result.threshold:g}, {result.method}, "
        f"reference {result.reference[0]}/{result.reference[1]}",
        color=_INK,
        fontsize=11.5,
        fontweight="semibold",
        loc="left",
    )

    # The label colour is decided by where the cell sits on the colour ramp,
    # not by where it sits in the data. The ramp runs light to dark, so the
    # switch to white ink happens at a fixed fraction of its range; keying off
    # the data mean instead puts dark ink on a dark cell whenever the values
    # happen to be skewed.
    finite = data.compressed()
    low = float(finite.min()) if finite.size else 0.0
    high = float(finite.max()) if finite.size else 1.0
    span = (high - low) or 1.0
    for row, chronic in enumerate(grid.index):
        for col, acute in enumerate(grid.columns):
            cell = grid.iat[row, col]
            if not np.isfinite(cell):
                continue
            position = (float(cell) - low) / span
            ax.text(
                col,
                row,
                f"{cell:.2f}",
                ha="center",
                va="center",
                fontsize=9,
                color="#ffffff" if position > 0.45 else _INK,
            )
            if (acute, chronic) == result.reference:
                ax.add_patch(
                    plt.Rectangle(
                        (col - 0.5, row - 0.5),
                        1,
                        1,
                        fill=False,
                        edgecolor=_SERIES[1],
                        linewidth=2.4,
                    )
                )

    fig.colorbar(mesh, ax=ax, shrink=0.8, pad=0.02)
    return fig, ax