|
| 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) |
0 commit comments