Skip to content

Commit 387ee28

Browse files
authored
feat: Enhance astronomical data fetching and display hourly forcast
feat: Enhance astronomical data fetching and display hourly forcast
2 parents 968f7c1 + 320f73b commit 387ee28

13 files changed

Lines changed: 4648 additions & 20 deletions

File tree

data/weather/forecast_hourly_sample.json

Lines changed: 4250 additions & 0 deletions
Large diffs are not rendered by default.

src/sample_python_app/app/runner.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
Handles fetching, validation, and display of astronomical data.
44
"""
55

6-
# app/runner.py
76
import json
87
import time
98
from datetime import date
@@ -19,7 +18,10 @@
1918
weather_settings,
2019
)
2120
from sample_python_app.exceptions import AppError
22-
from sample_python_app.services import fetch_astronomical_data_from_api
21+
from sample_python_app.services import (
22+
fetch_astronomical_data_from_api,
23+
fetch_hourly_forecast_from_api,
24+
)
2325
from sample_python_app.ui import display_astronomical_data
2426

2527
logger = setup_logger("normal")
@@ -40,6 +42,7 @@ def fetch(self, *, exit_on_error: bool = True) -> None:
4042
start = time.time()
4143
try:
4244
astro = fetch_astronomical_data_from_api(lat, lon)
45+
forecast = fetch_hourly_forecast_from_api(lat, lon)
4346
FETCH_COUNTER.inc()
4447
except (
4548
httpx.HTTPStatusError,
@@ -54,7 +57,7 @@ def fetch(self, *, exit_on_error: bool = True) -> None:
5457
FETCH_DURATION.observe(time.time() - start)
5558
today_str = date.today().isoformat()
5659
if self._last_displayed_day != today_str:
57-
display_astronomical_data(astro)
60+
display_astronomical_data(astro, forecast)
5861
self._last_displayed_day = today_str
5962

6063
def reset_display(self):

src/sample_python_app/app/scheduler.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ def start_scheduler(test_mode: bool = False) -> None:
1717
scheduler_logger = logger.bind(component="scheduler")
1818

1919
if test_mode:
20-
# Ensure display will run in tests by clearing any previous state
2120
if hasattr(fetcher, "reset_display") and callable(fetcher.reset_display):
2221
fetcher.reset_display()
2322
fetcher.fetch(exit_on_error=False)
@@ -37,7 +36,7 @@ def shutdown(signum, frame):
3736
scheduler.add_job(
3837
fetcher.fetch,
3938
trigger="interval",
40-
minutes=5,
39+
minutes=1,
4140
next_run_time=datetime.now(UTC),
4241
misfire_grace_time=3600,
4342
coalesce=True,

src/sample_python_app/core/metrics.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
from prometheus_client import Counter, Histogram, start_http_server
44

55
FETCH_COUNTER = Counter(
6-
"astro_fetch_total",
6+
"fetch_all_total",
77
"Total number of astronomical data fetches",
88
)
99
FETCH_ERRORS = Counter(
10-
"astro_fetch_errors_total",
10+
"fetch_errors_total",
1111
"Total number of astronomical data fetch errors",
1212
)
1313
FETCH_DURATION = Histogram(
14-
"astro_fetch_duration_seconds",
14+
"fetch_duration_seconds",
1515
"Duration of astronomical data fetches in seconds",
1616
)
1717

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Models package for weather.gov API response parsing."""
22

3+
from sample_python_app.models.forecast_geojson import ForecastFeature
34
from sample_python_app.models.weather_gov import AstronomicalData, WeatherGovFeature
45

5-
__all__ = ["WeatherGovFeature", "AstronomicalData"]
6+
__all__ = ["WeatherGovFeature", "AstronomicalData", "ForecastFeature"]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Pydantic models for hourly forecast GeoJSON returned by weather.gov."""
2+
3+
from datetime import datetime
4+
from typing import Any
5+
6+
from pydantic import BaseModel, Field
7+
8+
from sample_python_app.models.weather_gov import Distance
9+
10+
11+
class PolygonGeometry(BaseModel):
12+
"""Polygon geometry (GeoJSON) for forecast area."""
13+
14+
type: str
15+
coordinates: list[list[list[float]]]
16+
17+
18+
class Period(BaseModel):
19+
"""Individual forecast period with detailed weather information."""
20+
21+
number: int
22+
name: str
23+
start_time: datetime = Field(..., alias="startTime")
24+
end_time: datetime = Field(..., alias="endTime")
25+
is_daytime: bool = Field(..., alias="isDaytime")
26+
temperature: int | None
27+
temperature_unit: str | None = Field(None, alias="temperatureUnit")
28+
temperature_trend: Any | None = Field(None, alias="temperatureTrend")
29+
probability_of_precipitation: Distance | None = Field(
30+
None, alias="probabilityOfPrecipitation"
31+
)
32+
dewpoint: Distance | None
33+
relative_humidity: Distance | None = Field(None, alias="relativeHumidity")
34+
wind_speed: str | None = Field(None, alias="windSpeed")
35+
wind_direction: str | None = Field(None, alias="windDirection")
36+
icon: str | None
37+
short_forecast: str | None = Field(None, alias="shortForecast")
38+
detailed_forecast: str | None = Field(None, alias="detailedForecast")
39+
40+
41+
class ForecastProperties(BaseModel):
42+
"""Properties of the forecast GeoJSON feature."""
43+
44+
units: str
45+
forecast_generator: str = Field(..., alias="forecastGenerator")
46+
generated_at: datetime = Field(..., alias="generatedAt")
47+
update_time: datetime = Field(..., alias="updateTime")
48+
valid_times: str = Field(..., alias="validTimes")
49+
elevation: Distance
50+
periods: list[Period]
51+
52+
53+
class ForecastFeature(BaseModel):
54+
"""Root model for forecast GeoJSON Feature."""
55+
56+
context: list[Any] = Field(..., alias="@context")
57+
type: str
58+
geometry: PolygonGeometry
59+
properties: ForecastProperties
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
"""Service layer for data loading and related business logic."""
22

3-
from sample_python_app.services.data_loader import fetch_astronomical_data_from_api
3+
from sample_python_app.services.data_loader import (
4+
fetch_astronomical_data_from_api,
5+
fetch_hourly_forecast_by_grid,
6+
fetch_hourly_forecast_from_api,
7+
)
48

5-
__all__ = ["fetch_astronomical_data_from_api"]
9+
__all__ = [
10+
"fetch_astronomical_data_from_api",
11+
"fetch_hourly_forecast_from_api",
12+
"fetch_hourly_forecast_by_grid",
13+
]

src/sample_python_app/services/data_loader.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from pydantic import ValidationError
55

66
from sample_python_app.core import setup_logger
7-
from sample_python_app.models import AstronomicalData, WeatherGovFeature
7+
from sample_python_app.models import (
8+
AstronomicalData,
9+
ForecastFeature,
10+
WeatherGovFeature,
11+
)
812

913

1014
def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData:
@@ -24,7 +28,7 @@ def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData
2428
"""
2529
logger = setup_logger(mode="silent")
2630
url = f"https://api.weather.gov/points/{lat},{lon}"
27-
headers = {"User-Agent": "(myweatherapp.com, contact@myweatherapp.com)"}
31+
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
2832
logger.info(f"Fetching astronomical data from URL: {url}")
2933
logger.info(f"Request headers: {headers}")
3034
try:
@@ -38,3 +42,80 @@ def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData
3842
except ValidationError as e:
3943
logger.error(f"Data validation error: {e}")
4044
raise
45+
46+
47+
def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
48+
"""Fetch hourly forecast Feature for the given coordinates.
49+
50+
This function first queries the `/points/{lat},{lon}` endpoint to resolve
51+
the appropriate grid URL, then requests the hourly forecast and validates
52+
it against `ForecastFeature`.
53+
54+
Args:
55+
lat (float): Latitude of the location.
56+
lon (float): Longitude of the location.
57+
58+
Returns:
59+
ForecastFeature: Parsed and validated hourly forecast feature.
60+
61+
Raises:
62+
httpx.HTTPError: If an HTTP request fails.
63+
ValidationError: If the response fails pydantic validation.
64+
65+
"""
66+
logger = setup_logger(mode="silent")
67+
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
68+
69+
# Resolve point metadata to find the hourly forecast URL
70+
points_url = f"https://api.weather.gov/points/{lat},{lon}"
71+
logger.info(f"Resolving grid for coordinates {lat},{lon}: {points_url}")
72+
resp = httpx.get(points_url, headers=headers)
73+
resp.raise_for_status()
74+
points_data = resp.json()
75+
76+
point_model = WeatherGovFeature.model_validate(points_data)
77+
forecast_url = point_model.properties.forecast_hourly
78+
logger.info(f"Fetching hourly forecast from: {forecast_url}")
79+
80+
resp2 = httpx.get(forecast_url, headers=headers)
81+
resp2.raise_for_status()
82+
forecast_data = resp2.json()
83+
84+
forecast_model = ForecastFeature.model_validate(forecast_data)
85+
logger.info("Hourly forecast fetched and validated.")
86+
return forecast_model
87+
88+
89+
def fetch_hourly_forecast_by_grid(
90+
grid_id: str, grid_x: int, grid_y: int
91+
) -> ForecastFeature:
92+
"""Fetch hourly forecast Feature directly from grid coordinates.
93+
94+
Builds the gridpoints URL (for example `HGX/59,98`) and fetches the
95+
hourly forecast Feature at
96+
`/gridpoints/{grid_id}/{grid_x},{grid_y}/forecast/hourly`.
97+
98+
Args:
99+
grid_id: Grid identifier (e.g. "HGX").
100+
grid_x: Grid X coordinate (e.g. 59).
101+
grid_y: Grid Y coordinate (e.g. 98).
102+
103+
Returns:
104+
ForecastFeature: Parsed and validated hourly forecast feature.
105+
106+
"""
107+
logger = setup_logger(mode="silent")
108+
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
109+
110+
url = (
111+
"https://api.weather.gov/gridpoints/ "
112+
f"{grid_id}/{grid_x},{grid_y}/forecast/hourly"
113+
)
114+
logger.info(f"Fetching hourly forecast by grid from: {url}")
115+
resp = httpx.get(url, headers=headers)
116+
resp.raise_for_status()
117+
data = resp.json()
118+
119+
model = ForecastFeature.model_validate(data)
120+
logger.info("Hourly forecast (grid) fetched and validated.")
121+
return model
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""UI package for synthwave terminal display and related components."""
22

33
from sample_python_app.ui.display import display_astronomical_data
4-
from sample_python_app.ui.synthwave import synthwave_display
4+
from sample_python_app.ui.synthwave import synthwave_dashboard, synthwave_display
55

6-
__all__ = ["synthwave_display", "display_astronomical_data"]
6+
__all__ = ["synthwave_display", "synthwave_dashboard", "display_astronomical_data"]
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"""Handles formatting and displaying astronomical data using rich and pyfiglet."""
22

33
from sample_python_app.core.config import settings
4-
from sample_python_app.ui.synthwave import synthwave_display
4+
from sample_python_app.models import AstronomicalData, ForecastFeature
55

6+
from .synthwave import synthwave_dashboard
67

7-
def display_astronomical_data(astro):
8-
"""Display astronomical data using the synthwave terminal UI."""
9-
synthwave_display(astro, settings)
8+
9+
def display_astronomical_data(astro: AstronomicalData, forecast: ForecastFeature):
10+
"""Display astronomical and hourly forecast data.
11+
12+
Combined synthwave dashboard will be shown.
13+
"""
14+
synthwave_dashboard(astro, forecast, settings)

0 commit comments

Comments
 (0)