Sports Heat Stress Risk equation#286
Conversation
Implement a function to assess heat stress risk based on temperature, humidity, and sport-specific parameters. This includes a comprehensive dictionary of sports with their respective metabolic rates, clothing values, and duration, enhancing the model's capability for accurate risk assessment.
Include notes for implementing documentation and references in the function docstring to enhance clarity and usability.
…ass and update risk calculation logic
build: changed tox docs command for paths with a space
…tress risk result
…d parameter descriptions and examples
…ress risk function
…ity with Python 3.10+
…and security fixes - Fix security vulnerabilities: CVE-2025-71176 (pytest), CVE-2025-68146 (filelock), CVE-2024-47081 (requests) - Update dependencies: numpy 2.3.5, scipy 1.17.0, numba 0.63.1, black 26.1.0 - Improve type validation: allow int/float for clo/met/vr parameters - Add monotonic ordering enforcement for temperature thresholds - Enhance risk level calculation with better boundary condition handling - Use TYPE_CHECKING for proper type hints without circular imports - Bump version to 3.9.0
- Replace type checks with a centralized validation function for clo, met, vr, and duration parameters. - Ensure that clo, met, and vr are positive values, and duration is a non-negative integer.
…culation Remove forced threshold adjustments and simplify boundary conditions for more accurate risk assessment.
Clean up formatting, type hints, error messages, and documentation examples
feat: enhance sports heat stress risk model with improved validation …
fix: format heat_index_lu.py
…s heat stress risk
…t stress calculation
fix: adjust risk level calculation to truncate instead of round
model_sports_heat_stress
…eat_stress_risk.py
|
Caution Review failedThe pull request is closed. WalkthroughThis PR introduces a version 3.9.0 release featuring a new sports heat stress risk assessment model. It includes a comprehensive sports heat stress risk calculation function with predefined sport profiles, updated input validation classes, new return dataclasses, enhanced dew point utilities aligned with WMO2024 standards, and corresponding documentation and test coverage. Changes
Sequence DiagramsequenceDiagram
participant User as User/Client
participant API as sports_heat_stress_risk()
participant Validator as SportsHeatStressInputs
participant Sport as Sports.RUNNING<br/>(or other)
participant Calc as _calc_risk_single_value()
participant Return as SportsHeatStressRisk
User->>API: Call sports_heat_stress_risk(tdb, tr, rh, vr, sport)
API->>Validator: Validate inputs (type, range, broadcasting)
Validator-->>API: Validated & array-converted inputs
API->>Calc: Vectorize _calc_risk_single_value<br/>over input arrays
loop For each input combination
Calc->>Sport: Fetch sport thresholds<br/>(t_medium, t_high, t_extreme)
Sport-->>Calc: Sport parameters
Calc->>Calc: Root-find thresholds via brentq<br/>(sweat loss, core temp equations)
Calc->>Calc: Interpolate risk_level across bands
Calc->>Calc: Generate recommendation<br/>via _get_recommendation()
end
Calc-->>API: Per-element risk results
API->>Return: Create SportsHeatStressRisk dataclass
Return-->>User: Return aggregated results<br/>(risk_level, t_medium, t_high, t_extreme, recommendation)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds a new Sports Heat Stress Risk model to pythermalcomfort, including inputs/outputs, documentation, and a comprehensive test suite, along with a version bump to 3.9.0.
Changes:
- Introduces
sports_heat_stress_risk()and aSportsnamespace of predefined sport parameters. - Adds
SportsHeatStressInputs(input validation) andSportsHeatStressRisk(return dataclass). - Updates docs/references and bumps package version/dependency constraints; adds a large new test module.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
pythermalcomfort/models/sports_heat_stress_risk.py |
New model implementation and sport parameter definitions. |
pythermalcomfort/classes_input.py |
Adds SportsHeatStressInputs with broadcasting + range validation. |
pythermalcomfort/classes_return.py |
Adds SportsHeatStressRisk return dataclass. |
pythermalcomfort/models/__init__.py |
Re-exports Sports and sports_heat_stress_risk. |
tests/test_sports_heat_stress_risk.py |
Adds extensive tests for scalar/array/broadcasting/edge cases. |
docs/documentation/models.rst |
Adds API docs entries for the new model. |
docs/documentation/references.rst |
Adds citations used by the new model docs. |
setup.py |
Bumps version and pins NumPy range. |
pythermalcomfort/__init__.py |
Bumps __version__ and adds an explicit type annotation. |
CHANGELOG.rst |
Adds 3.9.0 entry describing the new model. |
pythermalcomfort/utilities.py |
Docstring edits for dew_point_tmp. |
pythermalcomfort/models/heat_index_lu.py |
Pure formatting change to lambdas for readability. |
docs/documentation/surveys.rst |
Adds a new “Thermal Disposition” survey section. |
README.rst |
Updates the Snyk badge URL/target. |
Pipfile |
Adjusts dev dependency entries and adds CVE-related pins/comments. |
.bumpversion.toml |
Updates current_version to 3.9.0. |
AUTHORS.rst |
Adds a contributor entry. |
| # 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] | ||
| ) |
There was a problem hiding this comment.
sports_heat_stress_risk() creates a new np.vectorize wrapper on every call. Since this function is computationally heavy (root finding + PHS), consider defining the vectorized wrapper once at module scope (or using the @np.vectorize decorator) to avoid repeated wrapper construction overhead.
| """Test that infinite values are handled or rejected appropriately.""" | ||
| # Positive infinity should likely cause issues in the model | ||
| # This tests robustness - behavior may vary depending on implementation | ||
| try: | ||
| result = sports_heat_stress_risk( | ||
| tdb=np.inf, tr=30, rh=50, vr=0.5, sport=Sports.RUNNING | ||
| ) | ||
| # If it doesn't raise an error, ensure result is still valid | ||
| assert isinstance(result, SportsHeatStressRisk) | ||
| except (ValueError, RuntimeError, Warning): | ||
| # It's acceptable to raise an error for infinity | ||
| pass | ||
|
|
||
| # Negative infinity | ||
| try: | ||
| result = sports_heat_stress_risk( | ||
| tdb=-np.inf, tr=30, rh=50, vr=0.5, sport=Sports.WALKING | ||
| ) | ||
| # Should treat as extreme cold (risk = 0) | ||
| if isinstance(result, SportsHeatStressRisk): | ||
| risk_value = float(np.asarray(result.risk_level_interpolated).item()) | ||
| assert risk_value == pytest.approx(0.0, abs=0.01) | ||
| except (ValueError, RuntimeError, Warning): | ||
| # It's acceptable to raise an error for infinity | ||
| pass |
There was a problem hiding this comment.
test_sports_heat_stress_risk_inf_values uses try/except ...: pass, which can make the test pass without asserting any behavior (and Warning isn’t normally raised as an exception). Prefer asserting one expected outcome (either a specific exception via pytest.raises or a valid result with explicit checks), and use pytest.warns if warnings are expected.
| """Test that infinite values are handled or rejected appropriately.""" | |
| # Positive infinity should likely cause issues in the model | |
| # This tests robustness - behavior may vary depending on implementation | |
| try: | |
| result = sports_heat_stress_risk( | |
| tdb=np.inf, tr=30, rh=50, vr=0.5, sport=Sports.RUNNING | |
| ) | |
| # If it doesn't raise an error, ensure result is still valid | |
| assert isinstance(result, SportsHeatStressRisk) | |
| except (ValueError, RuntimeError, Warning): | |
| # It's acceptable to raise an error for infinity | |
| pass | |
| # Negative infinity | |
| try: | |
| result = sports_heat_stress_risk( | |
| tdb=-np.inf, tr=30, rh=50, vr=0.5, sport=Sports.WALKING | |
| ) | |
| # Should treat as extreme cold (risk = 0) | |
| if isinstance(result, SportsHeatStressRisk): | |
| risk_value = float(np.asarray(result.risk_level_interpolated).item()) | |
| assert risk_value == pytest.approx(0.0, abs=0.01) | |
| except (ValueError, RuntimeError, Warning): | |
| # It's acceptable to raise an error for infinity | |
| pass | |
| """Test that infinite dry-bulb temperatures are rejected.""" | |
| # Positive infinity should be considered invalid input | |
| with pytest.raises(ValueError): | |
| sports_heat_stress_risk( | |
| tdb=np.inf, | |
| tr=30, | |
| rh=50, | |
| vr=0.5, | |
| sport=Sports.RUNNING, | |
| ) | |
| # Negative infinity should also be considered invalid input | |
| with pytest.raises(ValueError): | |
| sports_heat_stress_risk( | |
| tdb=-np.inf, | |
| tr=30, | |
| rh=50, | |
| vr=0.5, | |
| sport=Sports.WALKING, | |
| ) |
| def test_sports_heat_stress_risk_all_sports(): | ||
| """Test that all predefined sports in Sports dataclass work correctly.""" | ||
| # Get all sport attributes from Sports dataclass | ||
| all_sports = [ | ||
| Sports.ABSEILING, | ||
| Sports.ARCHERY, | ||
| Sports.AUSTRALIAN_FOOTBALL, | ||
| Sports.BASEBALL, | ||
| Sports.BASKETBALL, | ||
| Sports.BOWLS, | ||
| Sports.CANOEING, | ||
| Sports.CRICKET, | ||
| Sports.CYCLING, | ||
| Sports.EQUESTRIAN, | ||
| Sports.FIELD_ATHLETICS, | ||
| Sports.FIELD_HOCKEY, | ||
| Sports.FISHING, | ||
| Sports.GOLF, | ||
| Sports.HORSEBACK, | ||
| Sports.KAYAKING, | ||
| Sports.RUNNING, | ||
| Sports.MTB, | ||
| Sports.NETBALL, | ||
| Sports.OZTAG, | ||
| Sports.PICKLEBALL, | ||
| Sports.CLIMBING, | ||
| Sports.ROWING, | ||
| Sports.RUGBY_LEAGUE, | ||
| Sports.RUGBY_UNION, | ||
| Sports.SAILING, | ||
| Sports.SHOOTING, | ||
| Sports.SOCCER, | ||
| Sports.SOFTBALL, | ||
| Sports.TENNIS, | ||
| Sports.TOUCH, | ||
| Sports.VOLLEYBALL, | ||
| Sports.WALKING, | ||
| ] | ||
|
|
||
| # Test moderate conditions with each sport | ||
| tdb, tr, rh, vr = 32, 32, 50, 0.5 | ||
|
|
||
| for sport in all_sports: | ||
| result = sports_heat_stress_risk(tdb=tdb, tr=tr, rh=rh, vr=vr, sport=sport) | ||
|
|
||
| # Verify all sports return valid results |
There was a problem hiding this comment.
This test loops through every sport and calls sports_heat_stress_risk each time. Because the model performs root-finding and repeatedly calls phs, this is likely to be very slow/flaky for unit tests. Consider reducing coverage to a representative subset here, or marking this test as slow and moving comprehensive coverage to a separate slow test suite.
| def test_sports_heat_stress_risk_large_array(): | ||
| """Test with large arrays (performance and correctness).""" | ||
| # Create large arrays | ||
| size = 100 | ||
| tdb_array = np.linspace(10, 45, size) | ||
| tr_array = np.linspace(10, 45, size) | ||
| rh_array = np.full(size, 50) | ||
| vr_array = np.full(size, 0.5) | ||
|
|
||
| result = sports_heat_stress_risk( | ||
| tdb=tdb_array, tr=tr_array, rh=rh_array, vr=vr_array, sport=Sports.RUNNING | ||
| ) |
There was a problem hiding this comment.
test_sports_heat_stress_risk_large_array uses a length-100 array; with per-element root finding and PHS evaluation this can be very expensive in CI. Consider reducing size to a small number sufficient to validate vectorization/broadcasting, or marking the test as slow/performance.
| tdb = 25.0 # dry bulb temperature in °C | ||
| rh = 60.0 # relative humidity in % | ||
| t_d = dew_point_tmp(tdb, rh) | ||
| The equation use the Magnus formula using the coefficients from |
There was a problem hiding this comment.
In the docstring, “The equation use …” is grammatically incorrect. Consider changing it to “The equation uses …” for clarity.
| The equation use the Magnus formula using the coefficients from | |
| The equation uses the Magnus formula using the coefficients from |
| 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. |
There was a problem hiding this comment.
The Sports docstring claims that using a frozen dataclass prevents modification of the namespace, but frozen=True only affects instances and does not prevent reassignment of class attributes (and _SportsValues instances themselves are mutable). Either adjust the docstring to match actual behavior or make the stored values immutable (e.g., make _SportsValues frozen and avoid implying the namespace can’t be modified).
| 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. | |
| Use attributes like ``Sports.RUNNING`` to obtain a :class:`_SportsValues` | |
| instance containing the corresponding clothing insulation (clo), metabolic | |
| rate (met), air velocity (vr), and typical activity duration. | |
| The attributes defined on this class are intended to be used as constants | |
| for common sports, and are provided as class-level attributes rather than | |
| instance fields. The ``frozen=True`` dataclass option applies to instances | |
| of :class:`Sports` and does not prevent reassignment of these class | |
| attributes or mutation of the stored :class:`_SportsValues` instances. |
| clo: float | ||
| met: float | ||
| vr: float | ||
| duration: int | ||
|
|
There was a problem hiding this comment.
_SportsValues defines a vr attribute and the public docs for sport mention it, but sport.vr is never used in the calculation (only the function argument vr is used). This is confusing for API consumers; either incorporate sport.vr into the model (e.g., as a default/override) or remove the field/documentation so there’s a single source of truth for air speed.
| 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 |
There was a problem hiding this comment.
The sweat_loss_g threshold is commented as “per hour”, but later the root function scales modeled sweat loss to a 45-minute basis (multiplying by 45). Please clarify the intended units and ensure the threshold and scaling are consistent (e.g., convert the threshold to the same time basis or use 60 minutes if the threshold is hourly).
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Chores