Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,250 changes: 4,250 additions & 0 deletions data/weather/forecast_hourly_sample.json

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions src/sample_python_app/app/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
Handles fetching, validation, and display of astronomical data.
"""

# app/runner.py
import json
import time
from datetime import date
Expand All @@ -19,7 +18,10 @@
weather_settings,
)
from sample_python_app.exceptions import AppError
from sample_python_app.services import fetch_astronomical_data_from_api
from sample_python_app.services import (
fetch_astronomical_data_from_api,
fetch_hourly_forecast_from_api,
)
from sample_python_app.ui import display_astronomical_data

logger = setup_logger("normal")
Expand All @@ -40,6 +42,7 @@ def fetch(self, *, exit_on_error: bool = True) -> None:
start = time.time()
try:
astro = fetch_astronomical_data_from_api(lat, lon)
forecast = fetch_hourly_forecast_from_api(lat, lon)
FETCH_COUNTER.inc()
except (
httpx.HTTPStatusError,
Expand All @@ -54,7 +57,7 @@ def fetch(self, *, exit_on_error: bool = True) -> None:
FETCH_DURATION.observe(time.time() - start)
today_str = date.today().isoformat()
if self._last_displayed_day != today_str:
display_astronomical_data(astro)
display_astronomical_data(astro, forecast)
self._last_displayed_day = today_str

def reset_display(self):
Expand Down
3 changes: 1 addition & 2 deletions src/sample_python_app/app/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ def start_scheduler(test_mode: bool = False) -> None:
scheduler_logger = logger.bind(component="scheduler")

if test_mode:
# Ensure display will run in tests by clearing any previous state
if hasattr(fetcher, "reset_display") and callable(fetcher.reset_display):
fetcher.reset_display()
fetcher.fetch(exit_on_error=False)
Expand All @@ -37,7 +36,7 @@ def shutdown(signum, frame):
scheduler.add_job(
fetcher.fetch,
trigger="interval",
minutes=5,
minutes=1,
next_run_time=datetime.now(UTC),
misfire_grace_time=3600,
coalesce=True,
Expand Down
6 changes: 3 additions & 3 deletions src/sample_python_app/core/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from prometheus_client import Counter, Histogram, start_http_server

FETCH_COUNTER = Counter(
"astro_fetch_total",
"fetch_all_total",
"Total number of astronomical data fetches",
)
FETCH_ERRORS = Counter(
"astro_fetch_errors_total",
"fetch_errors_total",
"Total number of astronomical data fetch errors",
)
FETCH_DURATION = Histogram(
"astro_fetch_duration_seconds",
"fetch_duration_seconds",
"Duration of astronomical data fetches in seconds",
)

Expand Down
3 changes: 2 additions & 1 deletion src/sample_python_app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Models package for weather.gov API response parsing."""

from sample_python_app.models.forecast_geojson import ForecastFeature
from sample_python_app.models.weather_gov import AstronomicalData, WeatherGovFeature

__all__ = ["WeatherGovFeature", "AstronomicalData"]
__all__ = ["WeatherGovFeature", "AstronomicalData", "ForecastFeature"]
59 changes: 59 additions & 0 deletions src/sample_python_app/models/forecast_geojson.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Pydantic models for hourly forecast GeoJSON returned by weather.gov."""

from datetime import datetime
from typing import Any

from pydantic import BaseModel, Field

from sample_python_app.models.weather_gov import Distance


class PolygonGeometry(BaseModel):
"""Polygon geometry (GeoJSON) for forecast area."""

type: str
coordinates: list[list[list[float]]]


class Period(BaseModel):
"""Individual forecast period with detailed weather information."""

number: int
name: str
start_time: datetime = Field(..., alias="startTime")
end_time: datetime = Field(..., alias="endTime")
is_daytime: bool = Field(..., alias="isDaytime")
temperature: int | None
temperature_unit: str | None = Field(None, alias="temperatureUnit")
temperature_trend: Any | None = Field(None, alias="temperatureTrend")
probability_of_precipitation: Distance | None = Field(
None, alias="probabilityOfPrecipitation"
)
dewpoint: Distance | None
relative_humidity: Distance | None = Field(None, alias="relativeHumidity")
wind_speed: str | None = Field(None, alias="windSpeed")
wind_direction: str | None = Field(None, alias="windDirection")
icon: str | None
short_forecast: str | None = Field(None, alias="shortForecast")
detailed_forecast: str | None = Field(None, alias="detailedForecast")


class ForecastProperties(BaseModel):
"""Properties of the forecast GeoJSON feature."""

units: str
forecast_generator: str = Field(..., alias="forecastGenerator")
generated_at: datetime = Field(..., alias="generatedAt")
update_time: datetime = Field(..., alias="updateTime")
valid_times: str = Field(..., alias="validTimes")
elevation: Distance
periods: list[Period]


class ForecastFeature(BaseModel):
"""Root model for forecast GeoJSON Feature."""

context: list[Any] = Field(..., alias="@context")
type: str
geometry: PolygonGeometry
properties: ForecastProperties
12 changes: 10 additions & 2 deletions src/sample_python_app/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
"""Service layer for data loading and related business logic."""

from sample_python_app.services.data_loader import fetch_astronomical_data_from_api
from sample_python_app.services.data_loader import (
fetch_astronomical_data_from_api,
fetch_hourly_forecast_by_grid,
fetch_hourly_forecast_from_api,
)

__all__ = ["fetch_astronomical_data_from_api"]
__all__ = [
"fetch_astronomical_data_from_api",
"fetch_hourly_forecast_from_api",
"fetch_hourly_forecast_by_grid",
]
85 changes: 83 additions & 2 deletions src/sample_python_app/services/data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from pydantic import ValidationError

from sample_python_app.core import setup_logger
from sample_python_app.models import AstronomicalData, WeatherGovFeature
from sample_python_app.models import (
AstronomicalData,
ForecastFeature,
WeatherGovFeature,
)


def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData:
Expand All @@ -24,7 +28,7 @@ def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData
"""
logger = setup_logger(mode="silent")
url = f"https://api.weather.gov/points/{lat},{lon}"
headers = {"User-Agent": "(myweatherapp.com, contact@myweatherapp.com)"}
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
logger.info(f"Fetching astronomical data from URL: {url}")
logger.info(f"Request headers: {headers}")
try:
Expand All @@ -38,3 +42,80 @@ def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData
except ValidationError as e:
logger.error(f"Data validation error: {e}")
raise


def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
"""Fetch hourly forecast Feature for the given coordinates.

This function first queries the `/points/{lat},{lon}` endpoint to resolve
the appropriate grid URL, then requests the hourly forecast and validates
it against `ForecastFeature`.

Args:
lat (float): Latitude of the location.
lon (float): Longitude of the location.

Returns:
ForecastFeature: Parsed and validated hourly forecast feature.

Raises:
httpx.HTTPError: If an HTTP request fails.
ValidationError: If the response fails pydantic validation.

"""
logger = setup_logger(mode="silent")
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}

# Resolve point metadata to find the hourly forecast URL
points_url = f"https://api.weather.gov/points/{lat},{lon}"
logger.info(f"Resolving grid for coordinates {lat},{lon}: {points_url}")
resp = httpx.get(points_url, headers=headers)
resp.raise_for_status()
points_data = resp.json()

point_model = WeatherGovFeature.model_validate(points_data)
forecast_url = point_model.properties.forecast_hourly
logger.info(f"Fetching hourly forecast from: {forecast_url}")

resp2 = httpx.get(forecast_url, headers=headers)
resp2.raise_for_status()
forecast_data = resp2.json()

forecast_model = ForecastFeature.model_validate(forecast_data)
logger.info("Hourly forecast fetched and validated.")
return forecast_model


