Skip to content

Commit f24db4e

Browse files
Merge pull request #177 from metno/161-spatial-metrics
161 spatial metrics
2 parents 29e5bb4 + a615d3f commit f24db4e

7 files changed

Lines changed: 575 additions & 3 deletions

File tree

bris/outputs/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ def instantiate(name: str, predict_metadata: PredictMetadata, workdir: str, init
3232
if name == "grib":
3333
return Grib(predict_metadata, workdir, **init_args)
3434

35+
if name == "powerspectrum_global":
36+
return SHPowerSpectrum(predict_metadata, workdir, **init_args)
37+
38+
if name == "powerspectrum_gridded":
39+
return DCTPowerSpectrum(predict_metadata, workdir, **init_args)
40+
3541
raise ValueError(f"Invalid output: {name}")
3642

3743

@@ -51,7 +57,7 @@ def get_required_variables(name, init_args):
5157
return variables
5258
return [None]
5359

54-
if name == "verif":
60+
if name in ["verif", "powerspectrum_gridded", "powerspectrum_global"]:
5561
if init_args["variable"] == "ws":
5662
return ["10u", "10v"]
5763
return [init_args["variable"]]
@@ -176,4 +182,5 @@ def flatten_pred(self, pred):
176182
from .grib import Grib
177183
from .intermediate import Intermediate
178184
from .netcdf import Netcdf
185+
from .spatial import DCTPowerSpectrum, SHPowerSpectrum
179186
from .verif import Verif

bris/outputs/intermediate.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,35 @@ def cleanup(self):
110110

111111
def finalize(self):
112112
pass
113+
114+
115+
class IntermediateSpatial(Intermediate):
116+
"""Intermediate output for spatial metrics, inheriting from Intermediate."""
117+
118+
def __init__(
119+
self,
120+
predict_metadata: PredictMetadata,
121+
workdir: str,
122+
metric_shape: tuple,
123+
extra_variables: Optional[list] = None,
124+
) -> None:
125+
super().__init__(predict_metadata, workdir, extra_variables)
126+
self.metric_shape = metric_shape
127+
128+
def get_forecast(self, forecast_reference_time, ensemble_member=None):
129+
if ensemble_member is None:
130+
shape = (
131+
(self.pm.num_leadtimes,) + self.metric_shape + (self.pm.num_members,)
132+
)
133+
pred = np.nan * np.zeros(shape)
134+
for e in range(self.pm.num_members):
135+
filename = self.get_filename(forecast_reference_time, e)
136+
if os.path.exists(filename):
137+
pred[..., e] = np.load(filename)
138+
else:
139+
assert isinstance(ensemble_member, int)
140+
141+
filename = self.get_filename(forecast_reference_time, ensemble_member)
142+
pred = np.load(filename) if os.path.exists(filename) else None
143+
144+
return pred

bris/outputs/spatial.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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

Comments
 (0)