|
| 1 | +import datetime |
| 2 | +from abc import abstractmethod |
| 3 | +from functools import cached_property |
| 4 | + |
| 5 | +import numpy as np |
| 6 | +import xarray as xr |
| 7 | +from pyshtools.expand import SHGLQ, SHExpandGLQ |
| 8 | +from scipy.fft import dctn |
| 9 | +from scipy.interpolate import griddata |
| 10 | + |
| 11 | +from bris import projections, utils |
| 12 | +from bris.conventions import cf |
| 13 | +from bris.outputs import Output |
| 14 | +from bris.outputs.intermediate import IntermediateSpatial |
| 15 | +from bris.predict_metadata import PredictMetadata |
| 16 | + |
| 17 | + |
| 18 | +class Spatial(Output): |
| 19 | + """Metrics that require spatial averaging. |
| 20 | +
|
| 21 | + Power spectrum (wavelet spectrum) |
| 22 | + Sharpness https://github.com/ai2es/sharpness/blob/main/src/sharpness/metrics.py |
| 23 | + """ |
| 24 | + |
| 25 | + def __init__( |
| 26 | + self, |
| 27 | + predict_metadata: PredictMetadata, |
| 28 | + workdir: str, |
| 29 | + filename: str, |
| 30 | + variable: str | None = None, |
| 31 | + ): |
| 32 | + extra_variables = [] |
| 33 | + if variable not in predict_metadata.variables: |
| 34 | + extra_variables += [variable] |
| 35 | + |
| 36 | + super().__init__(predict_metadata, extra_variables) |
| 37 | + shape = self.get_metric_shape() |
| 38 | + self.intermediate = IntermediateSpatial( |
| 39 | + predict_metadata, workdir, shape, extra_variables=extra_variables |
| 40 | + ) |
| 41 | + self.variable = variable |
| 42 | + self.metric_name = self.get_metric_name() |
| 43 | + self.metric_shape = self.get_metric_shape() |
| 44 | + self.filename = filename |
| 45 | + |
| 46 | + @abstractmethod |
| 47 | + def calculate_metric(self, prediction: np.ndarray) -> np.ndarray: ... |
| 48 | + |
| 49 | + """Calculate the metric from a single ensemble member |
| 50 | +
|
| 51 | + Args: |
| 52 | + prediction: np.ndarray with shape (leadtime, location, variable) |
| 53 | +
|
| 54 | + Returns: |
| 55 | + metric: np.ndarray with dimensions given by get_metric_shape() |
| 56 | + """ |
| 57 | + |
| 58 | + @abstractmethod |
| 59 | + def get_metric_shape(self, **kwargs) -> tuple: ... |
| 60 | + |
| 61 | + """Shape of the metric output from a single leadtime and ensemble member""" |
| 62 | + |
| 63 | + @abstractmethod |
| 64 | + def get_metric_name(self) -> str: ... |
| 65 | + |
| 66 | + """Name of the metric variable in the output dataset""" |
| 67 | + |
| 68 | + @abstractmethod |
| 69 | + def get_extra_dimensions(self) -> dict: ... |
| 70 | + |
| 71 | + """Returns {name: values} of the metric specific dimensions""" |
| 72 | + |
| 73 | + def _add_forecast( |
| 74 | + self, times: list, ensemble_member: int, pred: np.ndarray |
| 75 | + ) -> None: |
| 76 | + """Registers a forecast from a single ensemble member in the output""" |
| 77 | + |
| 78 | + metric = self.calculate_metric(pred) |
| 79 | + self.intermediate._add_forecast(times, ensemble_member, metric) |
| 80 | + |
| 81 | + def finalize(self) -> None: |
| 82 | + """Writes output to file""" |
| 83 | + coords = {} |
| 84 | + |
| 85 | + frts = self.intermediate.get_forecast_reference_times() |
| 86 | + times_unix = utils.datetime_to_unixtime(frts).astype(np.double) |
| 87 | + coords["time"] = (["time"], times_unix, cf.get_attributes("time")) |
| 88 | + coords["leadtime"] = ( |
| 89 | + ["leadtime"], |
| 90 | + self.intermediate.pm.leadtimes.astype(np.float32) / 3600, |
| 91 | + {"units": "hour"}, |
| 92 | + ) |
| 93 | + for name, values in self.get_extra_dimensions().items(): |
| 94 | + coords[name] = ([name], values, cf.get_attributes(name)) |
| 95 | + if self.pm.num_members > 1: |
| 96 | + coords["ensemble_member"] = ( |
| 97 | + ["ensemble_member"], |
| 98 | + np.arange(self.pm.num_members), |
| 99 | + ) |
| 100 | + |
| 101 | + self.ds = xr.Dataset(coords=coords) |
| 102 | + |
| 103 | + data_shape = ( |
| 104 | + (len(frts),) |
| 105 | + + (self.intermediate.pm.num_leadtimes,) |
| 106 | + + self.metric_shape |
| 107 | + + (self.pm.num_members,) |
| 108 | + ) |
| 109 | + dims = ["time", "leadtime"] + list(self.get_extra_dimensions().keys()) |
| 110 | + |
| 111 | + data = np.full(data_shape, np.nan, dtype=np.float32) |
| 112 | + |
| 113 | + for i, frt in enumerate(frts): |
| 114 | + curr = self.intermediate.get_forecast(frt) |
| 115 | + data[i, ...] = curr |
| 116 | + |
| 117 | + if self.pm.num_members > 1: |
| 118 | + dims += ["ensemble_member"] |
| 119 | + else: |
| 120 | + data = data.squeeze(-1) |
| 121 | + |
| 122 | + self.ds[self.metric_name] = (dims, data) |
| 123 | + |
| 124 | + datestr = datetime.datetime.now(datetime.timezone.utc).strftime( |
| 125 | + "%Y-%m-%d %H:%M:%S +00:00" |
| 126 | + ) |
| 127 | + self.ds.attrs["history"] = f"{datestr} Created by bris-inference" |
| 128 | + self.ds.attrs["Convensions"] = "CF-1.6" |
| 129 | + |
| 130 | + utils.create_directory(self.filename) |
| 131 | + self.ds.to_netcdf(self.filename, mode="w", engine="netcdf4") |
| 132 | + |
| 133 | + @cached_property |
| 134 | + def get_latlons(self) -> tuple: |
| 135 | + return self.pm.lats, self.pm.lons |
| 136 | + |
| 137 | + |
| 138 | +class SHPowerSpectrum(Spatial): |
| 139 | + """Calculates the isotropic power spectrum of a variables for global grids using the Spherical Harmonics fourier transform""" |
| 140 | + |
| 141 | + def __init__( |
| 142 | + self, |
| 143 | + predict_metadata: PredictMetadata, |
| 144 | + workdir: str, |
| 145 | + filename: str, |
| 146 | + variable: str, |
| 147 | + delta_degrees: float | None = None, |
| 148 | + ): |
| 149 | + self.delta_degrees = delta_degrees |
| 150 | + super().__init__(predict_metadata, workdir, filename, variable) |
| 151 | + assert not self.pm.is_gridded, ( |
| 152 | + "SHPowerSpectrum is meant to be used for global ungridded data" |
| 153 | + ) |
| 154 | + |
| 155 | + def get_metric_name(self) -> str: |
| 156 | + return f"sh_power_spectrum_{self.variable}" |
| 157 | + |
| 158 | + def get_metric_shape(self, **kwargs) -> tuple: |
| 159 | + lats_reg_grid, _ = self.get_grid_reg_latlons |
| 160 | + return (lats_reg_grid.shape[0] - 1,) |
| 161 | + |
| 162 | + def get_extra_dimensions(self) -> dict: |
| 163 | + l_max = self.metric_shape[0] |
| 164 | + return {"l": np.arange(1, l_max + 1)} |
| 165 | + |
| 166 | + @cached_property |
| 167 | + def get_grid_reg_latlons(self) -> tuple: |
| 168 | + "Create a regular lat-lon grid based on the data resolution or input resolution if delta_degrees is given" |
| 169 | + lats = self.pm.lats |
| 170 | + lons = self.pm.lons |
| 171 | + |
| 172 | + if self.delta_degrees is not None: |
| 173 | + delta = self.delta_degrees |
| 174 | + else: |
| 175 | + lat_min = abs(np.diff(lats)) |
| 176 | + delta = np.min(abs(lat_min[lat_min != 0])) |
| 177 | + |
| 178 | + n_lats = int(np.floor((lats.max() - lats.min()) / delta)) |
| 179 | + n_lons = (n_lats - 1) * 2 + 1 |
| 180 | + lats_regular = np.linspace(lats.min(), lats.max(), n_lats) |
| 181 | + lons_regular = np.linspace(lons.min(), lons.max(), n_lons) |
| 182 | + lons_reg_grid, lats_reg_grid = np.meshgrid(lons_regular, lats_regular) |
| 183 | + return lons_reg_grid, lats_reg_grid |
| 184 | + |
| 185 | + def calculate_metric(self, prediction: np.ndarray) -> np.ndarray: |
| 186 | + """Calculate the wavenumber spherical harmonic power spectrum of the variable""" |
| 187 | + |
| 188 | + var_index = self.pm.variables.index(self.variable) |
| 189 | + leadtimes = self.pm.num_leadtimes |
| 190 | + metric = np.full((leadtimes,) + self.metric_shape, np.nan, dtype=np.float32) |
| 191 | + |
| 192 | + lats, lons = self.get_latlons |
| 193 | + lons_reg_grid, lats_reg_grid = self.get_grid_reg_latlons |
| 194 | + |
| 195 | + for lt in range(leadtimes): |
| 196 | + field = prediction[lt, :, var_index] |
| 197 | + |
| 198 | + field_reg = griddata( |
| 199 | + (lons, lats), |
| 200 | + field, |
| 201 | + (lons_reg_grid, lats_reg_grid), |
| 202 | + method="nearest", |
| 203 | + fill_value=np.nan, |
| 204 | + ) |
| 205 | + nanmask = np.isnan(field_reg) |
| 206 | + if nanmask.any(): |
| 207 | + print( |
| 208 | + "Warning: SHPowerSpectrum - missing values in regular grid, replacing with zeros" |
| 209 | + ) |
| 210 | + field_reg[nanmask] = 0.0 |
| 211 | + |
| 212 | + # Compute spherical harmonic coefficients and power spectrum |
| 213 | + lmax = lats_reg_grid.shape[0] - 1 |
| 214 | + zero, w = SHGLQ(lmax) |
| 215 | + coeffs_field = SHExpandGLQ(field_reg, w=w, zero=zero) |
| 216 | + power_spectrum = np.sum(coeffs_field**2, axis=(0, 2)) |
| 217 | + |
| 218 | + metric[lt, ...] = power_spectrum[1:] |
| 219 | + |
| 220 | + return metric |
| 221 | + |
| 222 | + |
| 223 | +class DCTPowerSpectrum(Spatial): |
| 224 | + """Calculates the isotropic power spectrum of a variables for regular projected grids using the Discrete Cosine Transform""" |
| 225 | + |
| 226 | + def __init__( |
| 227 | + self, |
| 228 | + predict_metadata: PredictMetadata, |
| 229 | + workdir: str, |
| 230 | + filename: str, |
| 231 | + variable: str, |
| 232 | + proj4_str: str | None = None, |
| 233 | + domain_name: str | None = None, |
| 234 | + n_bins: int | None = None, |
| 235 | + ): |
| 236 | + self.n_bins = n_bins |
| 237 | + if domain_name is not None: |
| 238 | + self.proj4_str = projections.get_proj4_str(domain_name) |
| 239 | + else: |
| 240 | + self.proj4_str = proj4_str |
| 241 | + assert self.proj4_str is not None, ( |
| 242 | + "Either domain_name or proj4_str must be provided" |
| 243 | + ) |
| 244 | + super().__init__(predict_metadata, workdir, filename, variable) |
| 245 | + assert self.pm.is_gridded, "PowerSpectrum is meant to be used for gridded data" |
| 246 | + |
| 247 | + def get_metric_name(self) -> str: |
| 248 | + return f"power_spectrum_{self.variable}" |
| 249 | + |
| 250 | + def get_metric_shape(self, **kwargs) -> tuple: |
| 251 | + _, k_bins, _ = self.get_bins |
| 252 | + return (k_bins.shape[0],) |
| 253 | + |
| 254 | + @cached_property |
| 255 | + def get_bins(self): |
| 256 | + """Calculates wavenumbers, bins and bin-edges used in the CDT calculation.""" |
| 257 | + nx, ny = self.pm.field_shape |
| 258 | + lats, lons = self.get_latlons |
| 259 | + x, y = projections.get_xy( |
| 260 | + lats.reshape(nx, ny), lons.reshape(nx, ny), self.proj4_str |
| 261 | + ) |
| 262 | + |
| 263 | + dx = np.mean(np.diff(x)) |
| 264 | + dy = np.mean(np.diff(y)) |
| 265 | + assert np.allclose(np.diff(x), dx, atol=1.0), ( |
| 266 | + "Non-uniform grid spacing in x-direction" |
| 267 | + ) |
| 268 | + assert np.allclose(np.diff(y), dy, atol=1.0), ( |
| 269 | + "Non-uniform grid spacing in y-direction" |
| 270 | + ) |
| 271 | + |
| 272 | + kx = np.pi * np.arange(nx) / (nx * dx) |
| 273 | + ky = np.pi * np.arange(ny) / (ny * dy) |
| 274 | + |
| 275 | + KX, KY = np.meshgrid(kx, ky, indexing="ij") |
| 276 | + k = np.sqrt(KX**2 + KY**2) |
| 277 | + k_max = k.max() |
| 278 | + |
| 279 | + n_bins = self.n_bins if self.n_bins is not None else min(nx, ny) // 2 |
| 280 | + |
| 281 | + k_edges = np.linspace(0.0, k_max, n_bins + 1) |
| 282 | + k_bins = 0.5 * (k_edges[1:] + k_edges[:-1]) |
| 283 | + return k_edges, k_bins, k |
| 284 | + |
| 285 | + def get_extra_dimensions(self) -> dict: |
| 286 | + _, k_bin, _ = self.get_bins |
| 287 | + return {"k": k_bin} |
| 288 | + |
| 289 | + def calculate_metric(self, prediction: np.ndarray) -> np.ndarray: |
| 290 | + "Calculate the isotropic power spectrum" |
| 291 | + |
| 292 | + var_index = self.pm.variables.index(self.variable) |
| 293 | + leadtimes = self.pm.num_leadtimes |
| 294 | + metric = np.full((leadtimes,) + self.metric_shape, np.nan, dtype=np.float32) |
| 295 | + nx, ny = self.pm.field_shape |
| 296 | + |
| 297 | + k_edges, k_bins, k = self.get_bins |
| 298 | + n_bins = k_bins.shape[0] |
| 299 | + digitized = np.digitize(k.flatten(), k_edges) |
| 300 | + |
| 301 | + for lt in range(leadtimes): |
| 302 | + field = prediction[lt, :, var_index].reshape(nx, ny) |
| 303 | + |
| 304 | + P = np.abs(dctn(field, type=2, norm="ortho")) ** 2 |
| 305 | + |
| 306 | + E_k = np.array( |
| 307 | + [ |
| 308 | + P.flatten()[digitized == i].mean() if np.any(digitized == i) else 0 |
| 309 | + for i in range(1, n_bins + 1) |
| 310 | + ] |
| 311 | + ) |
| 312 | + metric[lt, :] = E_k |
| 313 | + |
| 314 | + return metric |
0 commit comments