diff --git a/src/accessiweather/thermal_comfort.py b/src/accessiweather/thermal_comfort.py index 369c0343..49425dd3 100644 --- a/src/accessiweather/thermal_comfort.py +++ b/src/accessiweather/thermal_comfort.py @@ -11,6 +11,8 @@ APPARENT_TEMPERATURE_SOLAR_ALLOWANCE_PER_DEGREE_F = 0.15 HEAT_INDEX_MIN_TEMP_F = 80.0 HEAT_INDEX_MIN_HUMIDITY = 40 +WIND_CHILL_MAX_TEMP_F = 50.0 +WIND_CHILL_MIN_WIND_MPH = 3.0 @dataclass(frozen=True) @@ -115,6 +117,20 @@ def calculate_heat_index_f(temperature_f: float, humidity: int | float) -> float return heat_index +def calculate_wind_chill_f(temperature_f: float, wind_speed_mph: float) -> float | None: + """Calculate the standard NWS wind chill in Fahrenheit when applicable.""" + if temperature_f > WIND_CHILL_MAX_TEMP_F or wind_speed_mph <= WIND_CHILL_MIN_WIND_MPH: + return None + + wind_factor = wind_speed_mph**0.16 + return ( + 35.74 + + (0.6215 * temperature_f) + - (35.75 * wind_factor) + + (0.4275 * temperature_f * wind_factor) + ) + + def warm_apparent_temperature_is_coherent( temperature_f: float | None, humidity: int | float | None, diff --git a/src/accessiweather/weather_client_nws_parsers.py b/src/accessiweather/weather_client_nws_parsers.py index a014a22b..12adbc76 100644 --- a/src/accessiweather/weather_client_nws_parsers.py +++ b/src/accessiweather/weather_client_nws_parsers.py @@ -8,7 +8,11 @@ normalize_pressure_pair, normalize_temperature_pair, ) -from .thermal_comfort import sanitize_thermal_comfort_readings +from .thermal_comfort import ( + calculate_heat_index_f, + calculate_wind_chill_f, + sanitize_thermal_comfort_readings, +) from .weather_client_nws_common import * # noqa: F403 from .weather_client_parsers import normalize_pressure @@ -81,13 +85,26 @@ def parse_nws_current_conditions( props.get("heatIndex", {}).get("unitCode") or "wmoUnit:degC", ) + derived_wind_chill_f = None + if wind_chill.fahrenheit is None and temperature.fahrenheit is not None and wind_speed_mph is not None: + derived_wind_chill_f = calculate_wind_chill_f(temperature.fahrenheit, wind_speed_mph) + + derived_heat_index_f = None + if heat_index.fahrenheit is None and temperature.fahrenheit is not None and humidity is not None: + derived_heat_index_f = calculate_heat_index_f(temperature.fahrenheit, humidity) + + derived_feels_like_f = ( + derived_wind_chill_f if derived_wind_chill_f is not None else derived_heat_index_f + ) + comfort = sanitize_thermal_comfort_readings( temperature_f=temperature.fahrenheit, temperature_c=temperature.celsius, humidity=humidity, - wind_chill_f=wind_chill.fahrenheit, + feels_like_f=derived_feels_like_f, + wind_chill_f=wind_chill.fahrenheit if wind_chill.fahrenheit is not None else derived_wind_chill_f, wind_chill_c=wind_chill.celsius, - heat_index_f=heat_index.fahrenheit, + heat_index_f=heat_index.fahrenheit if heat_index.fahrenheit is not None else derived_heat_index_f, heat_index_c=heat_index.celsius, ) diff --git a/tests/test_nws_current_parser.py b/tests/test_nws_current_parser.py index 3dd4d5eb..8ae8cf5c 100644 --- a/tests/test_nws_current_parser.py +++ b/tests/test_nws_current_parser.py @@ -4,6 +4,7 @@ import pytest +from accessiweather.thermal_comfort import calculate_heat_index_f, calculate_wind_chill_f from accessiweather.weather_client_nws import parse_nws_current_conditions @@ -42,3 +43,45 @@ def test_parse_nws_current_conditions_normalizes_measurements_to_current_model() assert current.heat_index_f is None assert current.condition == "Mostly Cloudy" assert current.uv_index == 2.5 + + +def test_parse_nws_current_conditions_derives_wind_chill_when_field_missing(): + payload = { + "properties": { + "temperature": {"value": 0.0, "unitCode": "wmoUnit:degC"}, + "relativeHumidity": {"value": 72.4, "unitCode": "wmoUnit:percent"}, + "windSpeed": {"value": 16.09344, "unitCode": "wmoUnit:km_h-1"}, + "windChill": {"value": None, "unitCode": "wmoUnit:degC"}, + "heatIndex": {"value": None, "unitCode": "wmoUnit:degC"}, + "textDescription": "Mostly Cloudy", + } + } + + current = parse_nws_current_conditions(payload) + + expected = calculate_wind_chill_f(32.0, 10.0) + assert expected is not None + assert current.feels_like_f == pytest.approx(expected, abs=0.1) + assert current.wind_chill_f == pytest.approx(expected, abs=0.1) + assert current.heat_index_f is None + + +def test_parse_nws_current_conditions_derives_heat_index_when_field_missing(): + payload = { + "properties": { + "temperature": {"value": 32.2222222, "unitCode": "wmoUnit:degC"}, + "relativeHumidity": {"value": 70.0, "unitCode": "wmoUnit:percent"}, + "windSpeed": {"value": 7.0, "unitCode": "wmoUnit:mi_h-1"}, + "windChill": {"value": None, "unitCode": "wmoUnit:degC"}, + "heatIndex": {"value": None, "unitCode": "wmoUnit:degC"}, + "textDescription": "Hot", + } + } + + current = parse_nws_current_conditions(payload) + + expected = calculate_heat_index_f(90.0, 70) + assert expected is not None + assert current.feels_like_f == pytest.approx(expected, abs=0.1) + assert current.heat_index_f == pytest.approx(expected, abs=0.1) + assert current.wind_chill_f is None