Skip to content

Commit 74e1423

Browse files
author
Gabriel de Courval-Pare
committed
init commit of extract_wind_data
1 parent 2837b41 commit 74e1423

File tree

2 files changed

+369
-0
lines changed

2 files changed

+369
-0
lines changed

msc_pygeoapi/process/weather/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
from msc_pygeoapi.process.weather.extract_sounding_data import extract_sounding_data_execute # noqa
3434

35+
from msc_pygeoapi.process.weather.extract_wind_data import extract_wind_data_execute
36+
3537
LOGGER = logging.getLogger(__name__)
3638

3739

@@ -54,6 +56,7 @@ def weather():
5456

5557
weather.add_command(execute)
5658
weather.add_command(extract_sounding_data_execute)
59+
weather.add_command(extract_wind_data_execute)
5760

5861
try:
5962
execute.add_command(extract_raster)
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
# =================================================================
2+
#
3+
# Author: Gabriel de Courval-Paré
4+
#
5+
# Copyright (c) 2023 Tom Kralidis
6+
# Copyright (c) 2025 Gabriel de Courval-Paré
7+
#
8+
# Permission is hereby granted, free of charge, to any person
9+
# obtaining a copy of this software and associated documentation
10+
# files (the "Software"), to deal in the Software without
11+
# restriction, including without limitation the rights to use,
12+
# copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
# copies of the Software, and to permit persons to whom the
14+
# Software is furnished to do so, subject to the following
15+
# conditions:
16+
#
17+
# The above copyright notice and this permission notice shall be
18+
# included in all copies or substantial portions of the Software.
19+
#
20+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
# OTHER DEALINGS IN THE SOFTWARE.
28+
#
29+
# =================================================================
30+
31+
import datetime
32+
import json
33+
import logging
34+
35+
import click
36+
from osgeo import gdal
37+
from pyproj import Transformer
38+
import math
39+
40+
LOGGER = logging.getLogger(__name__)
41+
42+
PROCESS_METADATA = {
43+
'version': '0.1.0',
44+
'id': 'wind-data',
45+
'title': 'GeoMet-Weather Wind Data process',
46+
'description': 'GeoMet-Weather Wind Data process',
47+
'keywords': ['wind data'],
48+
'links': [],
49+
'inputs': {
50+
'model': {
51+
'title': 'model name',
52+
'description': 'GDWPS, GDPS, GEPS, RDWPS, HRDPS, REPS',
53+
'schema': {
54+
'type': 'string',
55+
},
56+
'minOccurs': 1,
57+
'maxOccurs': 1
58+
},
59+
'model_run': {
60+
'title': 'Model run',
61+
'description': 'Model run in %Y-%m-%dTH:M:SZ format.',
62+
'schema': {'type': 'string'},
63+
'minOccurs': 1,
64+
'maxOccurs': 1,
65+
},
66+
'forecast_hour': {
67+
'title': 'Forecast hour',
68+
'description': 'Forecast hour in XXX format.',
69+
'schema': {'type': 'string'},
70+
'minOccurs': 1,
71+
'maxOccurs': 1,
72+
},
73+
'lon': {
74+
'title': 'lon coordinate',
75+
'description': 'Longitude of the requested location (EPSG:4326)',
76+
'schema': {
77+
'type': 'number',
78+
},
79+
'minOccurs': 1,
80+
'maxOccurs': 1
81+
},
82+
'lat': {
83+
'title': 'lat coordinate',
84+
'description': 'Latitude of the requested location (EPSG:4326)',
85+
'schema': {
86+
'type': 'number',
87+
},
88+
'minOccurs': 1,
89+
'maxOccurs': 1
90+
},
91+
},
92+
"outputs": {
93+
"extract_wind_data_response": {
94+
"title": "extract_wind_data_response",
95+
"schema": {"contentMediaType": "application/json"},
96+
}
97+
},
98+
'example': {
99+
'inputs': {
100+
'model': 'GDWPS',
101+
'model_run': f'{(datetime.date.today() - datetime.timedelta(days=1)).strftime("%Y-%m-%d")}T12:00:00Z', # noqa
102+
'forecast_hour': '003',
103+
'lon': -28.75,
104+
'lat': 39.25,
105+
}
106+
}
107+
}
108+
109+
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
110+
MS_TO_KNOTS = 1.943844
111+
112+
113+
def get_norm(*components):
114+
norm = 0
115+
for component in components:
116+
norm += component*component
117+
return math.sqrt(norm)
118+
119+
120+
def get_dir(u, v):
121+
phi = 180 + (180 / math.pi) * math.atan2(u, v)
122+
phi = phi % 360
123+
124+
return phi
125+
126+
127+
def geo2xy(ds, x, y):
128+
"""
129+
transforms geographic coordinate to x/y pixel values
130+
131+
:param ds: GDAL dataset object
132+
:param x: x coordinate
133+
:param y: y coordinate
134+
135+
:returns: list of x/y pixel values
136+
"""
137+
138+
LOGGER.debug('Running affine transformation')
139+
geotransform = ds.GetGeoTransform()
140+
141+
origin_x = geotransform[0]
142+
origin_y = geotransform[3]
143+
144+
width = geotransform[1]
145+
height = geotransform[5]
146+
147+
x = int((x - origin_x) / width)
148+
y = int((y - origin_y) / height)
149+
150+
return (x, y)
151+
152+
153+
def extract_wind_data(
154+
model,
155+
model_run,
156+
forecast_hour,
157+
lon,
158+
lat,
159+
):
160+
from msc_pygeoapi.env import GEOMET_HPFX_BASEPATH
161+
162+
data_basepath = GEOMET_HPFX_BASEPATH
163+
164+
date = datetime.datetime.strptime(model_run, DATE_FORMAT)
165+
run_hour = f"{date.hour:02}"
166+
date_formatted = date.strftime("%Y%m%d")
167+
168+
match(model):
169+
case "GDWPS":
170+
inter_path = f"/model_gdwps/25km/{run_hour}/"
171+
file_name_x = f"{date_formatted}T{run_hour}Z_MSC_GDWPS_UGRD_AGL-10m_LatLon0.25_PT{forecast_hour}H.grib2"
172+
file_name_y = f"{date_formatted}T{run_hour}Z_MSC_GDWPS_VGRD_AGL-10m_LatLon0.25_PT{forecast_hour}H.grib2"
173+
174+
case "GDPS":
175+
inter_path = f"/model_gem_global/15km/grib2/lat_lon/{run_hour}/{forecast_hour}/"
176+
file_name_x = f"CMC_glb_UGRD_TGL_10_latlon.15x.15_{date_formatted}{run_hour}_P{forecast_hour}.grib2"
177+
file_name_y = f"CMC_glb_VGRD_TGL_10_latlon.15x.15_{date_formatted}{run_hour}_P{forecast_hour}.grib2"
178+
179+
case "GEPS":
180+
raise NotImplementedError("GEPS raw data not yet in Geomet HPFX")
181+
182+
case "RDWPS":
183+
inter_path = f"/model_rdwps/national/2.5km/{run_hour}/"
184+
file_name_x = f"{date_formatted}T{run_hour}Z_MSC_RDWPS_UGRD_AGL-10m_RLatLon0.0225_PT{forecast_hour}H.grib2"
185+
file_name_y = f"{date_formatted}T{run_hour}Z_MSC_RDWPS_VGRD_AGL-10m_RLatLon0.0225_PT{forecast_hour}H.grib2"
186+
187+
case "HRDPS":
188+
inter_path = f"/model_hrdps/continental/2.5km/{run_hour}/{forecast_hour}/"
189+
file_name_x = f"{date_formatted}T{run_hour}Z_MSC_HRDPS_UGRD_AGL-10m_RLatLon0.0225_PT{forecast_hour}H.grib2"
190+
file_name_y = f"{date_formatted}T{run_hour}Z_MSC_HRDPS_VGRD_AGL-10m_RLatLon0.0225_PT{forecast_hour}H.grib2"
191+
192+
case "REPS":
193+
inter_path = f"/ensemble/reps/10km/grib2/{run_hour}/{forecast_hour}/"
194+
file_name_x = f"{date_formatted}T{run_hour}Z_MSC_REPS_UGRD_AGL-10m_RLatLon0.09x0.09_PT{forecast_hour}H.grib2"
195+
file_name_y = f"{date_formatted}T{run_hour}Z_MSC_REPS_VGRD_AGL-10m_RLatLon0.09x0.09_PT{forecast_hour}H.grib2"
196+
197+
case _:
198+
raise ValueError(f"Unknown model: {model}")
199+
200+
output = {
201+
'type': "Feature",
202+
'geometry': {"type": "Point", "coordinates": [lon, lat]},
203+
'properties': {
204+
'wind_speed_unit': "knots",
205+
'wind_direction_unit': "°",
206+
},
207+
}
208+
209+
full_path_x = f"{data_basepath}{inter_path}{file_name_x}"
210+
full_path_y = f"{data_basepath}{inter_path}{file_name_y}"
211+
212+
ds_x = gdal.Open(full_path_x, gdal.GA_ReadOnly)
213+
ds_y = gdal.Open(full_path_y, gdal.GA_ReadOnly)
214+
215+
if ds_x is None:
216+
raise NameError(f"Couldn't open {full_path_x}, check if file exists")
217+
218+
if ds_y is None:
219+
raise NameError(f"Couldn't open {full_path_y}, check if file exists")
220+
221+
out_proj = ds_x.GetProjection()
222+
transformer = Transformer.from_crs("EPSG:4326", out_proj, always_xy=True)
223+
_x, _y = transformer.transform(lon, lat)
224+
x, y = geo2xy(ds_x, _x, _y)
225+
226+
band_x = ds_x.GetRasterBand(1)
227+
bitmap_x = band_x.GetMaskBand().ReadAsArray()
228+
229+
try:
230+
_ = bitmap_x[y, x]
231+
except IndexError:
232+
raise IndexError("ERROR: no data at requested latitude and longitude - point outside of model grid")
233+
234+
if not bitmap_x[y, x]:
235+
raise IndexError("ERROR: no data at requested latitude and longitude - data at point is masked")
236+
237+
band_y = ds_y.GetRasterBand(1)
238+
239+
data_array_x = band_x.ReadAsArray()
240+
data_array_y = band_y.ReadAsArray()
241+
242+
wind_speed_x = data_array_x[y, x]
243+
wind_speed_y = data_array_y[y, x]
244+
245+
norm = get_norm(wind_speed_x, wind_speed_y) * MS_TO_KNOTS
246+
dir = get_dir(wind_speed_x, wind_speed_y)
247+
248+
output['properties']['wind_speed'] = norm
249+
output['properties']['wind_dir'] = dir
250+
251+
return output
252+
253+
254+
@click.group("execute")
255+
def extract_wind_data_execute():
256+
pass
257+
258+
259+
@click.command("extract-wind-data")
260+
@click.pass_context
261+
@click.option(
262+
"-m",
263+
"--model",
264+
help="GDWPS, GDPS, GEPS, RDWPS, HRDPS or REPS",
265+
required=True)
266+
@click.option(
267+
"-mr",
268+
"--model_run",
269+
help="model run in %Y-%m-%dT%H:%M:%SZ format",
270+
required=True
271+
)
272+
@click.option(
273+
"-fh",
274+
"--forecast_hour",
275+
help="forecast hour 3 digits format",
276+
required=True
277+
)
278+
@click.option(
279+
"--lon",
280+
help="longitude in number format, i.e. -85.000, not 85.000W",
281+
required=True
282+
)
283+
@click.option(
284+
"--lat",
285+
help="latitude in number format, i.e. -85.000, not 85.000S",
286+
required=True
287+
)
288+
def extract_wind_data_cli(
289+
ctx,
290+
model,
291+
model_run,
292+
forecast_hour,
293+
lon,
294+
lat,
295+
):
296+
import rasterio
297+
import time
298+
299+
start = time.time()
300+
with rasterio.Env():
301+
output = extract_wind_data(
302+
model,
303+
model_run,
304+
forecast_hour,
305+
float(lon),
306+
float(lat),
307+
)
308+
end = time.time()
309+
print("Time elapsed array:", end - start)
310+
click.echo(json.dumps(output, ensure_ascii=False))
311+
312+
313+
extract_wind_data_execute.add_command(extract_wind_data_cli)
314+
315+
try:
316+
from pygeoapi.process.base import BaseProcessor, ProcessorExecuteError
317+
318+
class ExtractWindDataProcessor(BaseProcessor):
319+
"""Extract Wind Data Processor"""
320+
321+
def __init__(self, provider_def):
322+
"""
323+
Initialize object
324+
325+
:param provider_def: provider definition
326+
327+
:returns: pygeoapi.process.weather.extract_wind_data.ExtractWindDataProcessor # noqa
328+
"""
329+
330+
BaseProcessor.__init__(self, provider_def, PROCESS_METADATA)
331+
332+
def execute(self, data, outputs=None):
333+
mimetype = "application/json"
334+
335+
required = ["model", "model_run", "forecast_hour", "lon", "lat"]
336+
if not all([param in data for param in required]):
337+
msg = "Missing required parameters."
338+
LOGGER.error(msg)
339+
raise ProcessorExecuteError(msg)
340+
341+
model = data.get("model")
342+
mr = data.get("model_run")
343+
fh = data.get("forecast_hour")
344+
lon = float(data.get("lon"))
345+
lat = float(data.get("lat"))
346+
347+
try:
348+
output = extract_wind_data(
349+
model,
350+
mr,
351+
fh,
352+
lon,
353+
lat,
354+
)
355+
except ValueError as err:
356+
msg = f'Process execution error: {err}'
357+
LOGGER.error(msg)
358+
raise ProcessorExecuteError(msg)
359+
360+
return mimetype, output
361+
362+
def __repr__(self):
363+
return f'<ExtractWindDataProcessor> {self.name}'
364+
365+
except (ImportError, RuntimeError) as err:
366+
LOGGER.warning(f'Import errors: {err}')

0 commit comments

Comments
 (0)