Skip to content

Commit 3c02748

Browse files
Point to Grid Translator for MetNet3 (OMO weather stations) (#85)
* initial commit for point to grid translator * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 9ca13d4 commit 3c02748

4 files changed

Lines changed: 239 additions & 4 deletions

File tree

metnet/data/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
11
"""Data loading and preprocessing utilities for MetNet."""
2-
3-
from .merge_single_band import merge_086um_band, merge_two_arrays
4-
5-
__all__ = ["merge_two_arrays", "merge_086um_band"]

metnet/data/point_to_grid.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Point-source to grid translator for MetNet-3 weather station data.
2+
3+
Translates sparse weather station observations (lat/lon point measurements)
4+
to a dense 2D grid, as described in the MetNet-3 paper:
5+
6+
'For surface variables, we take the OMO station point measurements and map
7+
them to a 4km by 4km pixel in which the station lies. If there are multiple
8+
stations in a given region, we take the average of their measurements.'
9+
10+
The 14 variables used are based on the OMO variable list at:
11+
https://madis.ncep.noaa.gov/sfc_OMO_variable_list.shtml
12+
"""
13+
14+
from typing import Dict, List
15+
16+
import numpy as np
17+
import torch
18+
from pyproj import Transformer
19+
20+
# Best approximation of the 14 OMO input variables using MADIS ASOS
21+
OMO_VARIABLES = [
22+
"T", # air temperature (K)
23+
"TD", # dewpoint temperature (K)
24+
"RH", # relative humidity (%)
25+
"P", # station pressure (Pa)
26+
"DD", # wind direction (deg)
27+
"FF", # wind speed (m/s)
28+
"U", # u wind component (m/s)
29+
"V", # v wind component (m/s)
30+
"FFGUST", # wind gust (m/s)
31+
"VIS", # visibility (m)
32+
"PCPRATE", # precipitation rate (kg/m²/s)
33+
"PCP1H", # accumulated precip 1h (m)
34+
"Q", # specific humidity (kg/kg)
35+
"ALTSE", # altimeter pressure (Pa)
36+
]
37+
38+
39+
class PointToGridTranslator:
40+
"""
41+
Translates sparse weather station observations to a dense grid.
42+
43+
Supports Lambert Conformal Conic projection, matches CONUS setup
44+
"""
45+
46+
def __init__(
47+
self,
48+
grid_height: int,
49+
grid_width: int,
50+
lat_min: float,
51+
lat_max: float,
52+
lon_min: float,
53+
lon_max: float,
54+
resolution_km: float = 4.0,
55+
variables: List[str] = OMO_VARIABLES,
56+
fill_value: float = 0.0,
57+
):
58+
"""
59+
Initialize the translator.
60+
61+
Args:
62+
grid_height: Number of grid points in y direction
63+
grid_width: Number of grid points in x direction
64+
lat_min: Minimum latitude of grid domain
65+
lat_max: Maximum latitude of grid domain
66+
lon_min: Minimum longitude of grid domain
67+
lon_max: Maximum longitude of grid domain
68+
resolution_km: Grid resolution in km, 4.0 as per MetNet-3
69+
variables: List of variable names to extract from station data
70+
fill_value: Value for grid cells with no station, default 0.0
71+
"""
72+
self.grid_height = grid_height
73+
self.grid_width = grid_width
74+
self.lat_min = lat_min
75+
self.lat_max = lat_max
76+
self.lon_min = lon_min
77+
self.lon_max = lon_max
78+
self.resolution_km = resolution_km
79+
self.variables = variables
80+
self.fill_value = fill_value
81+
self.num_variables = len(variables)
82+
83+
self.transformer = Transformer.from_crs(
84+
"EPSG:4326", # WGS84 lat/lon
85+
"+proj=lcc +lat_1=25 +lat_2=60 +lat_0=40 +lon_0=-96", # CONUS LCC
86+
always_xy=True,
87+
)
88+
self.x_min, self.y_min = self.transformer.transform(lon_min, lat_min)
89+
90+
def _latlon_to_pixel(self, lat: float, lon: float):
91+
"""Convert lat/lon to grid pixel coordinates."""
92+
x, y = self.transformer.transform(lon, lat)
93+
col = int((x - self.x_min) / (self.resolution_km * 1000))
94+
row = int((y - self.y_min) / (self.resolution_km * 1000))
95+
return row, col
96+
97+
def translate(
98+
self,
99+
stations: List[Dict],
100+
) -> torch.Tensor:
101+
"""
102+
Translate station observations to a dense grid.
103+
104+
Args:
105+
stations: List of dicts, each with keys:
106+
- 'lat': station latitude
107+
- 'lon': station longitude
108+
- variable names matching self.variables with float values
109+
110+
Returns:
111+
Dense grid tensor of shape [num_variables, grid_height, grid_width]
112+
with fill_value where no station is present
113+
"""
114+
# Initialize grid and count grids for averaging
115+
grid = np.full(
116+
(self.num_variables, self.grid_height, self.grid_width),
117+
self.fill_value,
118+
dtype=np.float32,
119+
)
120+
counts = np.zeros(
121+
(self.grid_height, self.grid_width),
122+
dtype=np.int32,
123+
)
124+
125+
for station in stations:
126+
lat = station["lat"]
127+
lon = station["lon"]
128+
129+
# Skip stations outside grid bounds
130+
if not (self.lat_min <= lat <= self.lat_max):
131+
continue
132+
if not (self.lon_min <= lon <= self.lon_max):
133+
continue
134+
135+
row, col = self._latlon_to_pixel(lat, lon)
136+
137+
# Skip if outside grid
138+
if not (0 <= row < self.grid_height and 0 <= col < self.grid_width):
139+
continue
140+
141+
# Add station values
142+
for i, var in enumerate(self.variables):
143+
if var in station and station[var] is not None:
144+
value = station[var]
145+
if np.isnan(value):
146+
continue
147+
if counts[row, col] == 0:
148+
grid[i, row, col] = value
149+
else:
150+
# Running sum for averaging
151+
grid[i, row, col] += value
152+
153+
counts[row, col] += 1
154+
155+
# Average where multiple stations fall in same pixel
156+
multiple = counts > 1
157+
if np.any(multiple):
158+
grid[:, multiple] /= counts[multiple]
159+
160+
return torch.from_numpy(grid)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ antialiased_cnns
55
axial_attention
66
pytorch_msssim
77
huggingface_hub
8+
pyproj>=3.0.0

tests/test_data.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import torch
2+
from metnet.data.point_to_grid import PointToGridTranslator
3+
4+
5+
def make_translator(
6+
grid_height=10,
7+
grid_width=10,
8+
# Domain sized to match a 10x10 grid at the default 4km resolution
9+
# (40km x 40km), with SAMPLE_STATION (40.0, -95.0) landing near the
10+
# center. A domain much larger than grid_height/width * resolution_km
11+
# will place stations outside the array, so translate() silently
12+
# drops them instead of raising.
13+
lat_min=39.8,
14+
lat_max=40.2,
15+
lon_min=-95.25,
16+
lon_max=-94.5,
17+
fill_value=0.0,
18+
):
19+
return PointToGridTranslator(
20+
grid_height=grid_height,
21+
grid_width=grid_width,
22+
lat_min=lat_min,
23+
lat_max=lat_max,
24+
lon_min=lon_min,
25+
lon_max=lon_max,
26+
fill_value=fill_value,
27+
)
28+
29+
30+
SAMPLE_STATION = {
31+
"lat": 40.0,
32+
"lon": -95.0,
33+
"T": 295.0,
34+
"TD": 288.0,
35+
"RH": 70.0,
36+
"P": 101325.0,
37+
"DD": 180.0,
38+
"FF": 5.0,
39+
"U": 0.0,
40+
"V": -5.0,
41+
"FFGUST": 8.0,
42+
"VIS": 10000.0,
43+
"PCPRATE": 0.0,
44+
"PCP1H": 0.0,
45+
"Q": 0.01,
46+
"ALTSE": 101325.0,
47+
}
48+
49+
50+
def test_output_shape():
51+
output = make_translator().translate([SAMPLE_STATION])
52+
assert output.shape == (14, 10, 10)
53+
54+
55+
def test_fill_value():
56+
output = make_translator().translate([])
57+
assert (output == 0.0).all()
58+
59+
60+
def test_nan_handling():
61+
station = {**SAMPLE_STATION, "T": float("nan")}
62+
output = make_translator().translate([station])
63+
assert output[0].max() == 0.0
64+
65+
66+
def test_outside_bounds():
67+
station = {**SAMPLE_STATION, "lat": 60.0}
68+
output = make_translator().translate([station])
69+
assert (output == 0.0).all()
70+
71+
72+
def test_averaging():
73+
translator = make_translator(fill_value=-999.0) # distinct fill value
74+
station1 = {**SAMPLE_STATION, "T": 290.0}
75+
station2 = {**SAMPLE_STATION, "T": 300.0} # exact same lat/lon
76+
output = translator.translate([station1, station2])
77+
non_fill = output[0][output[0] != -999.0]
78+
assert torch.isclose(non_fill, torch.tensor(295.0)).any()

0 commit comments

Comments
 (0)