Skip to content

Commit f0a5e4f

Browse files
authored
feat: implement weather.gov astronomical data fetching and display
feat: implement weather.gov astronomical data fetching and display
2 parents 9759250 + 3131aa2 commit f0a5e4f

10 files changed

Lines changed: 387 additions & 18 deletions

File tree

data/weather/sample.json

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"@context": [
3+
"https://geojson.org/geojson-ld/geojson-context.jsonld",
4+
{
5+
"@version": "1.1",
6+
"wx": "https://api.weather.gov/ontology#",
7+
"s": "https://schema.org/",
8+
"geo": "http://www.opengis.net/ont/geosparql#",
9+
"unit": "http://codes.wmo.int/common/unit/",
10+
"@vocab": "https://api.weather.gov/ontology#",
11+
"geometry": {
12+
"@id": "s:GeoCoordinates",
13+
"@type": "geo:wktLiteral"
14+
},
15+
"city": "s:addressLocality",
16+
"state": "s:addressRegion",
17+
"distance": {
18+
"@id": "s:Distance",
19+
"@type": "s:QuantitativeValue"
20+
},
21+
"bearing": {
22+
"@type": "s:QuantitativeValue"
23+
},
24+
"value": {
25+
"@id": "s:value"
26+
},
27+
"unitCode": {
28+
"@id": "s:unitCode",
29+
"@type": "@id"
30+
},
31+
"forecastOffice": {
32+
"@type": "@id"
33+
},
34+
"forecastGridData": {
35+
"@type": "@id"
36+
},
37+
"publicZone": {
38+
"@type": "@id"
39+
},
40+
"county": {
41+
"@type": "@id"
42+
}
43+
}
44+
],
45+
"id": "https://api.weather.gov/points/29.8249,-95.4543",
46+
"type": "Feature",
47+
"geometry": {
48+
"type": "Point",
49+
"coordinates": [-95.4543, 29.8249]
50+
},
51+
"properties": {
52+
"@id": "https://api.weather.gov/points/29.8249,-95.4543",
53+
"@type": "wx:Point",
54+
"cwa": "HGX",
55+
"type": "land",
56+
"forecastOffice": "https://api.weather.gov/offices/HGX",
57+
"gridId": "HGX",
58+
"gridX": 60,
59+
"gridY": 97,
60+
"forecast": "https://api.weather.gov/gridpoints/HGX/60,97/forecast",
61+
"forecastHourly": "https://api.weather.gov/gridpoints/HGX/60,97/forecast/hourly",
62+
"forecastGridData": "https://api.weather.gov/gridpoints/HGX/60,97",
63+
"observationStations": "https://api.weather.gov/gridpoints/HGX/60,97/stations",
64+
"relativeLocation": {
65+
"type": "Feature",
66+
"geometry": {
67+
"type": "Point",
68+
"coordinates": [-95.488497, 29.790849]
69+
},
70+
"properties": {
71+
"city": "Hilshire Village",
72+
"state": "TX",
73+
"distance": {
74+
"unitCode": "wmoUnit:m",
75+
"value": 5022.1983814209
76+
},
77+
"bearing": {
78+
"unitCode": "wmoUnit:degree_(angle)",
79+
"value": 41
80+
}
81+
}
82+
},
83+
"forecastZone": "https://api.weather.gov/zones/forecast/TXZ213",
84+
"county": "https://api.weather.gov/zones/county/TXC201",
85+
"fireWeatherZone": "https://api.weather.gov/zones/fire/TXZ213",
86+
"timeZone": "America/Chicago",
87+
"radarStation": "KHGX",
88+
"astronomicalData": {
89+
"sunrise": "2026-02-12T07:02:22-06:00",
90+
"sunset": "2026-02-12T18:09:37-06:00",
91+
"transit": "2026-02-12T12:36:00-06:00",
92+
"civilTwilightBegin": "2026-02-12T06:39:09-06:00",
93+
"civilTwilightEnd": "2026-02-12T18:32:51-06:00",
94+
"nauticalTwilightBegin": "2026-02-12T06:11:00-06:00",
95+
"nauticalTwilightEnd": "2026-02-12T19:00:59-06:00",
96+
"astronomicalTwilightBegin": "2026-02-12T05:43:07-06:00",
97+
"astronomicalTwilightEnd": "2026-02-12T19:28:52-06:00"
98+
},
99+
"nwr": {
100+
"transmitter": "KGG68",
101+
"sameCode": "048201",
102+
"areaBroadcast": "https://api.weather.gov/radio/KGG68/broadcast",
103+
"pointBroadcast": "https://api.weather.gov/points/29.8249,-95.4543/radio"
104+
}
105+
}
106+
}