def fetch_hourly_forecast_by_grid(
grid_id: str, grid_x: int, grid_y: int
) -> ForecastFeature:
"""Fetch hourly forecast Feature directly from grid coordinates.

Builds the gridpoints URL (for example `HGX/59,98`) and fetches the
hourly forecast Feature at
`/gridpoints/{grid_id}/{grid_x},{grid_y}/forecast/hourly`.

Args:
grid_id: Grid identifier (e.g. "HGX").
grid_x: Grid X coordinate (e.g. 59).
grid_y: Grid Y coordinate (e.g. 98).

Returns:
ForecastFeature: Parsed and validated hourly forecast feature.

"""
logger = setup_logger(mode="silent")
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}

url = (
"https://api.weather.gov/gridpoints/ "
f"{grid_id}/{grid_x},{grid_y}/forecast/hourly"
)
logger.info(f"Fetching hourly forecast by grid from: {url}")
resp = httpx.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()

model = ForecastFeature.model_validate(data)
logger.info("Hourly forecast (grid) fetched and validated.")
return model
4 changes: 2 additions & 2 deletions src/sample_python_app/ui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""UI package for synthwave terminal display and related components."""

from sample_python_app.ui.display import display_astronomical_data
from sample_python_app.ui.synthwave import synthwave_display
from sample_python_app.ui.synthwave import synthwave_dashboard, synthwave_display

__all__ = ["synthwave_display", "display_astronomical_data"]
__all__ = ["synthwave_display", "synthwave_dashboard", "display_astronomical_data"]
13 changes: 9 additions & 4 deletions src/sample_python_app/ui/display.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""Handles formatting and displaying astronomical data using rich and pyfiglet."""

from sample_python_app.core.config import settings
from sample_python_app.ui.synthwave import synthwave_display
from sample_python_app.models import AstronomicalData, ForecastFeature

from .synthwave import synthwave_dashboard

def display_astronomical_data(astro):
"""Display astronomical data using the synthwave terminal UI."""
synthwave_display(astro, settings)

def display_astronomical_data(astro: AstronomicalData, forecast: ForecastFeature):
"""Display astronomical and hourly forecast data.

Combined synthwave dashboard will be shown.
"""
synthwave_dashboard(astro, forecast, settings)
41 changes: 41 additions & 0 deletions src/sample_python_app/ui/header_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Header panel for synthwave dashboards."""

from pyfiglet import Figlet
from rich.align import Align
from rich.console import Group
from rich.panel import Panel
from rich.text import Text

from sample_python_app.core import Settings
from sample_python_app.models import AstronomicalData


def build_header_panel(
astro: AstronomicalData, settings: Settings, *, preferred_height: int | None = None
) -> Panel:
"""Build the compact header panel used by dashboards.

The optional ``preferred_height`` is informational and intended to be
used by callers when placing the panel into a ``rich.layout.Layout``.
The panel itself remains flexible and does not enforce a fixed height.
"""
sunrise_local = astro.sunrise.astimezone(settings.tz)
# Render as three stacked lines: SYNTHWAVE, SUNRISE, then the date
header_main = Figlet(font="slant", width=80).renderText("SYNTHWAVE")
header_main_text = Text(header_main, style="bold magenta")
header_sub = Figlet(font="small", width=80).renderText("SUNRISE")
header_sub_text = Text(header_sub, style="bold yellow")
date_str = sunrise_local.strftime("%A, %B %d, %Y")
date_text = Text(date_str, style="bold cyan")
content = Group(
Align.center(header_main_text),
Align.center(header_sub_text),
Align.center(date_text),
)
# Vertically center the header + date within the panel
return Panel(
Align(content, align="center", vertical="middle"),
title="[bold #ff6ec7]Synthwave[/bold #ff6ec7]",
border_style="#ff00cc",
padding=(0, 1),
)
Loading
Loading