Files
weather-alerts/app/services/rule_engine.py
2026-01-26 15:08:24 -06:00

232 lines
7.6 KiB
Python

"""Rule engine for evaluating weather conditions against alert rules."""
from typing import Optional
from app.models.alerts import AlertRules, AlertType, TriggeredAlert
from app.models.weather import HourlyForecast, WeatherAlert, WeatherForecast
from app.utils.logging_config import get_logger
class RuleEngine:
"""Evaluates weather forecasts against configured alert rules."""
def __init__(self, rules: AlertRules) -> None:
"""Initialize the rule engine.
Args:
rules: The alert rules configuration.
"""
self.rules = rules
self.logger = get_logger(__name__)
def evaluate(self, forecast: WeatherForecast) -> list[TriggeredAlert]:
"""Evaluate a forecast against all enabled rules.
Args:
forecast: The weather forecast to evaluate.
Returns:
List of triggered alerts.
"""
alerts: list[TriggeredAlert] = []
# Evaluate hourly forecasts
for hourly in forecast.hourly_forecasts:
alerts.extend(self._evaluate_hourly(hourly))
# Evaluate severe weather alerts from API
if self.rules.severe_weather.enabled:
alerts.extend(self._evaluate_severe_alerts(forecast.alerts))
self.logger.info(
"rules_evaluated",
hourly_count=len(forecast.hourly_forecasts),
triggered_count=len(alerts),
)
return alerts
def _evaluate_hourly(self, hourly: HourlyForecast) -> list[TriggeredAlert]:
"""Evaluate a single hourly forecast against rules.
Args:
hourly: The hourly forecast data.
Returns:
List of triggered alerts for this hour.
"""
alerts: list[TriggeredAlert] = []
# Temperature rules
if self.rules.temperature.enabled:
alert = self._check_temperature(hourly)
if alert:
alerts.append(alert)
# Precipitation rules
if self.rules.precipitation.enabled:
alert = self._check_precipitation(hourly)
if alert:
alerts.append(alert)
# Wind rules
if self.rules.wind.enabled:
wind_alerts = self._check_wind(hourly)
alerts.extend(wind_alerts)
return alerts
def _check_temperature(
self,
hourly: HourlyForecast,
) -> Optional[TriggeredAlert]:
"""Check temperature thresholds.
Args:
hourly: The hourly forecast data.
Returns:
TriggeredAlert if threshold exceeded, None otherwise.
"""
temp_rule = self.rules.temperature
# Check low temperature
if temp_rule.below is not None and hourly.temp < temp_rule.below:
return TriggeredAlert(
alert_type=AlertType.TEMPERATURE_LOW,
title="Low Temperature Alert",
message=(
f"Temperature expected to drop to {hourly.temp:.0f}°F "
f"at {hourly.datetime.strftime('%I:%M %p on %b %d')}. "
f"Threshold: {temp_rule.below:.0f}°F"
),
forecast_hour=hourly.hour_key,
value=hourly.temp,
threshold=temp_rule.below,
)
# Check high temperature
if temp_rule.above is not None and hourly.temp > temp_rule.above:
return TriggeredAlert(
alert_type=AlertType.TEMPERATURE_HIGH,
title="High Temperature Alert",
message=(
f"Temperature expected to reach {hourly.temp:.0f}°F "
f"at {hourly.datetime.strftime('%I:%M %p on %b %d')}. "
f"Threshold: {temp_rule.above:.0f}°F"
),
forecast_hour=hourly.hour_key,
value=hourly.temp,
threshold=temp_rule.above,
)
return None
def _check_precipitation(
self,
hourly: HourlyForecast,
) -> Optional[TriggeredAlert]:
"""Check precipitation probability threshold.
Args:
hourly: The hourly forecast data.
Returns:
TriggeredAlert if threshold exceeded, None otherwise.
"""
precip_rule = self.rules.precipitation
threshold = precip_rule.probability_above
if hourly.precip_prob > threshold:
return TriggeredAlert(
alert_type=AlertType.PRECIPITATION,
title="Precipitation Alert",
message=(
f"{hourly.precip_prob:.0f}% chance of precipitation "
f"at {hourly.datetime.strftime('%I:%M %p on %b %d')}. "
f"Conditions: {hourly.conditions}"
),
forecast_hour=hourly.hour_key,
value=hourly.precip_prob,
threshold=threshold,
)
return None
def _check_wind(self, hourly: HourlyForecast) -> list[TriggeredAlert]:
"""Check wind speed and gust thresholds.
Args:
hourly: The hourly forecast data.
Returns:
List of triggered wind alerts.
"""
alerts: list[TriggeredAlert] = []
wind_rule = self.rules.wind
# Check sustained wind speed
if hourly.wind_speed > wind_rule.speed_above:
alerts.append(
TriggeredAlert(
alert_type=AlertType.WIND_SPEED,
title="High Wind Alert",
message=(
f"Sustained winds of {hourly.wind_speed:.0f} mph expected "
f"at {hourly.datetime.strftime('%I:%M %p on %b %d')}. "
f"Threshold: {wind_rule.speed_above:.0f} mph"
),
forecast_hour=hourly.hour_key,
value=hourly.wind_speed,
threshold=wind_rule.speed_above,
)
)
# Check wind gusts
if hourly.wind_gust > wind_rule.gust_above:
alerts.append(
TriggeredAlert(
alert_type=AlertType.WIND_GUST,
title="Wind Gust Alert",
message=(
f"Wind gusts up to {hourly.wind_gust:.0f} mph expected "
f"at {hourly.datetime.strftime('%I:%M %p on %b %d')}. "
f"Threshold: {wind_rule.gust_above:.0f} mph"
),
forecast_hour=hourly.hour_key,
value=hourly.wind_gust,
threshold=wind_rule.gust_above,
)
)
return alerts
def _evaluate_severe_alerts(
self,
api_alerts: list[WeatherAlert],
) -> list[TriggeredAlert]:
"""Convert API severe weather alerts to triggered alerts.
Args:
api_alerts: List of WeatherAlert from the API.
Returns:
List of triggered severe weather alerts.
"""
triggered: list[TriggeredAlert] = []
for api_alert in api_alerts:
# Use alert ID as the hour key for deduplication
triggered.append(
TriggeredAlert(
alert_type=AlertType.SEVERE_WEATHER,
title=f"Severe Weather: {api_alert.event}",
message=api_alert.headline or api_alert.description[:200],
forecast_hour=api_alert.id or api_alert.event,
value=1.0, # Placeholder - severe alerts don't have numeric values
threshold=0.0,
)
)
return triggered