Skip to content

Commit fb4f4fa

Browse files
authored
[1710] power spectra (ecmwf#1808)
* power spectra * minor fixes * target as baseline * adding fullglobe * adding docustrings * fixing dependencies * linting * putting dependency in shared_library * adding comments * removing env file * linting * refactoring * changing to scratch path * removing full paths * typo
1 parent 28c76ee commit fb4f4fa

File tree

5 files changed

+874
-0
lines changed

5 files changed

+874
-0
lines changed

packages/evaluate/src/weathergen/evaluate/example_extras/power_spectra/__init__.py

Whitespace-only changes.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import iris
2+
import iris.cube
3+
import numpy as np
4+
5+
6+
def psd(ht: np.typing.NDArray) -> np.typing.NDArray:
7+
"""
8+
Returns a power spectrum density for the positive non-zero frequencies
9+
Assumes ht has an even number of points
10+
"""
11+
n = len(ht)
12+
# Hf = np.fft.fft(ht, norm='forward')
13+
hf = np.fft.rfft(ht, norm="forward")
14+
psd = np.abs(hf[1 : round(n / 2 + 1)]) ** 2
15+
# Compensate for positive frequencies only
16+
psd *= 2.0
17+
return psd
18+
19+
20+
def cubepsd(
21+
cubes: iris.cube.CubeList | iris.cube.Cube, dimension: str = "longitude"
22+
) -> np.typing.NDArray:
23+
"""
24+
Returns a power spectrum density for a cube
25+
Assumes that cube.data has an even number of points in dimension dim
26+
"""
27+
28+
if isinstance(cubes, iris.cube.CubeList):
29+
# being passed a cube list
30+
npoints = len(cubes[0].coord(dimension).points)
31+
else:
32+
# Assume it is just a cube
33+
npoints = len(cubes.coord(dimension).points)
34+
35+
field_psd = np.zeros([round(npoints / 2)])
36+
37+
nloc = 0
38+
if isinstance(cubes, iris.cube.CubeList):
39+
for cube in cubes:
40+
for field_slice in cube.slices([dimension]):
41+
nloc += 1
42+
field_psd += psd(field_slice.data)
43+
else:
44+
for field_slice in cubes.slices([dimension]):
45+
nloc += 1
46+
field_psd += psd(field_slice.data)
47+
48+
field_psd /= nloc
49+
50+
return field_psd
51+
52+
53+
def calcposfreq(cube: iris.cube.Cube, dimension: str = "longitude") -> np.typing.NDArray:
54+
"""
55+
Given a cube and dimension returns the positive frequencies
56+
Assumes gridpoints are evenly spaced
57+
"""
58+
npoints = len(cube.coord(dimension).points)
59+
60+
# Create frequencies
61+
freq = np.fft.fftfreq(npoints, d=360.0 / npoints)
62+
63+
# Positive half
64+
posfreq = np.absolute(freq[1 : round(npoints / 2 + 1)])
65+
66+
return posfreq
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
variables: ['q'] # , 'v10', 'z'] etc.
2+
regions: ['FullGlobe'] #, 'FullGlobe', 'ShortGlobe' 'N-Mid-Lats', 'S-Mid-Lats', 'Tropics']
3+
pressure_levels: [250]
4+
forecast_steps : [6, 12] # choose the forecast steps in hours (must be common to all the datasets)
5+
output_dir: "./plots/power_spectra" #relative to dir in which script is run
6+
prefix: "comparison_uvpy16mo"
7+
# define prefix for images
8+
comparisons:
9+
# if directory all paths chosen, or use wildcard, or specify single path
10+
wg_target:
11+
netcdf_paths: ['../uvpy16mo/targ*'] # replace with paths to netcdfs
12+
wg_uvpy16mo:
13+
netcdf_paths: ['../uvpy16mo/pred*']
14+
15+
## it will produce powerspectra and average across all the samples
16+
# available then plot the fsteps as single plots
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
#!/usr/bin/env -S uv run --script
2+
# ruff: noqa: E501
3+
# /// script
4+
# dependencies = [
5+
# "cf-units",
6+
# "scitools-iris>=3.11",
7+
# "weathergen-common",
8+
# "omegaconf"
9+
# ]
10+
#
11+
# [tool.uv.sources]
12+
# weathergen-common = { path = "../../../../../../common" }
13+
# ///
14+
15+
"""
16+
Plots the power spectrum of the analysis increments
17+
Adapted from Martin Willet's code for power spectra
18+
for use with the WeatherGenerator model:
19+
20+
.packages/evaluate/src/weathergen/evaluate/example_extras/power_spectra/psd_main.py
21+
--run-id gn3gotvh --export-dir /p/home/jusers/owens1/juwels/WeatherGen/gn3gotvh
22+
23+
OR
24+
25+
./packages/evaluate/src/weathergen/evaluate/example_extras/power_spectra/psd_main.py \
26+
--config ./packages/evaluate/src/weathergen/evaluate/example_extras/power_spectra/psd_config.yml
27+
28+
Prerequisties:
29+
30+
Please export the inference into a regular lat lon gridded netcdf first using the export package:
31+
e.g.
32+
uv run export --run-id <INFERENCE_ID> --stream ERA5 \
33+
--output-dir ../output_nc --format netcdf --regrid-degree 1 \
34+
--regrid-type regular_ll
35+
36+
Add the following line to the bashrc:
37+
export LD_LIBRARY_PATH=/capstor/store/cscs/userlab/ch17/assets1/shared_libraries/udunits-2.2.28/lib:$LD_LIBRARY_PATH
38+
"""
39+
40+
import argparse
41+
import glob
42+
import logging
43+
import os
44+
import sys
45+
from pathlib import Path
46+
47+
import psd_plots as psd_plots
48+
from omegaconf import DictConfig, OmegaConf
49+
50+
# Local application / package
51+
from weathergen.common.config import _REPO_ROOT
52+
from weathergen.common.logger import init_loggers
53+
54+
_logger = logging.getLogger(__name__)
55+
56+
57+
def extract_filepaths(netcdf_paths: list) -> list:
58+
"""
59+
Extracts filepaths from a list of netcdf paths.
60+
If a directory is given, all files in the directory are returned.
61+
Parameters
62+
----------
63+
netcdf_paths:
64+
List of netcdf paths
65+
Returns
66+
-------
67+
list:
68+
List of filepaths
69+
"""
70+
if len(netcdf_paths) > 1:
71+
# list of files
72+
return netcdf_paths
73+
else:
74+
netcdf_path = netcdf_paths[0]
75+
if os.path.isfile(netcdf_path):
76+
return netcdf_paths
77+
elif os.path.isdir(netcdf_path):
78+
glob_path = netcdf_path + "/*"
79+
else:
80+
glob_path = netcdf_path
81+
return glob.glob(glob_path)
82+
83+
84+
def psd_from_config(cfg: dict) -> None:
85+
"""
86+
Main function that controls power spectra density plotting.
87+
Parameters
88+
----------
89+
cfg:
90+
Configuration input stored as dictionary
91+
"""
92+
diags = cfg.variables
93+
regions = cfg.regions
94+
plevels = cfg.pressure_levels
95+
comparison_dict = {}
96+
for comp in cfg.comparisons:
97+
# extract file paths
98+
comparison_dict[comp] = extract_filepaths(cfg.comparisons[comp]["netcdf_paths"])
99+
outdir = cfg.output_dir
100+
os.makedirs(outdir, exist_ok=True)
101+
fname = cfg.prefix
102+
fc_times = cfg.forecast_steps
103+
104+
psd_plots.plot_psds(
105+
comparison_dict,
106+
regions,
107+
diags,
108+
fname=fname,
109+
outdir=outdir,
110+
usencname=True,
111+
plevels=plevels,
112+
fc_times=fc_times,
113+
)
114+
115+
116+
def parse_args(args: list) -> None:
117+
"""
118+
Parse command line arguments.
119+
Parameters
120+
----------
121+
args : List of command line arguments.
122+
"""
123+
parser = argparse.ArgumentParser(description="Plot power spectral densities from NetCDF files.")
124+
125+
parser.add_argument(
126+
"--config",
127+
type=str,
128+
default=None,
129+
help="Path to the configuration YAML file.",
130+
)
131+
132+
parser.add_argument(
133+
"--output-dir",
134+
type=str,
135+
default=_REPO_ROOT / "plots" / "power_spectra",
136+
help="Directory to save the output plots.",
137+
)
138+
139+
parser.add_argument(
140+
"--run-id",
141+
type=str,
142+
help="Run ID to construct configuration if --config is not provided.",
143+
)
144+
145+
parser.add_argument(
146+
"--variables",
147+
type=str,
148+
nargs="+",
149+
help="List of variables to plot (e.g., 'u', 't2m'). If None, uses all",
150+
choices=["q", "t", "u", "v", "z", "t2m", "msl", "u10", "v10", "d2m", "skt", "sp"],
151+
default=["z", "u10", "v10"],
152+
)
153+
154+
parser.add_argument(
155+
"--regions",
156+
type=str,
157+
nargs="+",
158+
help="List of regions to plot (e.g., 'ShortGlobe', 'N-Mid-Lats'). If None, uses all",
159+
choices=["FullGlobe", "ShortGlobe", "N-Mid-Lats", "S-Mid-Lats", "Tropics"],
160+
default=["ShortGlobe"],
161+
)
162+
163+
parser.add_argument(
164+
"--pressure-levels",
165+
type=int,
166+
nargs="+",
167+
help="List of pressure levels to plot (e.g., 250, 500). \
168+
If not provided uses all",
169+
default=[100, 850],
170+
)
171+
172+
parser.add_argument(
173+
"--forecast-steps",
174+
type=int,
175+
nargs="+",
176+
help="List of forecast steps to plot (e.g., 6, 12). \
177+
If not provided averages over all forecast steps",
178+
default=None,
179+
)
180+
181+
parser.add_argument(
182+
"--prefix",
183+
type=str,
184+
default="",
185+
help="Prefix for output files (default: empty).",
186+
)
187+
188+
parser.add_argument(
189+
"--export-dir",
190+
type=str,
191+
help="Directory where exported NetCDF files were saved.",
192+
default=None,
193+
)
194+
195+
args, unknown_args = parser.parse_known_args(args)
196+
if unknown_args:
197+
_logger.warning(f"Unknown arguments: {unknown_args}")
198+
return args
199+
200+
201+
def construct_config_from_run_id(run_id: str, args: argparse.Namespace) -> DictConfig:
202+
"""
203+
Construct configuration from run ID and command line arguments.
204+
Parameters
205+
----------
206+
run_id : Run ID to construct configuration for.
207+
args : Command line arguments.
208+
Returns
209+
-------
210+
DictConfig: Constructed configuration.
211+
"""
212+
run_id_config = {
213+
"variables": args.variables,
214+
"regions": args.regions,
215+
"pressure_levels": args.pressure_levels,
216+
"forecast_steps": args.forecast_steps,
217+
"prefix": args.prefix,
218+
"output_dir": Path(args.output_dir),
219+
"comparisons": {
220+
"target": {"netcdf_paths": [f"{args.export_dir}/targ*.nc"]},
221+
run_id: {"netcdf_paths": [f"{args.export_dir}/pred*.nc"]},
222+
},
223+
}
224+
run_id_config = DictConfig(run_id_config)
225+
return run_id_config
226+
227+
228+
def psd_from_args(args: list) -> None:
229+
# Get run_id zarr data as lists of xarray DataArrays
230+
"""
231+
Export data from Zarr store to NetCDF files based on command line arguments.
232+
Parameters
233+
----------
234+
args : List of command line arguments.
235+
"""
236+
init_loggers()
237+
238+
args = parse_args(sys.argv[1:])
239+
240+
# Load configuration
241+
if args.config:
242+
config_file = Path(args.config)
243+
config = OmegaConf.load(config_file)
244+
# check config loaded correctly
245+
assert isinstance(config, DictConfig), "Config file not loaded correctly"
246+
# use PosixPath for output_dir
247+
config.output_dir = Path(config.output_dir)
248+
249+
# Use run id to construct config if not provided
250+
elif args.run_id:
251+
if args.export_dir is None:
252+
# TODO: automatically run export into results directory and use that path here
253+
raise ValueError("When using --run-id, --export-dir must also be provided.")
254+
config = construct_config_from_run_id(args.run_id, args)
255+
256+
else:
257+
raise ValueError("Either --config or --run-id must be provided.")
258+
259+
_logger.info(f"starting power spectral density plotting with config: {config}")
260+
261+
psd_from_config(config)
262+
263+
264+
def psd() -> None:
265+
"""
266+
Main function to plot power spectral densities.
267+
"""
268+
# By default, arguments from the command line are read.
269+
psd_from_args(sys.argv[1:])
270+
271+
272+
if __name__ == "__main__":
273+
psd()

0 commit comments

Comments
 (0)