from __future__ import annotations
import warnings
from dataclasses import dataclass
import numpy as np
from scipy.optimize import brentq
from pythermalcomfort.classes_input import SportsHeatStressInputs
from pythermalcomfort.classes_return import SportsHeatStressRisk
from pythermalcomfort.models import phs
from pythermalcomfort.utilities import validate_type
@dataclass
class _SportsValues:
"""Class to hold sport values."""
clo: float
met: float
vr: float
duration: int
def __post_init__(self):
validate_type(self.clo, "clo", (int, float))
validate_type(self.met, "met", (int, float))
validate_type(self.vr, "vr", (int, float))
validate_type(self.duration, "duration", (int,))
if self.clo <= 0:
msg = f"clo must be a positive number > 0, got {self.clo}"
raise ValueError(msg)
if self.met <= 0:
msg = f"met must be a positive number > 0, got {self.met}"
raise ValueError(msg)
if self.vr <= 0:
msg = f"vr must be a positive number > 0, got {self.vr}"
raise ValueError(msg)
if self.duration < 0:
msg = f"duration must be a non-negative integer >= 0, got {self.duration}"
raise ValueError(msg)
[docs]
@dataclass(frozen=True)
class Sports:
"""Namespace of predefined sport values.
Use attributes like `Sports.RUNNING` to obtain a `_SportsValues` instance.
This class uses a frozen dataclass decorator to prevent modification of the
namespace. Attributes are class-level constants, not instance fields.
"""
ABSEILING = _SportsValues(clo=0.6, met=6.0, vr=0.5, duration=120)
ARCHERY = _SportsValues(clo=0.75, met=4.5, vr=0.5, duration=180)
AUSTRALIAN_FOOTBALL = _SportsValues(clo=0.47, met=7.5, vr=0.75, duration=45)
BASEBALL = _SportsValues(clo=0.7, met=6.0, vr=0.75, duration=120)
BASKETBALL = _SportsValues(clo=0.37, met=7.5, vr=0.75, duration=45)
BOWLS = _SportsValues(clo=0.5, met=5.0, vr=0.5, duration=180)
CANOEING = _SportsValues(clo=0.6, met=7.5, vr=2.0, duration=60)
CRICKET = _SportsValues(clo=0.7, met=6.0, vr=0.75, duration=120)
CYCLING = _SportsValues(clo=0.4, met=7.0, vr=3.0, duration=60)
EQUESTRIAN = _SportsValues(clo=0.9, met=7.4, vr=3.0, duration=60)
FIELD_ATHLETICS = _SportsValues(clo=0.3, met=7.0, vr=1.0, duration=60)
FIELD_HOCKEY = _SportsValues(clo=0.6, met=7.4, vr=0.75, duration=45)
FISHING = _SportsValues(clo=0.9, met=4.0, vr=0.5, duration=180)
GOLF = _SportsValues(clo=0.5, met=5.0, vr=0.5, duration=180)
HORSEBACK = _SportsValues(clo=0.9, met=7.4, vr=3.0, duration=60)
KAYAKING = _SportsValues(clo=0.6, met=7.5, vr=2.0, duration=60)
RUNNING = _SportsValues(clo=0.37, met=7.5, vr=2.0, duration=60)
MTB = _SportsValues(clo=0.55, met=7.5, vr=3.0, duration=60)
NETBALL = _SportsValues(clo=0.37, met=7.5, vr=0.75, duration=45)
OZTAG = _SportsValues(clo=0.4, met=7.5, vr=0.75, duration=45)
PICKLEBALL = _SportsValues(clo=0.4, met=6.5, vr=0.5, duration=60)
CLIMBING = _SportsValues(clo=0.6, met=7.5, vr=1.0, duration=45)
ROWING = _SportsValues(clo=0.4, met=7.5, vr=2.0, duration=60)
RUGBY_LEAGUE = _SportsValues(clo=0.47, met=7.5, vr=0.75, duration=45)
RUGBY_UNION = _SportsValues(clo=0.47, met=7.5, vr=0.75, duration=45)
SAILING = _SportsValues(clo=1.0, met=6.5, vr=2.0, duration=180)
SHOOTING = _SportsValues(clo=0.6, met=5.0, vr=0.5, duration=120)
SOCCER = _SportsValues(clo=0.47, met=7.5, vr=1.0, duration=45)
SOFTBALL = _SportsValues(clo=0.9, met=6.1, vr=1.0, duration=120)
TENNIS = _SportsValues(clo=0.4, met=7.0, vr=0.75, duration=60)
TOUCH = _SportsValues(clo=0.4, met=7.5, vr=0.75, duration=45)
VOLLEYBALL = _SportsValues(clo=0.37, met=6.8, vr=0.75, duration=60)
WALKING = _SportsValues(clo=0.5, met=5.0, vr=0.5, duration=180)
[docs]
def sports_heat_stress_risk(
tdb: float | list[float] | np.ndarray,
tr: float | list[float] | np.ndarray,
rh: float | list[float] | np.ndarray,
vr: float | list[float] | np.ndarray,
sport: _SportsValues,
) -> SportsHeatStressRisk:
"""Calculate sports heat stress risk levels based on environmental conditions and
sport-specific parameters.
This function assesses heat stress risk for athletes during outdoor sports by
combining environmental conditions with sport-specific metabolic rates and clothing
insulation. It uses the Predicted Heat Strain (PHS) model to determine threshold
temperatures for different risk categories (Low, Medium, High, Extreme). The method
is based on the Sports Medicine Australia heat policy framework [SportsHeatStress2025]_,
with detailed guidelines [SportsHeatPolicy2025]_ and an online implementation available
at the Sports Heat Tool [SportsHeatTool]_.
Parameters
----------
tdb : float or list of float
Dry bulb air temperature [°C].
tr : float or list of float
Mean radiant temperature [°C].
rh : float or list of float
Relative humidity [%].
vr : float or list of float
Relative air speed [m/s]. Relative air speed [m/s]. If the input ``vr``
is lower than the minimum relative air speed defined for the selected
sport (``sport.vr``), then ``sport.vr`` will be used for the calculation.
sport : _SportsValues
Sport-specific activity dataclass with fields ``clo`` (clothing insulation),
``met`` (metabolic rate), ``vr`` (minimum relative air speed),
and ``duration`` (activity duration). Use one of the predefined entries from
the :py:class:`Sports` class, e.g., ``Sports.RUNNING``, ``Sports.SOCCER``,
``Sports.TENNIS``, etc.
Returns
-------
SportsHeatStressRisk
A dataclass containing the heat stress risk assessment results.
See :py:class:`~pythermalcomfort.classes_return.SportsHeatStressRisk` for
more details. To access individual values, use the corresponding attributes
of the returned instance, e.g., ``result.risk_level_interpolated``.
Raises
------
ValueError
If the risk level could not be determined due to NaN thresholds or if the internal
solver fails to produce thresholds that allow a risk determination.
TypeError
If sport is not a valid _SportsValues instance.
Examples
--------
.. code-block:: python
from pythermalcomfort.models.sports_heat_stress_risk import (
sports_heat_stress_risk,
Sports,
)
# Example 1: Single condition for running
result = sports_heat_stress_risk(
tdb=35, tr=35, rh=40, vr=2.0, sport=Sports.RUNNING
)
print(result.risk_level_interpolated) # 2.1 (Medium risk)
print(result.t_medium) # 34.5 (Temperature threshold for medium risk)
print(result.t_high) # 39.0 (Temperature threshold for high risk)
print(result.t_extreme) # 41.6 (Temperature threshold for extreme risk)
print(
result.recommendation
) # "Increase frequency and/or duration of rest breaks"
# Example 2: Array inputs for multiple conditions
result = sports_heat_stress_risk(
tdb=[30, 35, 40],
tr=[30, 35, 40],
rh=[50, 50, 50],
vr=[1.0, 1.0, 1.5],
sport=Sports.SOCCER,
)
print(result.risk_level_interpolated) # Array of risk levels
# Example 3: vr clamping — input vr (0.5 m/s) is below Sports.RUNNING.vr
# (2.0 m/s), so the calculation uses sport.vr=2.0 m/s as the effective wind speed.
result_clamped = sports_heat_stress_risk(
tdb=35, tr=35, rh=40, vr=0.5, sport=Sports.RUNNING
)
# Because vr is clamped to 2.0 m/s (same as Example 1), the output matches:
print(result_clamped.risk_level_interpolated) # same as Example 1
print(result_clamped.t_medium) # same as Example 1
# Example 4: Different sports
result_tennis = sports_heat_stress_risk(
tdb=33, tr=70, rh=60, vr=0.75, sport=Sports.TENNIS
)
result_cycling = sports_heat_stress_risk(
tdb=33, tr=70, rh=60, vr=3.0, sport=Sports.CYCLING
)
"""
# Validate inputs using the input dataclass
inputs = SportsHeatStressInputs(tdb=tdb, tr=tr, rh=rh, vr=vr, sport=sport)
# Convert to numpy arrays for vectorized calculation
tdb = np.asarray(inputs.tdb, dtype=float)
tr = np.asarray(inputs.tr, dtype=float)
rh = np.asarray(inputs.rh, dtype=float)
vr = np.asarray(inputs.vr, dtype=float)
vr_effective = np.maximum(vr, sport.vr)
# Vectorize the calculation function to handle arrays
# Returns (risk_level_interpolated, t_medium, t_high, t_extreme, recommendation) for each input
vectorized_calc = np.vectorize(
_calc_risk_single_value, otypes=[float, float, float, float, str]
)
risk_levels, t_mediums, t_highs, t_extremes, recommendations = vectorized_calc(
tdb=tdb, tr=tr, rh=rh, vr=vr_effective, sport=sport
)
return SportsHeatStressRisk(
risk_level_interpolated=risk_levels,
t_medium=t_mediums,
t_high=t_highs,
t_extreme=t_extremes,
recommendation=recommendations,
)
def _calc_risk_single_value(
tdb: float, tr: float, rh: float, vr: float, sport: _SportsValues
) -> tuple[float, float, float, float, str]:
"""Calculate the risk level and threshold temperatures for a single set of inputs.
Parameters
----------
tdb : float
Dry bulb air temperature, [°C].
tr : float
Mean radiant temperature, [°C].
rh : float
Relative humidity, [%].
vr : float
Relative air speed, [m/s].
sport : _SportsValues
Sport-specific parameters (clo, met, vr, duration).
Returns
-------
tuple of (float, float, float, float, str)
Tuple containing (risk_level_interpolated, t_medium, t_high, t_extreme, recommendation).
"""
# set the max and min thresholds for the risk levels
sweat_loss_g = 850 # 850 g per hour
max_t_low = 34.5 # maximum tdb for low risk
max_t_medium = 39 # maximum tdb for medium risk
max_t_high = 43.5 # maximum tdb for high risk
min_t_low = 21 # minimum tdb for low risk
min_t_medium = 23 # minimum tdb for medium risk
min_t_high = 25 # minimum tdb for high risk
min_t_extreme = 26 # minimum tdb for extreme risk
t_cr_extreme = 40 # core temperature for extreme risk
if tdb < min_t_medium:
# Low risk - use default thresholds and risk level 1
return (
1.0,
min_t_medium,
min_t_high,
min_t_extreme,
_get_recommendation(1.0),
)
if tdb > max_t_high:
# Extreme risk - use maximum thresholds and risk level 4
return (
4.0,
max_t_low,
max_t_medium,
max_t_high,
_get_recommendation(4.0),
)
def calculate_threshold_water_loss(x):
sl = phs(
tdb=x,
tr=tr,
v=vr,
rh=rh,
met=sport.met,
clo=sport.clo,
posture="standing",
duration=sport.duration,
round_output=False,
limit_inputs=False,
acclimatized=100,
i_mst=0.4,
).sweat_loss_g
# Ensure a scalar float is returned for the root solver
sl_scalar = float(np.asarray(sl))
return float(sl_scalar / float(sport.duration) * 45.0 - float(sweat_loss_g))
for min_t, max_t in [(0, 36), (20, 50)]:
try:
t_medium = brentq(calculate_threshold_water_loss, min_t, max_t)
break
except ValueError:
continue
else:
msg = (
f"Solver did not find a solution for low-medium threshold for {tdb=} and {rh=}: "
f"all bracket ranges failed. Setting t_medium to max threshold of {max_t_low}°C."
)
warnings.warn(msg, stacklevel=2)
t_medium = max_t_low
def calculate_threshold_core(x):
tcr = phs(
tdb=x,
tr=tr,
v=vr,
rh=rh,
met=sport.met,
clo=sport.clo,
posture="standing",
duration=sport.duration,
round_output=False,
limit_inputs=False,
acclimatized=100,
i_mst=0.4,
).t_cr
return float(float(np.asarray(tcr)) - float(t_cr_extreme))
for min_t, max_t in [(0, 36), (20, 50)]:
try:
t_extreme = brentq(calculate_threshold_core, min_t, max_t)
break
except ValueError:
continue
else:
msg = (
f"Solver did not find a solution for high-extreme threshold for {tdb=} and {rh=}: "
f"all bracket ranges failed. Setting t_extreme to max threshold of {max_t_high}°C."
)
warnings.warn(msg, stacklevel=2)
t_extreme = max_t_high
# calculate t_high as the average of t_medium and t_extreme
t_high = (
(t_medium + t_extreme) / 2
if not (np.isnan(t_medium) or np.isnan(t_extreme))
else np.nan
)
# check if the thresholds are within the min and max limits defined above
if t_medium > max_t_low:
t_medium = max_t_low
if t_high > max_t_medium:
t_high = max_t_medium
if t_extreme > max_t_high:
t_extreme = max_t_high
# cap the thresholds to the minimum values defined above
if t_extreme < min_t_extreme:
t_extreme = min_t_extreme
if t_high < min_t_high:
t_high = min_t_high
if t_medium < min_t_medium:
t_medium = min_t_medium
risk_level_interpolated = np.nan
# calculate the risk level with one decimal place
if min_t_low <= tdb < t_medium:
risk_level_interpolated = 1.0 + (tdb - min_t_medium) / (t_medium - min_t_medium)
elif t_medium <= tdb < t_high:
risk_level_interpolated = 2.0 + (tdb - t_medium) / (t_high - t_medium)
elif t_high <= tdb < t_extreme:
risk_level_interpolated = 3.0 + (tdb - t_high) / (t_extreme - t_high)
elif tdb >= t_extreme:
risk_level_interpolated = 4.0
if np.isnan(risk_level_interpolated):
raise ValueError("Risk level could not be determined due to NaN thresholds.")
# Truncate to one decimal place toward negative infinity.
risk_level_floor = np.floor(risk_level_interpolated * 10.0) / 10.0
# Generate recommendation based on the FLOORED risk level for consistency
recommendation = _get_recommendation(risk_level_floor)
return (
risk_level_floor,
round(t_medium, 1),
round(t_high, 1),
round(t_extreme, 1),
recommendation,
)
def _get_recommendation(risk_level: float) -> str:
"""Get heat stress management recommendations based on risk level.
Parameters
----------
risk_level : float
Interpolated risk level (1.0-4.0).
Returns
-------
str
Evidence-based recommendation text for managing heat stress at the given
risk level.
"""
if risk_level < 2.0:
return "Increase hydration & modify clothing"
elif risk_level < 3.0:
return "Increase frequency and/or duration of rest breaks"
elif risk_level < 4.0:
return "Apply active cooling strategies"
else:
return "Consider suspending play"