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

13
app/services/__init__.py Normal file
View 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",
]

View 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

View 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
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

View 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

View 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