init commit
This commit is contained in:
13
app/services/__init__.py
Normal file
13
app/services/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Service layer for weather alerts."""
|
||||
|
||||
from app.services.weather_service import WeatherService
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.services.rule_engine import RuleEngine
|
||||
from app.services.state_manager import StateManager
|
||||
|
||||
__all__ = [
|
||||
"WeatherService",
|
||||
"NotificationService",
|
||||
"RuleEngine",
|
||||
"StateManager",
|
||||
]
|
||||
283
app/services/alert_aggregator.py
Normal file
283
app/services/alert_aggregator.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Alert aggregator service for combining multiple alerts of the same type."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.alerts import AggregatedAlert, AlertType, TriggeredAlert
|
||||
from app.utils.logging_config import get_logger
|
||||
|
||||
|
||||
class AlertAggregator:
|
||||
"""Aggregates multiple alerts of the same type into single notifications."""
|
||||
|
||||
# Alert types where lower values are worse (e.g., low temperature)
|
||||
LOWER_IS_WORSE: set[AlertType] = {AlertType.TEMPERATURE_LOW}
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the alert aggregator."""
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
def aggregate(
|
||||
self,
|
||||
alerts: list[TriggeredAlert],
|
||||
) -> list[AggregatedAlert]:
|
||||
"""Aggregate alerts by type into summary notifications.
|
||||
|
||||
Severe weather alerts pass through unchanged.
|
||||
Other alert types are grouped and combined into a single notification
|
||||
per type with time range and extreme value information.
|
||||
|
||||
Args:
|
||||
alerts: List of triggered alerts to aggregate.
|
||||
|
||||
Returns:
|
||||
List of aggregated alerts (one per alert type).
|
||||
"""
|
||||
if not alerts:
|
||||
return []
|
||||
|
||||
# Separate severe weather alerts from regular hourly alerts
|
||||
severe_alerts: list[TriggeredAlert] = []
|
||||
hourly_alerts: list[TriggeredAlert] = []
|
||||
|
||||
for alert in alerts:
|
||||
if alert.alert_type == AlertType.SEVERE_WEATHER:
|
||||
severe_alerts.append(alert)
|
||||
else:
|
||||
hourly_alerts.append(alert)
|
||||
|
||||
aggregated: list[AggregatedAlert] = []
|
||||
|
||||
# Convert severe weather alerts to AggregatedAlert (pass through)
|
||||
aggregated.extend(self._convert_severe_alerts(severe_alerts))
|
||||
|
||||
# Aggregate hourly alerts by type
|
||||
aggregated.extend(self._aggregate_by_type(hourly_alerts))
|
||||
|
||||
self.logger.info(
|
||||
"alerts_aggregated",
|
||||
input_count=len(alerts),
|
||||
output_count=len(aggregated),
|
||||
severe_count=len(severe_alerts),
|
||||
)
|
||||
|
||||
return aggregated
|
||||
|
||||
def _convert_severe_alerts(
|
||||
self,
|
||||
alerts: list[TriggeredAlert],
|
||||
) -> list[AggregatedAlert]:
|
||||
"""Convert severe weather alerts to AggregatedAlert format.
|
||||
|
||||
Severe weather alerts are not aggregated - each one becomes
|
||||
its own AggregatedAlert for individual notification.
|
||||
|
||||
Args:
|
||||
alerts: List of severe weather triggered alerts.
|
||||
|
||||
Returns:
|
||||
List of AggregatedAlert, one per severe weather alert.
|
||||
"""
|
||||
return [
|
||||
AggregatedAlert(
|
||||
alert_type=alert.alert_type,
|
||||
title=alert.title,
|
||||
message=alert.message,
|
||||
triggered_hours=[alert.forecast_hour],
|
||||
start_time=alert.forecast_hour,
|
||||
end_time=alert.forecast_hour,
|
||||
extreme_value=alert.value,
|
||||
extreme_hour=alert.forecast_hour,
|
||||
threshold=alert.threshold,
|
||||
created_at=alert.created_at,
|
||||
)
|
||||
for alert in alerts
|
||||
]
|
||||
|
||||
def _aggregate_by_type(
|
||||
self,
|
||||
alerts: list[TriggeredAlert],
|
||||
) -> list[AggregatedAlert]:
|
||||
"""Aggregate hourly alerts by alert type.
|
||||
|
||||
Args:
|
||||
alerts: List of hourly triggered alerts.
|
||||
|
||||
Returns:
|
||||
List of AggregatedAlert, one per alert type.
|
||||
"""
|
||||
# Group alerts by type
|
||||
by_type: dict[AlertType, list[TriggeredAlert]] = defaultdict(list)
|
||||
|
||||
for alert in alerts:
|
||||
by_type[alert.alert_type].append(alert)
|
||||
|
||||
aggregated: list[AggregatedAlert] = []
|
||||
|
||||
for alert_type, type_alerts in by_type.items():
|
||||
aggregated_alert = self._aggregate_type_group(alert_type, type_alerts)
|
||||
aggregated.append(aggregated_alert)
|
||||
|
||||
return aggregated
|
||||
|
||||
def _aggregate_type_group(
|
||||
self,
|
||||
alert_type: AlertType,
|
||||
alerts: list[TriggeredAlert],
|
||||
) -> AggregatedAlert:
|
||||
"""Create a single AggregatedAlert from a group of same-type alerts.
|
||||
|
||||
Args:
|
||||
alert_type: The type of all alerts in the group.
|
||||
alerts: List of alerts of the same type.
|
||||
|
||||
Returns:
|
||||
A single AggregatedAlert summarizing the group.
|
||||
"""
|
||||
# Sort by forecast hour for chronological ordering
|
||||
sorted_alerts = sorted(alerts, key=lambda a: a.forecast_hour)
|
||||
|
||||
# Collect all triggered hours
|
||||
triggered_hours = [a.forecast_hour for a in sorted_alerts]
|
||||
start_time = sorted_alerts[0].forecast_hour
|
||||
end_time = sorted_alerts[-1].forecast_hour
|
||||
|
||||
# Find extreme value (lowest for low temp, highest for others)
|
||||
if alert_type in self.LOWER_IS_WORSE:
|
||||
extreme_alert = min(sorted_alerts, key=lambda a: a.value)
|
||||
else:
|
||||
extreme_alert = max(sorted_alerts, key=lambda a: a.value)
|
||||
|
||||
extreme_value = extreme_alert.value
|
||||
extreme_hour = extreme_alert.forecast_hour
|
||||
threshold = sorted_alerts[0].threshold # Same for all alerts of same type
|
||||
|
||||
# Build summary message
|
||||
message = self._build_summary_message(
|
||||
alert_type=alert_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
extreme_value=extreme_value,
|
||||
extreme_hour=extreme_hour,
|
||||
threshold=threshold,
|
||||
hour_count=len(sorted_alerts),
|
||||
)
|
||||
|
||||
# Build title
|
||||
title = self._build_title(alert_type)
|
||||
|
||||
return AggregatedAlert(
|
||||
alert_type=alert_type,
|
||||
title=title,
|
||||
message=message,
|
||||
triggered_hours=triggered_hours,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
extreme_value=extreme_value,
|
||||
extreme_hour=extreme_hour,
|
||||
threshold=threshold,
|
||||
)
|
||||
|
||||
def _build_title(self, alert_type: AlertType) -> str:
|
||||
"""Build a title for the aggregated alert.
|
||||
|
||||
Args:
|
||||
alert_type: The type of alert.
|
||||
|
||||
Returns:
|
||||
Title string.
|
||||
"""
|
||||
titles = {
|
||||
AlertType.TEMPERATURE_LOW: "Low Temperature Alert",
|
||||
AlertType.TEMPERATURE_HIGH: "High Temperature Alert",
|
||||
AlertType.PRECIPITATION: "Precipitation Alert",
|
||||
AlertType.WIND_SPEED: "High Wind Alert",
|
||||
AlertType.WIND_GUST: "Wind Gust Alert",
|
||||
}
|
||||
return titles.get(alert_type, f"{alert_type.value} Alert")
|
||||
|
||||
def _build_summary_message(
|
||||
self,
|
||||
alert_type: AlertType,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
extreme_value: float,
|
||||
extreme_hour: str,
|
||||
threshold: float,
|
||||
hour_count: int,
|
||||
) -> str:
|
||||
"""Build a summary message for the aggregated alert.
|
||||
|
||||
Args:
|
||||
alert_type: The type of alert.
|
||||
start_time: First hour that triggered (YYYY-MM-DD-HH format).
|
||||
end_time: Last hour that triggered (YYYY-MM-DD-HH format).
|
||||
extreme_value: The most extreme value recorded.
|
||||
extreme_hour: Hour when extreme value occurred.
|
||||
threshold: The threshold that was exceeded.
|
||||
hour_count: Number of hours that triggered.
|
||||
|
||||
Returns:
|
||||
Human-readable summary message.
|
||||
"""
|
||||
# Format times for display
|
||||
start_display = self._format_hour_display(start_time)
|
||||
end_display = self._format_hour_display(end_time)
|
||||
extreme_display = self._format_hour_display(extreme_hour)
|
||||
|
||||
# Build type-specific message
|
||||
if alert_type == AlertType.TEMPERATURE_LOW:
|
||||
return (
|
||||
f"Low temps from {start_display} - {end_display}. "
|
||||
f"Lowest: {extreme_value:.0f}°F at {extreme_display}. "
|
||||
f"({hour_count} hours below {threshold:.0f}°F)"
|
||||
)
|
||||
|
||||
elif alert_type == AlertType.TEMPERATURE_HIGH:
|
||||
return (
|
||||
f"High temps from {start_display} - {end_display}. "
|
||||
f"Highest: {extreme_value:.0f}°F at {extreme_display}. "
|
||||
f"({hour_count} hours above {threshold:.0f}°F)"
|
||||
)
|
||||
|
||||
elif alert_type == AlertType.PRECIPITATION:
|
||||
return (
|
||||
f"Precipitation likely from {start_display} - {end_display}. "
|
||||
f"Peak: {extreme_value:.0f}% at {extreme_display}. "
|
||||
f"({hour_count} hours above {threshold:.0f}%)"
|
||||
)
|
||||
|
||||
elif alert_type == AlertType.WIND_SPEED:
|
||||
return (
|
||||
f"High winds from {start_display} - {end_display}. "
|
||||
f"Peak: {extreme_value:.0f} mph at {extreme_display}. "
|
||||
f"({hour_count} hours above {threshold:.0f} mph)"
|
||||
)
|
||||
|
||||
elif alert_type == AlertType.WIND_GUST:
|
||||
return (
|
||||
f"Wind gusts from {start_display} - {end_display}. "
|
||||
f"Peak: {extreme_value:.0f} mph at {extreme_display}. "
|
||||
f"({hour_count} hours above {threshold:.0f} mph)"
|
||||
)
|
||||
|
||||
# Fallback for unknown types
|
||||
return (
|
||||
f"Alert from {start_display} - {end_display}. "
|
||||
f"({hour_count} hours affected)"
|
||||
)
|
||||
|
||||
def _format_hour_display(self, hour_key: str) -> str:
|
||||
"""Format an hour key for human display.
|
||||
|
||||
Args:
|
||||
hour_key: Hour key in YYYY-MM-DD-HH format.
|
||||
|
||||
Returns:
|
||||
Human-readable time string (e.g., "3 PM" or "6 AM").
|
||||
"""
|
||||
try:
|
||||
dt = datetime.strptime(hour_key, "%Y-%m-%d-%H")
|
||||
return dt.strftime("%-I %p")
|
||||
except ValueError:
|
||||
return hour_key
|
||||
177
app/services/notification_service.py
Normal file
177
app/services/notification_service.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Notification service for sending alerts via ntfy."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Union
|
||||
|
||||
from app.models.alerts import AggregatedAlert, AlertType, TriggeredAlert
|
||||
from app.utils.http_client import HttpClient
|
||||
from app.utils.logging_config import get_logger
|
||||
|
||||
# Type alias for alerts that can be sent
|
||||
SendableAlert = Union[TriggeredAlert, AggregatedAlert]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
"""Result of sending a notification."""
|
||||
|
||||
alert: SendableAlert
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationServiceError(Exception):
|
||||
"""Raised when notification service encounters an error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for sending notifications via ntfy."""
|
||||
|
||||
# Map alert types to emoji tags
|
||||
ALERT_TYPE_TAGS: dict[AlertType, list[str]] = {
|
||||
AlertType.TEMPERATURE_LOW: ["cold_face", "thermometer"],
|
||||
AlertType.TEMPERATURE_HIGH: ["hot_face", "thermometer"],
|
||||
AlertType.PRECIPITATION: ["cloud_with_rain", "umbrella"],
|
||||
AlertType.WIND_SPEED: ["wind_face", "dash"],
|
||||
AlertType.WIND_GUST: ["tornado", "dash"],
|
||||
AlertType.SEVERE_WEATHER: ["rotating_light", "warning"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
topic: str,
|
||||
access_token: str = "",
|
||||
priority: str = "high",
|
||||
default_tags: Optional[list[str]] = None,
|
||||
http_client: Optional[HttpClient] = None,
|
||||
) -> None:
|
||||
"""Initialize the notification service.
|
||||
|
||||
Args:
|
||||
server_url: The ntfy server URL.
|
||||
topic: The topic to publish to.
|
||||
access_token: Optional bearer token for authentication.
|
||||
priority: Default notification priority.
|
||||
default_tags: Default tags to include with notifications.
|
||||
http_client: Optional HTTP client instance.
|
||||
"""
|
||||
self.server_url = server_url.rstrip("/")
|
||||
self.topic = topic
|
||||
self.access_token = access_token
|
||||
self.priority = priority
|
||||
self.default_tags = default_tags or ["cloud", "warning"]
|
||||
self.http_client = http_client or HttpClient()
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
def send(self, alert: SendableAlert) -> NotificationResult:
|
||||
"""Send a single alert notification.
|
||||
|
||||
Args:
|
||||
alert: The triggered or aggregated alert to send.
|
||||
|
||||
Returns:
|
||||
NotificationResult indicating success or failure.
|
||||
"""
|
||||
url = f"{self.server_url}/{self.topic}"
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"Title": alert.title,
|
||||
"Priority": self._get_priority(alert),
|
||||
"Tags": ",".join(self._get_tags(alert)),
|
||||
}
|
||||
|
||||
if self.access_token:
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
|
||||
self.logger.debug(
|
||||
"sending_notification",
|
||||
alert_type=alert.alert_type.value,
|
||||
title=alert.title,
|
||||
)
|
||||
|
||||
response = self.http_client.post(
|
||||
url,
|
||||
data=alert.message.encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if response.success:
|
||||
self.logger.info(
|
||||
"notification_sent",
|
||||
alert_type=alert.alert_type.value,
|
||||
dedup_key=alert.dedup_key,
|
||||
)
|
||||
return NotificationResult(alert=alert, success=True)
|
||||
else:
|
||||
error_msg = f"HTTP {response.status_code}: {response.text[:100]}"
|
||||
self.logger.error(
|
||||
"notification_failed",
|
||||
alert_type=alert.alert_type.value,
|
||||
error=error_msg,
|
||||
)
|
||||
return NotificationResult(alert=alert, success=False, error=error_msg)
|
||||
|
||||
def send_batch(
|
||||
self,
|
||||
alerts: list[SendableAlert],
|
||||
) -> list[NotificationResult]:
|
||||
"""Send multiple alert notifications.
|
||||
|
||||
Args:
|
||||
alerts: List of triggered or aggregated alerts to send.
|
||||
|
||||
Returns:
|
||||
List of NotificationResult for each alert.
|
||||
"""
|
||||
if not alerts:
|
||||
self.logger.info("no_alerts_to_send")
|
||||
return []
|
||||
|
||||
results: list[NotificationResult] = []
|
||||
|
||||
for alert in alerts:
|
||||
result = self.send(alert)
|
||||
results.append(result)
|
||||
|
||||
success_count = sum(1 for r in results if r.success)
|
||||
self.logger.info(
|
||||
"batch_send_complete",
|
||||
total=len(alerts),
|
||||
success=success_count,
|
||||
failed=len(alerts) - success_count,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _get_priority(self, alert: SendableAlert) -> str:
|
||||
"""Determine notification priority based on alert type.
|
||||
|
||||
Args:
|
||||
alert: The triggered or aggregated alert.
|
||||
|
||||
Returns:
|
||||
Priority string for ntfy.
|
||||
"""
|
||||
# Severe weather always gets urgent priority
|
||||
if alert.alert_type == AlertType.SEVERE_WEATHER:
|
||||
return "urgent"
|
||||
|
||||
return self.priority
|
||||
|
||||
def _get_tags(self, alert: SendableAlert) -> list[str]:
|
||||
"""Get notification tags for an alert.
|
||||
|
||||
Args:
|
||||
alert: The triggered or aggregated alert.
|
||||
|
||||
Returns:
|
||||
List of emoji tags for ntfy.
|
||||
"""
|
||||
# Start with alert-specific tags
|
||||
tags = list(self.ALERT_TYPE_TAGS.get(alert.alert_type, self.default_tags))
|
||||
|
||||
return tags
|
||||
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
|
||||
185
app/services/state_manager.py
Normal file
185
app/services/state_manager.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""State manager for alert deduplication with atomic file persistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from app.models.alerts import AggregatedAlert, TriggeredAlert
|
||||
from app.models.state import AlertState
|
||||
from app.utils.logging_config import get_logger
|
||||
|
||||
# Type alias for alerts that can be deduplicated
|
||||
DeduplicableAlert = Union[TriggeredAlert, AggregatedAlert]
|
||||
|
||||
|
||||
class StateManagerError(Exception):
|
||||
"""Raised when state management encounters an error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Manages alert state persistence for deduplication."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: str,
|
||||
dedup_window_hours: int = 24,
|
||||
) -> None:
|
||||
"""Initialize the state manager.
|
||||
|
||||
Args:
|
||||
file_path: Path to the state JSON file.
|
||||
dedup_window_hours: Hours to retain sent alert records.
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self.dedup_window_hours = dedup_window_hours
|
||||
self.logger = get_logger(__name__)
|
||||
self._state: Optional[AlertState] = None
|
||||
|
||||
@property
|
||||
def state(self) -> AlertState:
|
||||
"""Get the current state, loading from file if necessary."""
|
||||
if self._state is None:
|
||||
self._state = self.load()
|
||||
return self._state
|
||||
|
||||
def load(self) -> AlertState:
|
||||
"""Load state from file.
|
||||
|
||||
Returns:
|
||||
AlertState instance, empty if file doesn't exist.
|
||||
"""
|
||||
if not self.file_path.exists():
|
||||
self.logger.info("state_file_not_found", path=str(self.file_path))
|
||||
return AlertState()
|
||||
|
||||
try:
|
||||
with open(self.file_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
state = AlertState.from_dict(data)
|
||||
self.logger.info(
|
||||
"state_loaded",
|
||||
path=str(self.file_path),
|
||||
record_count=len(state.sent_alerts),
|
||||
)
|
||||
return state
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.warning(
|
||||
"state_file_corrupt",
|
||||
path=str(self.file_path),
|
||||
error=str(e),
|
||||
)
|
||||
return AlertState()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save state to file with atomic write.
|
||||
|
||||
Uses write-to-temp-then-rename for crash safety.
|
||||
"""
|
||||
if self._state is None:
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to temp file first
|
||||
dir_path = self.file_path.parent
|
||||
try:
|
||||
fd, temp_path = tempfile.mkstemp(
|
||||
suffix=".tmp",
|
||||
prefix="state_",
|
||||
dir=dir_path,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(self._state.to_dict(), f, indent=2)
|
||||
|
||||
# Atomic rename
|
||||
os.replace(temp_path, self.file_path)
|
||||
|
||||
self.logger.debug(
|
||||
"state_saved",
|
||||
path=str(self.file_path),
|
||||
record_count=len(self._state.sent_alerts),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Clean up temp file on error
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
raise
|
||||
|
||||
except OSError as e:
|
||||
self.logger.error("state_save_failed", error=str(e))
|
||||
raise StateManagerError(f"Failed to save state: {e}")
|
||||
|
||||
def filter_duplicates(
|
||||
self,
|
||||
alerts: list[DeduplicableAlert],
|
||||
) -> list[DeduplicableAlert]:
|
||||
"""Filter out alerts that have already been sent.
|
||||
|
||||
Args:
|
||||
alerts: List of triggered or aggregated alerts.
|
||||
|
||||
Returns:
|
||||
List of alerts that haven't been sent within the dedup window.
|
||||
"""
|
||||
new_alerts: list[DeduplicableAlert] = []
|
||||
|
||||
for alert in alerts:
|
||||
if not self.state.is_duplicate(alert.dedup_key):
|
||||
new_alerts.append(alert)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"alert_filtered_duplicate",
|
||||
dedup_key=alert.dedup_key,
|
||||
)
|
||||
|
||||
filtered_count = len(alerts) - len(new_alerts)
|
||||
if filtered_count > 0:
|
||||
self.logger.info(
|
||||
"duplicates_filtered",
|
||||
total=len(alerts),
|
||||
new=len(new_alerts),
|
||||
duplicates=filtered_count,
|
||||
)
|
||||
|
||||
return new_alerts
|
||||
|
||||
def record_sent(self, alert: DeduplicableAlert) -> None:
|
||||
"""Record that an alert was sent.
|
||||
|
||||
Args:
|
||||
alert: The triggered or aggregated alert that was sent.
|
||||
"""
|
||||
# Get the forecast hour - AggregatedAlert uses start_time, TriggeredAlert uses forecast_hour
|
||||
if isinstance(alert, AggregatedAlert):
|
||||
forecast_hour = alert.start_time
|
||||
else:
|
||||
forecast_hour = alert.forecast_hour
|
||||
|
||||
self.state.record_sent(
|
||||
dedup_key=alert.dedup_key,
|
||||
alert_type=alert.alert_type.value,
|
||||
forecast_hour=forecast_hour,
|
||||
)
|
||||
self.logger.debug("alert_recorded", dedup_key=alert.dedup_key)
|
||||
|
||||
def purge_old_records(self) -> int:
|
||||
"""Remove records older than the deduplication window.
|
||||
|
||||
Returns:
|
||||
Number of records purged.
|
||||
"""
|
||||
purged = self.state.purge_old_records(self.dedup_window_hours)
|
||||
|
||||
if purged > 0:
|
||||
self.logger.info("old_records_purged", count=purged)
|
||||
|
||||
return purged
|
||||
101
app/services/weather_service.py
Normal file
101
app/services/weather_service.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Weather service for fetching forecasts from VisualCrossing API."""
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.models.weather import WeatherForecast
|
||||
from app.utils.http_client import HttpClient
|
||||
from app.utils.logging_config import get_logger
|
||||
|
||||
|
||||
class WeatherServiceError(Exception):
|
||||
"""Raised when the weather service encounters an error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WeatherService:
|
||||
"""Client for the VisualCrossing Weather API."""
|
||||
|
||||
BASE_URL = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
http_client: Optional[HttpClient] = None,
|
||||
) -> None:
|
||||
"""Initialize the weather service.
|
||||
|
||||
Args:
|
||||
api_key: VisualCrossing API key.
|
||||
http_client: Optional HTTP client instance. Creates one if not provided.
|
||||
"""
|
||||
if not api_key:
|
||||
raise WeatherServiceError("VisualCrossing API key is required")
|
||||
|
||||
self.api_key = api_key
|
||||
self.http_client = http_client or HttpClient()
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
def get_forecast(
|
||||
self,
|
||||
location: str,
|
||||
hours_ahead: int = 24,
|
||||
unit_group: str = "us",
|
||||
) -> WeatherForecast:
|
||||
"""Fetch weather forecast for a location.
|
||||
|
||||
Args:
|
||||
location: Location string (e.g., "viola,tn" or ZIP code).
|
||||
hours_ahead: Number of hours of forecast to retrieve.
|
||||
unit_group: Unit system ("us" for imperial, "metric" for metric).
|
||||
|
||||
Returns:
|
||||
WeatherForecast with hourly data and any active alerts.
|
||||
|
||||
Raises:
|
||||
WeatherServiceError: If the API request fails.
|
||||
"""
|
||||
self.logger.info(
|
||||
"fetching_forecast",
|
||||
location=location,
|
||||
hours_ahead=hours_ahead,
|
||||
)
|
||||
|
||||
# Build API URL
|
||||
encoded_location = quote(location, safe="")
|
||||
url = f"{self.BASE_URL}/{encoded_location}"
|
||||
|
||||
params = {
|
||||
"unitGroup": unit_group,
|
||||
"include": "days,hours,alerts,current,events",
|
||||
"key": self.api_key,
|
||||
"contentType": "json",
|
||||
}
|
||||
|
||||
response = self.http_client.get(url, params=params)
|
||||
|
||||
if not response.success:
|
||||
self.logger.error(
|
||||
"forecast_fetch_failed",
|
||||
status_code=response.status_code,
|
||||
error=response.text,
|
||||
)
|
||||
raise WeatherServiceError(
|
||||
f"Failed to fetch forecast: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
if response.json_data is None:
|
||||
self.logger.error("forecast_invalid_json", response_text=response.text[:200])
|
||||
raise WeatherServiceError("Invalid JSON response from weather API")
|
||||
|
||||
forecast = WeatherForecast.from_api_data(response.json_data, hours_ahead)
|
||||
|
||||
self.logger.info(
|
||||
"forecast_fetched",
|
||||
location=forecast.resolved_address,
|
||||
hourly_count=len(forecast.hourly_forecasts),
|
||||
alert_count=len(forecast.alerts),
|
||||
)
|
||||
|
||||
return forecast
|
||||
Reference in New Issue
Block a user