src/sample_python_app/core/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
"""
44

55
from sample_python_app.core.config import settings
6+
from sample_python_app.core.data_loader import fetch_astronomical_data_from_api
7+
from sample_python_app.core.display import display_astronomical_data
68
from sample_python_app.core.logging import setup_logger
79

8-
__all__ = ["settings", "setup_logger"]
10+
__all__ = [
11+
"settings",
12+
"setup_logger",
13+
"display_astronomical_data",
14+
"fetch_astronomical_data_from_api",
15+
]

src/sample_python_app/core/config.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,27 @@
22
Main configuration file for the API
33
"""
44

5+
from zoneinfo import ZoneInfo
6+
57
from pydantic_settings import BaseSettings, SettingsConfigDict
68

79

810
class Settings(BaseSettings):
9-
"""
10-
Settings
11-
"""
11+
"""Application settings display."""
1212

1313
APP_NAME: str = "python-app-template"
14+
DATE_FORMAT: str = "%Y-%m-%d %I:%M:%S %p %Z"
15+
TIMEZONE: str = "America/Chicago"
1416

1517
model_config = SettingsConfigDict(
18+
env_file=".env",
1619
env_file_encoding="utf-8",
17-
env_file=[".env"],
20+
case_sensitive=False,
1821
)
1922

23+
@property
24+
def tz(self) -> ZoneInfo:
25+
return ZoneInfo(self.TIMEZONE)
26+
2027

2128
settings = Settings()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
Handles loading and validating weather.gov astronomical data from file.
3+
"""
4+
5+
import httpx
6+
from pydantic import ValidationError
7+
8+
from sample_python_app.core.logging import setup_logger
9+
from sample_python_app.models import WeatherGovFeature
10+
11+
12+
def fetch_astronomical_data_from_api(lat: float, lon: float):
13+
logger = setup_logger(mode="silent")
14+
url = f"https://api.weather.gov/points/{lat},{lon}"
15+
headers = {"User-Agent": "(myweatherapp.com, contact@myweatherapp.com)"}
16+
try:
17+
response = httpx.get(url, headers=headers)
18+
response.raise_for_status()
19+
data = response.json()
20+
model = WeatherGovFeature.model_validate(data)
21+
astro = model.properties.astronomical_data
22+
logger.info("AstronomicalData fetched and validated from API.")
23+
return astro
24+
except ValidationError as e:
25+
logger.error(f"Data validation error: {e}")
26+
raise
27+
except httpx.HTTPError as e:
28+
logger.error(f"Failed to fetch or validate data from API: {e}")
29+
raise
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Handles formatting and displaying astronomical data using rich and pyfiglet.
3+
"""
4+
5+
from pyfiglet import Figlet
6+
from rich.console import Console
7+
8+
from sample_python_app.core.config import settings
9+
from sample_python_app.core.logging import setup_logger
10+
11+
12+
def display_astronomical_data(astro):
13+
logger = setup_logger(mode="silent")
14+
console = Console()
15+
header = Figlet(font="small", width=100).renderText("Astronomical Data")
16+
logger.info("Displaying Astronomical Data header.")
17+
console.print(f"[bold magenta]{header}[/bold magenta]")
18+
sunrise_local = astro.sunrise.astimezone(settings.tz)
19+
date_art = Figlet(font="mini", width=150).renderText(
20+
sunrise_local.strftime("%A, %B %d, %Y")
21+
)
22+
logger.info(f'Displaying date: {sunrise_local.strftime("%A, %B %d, %Y")}')
23+
console.print(f"[bold cyan]{date_art}[/bold cyan]")
24+
for name, value in astro.formatted(settings.tz, settings.DATE_FORMAT).items():
25+
label = name.replace("_", " ").title()
26+
logger.info(f"Displaying {label}: {value}")
27+
console.print(f"[bold cyan]{label}: [white]{value}[/white]")

