Skip to content

Power spectra plots - #170

Open
frazane wants to merge 3 commits into
mainfrom
feat/power-spectra
Open

frazane wants to merge 3 commits into
mainfrom
feat/power-spectra

Conversation

@frazane

@frazane frazane commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Adds an optional power-spectra diagnostic to the experiment pipeline, so we can compare the effective resolution of model outputs against the truth and spot over-smoothing or spurious small-scale noise that point metrics like RMSE do not reveal.

The diagnostic computes 2D variance spectra (DCT by default, FFT optional) per participant and lead time, averages over initialisation times, and overlays each model against the truth with grid-resolution, effective-resolution and Nyquist reference lines.

What changed

  • New src/spectra package: numpy-only spectral core (DCT/FFT with variance-conserving normalisation and radial averaging), ICON native-to-regular regridding with runtime grid detection, plus field extraction and IO helpers.
  • Snakemake wiring (rules/spectra.smk): per-participant compute, aggregate over inits, overlay plot, gated behind an experiment target.
  • Config: new experiment.spectra section (enable flag, method, lead times, variables, optional init-hour subset) with validation and regenerated JSON schema.
  • Unit tests for the spectral core, regridding and IO.

Notes

  • Disabled by default; opt in with experiment.spectra.enabled: true.
  • ICON native grids are regridded to a regular grid before the transform; the eckit grid file is fetched automatically.

Example output, T_2M at +24h:

T_2M power spectrum at +24h

@frazane frazane changed the title add power-spectra QC diagnostic Power spectra plots Jun 5, 2026
@frazane
frazane marked this pull request as draft June 5, 2026 15:57
frazane added 2 commits June 16, 2026 16:38
resolve conflicts in config, snakefile and schema (additive: scoremaps +
spectra side by side). drop the eckit_grids input from the spectra rules,
mirroring 3e7660b which deleted data_download_eckit_geo_grids.
@frazane
frazane marked this pull request as ready for review September 11, 2026 12:55

@jonasbhend jonasbhend left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frazane Thanks for pushing this. I have a few comments in particular around organization and generalization that should be easy to address. Good to go after that.

Comment thread src/evalml/config.py
Comment on lines +398 to +400
variables: List[str] = Field(
default=["T_2M", "WIND_KE", "TOT_PREC"],
description="Spectra variables. Supported: T_2M, WIND_KE (from U/V_10M), TOT_PREC.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now uses explicit aggregations, so this should probably be either TOT_PREC1 and/or TOT_PREC6

Comment thread src/evalml/config.py
raise ValueError(
"`variables` must list at least one variable when spectra is configured."
)
allowed = {"T_2M", "WIND_KE", "TOT_PREC"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above for TOT_PREC explicit aggregations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, spectra for all the parameters that can be accessed with load_forecast/truth_data should be able to be computed, or does the computation require variable-specific configuration?

Comment thread src/spectra/compute.py
def compute_source_spectra(ds: xr.Dataset, variables, lead_times, method, label):
"""Compute spectra for one source (already-loaded native dataset).

Returns an x.Dataset with dims (variable, leadtime, wavenumber) and a shared

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Returns an x.Dataset with dims (variable, leadtime, wavenumber) and a shared
Returns an xr.Dataset with dims (variable, leadtime, wavenumber) and a shared

Comment thread src/spectra/compute.py
Comment on lines +64 to +76
def aggregate_spectra(spectra_files) -> xr.Dataset:
"""Average power over init times (nanmean). All inputs share one grid."""
datasets = [xr.open_dataset(f) for f in spectra_files]
try:
stacked = xr.concat(datasets, dim="init")
agg = stacked.mean(dim="init", skipna=True)
agg.attrs = datasets[0].attrs
agg["wavelength"] = datasets[0]["wavelength"].copy()
agg.load()
finally:
for ds in datasets:
ds.close()
return agg

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In verification_aggregation.py we verify stratified by season (if configured). Could this be leveraged here, i.e. would it be easy to harmonize how we aggregate by initialization results to average results across the different sets of scores and metrics?

Comment thread src/spectra/compute.py
Comment on lines +79 to +81
def plot_experiment_spectra(
truth_file, participant_files, out_dir, variables, lead_times
):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I agree on the way this module is organized. To me plotting definitely doesn't belong in compute, but would be better put in a separate sub-module. But as evalml is quite chaotic in this regard, I would be happy to defer this to a more general clean-up during the planned refactor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seeing that plot_power_spectra is defined in core, could this also go there?

Comment thread src/spectra/core.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to move all plotting to a separate sub-module for clarity.

Comment thread src/spectra/io.py
Comment on lines +11 to +15
VARIABLE_COMPONENTS: dict[str, tuple[list[str], float]] = {
"T_2M": (["T_2M"], 1.0),
"WIND_KE": (["U_10M", "V_10M"], 0.5),
"TOT_PREC": (["TOT_PREC"], 1.0),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be generalized to all available parameters (if computed directly) and only WIND_KE needs special treatment.

Comment thread src/spectra/regrid.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the regridding will be useful for other metrics, would it make sense to move into a separate module?

Comment on lines +80 to +103
def test_aggregate_spectra_nanmean_ignores_missing(tmp_path):
wl = np.array([100.0, 50.0])

def mk(power, init):
ds = xr.Dataset(
{"power": (("variable", "leadtime", "wavenumber"), np.array([[power]]))},
coords={
"variable": ["T_2M"],
"leadtime": [6],
"wavenumber": np.arange(2),
"wavelength": ("wavenumber", wl),
},
attrs={"dx_km": 1.1, "npoints": 10, "label": "m"},
)
p = tmp_path / f"s_{init}.nc"
ds.to_netcdf(p)
return p

a = mk([np.nan, 2.0], 0)
b = mk([4.0, 4.0], 1)
agg = compute.aggregate_spectra([a, b])
np.testing.assert_allclose(
agg["power"].sel(variable="T_2M", leadtime=6).values, [4.0, 3.0]
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how could missing values, that we would want to exclude from aggregation, occur? Aren't all missing values real missing values that should result in a missing value in the aggregation? I am worried that we may get aggregated results based on very few non-missing samples without noticing.

Comment on lines +32 to +40
def spectra_participants():
"""participant key -> aggregated spectra path (runs + baselines)."""
out = {}
for base in BASELINE_CONFIGS:
out[base] = OUT_ROOT / f"data/baselines/{base}/spectra_aggregated.nc"
for run_id, cfg in RUN_CONFIGS.items():
if cfg.get("_is_candidate", False):
out[run_id] = OUT_ROOT / f"data/runs/{run_id}/spectra_aggregated.nc"
return out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are spectra participants different from experiment participants? Or should we consolidate and use only one?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants