init commit

This commit is contained in:
2026-01-26 15:08:24 -06:00
commit 67225a725a
33 changed files with 3350 additions and 0 deletions

231
app/services/rule_engine.py Normal file
View 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