src/sample_python_app/main.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
"""
2-
Main entry point for the python-app-template project.
2+
Main entry point for weather.gov astronomical data display.
3+
Orchestrates loading and displaying astronomical data.
34
"""
45

5-
from pyfiglet import Figlet
6-
from rich.console import Console
6+
import json
77

8-
from sample_python_app.core import settings, setup_logger
8+
import httpx
9+
from pydantic import ValidationError
10+
11+
from sample_python_app.core import (
12+
display_astronomical_data,
13+
fetch_astronomical_data_from_api,
14+
)
915

1016

1117
def run_app():
12-
console = Console()
13-
f = Figlet(font="slant")
14-
ascii_art = f.renderText(f"Welcome to {settings.APP_NAME}!")
15-
console.print(f"[bold magenta]{ascii_art}[/bold magenta]")
16-
logger = setup_logger(mode="silent")
17-
logger.info(f"Starting {settings.APP_NAME}...")
18-
logger.info("Hello from python-app-template!")
18+
lat, lon = 29.8469, -95.4689
19+
try:
20+
astro = fetch_astronomical_data_from_api(lat, lon)
21+
except httpx.HTTPStatusError as e:
22+
print(f"HTTP error: {e.response.status_code} {e.response.reason_phrase}")
23+
return
24+
except httpx.RequestError as e:
25+
print(f"Network error: {e}")
26+
return
27+
except ValidationError as e:
28+
print(f"Validation error: {e}")
29+
return
30+
except json.JSONDecodeError as e:
31+
print(f"JSON decode error: {e}")
32+
return
33+
display_astronomical_data(astro)
1934

2035

