init commit
This commit is contained in:
231
app/services/rule_engine.py
Normal file
231
app/services/rule_engine.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user