"""Ridge regression model for predicting rectal and skin temperature.
Based on fold 1 from Forbes et al. (2025), doi:10.1016/j.jtherbio.2025.104078. Trained
on adults aged 60-100 in minimal clothing under stationary conditions. Uses Min-Max
scaling on features and outputs.
"""
from __future__ import annotations
import numpy as np
from pythermalcomfort.classes_input import RidgeRegressionInputs
from pythermalcomfort.classes_return import PredictedBodyTemperatures
from pythermalcomfort.shared_functions import valid_range
from pythermalcomfort.utilities import Sex
# --- Model Constants ---
# Minimum values for each of the 8 input features, used for Min-Max scaling.
_FEATURES_SCALER_OFFSET = np.array(
[
0.0, # sex
-0.3114754098360656, # age
-3.020833333333333, # height
-0.6183587248751761, # weight
-1.222222222222222, # tdb
-0.21951219512195122, # rh
-11.197107405358395, # t_re
-2.105553500973682, # t_sk
]
)
# Scaling factors (1 / (max - min)) for each of the 8 input features, used for Min-Max scaling.
_FEATURES_SCALER_SCALE = np.array(
[
1.0, # sex
0.01639344262295082, # age
0.020833333333333332, # height
0.012802458071949815, # weight
0.05555555555555555, # tdb
0.024390243902439025, # rh
0.31613056192207334, # t_re
0.08119094120192633, # t_sk
]
)
# Minimum values for the 2 output variables (Rectal Temp, Mean Skin Temp), used for inverse scaling.
_OUTPUT_SCALER_OFFSET = np.array(
[
-11.197107405358395, # t_re
-2.0197777680408033, # t_sk
]
)
# Scaling factors (1 / (max - min)) for the 2 output variables, used for inverse scaling.
_OUTPUT_SCALER_SCALE = np.array(
[
0.31613056192207334, # t_re
0.07894843838015173, # t_sk
]
)
# Coefficients for the Rectal Temperature (t_re) ridge regression model.
# Corresponds to the 8 input features in order.
_T_RE_COEFFS = np.array(
[
0.00016261586852849347, # sex
0.0007368142143779594, # age
-0.00043916987857211637, # height
0.00046532701146677997, # weight
0.0008443934806620367, # tdb
0.0006663379066237714, # rh
0.9932810428489056, # t_re (previous)
0.006016233208250791, # t_sk (previous)
]
)
# Intercept term for the Rectal Temperature (t_re) ridge regression model.
_T_RE_INTERCEPT = -0.0013528489525256315
# Coefficients for the Mean Skin Temperature (t_sk) ridge regression model.
# Corresponds to the 8 input features in order.
_T_SK_COEFFS = np.array(
[
0.0006157845452869151, # sex
0.00014854705372386215, # age
-0.0004329826169348138, # height
-0.0011471088118388912, # weight
0.018904677058503336, # tdb
0.003188995712763656, # rh
-0.0010477636196332153, # t_re (previous)
0.933918210580563, # t_sk (previous)
]
)
# Intercept term for the Mean Skin Temperature (t_sk) ridge regression model.
_T_SK_INTERCEPT = 0.04356328728329839
# Thermoneutral baseline configuration
_BASELINE_DURATION = 120 # minutes
_THERMONEUTRAL_TDB = 23.0 # °C
_THERMONEUTRAL_RH = 50.0 # %
_BASELINE_T_RE_INITIAL = 37.0 # °C, initial rectal temperature for baseline
_BASELINE_T_SK_INITIAL = 32.0 # °C, initial skin temperature for baseline
def _scale_features(features: np.ndarray) -> np.ndarray:
"""Scales input features using predefined scaling constants."""
return features * _FEATURES_SCALER_SCALE + _FEATURES_SCALER_OFFSET
def _inverse_scale_output(scaled_output: np.ndarray) -> np.ndarray:
"""Apply inverse scaling to the model output."""
return (scaled_output - _OUTPUT_SCALER_OFFSET) / _OUTPUT_SCALER_SCALE
def _predict_temperature_simulation(
features: np.ndarray, duration: int
) -> tuple[np.ndarray, np.ndarray]:
"""Core simulation loop for temperature prediction."""
# Scale input features
scaled_features = _scale_features(features)
# Precompute static components of the regression
static_t_re = np.dot(scaled_features[:, :6], _T_RE_COEFFS[:6]) + _T_RE_INTERCEPT
static_t_sk = np.dot(scaled_features[:, :6], _T_SK_COEFFS[:6]) + _T_SK_INTERCEPT
# Initialize temperatures from scaled features
prev_t_re = scaled_features[:, 6]
prev_t_sk = scaled_features[:, 7]
# Store history of temperatures
n_scenarios = features.shape[0]
t_re_history_scaled = np.empty((n_scenarios, duration), dtype=float)
t_sk_history_scaled = np.empty((n_scenarios, duration), dtype=float)
# Run simulation
for i in range(duration):
new_t_re = (
static_t_re + _T_RE_COEFFS[6] * prev_t_re + _T_RE_COEFFS[7] * prev_t_sk
)
new_t_sk = (
static_t_sk + _T_SK_COEFFS[6] * prev_t_re + _T_SK_COEFFS[7] * prev_t_sk
)
prev_t_re, prev_t_sk = new_t_re, new_t_sk
t_re_history_scaled[:, i] = new_t_re
t_sk_history_scaled[:, i] = new_t_sk
# Stack as (n_scenarios, duration, 2) and inverse-scale via broadcasting
stacked = np.stack([t_re_history_scaled, t_sk_history_scaled], axis=-1)
inv = _inverse_scale_output(stacked)
t_re_history = inv[..., 0]
t_sk_history = inv[..., 1]
return t_re_history, t_sk_history
def _check_ridge_regression_compliance(
age: np.ndarray,
height: np.ndarray,
weight: np.ndarray,
tdb: np.ndarray,
rh: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Check if the inputs are within the model's applicability limits.
Parameters
----------
age : np.ndarray
Age in years.
height : np.ndarray
Body height in cm.
weight : np.ndarray
Body weight in kg.
tdb : np.ndarray
Ambient air temperature in °C.
rh : np.ndarray
Relative humidity in %.
Returns
-------
tuple of np.ndarray
Five arrays with valid values preserved and out-of-range values set to NaN:
(age_valid, height_valid, weight_valid, temp_valid, rh_valid).
"""
age_valid = valid_range(age, (60, 100))
height_valid = valid_range(height, (130, 230))
weight_valid = valid_range(weight, (40, 140))
temp_valid = valid_range(tdb, (0, 60))
rh_valid = valid_range(rh, (0, 100))
return age_valid, height_valid, weight_valid, temp_valid, rh_valid
[docs]
def ridge_regression_predict_t_re_t_sk(
sex: Sex | str | list[Sex | str],
age: float | list[float],
height: float | list[float],
weight: float | list[float],
tdb: float | list[float],
rh: float | list[float],
duration: int,
t_re: float | list[float] | None = None,
t_sk: float | list[float] | None = None,
limit_inputs: bool = True,
round_output: bool = True,
) -> PredictedBodyTemperatures:
"""Predicts the full history of core and skin temperature changes based on a ridge
regression model.
This model simulates the rectal (t_re) and mean skin (t_sk)
temperatures by first establishing a baseline in a thermoneutral environment
(23°C, 50% RH for 120 mins) and then simulating exposure to the specified
environmental conditions for `duration`. If a `t_re` and
`t_sk` are provided, then the initial 120 min simulation is skipped.
Coefficients are taken from fold one from the study [Forbes2025]_
(https://doi.org/10.1016/j.jtherbio.2025.104078). See notes documentation
for limitations with this model.
Parameters
----------
sex : Sex or str or list of (Sex or str)
Biological sex. Pass the enum instance (e.g., `Sex.male`),
its string value (e.g., `Sex.male.value` or `"male"`),
or a list of either for vectorized inputs.
age : float or list
Age, in years.
height : float or list
Body height in meters [m].
weight : float or list
Body weight in kilograms [kg].
tdb : float or list
Ambient (dry bulb) air temperature, in °C.
rh : float or list
Relative humidity, in %.
duration : int
Duration of the simulation in the specified environment, in minutes.
t_re : float or list, optional
Initial rectal temperature (°C). If provided, the baseline simulation
at 23°C, 50% RH for 120 minutes is skipped, and this value is used
as the starting rectal temperature for the main simulation.
t_sk : float or list, optional
Initial mean skin temperature (°C). If provided, the baseline simulation
at 23°C, 50% RH for 120 minutes is skipped, and this value is used
as the starting mean skin temperature for the main simulation.
limit_inputs : bool, optional
If True, limits the inputs to the standard applicability limits. Defaults to True.
round_output : bool, optional
If True, rounds outputs to 2 decimal places; otherwise leaves full precision.
Defaults to True.
Returns
-------
PredictedBodyTemperatures
A dataclass containing the predicted rectal (`t_re`) and skin (`t_sk`)
temperature history in °C. See
:py:class:`~pythermalcomfort.classes_return.PredictedBodyTemperatures`
for more details. The outputs are numpy arrays. For scalar inputs, the shape
is (`duration`,). For vector inputs, the shape is (`n_inputs`, `duration`).
Raises
------
ValueError
If input arrays have inconsistent shapes after broadcasting.
If sex contains invalid values (must be "male"/"female" or Sex enum members).
If only one of t_re or t_sk is provided.
If t_re and t_sk are not broadcastable to the input shape.
Notes
-----
The model is applicable for adults aged between 60 and 100 years. The ranges for
the input parameters are:
- **Age**: 60 to 100 years
- **Height**: 1.30 to 2.30 m
- **Weight**: 40 to 140 kg
- **Ambient Temperature**: 0 to 60 °C
- **Relative Humidity**: 0 to 100 %
If inputs fall outside applicability limits and `limit_inputs=True`, the
corresponding output time series are filled with NaN for all time steps.
The model does not have inputs such as: air velocity, radiative heat transfer,
clothing level and an individual's activity level due to there being little
variation in the dataset.
All individuals in the dataset were in minimal clothing while stationary in a
heat chamber, therefore stationary individuals in minimal clothing are best
represented by this model.
The function supports vectorized inputs for all parameters, allowing for
batch predictions.
Examples
--------
.. code-block:: python
from pythermalcomfort.utilities import Sex
from pythermalcomfort.models.ridge_regression_predict_t_re_t_sk import (
ridge_regression_predict_t_re_t_sk,
)
# Scalar example for a single person
results = ridge_regression_predict_t_re_t_sk(
sex=Sex.male.value,
age=60,
height=1.8,
weight=75,
tdb=35,
rh=60,
duration=540,
)
print(f"Final Rectal temp: {results.t_re[-1]:.2f}°C")
print(f"Final Skin temp: {results.t_sk[-1]:.2f}°C")
# Vectorized example for multiple scenarios
results_vec = ridge_regression_predict_t_re_t_sk(
sex=[Sex.male.value, Sex.female.value],
age=[60, 65],
height=[1.8, 1.65],
weight=[75, 60],
tdb=[35, 40],
rh=[60, 50],
duration=540,
)
print(f"Final rectal temps: {results_vec.t_re[:, -1]}")
print(f"Rectal temp history shape: {results_vec.t_re.shape}")
"""
# Validate inputs
RidgeRegressionInputs(
sex=sex,
age=age,
height=height,
weight=weight,
tdb=tdb,
rh=rh,
duration=duration,
t_re=t_re,
t_sk=t_sk,
limit_inputs=limit_inputs,
round_output=round_output,
)
# Convert height from m to cm, handling both scalar and list-like inputs
height_cm = np.asarray(height) * 100
# Convert sex (enum or string) to string values and then 0/1 encoding
sex_arr = np.atleast_1d(sex)
sex_str = np.array(
[(s.value if isinstance(s, Sex) else str(s)).strip().lower() for s in sex_arr]
)
valid_sex_values = np.array([Sex.male.value, Sex.female.value])
if not np.all(np.isin(sex_str, valid_sex_values)):
err_msg = f"Invalid input for sex. Must be one of {valid_sex_values.tolist()}"
raise ValueError(err_msg)
# 1 for female, 0 for male
sex_value_arr = (sex_str == Sex.female.value).astype(int)
# If the original input was a scalar, convert the array back to a scalar
# to preserve the correct output shape.
supported_sex_values = (list, tuple, np.ndarray)
if not isinstance(sex, supported_sex_values):
sex_value = sex_value_arr.item()
else:
sex_value = sex_value_arr
# Convert inputs to numpy arrays for vectorization
inputs = np.broadcast_arrays(sex_value, age, height_cm, weight, tdb, rh)
original_shape = inputs[0].shape
flat_inputs = [np.ravel(i) for i in inputs]
if limit_inputs:
(
age_valid,
height_valid,
weight_valid,
temp_valid,
rh_valid,
) = _check_ridge_regression_compliance(
flat_inputs[1],
flat_inputs[2],
flat_inputs[3],
flat_inputs[4],
flat_inputs[5],
)
if t_re is not None and t_sk is not None:
try:
# Use provided baseline temperatures
current_t_re = np.broadcast_to(np.asarray(t_re), original_shape).ravel()
current_t_sk = np.broadcast_to(np.asarray(t_sk), original_shape).ravel()
except ValueError as err:
message = (
"t_re and t_sk must be broadcastable to the input shape "
f"{original_shape}."
)
raise ValueError(message) from err
else:
# Baseline simulation (120 mins in a neutral environment)
t_re = np.full_like(flat_inputs[0], _BASELINE_T_RE_INITIAL, dtype=float)
t_sk = np.full_like(flat_inputs[0], _BASELINE_T_SK_INITIAL, dtype=float)
baseline_features = np.stack(
[
flat_inputs[0], # sex
flat_inputs[1], # age
flat_inputs[2], # height
flat_inputs[3], # weight
np.full_like(flat_inputs[0], _THERMONEUTRAL_TDB), # tdb
np.full_like(flat_inputs[0], _THERMONEUTRAL_RH), # relative humidity
t_re,
t_sk,
],
axis=1,
)
baseline_t_re_hist, baseline_t_sk_hist = _predict_temperature_simulation(
baseline_features, duration=_BASELINE_DURATION
)
current_t_re = baseline_t_re_hist[:, -1]
current_t_sk = baseline_t_sk_hist[:, -1]
# Final simulation in specified environment
final_features = np.stack(
[
flat_inputs[0], # sex
flat_inputs[1], # age
flat_inputs[2], # height
flat_inputs[3], # weight
flat_inputs[4], # tdb
flat_inputs[5], # humidity
current_t_re,
current_t_sk,
],
axis=1,
)
final_t_re_hist, final_t_sk_hist = _predict_temperature_simulation(
final_features,
duration=duration,
)
if limit_inputs:
all_valid = ~(
np.isnan(age_valid)
| np.isnan(height_valid)
| np.isnan(weight_valid)
| np.isnan(temp_valid)
| np.isnan(rh_valid)
)
# Apply validity mask across the time dimension
validity_mask = all_valid[:, np.newaxis]
final_t_re_hist = np.where(validity_mask, final_t_re_hist, np.nan)
final_t_sk_hist = np.where(validity_mask, final_t_sk_hist, np.nan)
if round_output:
final_t_re_hist = np.round(final_t_re_hist, 2)
final_t_sk_hist = np.round(final_t_sk_hist, 2)
# If original input was scalar, return 1D array instead of 2D
if not original_shape:
final_t_re = final_t_re_hist.squeeze()
final_t_sk = final_t_sk_hist.squeeze()
else:
final_t_re = final_t_re_hist
final_t_sk = final_t_sk_hist
return PredictedBodyTemperatures(t_re=final_t_re, t_sk=final_t_sk)