2136
if __name__ == "__main__": # pragma: no cover
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Models package for weather.gov API response parsing.
3+
"""
4+
5+
from sample_python_app.models.weather_gov import WeatherGovFeature
6+
7+
__all__ = ["WeatherGovFeature"]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
Pydantic models for weather.gov API response, including astronomical data utilities.
3+
"""
4+
5+
from datetime import datetime
6+
from typing import Any
7+
from zoneinfo import ZoneInfo
8+
9+
from pydantic import BaseModel, Field
10+
from typing_extensions import Annotated
11+
12+
13+
class Distance(BaseModel):
14+
"""Represents a distance value with unit code."""
15+
16+
unit_code: Annotated[str, Field(..., alias="unitCode")]
17+
value: float
18+
19+
20+
class Bearing(BaseModel):
21+
"""Represents a bearing value with unit code."""
22+
23+
unit_code: Annotated[str, Field(..., alias="unitCode")]
24+
value: float
25+
26+
27+
class RelativeLocationProperties(BaseModel):
28+
"""Properties for a relative location including
29+
city, state, distance, and bearing."""
30+
31+
city: str
32+
state: str
33+
distance: Distance
34+
bearing: Bearing
35+
36+
37+
class RelativeLocationGeometry(BaseModel):
38+
"""Geometry for a relative location (type and coordinates)."""
39+
40+
type: str
41+
coordinates: list[float]
42+
43+
44+
class RelativeLocation(BaseModel):
45+
"""Relative location feature with geometry and properties."""
46+
47+
type: str
48+
geometry: RelativeLocationGeometry
49+
properties: RelativeLocationProperties
50+
51+
52+
class AstronomicalData(BaseModel):
53+
"""Astronomical event times for a given location,
54+
with timezone conversion and formatting methods."""
55+
56+
sunrise: datetime
57+
sunset: datetime
58+
transit: datetime
59+
civil_twilight_begin: Annotated[datetime, Field(..., alias="civilTwilightBegin")]
60+
civil_twilight_end: Annotated[datetime, Field(..., alias="civilTwilightEnd")]
61+
nautical_twilight_begin: Annotated[
62+
datetime, Field(..., alias="nauticalTwilightBegin")
63+
]
64+
nautical_twilight_end: Annotated[datetime, Field(..., alias="nauticalTwilightEnd")]
65+
astronomical_twilight_begin: Annotated[
66+
datetime, Field(..., alias="astronomicalTwilightBegin")
67+
]
68+
astronomical_twilight_end: Annotated[
69+
datetime, Field(..., alias="astronomicalTwilightEnd")
70+
]
71+
72+
def as_local(self, tz: ZoneInfo) -> dict[str, datetime]:
73+
return {name: value.astimezone(tz) for name, value in self.__dict__.items()}
74+
75+
def formatted(self, tz: ZoneInfo, fmt: str) -> dict[str, str]:
76+
return {name: dt.strftime(fmt) for name, dt in self.as_local(tz).items()}
77+
78+
79+
class NWR(BaseModel):
80+
"""NOAA Weather Radio transmitter info."""
81+
82+
transmitter: str
83+
same_code: Annotated[str, Field(..., alias="sameCode")]
84+
area_broadcast: Annotated[str, Field(..., alias="areaBroadcast")]
85+
point_broadcast: Annotated[str, Field(..., alias="pointBroadcast")]
86+
87+
88+
class Properties(BaseModel):
89+
"""Top-level properties for a weather.gov point feature."""
90+
91+
id: Annotated[str, Field(..., alias="@id")]
92+
type_: Annotated[str, Field(..., alias="@type")]
93+
cwa: str
94+
type: str
95+
forecast_office: Annotated[str, Field(..., alias="forecastOffice")]
96+
grid_id: Annotated[str, Field(..., alias="gridId")]
97+
grid_x: Annotated[int, Field(..., alias="gridX")]
98+
grid_y: Annotated[int, Field(..., alias="gridY")]
99+
forecast: Annotated[str, Field(..., alias="forecast")]
100+
forecast_hourly: Annotated[str, Field(..., alias="forecastHourly")]
101+
forecast_grid_data: Annotated[str, Field(..., alias="forecastGridData")]
102+
observation_stations: Annotated[str, Field(..., alias="observationStations")]
103+
relative_location: Annotated[RelativeLocation, Field(..., alias="relativeLocation")]
104+
forecast_zone: Annotated[str, Field(..., alias="forecastZone")]
105+
county: str
106+
fire_weather_zone: Annotated[str, Field(..., alias="fireWeatherZone")]
107+
time_zone: Annotated[str, Field(..., alias="timeZone")]
108+
radar_station: Annotated[str, Field(..., alias="radarStation")]
109+
astronomical_data: Annotated[AstronomicalData, Field(..., alias="astronomicalData")]
110+
nwr: NWR
111+
112+
113+
class Geometry(BaseModel):
114+
"""Geometry for a weather.gov feature (type and coordinates)."""
115+
116+
type: str
117+
coordinates: list[float]
118+
119+
120+
class WeatherGovFeature(BaseModel):
121+
"""Root model for weather.gov point feature response."""
122+
123+
context: Annotated[list[Any], Field(..., alias="@context")]
124+
id: str
125+
type: str
126+
geometry: Geometry
127+
properties: Properties

0 commit comments

Comments
 (0)