Skip to content

Commit f38661f

Browse files
authored
Refactor: create wavey.grib submodule (#10)
* refactor: move download_most_recent_forecast() function * refactor: create wavey.grib and wavey.common modules * .
1 parent 819882f commit f38661f

File tree

4 files changed

+153
-138
lines changed

4 files changed

+153
-138
lines changed

main.py

Lines changed: 11 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import datetime
22
import logging
3-
import os
4-
from enum import IntEnum
53
from pathlib import Path
6-
from typing import NamedTuple
7-
from zoneinfo import ZoneInfo
84

95
import matplotlib
106
import matplotlib.colors as mcolors
@@ -13,35 +9,19 @@
139
import mpld3
1410
import numpy as np
1511
import pygrib
16-
import requests
1712
from jinja2 import Environment, PackageLoader, select_autoescape
1813
from mpl_toolkits.basemap import Basemap
1914
from tqdm import tqdm
2015

21-
from wavey.nwfs import get_most_recent_forecast
16+
from wavey.common import DATETIME_FORMAT, FEET_PER_METER, LAT_MAX, LAT_MIN, LON_MAX, LON_MIN, TZ_PACIFIC, TZ_UTC
17+
from wavey.grib import NUM_DATA_POINTS, ForecastType, read_forecast_data
18+
from wavey.nwfs import download_forecast, get_most_recent_forecast
2219

2320
# Force non-interactive backend to keep consistency between local and github actions
2421
matplotlib.rcParams["backend"] = "agg"
2522

2623
LOG = logging.getLogger(__name__)
2724

28-
TZ_UTC = ZoneInfo("UTC")
29-
TZ_PACIFIC = ZoneInfo("America/Los_Angeles")
30-
31-
DATETIME_FORMAT = "%a %b %d %H:%M (Pacific)"
32-
"""Format used when formatting datetimes."""
33-
34-
FEET_PER_METER = 3.28
35-
36-
NUM_FORECASTS = 145 # 1 + 24 * 6 hours
37-
"""Number of forecasts for each data type in the NWFS GRIB file."""
38-
39-
# Lat/lon bounding box for zoom-in on Monterey peninsula
40-
LAT_MIN = 36.4 # 36.2
41-
LAT_MAX = 36.7 # 37.0
42-
LON_MIN = 237.9 # 237.8
43-
LON_MAX = 238.2 # 238.3
44-
4525
# Location of San Carlos Beach (aka Breakwater)
4626
BREAKWATER_LAT = 36.611
4727
BREAKWATER_LON = 238.108
@@ -60,111 +40,6 @@
6040
"""Matplotlib figure dpi."""
6141

6242

63-
class DataType(IntEnum):
64-
"""Data types in the NWFS GRIB file, in order."""
65-
66-
WaveHeight = 0
67-
"""Significant height of combined wind waves and swell (m)"""
68-
WaveDirection = 1
69-
"""Primary wave direction (deg)"""
70-
WavePeriod = 2
71-
"""Primary wave mean period (s)"""
72-
SwellHeight = 3
73-
"""Significant height of total swell (m)"""
74-
WindDirection = 4
75-
"""Wind direction (deg)"""
76-
WindSpeed = 5
77-
"""Wind speed (m/s)"""
78-
SeaSurfaceHeight = 6
79-
"""Sea surface height (m)"""
80-
CurrentDirection = 7
81-
"""Current direction (deg)"""
82-
CurrentSpeed = 8
83-
"""Current speed (m/s)"""
84-
85-
86-
class ForecastData(NamedTuple):
87-
data: np.ma.MaskedArray
88-
"""Array with shape (NUM_FORECASTS, LATS, LONS). May contain missing values."""
89-
lats: np.ndarray
90-
"""Array with shape (LATS, LONS)."""
91-
lons: np.ndarray
92-
"""Array with shape (LATS, LONS)."""
93-
analysis_date_utc: datetime.datetime
94-
"""Date and time of analysis, i.e. start of forecast, in UTC."""
95-
96-
97-
def download_most_recent_forecast_data(dir: Path) -> Path:
98-
"""
99-
Downloads the most recent NWFS GRIB file and returns the path.
100-
101-
Args:
102-
dir: Directory to save the file in.
103-
104-
Returns:
105-
Path to the GRIB file.
106-
"""
107-
108-
url = get_most_recent_forecast()
109-
110-
file_path = dir / os.path.basename(url)
111-
if file_path.exists():
112-
LOG.info(f"'{file_path}' already exists. Skipping download")
113-
return file_path
114-
115-
LOG.info(f"Downloading '{url}' to '{file_path}'")
116-
r = requests.get(url, stream=True)
117-
r.raise_for_status()
118-
119-
with open(file_path, "wb") as file:
120-
for chunk in r.iter_content(chunk_size=8192):
121-
file.write(chunk)
122-
123-
return file_path
124-
125-
126-
def read_forecast_data(grbs: pygrib.open, data_type: DataType) -> ForecastData:
127-
"""
128-
Read forecast data from Monterey Bay NWFS GRIB file, zoomed-in near the
129-
peninsula (see {LAT|LON}_{MIN|MAX} values).
130-
131-
Args:
132-
grbs: GRIB file.
133-
data_type: Type of data to read.
134-
135-
Returns:
136-
Forecast data of the specified type.
137-
"""
138-
139-
grbs.seek(data_type * NUM_FORECASTS) # message offset
140-
141-
data_list: list[np.ma.MaskedArray] = []
142-
lats: np.ndarray | None = None
143-
lons: np.ndarray | None = None
144-
analysis_date: datetime.datetime | None = None
145-
146-
for grb in grbs.read(NUM_FORECASTS):
147-
data, lats, lons = grb.data(lat1=LAT_MIN, lat2=LAT_MAX, lon1=LON_MIN, lon2=LON_MAX)
148-
data_list.append(data)
149-
150-
if analysis_date is None:
151-
analysis_date = grb.analDate
152-
153-
# assertions will fail if no messages were read
154-
assert lats is not None
155-
assert lons is not None
156-
assert analysis_date is not None
157-
analysis_date_utc = analysis_date.replace(tzinfo=TZ_UTC)
158-
data_collated = np.ma.stack(data_list)
159-
160-
return ForecastData(
161-
data=data_collated,
162-
lats=lats,
163-
lons=lons,
164-
analysis_date_utc=analysis_date_utc,
165-
)
166-
167-
16843
def utc_to_pt(dt: datetime.datetime) -> datetime.datetime:
16944
"""Convert UTC to pacific time."""
17045

@@ -279,7 +154,7 @@ def main(
279154
Args:
280155
grib_path: Path to GRIB file. These are downloaded from:
281156
https://nomads.ncep.noaa.gov/pub/data/nccf/com/nwps/prod/. If none,
282-
will download the most recent one.
157+
will download the most recent one to the current directory.
283158
out_dir: Path to output directory.
284159
"""
285160

@@ -288,15 +163,15 @@ def main(
288163
# Download data, if needed
289164

290165
if grib_path is None:
291-
dir = Path(".") # download to current directory
292-
grib_path = download_most_recent_forecast_data(dir)
166+
most_recent_forecast = get_most_recent_forecast()
167+
grib_path = download_forecast(most_recent_forecast)
293168

294169
# Extract data
295170

296171
LOG.info(f"Reading '{grib_path}'")
297172
with pygrib.open(grib_path) as grbs:
298-
wave_height_forecast = read_forecast_data(grbs, DataType.WaveHeight)
299-
wave_direction_forecast = read_forecast_data(grbs, DataType.WaveDirection)
173+
wave_height_forecast = read_forecast_data(grbs, ForecastType.WaveHeight)
174+
wave_direction_forecast = read_forecast_data(grbs, ForecastType.WaveDirection)
300175

301176
wave_height_ft = wave_height_forecast.data * FEET_PER_METER
302177
wave_direction_rad = wave_direction_forecast.data * np.pi / 180
@@ -327,7 +202,7 @@ def main(
327202

328203
# NOTE: need to erase timezone info for mlpd3 to plot local times correctly
329204
x0 = analysis_date_pacific.replace(tzinfo=None)
330-
x = [x0 + datetime.timedelta(hours=hour_i) for hour_i in range(NUM_FORECASTS)]
205+
x = [x0 + datetime.timedelta(hours=hour_i) for hour_i in range(NUM_DATA_POINTS)]
331206
for label, y in (("Breakwater", bw_wave_heights_ft), ("Monastery", mon_wave_heights_ft)):
332207
ax.plot(x, y, label=label) # type: ignore[arg-type]
333208

@@ -338,7 +213,7 @@ def main(
338213
ax.grid(linestyle=":")
339214

340215
plt.tight_layout()
341-
fig_div = mpld3.fig_to_html(fig, figid="graph")
216+
fig_div = mpld3.fig_to_html(fig)
342217

343218
# Draw figure
344219

@@ -368,7 +243,7 @@ def main(
368243

369244
plot_dir = out_dir / "plots"
370245
plot_dir.mkdir(parents=True, exist_ok=True)
371-
for hour_i in tqdm(range(NUM_FORECASTS)):
246+
for hour_i in tqdm(range(NUM_DATA_POINTS)):
372247
pacific_time = analysis_date_pacific + datetime.timedelta(hours=hour_i)
373248
pacific_time_str = pacific_time.strftime(DATETIME_FORMAT)
374249

wavey/common.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from zoneinfo import ZoneInfo
2+
3+
TZ_UTC = ZoneInfo("UTC")
4+
TZ_PACIFIC = ZoneInfo("America/Los_Angeles")
5+
6+
DATETIME_FORMAT = "%a %b %d %H:%M (Pacific)"
7+
"""Format used when formatting datetimes."""
8+
9+
FEET_PER_METER = 3.28
10+
11+
# Lat/lon bounding box for zoom-in on Monterey peninsula
12+
LAT_MIN = 36.4
13+
LAT_MAX = 36.7
14+
LON_MIN = 237.9
15+
LON_MAX = 238.2

wavey/grib.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import datetime
2+
from enum import IntEnum
3+
from typing import NamedTuple
4+
5+
import numpy as np
6+
import pygrib
7+
8+
from wavey.common import LAT_MAX, LAT_MIN, LON_MAX, LON_MIN, TZ_UTC
9+
10+
NUM_DATA_POINTS = 145 # 1 + 24 * 6 hours
11+
"""Number of data points for each forecast type in the NWFS GRIB file."""
12+
13+
14+
class ForecastType(IntEnum):
15+
"""Forecast types in the NWFS GRIB file, in order."""
16+
17+
WaveHeight = 0
18+
"""Significant height of combined wind waves and swell (m)"""
19+
WaveDirection = 1
20+
"""Primary wave direction (deg)"""
21+
WavePeriod = 2
22+
"""Primary wave mean period (s)"""
23+
SwellHeight = 3
24+
"""Significant height of total swell (m)"""
25+
WindDirection = 4
26+
"""Wind direction (deg)"""
27+
WindSpeed = 5
28+
"""Wind speed (m/s)"""
29+
SeaSurfaceHeight = 6
30+
"""Sea surface height (m)"""
31+
CurrentDirection = 7
32+
"""Current direction (deg)"""
33+
CurrentSpeed = 8
34+
"""Current speed (m/s)"""
35+
36+
37+
class ForecastData(NamedTuple):
38+
"""Data for a single forecast type from the NWFS GRIB file."""
39+
40+
data: np.ma.MaskedArray
41+
"""Array with shape (NUM_DATA_POINTS, LATS, LONS). May contain missing values."""
42+
lats: np.ndarray
43+
"""Array with shape (LATS, LONS)."""
44+
lons: np.ndarray
45+
"""Array with shape (LATS, LONS)."""
46+
analysis_date_utc: datetime.datetime
47+
"""Date and time of analysis, i.e. start of forecast, in UTC."""
48+
49+
50+
def read_forecast_data(grbs: pygrib.open, forecast_type: ForecastType) -> ForecastData:
51+
"""
52+
Read forecast data from Monterey Bay NWFS GRIB file, zoomed-in near the
53+
peninsula (see {LAT|LON}_{MIN|MAX} values).
54+
55+
Args:
56+
grbs: GRIB file.
57+
forecast_type: Type of data to read.
58+
59+
Returns:
60+
Forecast data of the specified type.
61+
"""
62+
63+
grbs.seek(forecast_type * NUM_DATA_POINTS) # message offset
64+
65+
data_list: list[np.ma.MaskedArray] = []
66+
lats: np.ndarray | None = None
67+
lons: np.ndarray | None = None
68+
analysis_date: datetime.datetime | None = None
69+
70+
for grb in grbs.read(NUM_DATA_POINTS):
71+
data, lats, lons = grb.data(lat1=LAT_MIN, lat2=LAT_MAX, lon1=LON_MIN, lon2=LON_MAX)
72+
data_list.append(data)
73+
74+
if analysis_date is None:
75+
analysis_date = grb.analDate
76+
77+
# assertions will fail if no messages were read
78+
assert lats is not None
79+
assert lons is not None
80+
assert analysis_date is not None
81+
analysis_date_utc = analysis_date.replace(tzinfo=TZ_UTC)
82+
data_collated = np.ma.stack(data_list)
83+
84+
return ForecastData(
85+
data=data_collated,
86+
lats=lats,
87+
lons=lons,
88+
analysis_date_utc=analysis_date_utc,
89+
)

wavey/nwfs.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import os
33
import re
4+
from pathlib import Path
45

56
import requests
67
from bs4 import BeautifulSoup
@@ -11,6 +12,8 @@
1112
_MTR = "mtr"
1213
_CG3 = "CG3"
1314

15+
_CHUNK_SIZE = 8192
16+
1417

1518
def get_most_recent_forecast() -> str:
1619
"""
@@ -46,8 +49,9 @@ def get_most_recent_forecast() -> str:
4649
if most_recent_date is not None:
4750
break
4851

49-
if most_recent_date is None or most_recent_time is None:
50-
raise RuntimeError("Unexpected: could not find any forecasts for Monterey bay.")
52+
assert most_recent_date is not None and most_recent_time is not None, (
53+
"Unexpected: could not find any forecasts for Monterey bay."
54+
)
5155

5256
# parse date and time
5357
date_match = re.search(r"\d{8}", most_recent_date)
@@ -140,6 +144,38 @@ def _check_time(date: str, time: str) -> bool:
140144
return r.ok
141145

142146

147+
def download_forecast(url: str, dir: Path | None = None) -> Path:
148+
"""
149+
Download NWFS forecast data to disk.
150+
151+
Args:
152+
url: URL to the GRIB file.
153+
dir: Directory to save the file in. If none, will download to the
154+
current directory.
155+
156+
Returns:
157+
Path to the GRIB file.
158+
"""
159+
160+
if dir is None:
161+
dir = Path(".")
162+
163+
file_path = dir / os.path.basename(url)
164+
if file_path.exists():
165+
LOG.info(f"'{file_path}' already exists. Skipping download")
166+
return file_path
167+
168+
LOG.info(f"Downloading '{url}' to '{file_path}'")
169+
r = requests.get(url, stream=True)
170+
r.raise_for_status()
171+
172+
with open(file_path, "wb") as file:
173+
for chunk in r.iter_content(chunk_size=_CHUNK_SIZE):
174+
file.write(chunk)
175+
176+
return file_path
177+
178+
143179
if __name__ == "__main__":
144180
logging.basicConfig(level=logging.INFO, format="[%(levelname)5s] [%(created)f] %(name)s: %(message)s")
145181

0 commit comments

Comments
 (0)