The intensity-duration modelling toolkit for endurance sports. Scikit-learn compatible.
Try the interactive playground 🚀
| Model | Parameters |
|---|---|
TwoParameterRegressor |
CP, W' |
ThreeParameterRegressor |
CP, W', P_max |
OmniDurationRegressor |
CP, W', P_max, a, tcp_max |
| Model | Parameters |
|---|---|
FPCARegressor |
FPC1 (gain), FPC2 (sprint/endurance bias), FPC3 (mid-duration) |
uv add silhouetteOr with pip:
pip install silhouetteimport numpy as np
from silhouette import OmniDurationRegressor
durations = np.array([5, 10, 30, 60, 120, 300, 600, 1200, 1800, 3600])
power = np.array([1050, 850, 600, 480, 400, 340, 310, 290, 275, 255])
reg = OmniDurationRegressor()
reg.fit(durations.reshape(-1, 1), power)
reg.cp_ # critical power (W)
reg.p_max_ # peak power (W)
reg.w_prime_ # anaerobic work capacity (J)
reg.predict(np.array([[300]])) # predicted power at 5 minutesAll parametric models share the same interface. Swap OmniDurationRegressor for TwoParameterRegressor or ThreeParameterRegressor and the code works the same way.
from silhouette import FPCARegressor
reg = FPCARegressor.from_model()
reg.fit(durations.reshape(-1, 1), power)
reg.fpc1_ # overall power level
reg.fpc2_ # sprint vs endurance bias
reg.fpc3_ # mid-duration specialization
reg.predict(np.array([[300]]))
reg.percentiles() # {"fpc1": 72.3, "fpc2": 34.1, "fpc3": 55.8}
reg.z_scores() # {"fpc1": 0.87, "fpc2": -0.41, "fpc3": 0.14}When parameters are already known, use curve directly without fitting:
from silhouette import TwoParameterRegressor, FPCARegressor
t = np.arange(1, 3601)
power = TwoParameterRegressor.curve(t, cp=250, w_prime=20_000)
power = FPCARegressor.curve(t, fpc1=0.5, fpc2=-0.1, fpc3=0.0)reg = OmniDurationRegressor(
bounds={"cp": (200, 400), "p_max": (800, 1500)},
initial_params={"cp": 280},
)TwoParameterRegressor supports an alternative fitting method that minimizes error in work space instead of power space:
reg = TwoParameterRegressor(fitting="work_duration")
reg.fit(X, power)This linearizes the model to W = W' + CP·t and fits via OLS, giving more weight to longer durations. The default (fitting="nonlinear") minimizes error in power space.
The inverse of the power-duration curve: given a power, how long can it be sustained?
# On a fitted model
tte = reg.predict_inverse(np.array([250, 300, 350]))
# With known parameters
tte = TwoParameterRegressor.curve_inverse(350, cp=250, w_prime=20_000)Install with plotting support:
uv add silhouette[plotting]Plot data with fitted models (sklearn Display pattern):
from silhouette.plotting import PowerDurationDisplay
# Single model
display = PowerDurationDisplay.from_estimator(reg, durations.reshape(-1, 1), power)
# Compare models
display = PowerDurationDisplay.from_estimators(
[reg_2p, reg_omni], durations.reshape(-1, 1), power,
)FPCA mode of variance:
from silhouette.plotting import ModeOfVarianceDisplay
display = ModeOfVarianceDisplay.from_estimator(fpca_reg)- Monod, H., & Scherrer, J. (1965). The work capacity of a synergic muscular group. Ergonomics, 8(3), 329-338.
- Morton, R. H. (1996). A 3-parameter critical power model. Ergonomics, 39(4), 611-619.
- Puchowicz, M. J., Baker, J., & Clarke, D. C. (2020). Development and field validation of an omni-domain power-duration model. Journal of Sports Sciences, 38(7), 801-813.
- Puchowicz, M. J., & Skiba, P. F. (2025). Functional Data Analysis of the Power-Duration Relationship in Cyclists. International Journal of Sports Physiology and Performance, 1(aop), 1-10.

