11import datetime
22import logging
3- import os
4- from enum import IntEnum
53from pathlib import Path
6- from typing import NamedTuple
7- from zoneinfo import ZoneInfo
84
95import matplotlib
106import matplotlib .colors as mcolors
139import mpld3
1410import numpy as np
1511import pygrib
16- import requests
1712from jinja2 import Environment , PackageLoader , select_autoescape
1813from mpl_toolkits .basemap import Basemap
1914from 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
2421matplotlib .rcParams ["backend" ] = "agg"
2522
2623LOG = 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)
4626BREAKWATER_LAT = 36.611
4727BREAKWATER_LON = 238.108
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-
16843def 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
0 commit comments