From d2a75ef5862dbc67a26f98f64a7d478d663286ae Mon Sep 17 00:00:00 2001 From: Louis-Frey Date: Tue, 21 Apr 2026 15:32:35 +0200 Subject: [PATCH 01/31] Add inference + experiment configs for new ICON-REA-L interpolator Co-authored-by: Francesco Zanetta <62377868+frazane@users.noreply.github.com> Co-authored-by: Michele Cattaneo <44707621+MicheleCattaneo@users.noreply.github.com> Co-authored-by: Hugues de Laroussilhe --- ...ich1-oper-try-interpolator_for-Santis.yaml | 72 ++++++ ...rs-ich1-oper-try-interpolator_initial.yaml | 68 +++++ ...-oper-try-interpolator_minimal_Claude.yaml | 68 +++++ debugging_summary_interpolator_2.txt | 195 ++++++++++++++ ...gm-interpolator-global_trimedge_multi.yaml | 82 ++++++ .../sgm-interpolator-ich1-oper-patch.yaml | 241 ++++++++++++++++++ 6 files changed, 726 insertions(+) create mode 100644 config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml create mode 100644 config/forecasters-ich1-oper-try-interpolator_initial.yaml create mode 100644 config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml create mode 100644 debugging_summary_interpolator_2.txt create mode 100644 resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml create mode 100644 resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml diff --git a/config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml b/config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml new file mode 100644 index 00000000..729bc100 --- /dev/null +++ b/config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml @@ -0,0 +1,72 @@ +# yaml-language-server: $schema=../workflow/tools/config.schema.json +description: | + Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) + driven by ICON-CH1 stage_E forecaster, using anemoi-inference patch from issue #482. + VERSION FOR SANTIS -- includes fixes for issues 2 (anemoi-datasets) and 3 (runner rename). + Does NOT include Balfrin-specific path remapping. + NOTE: requires sgm-interpolator-global_trimedge_multi.yaml inference config (runner: time_multi_interpolator). + NOTE: may also need dataset date-range fix (issue 6) if training datasets end at 2024-12-31 on Santis. + +dates: + start: 2025-03-01T00:00 + end: 2025-03-02T00:00 + frequency: 24h + + +runs: + + - interpolator: + inference_resources: + slurm_partition: normal-shared + checkpoint: https://mlflow.ecmwf.int/#/experiments/456/runs/f9279244ed6f4c458597bdcf335ab36f + label: interpolator_ICON-REA-L + steps: 0/120/1 + config: resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml + forecaster: + checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 + config: resources/inference/configs/sgm-multidataset-forecaster-global-ich1-oper.yaml + steps: 0/120/6 + extra_requirements: + - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + extra_requirements: + - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + - anemoi-datasets==0.5.35 + + +baselines: + - baseline: + baseline_id: ICON-CH2-EPS + label: ICON-CH2-ctrl + root: /scratch/mch/cmerker/ICON-CH2-EPS + steps: 0/120/6 + +truth: + label: KENDA-CH1 + root: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-v1.0.zarr + +stratification: + regions: + - jura + - mittelland + - voralpen + - alpennordhang + - innerealpentaeler + - alpensuedseite + root: /scratch/mch/bhendj/regions/Prognoseregionen_LV95_20220517 + +locations: + output_root: output/ + +profile: + executor: slurm + global_resources: + gpus: 16 + default_resources: + slurm_partition: "postproc" + cpus_per_task: 1 + mem_mb_per_cpu: 1800 + runtime: "1h" + gpus: 0 + jobs: 50 + batch_rules: + plot_forecast_frame: 32 diff --git a/config/forecasters-ich1-oper-try-interpolator_initial.yaml b/config/forecasters-ich1-oper-try-interpolator_initial.yaml new file mode 100644 index 00000000..7f11199f --- /dev/null +++ b/config/forecasters-ich1-oper-try-interpolator_initial.yaml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=../workflow/tools/config.schema.json +description: | + Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) + driven by ICON-CH1 stage_E forecaster, using anemoi-inference patch from issue #482. + INITIAL VERSION -- before any debugging fixes. + +dates: + start: 2025-03-01T00:00 + end: 2025-03-02T00:00 + frequency: 24h + + +runs: + + - interpolator: + inference_resources: + slurm_partition: normal-shared + checkpoint: https://mlflow.ecmwf.int/#/experiments/456/runs/f9279244ed6f4c458597bdcf335ab36f + label: interpolator_ICON-REA-L + steps: 0/120/1 + config: resources/inference/configs/sgm-interpolator-global_trimedge.yaml + forecaster: + checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 + config: resources/inference/configs/sgm-multidataset-forecaster-global-ich1-oper.yaml + steps: 0/120/6 + extra_requirements: + - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + extra_requirements: + - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + + +baselines: + - baseline: + baseline_id: ICON-CH2-EPS + label: ICON-CH2-ctrl + root: /scratch/mch/cmerker/ICON-CH2-EPS + steps: 0/120/6 + +truth: + label: KENDA-CH1 + root: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-v1.0.zarr + +stratification: + regions: + - jura + - mittelland + - voralpen + - alpennordhang + - innerealpentaeler + - alpensuedseite + root: /scratch/mch/bhendj/regions/Prognoseregionen_LV95_20220517 + +locations: + output_root: output/ + +profile: + executor: slurm + global_resources: + gpus: 16 + default_resources: + slurm_partition: "postproc" + cpus_per_task: 1 + mem_mb_per_cpu: 1800 + runtime: "1h" + gpus: 0 + jobs: 50 + batch_rules: + plot_forecast_frame: 32 diff --git a/config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml b/config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml new file mode 100644 index 00000000..74b98dac --- /dev/null +++ b/config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=../workflow/tools/config.schema.json +description: | + Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) + driven by ICON-CH1 stage_E forecaster, using anemoi-inference patch from issue #482. + +dates: + start: 2025-03-01T00:00 + end: 2025-03-02T00:00 + frequency: 24h + + +runs: + + - interpolator: + inference_resources: + slurm_partition: normal-shared + checkpoint: /scratch/mch/miccatta/ICON_interpolator_checkpoints/checkpoint_stage-C-interpolator-n320-6hto1h-reduced-variables/f9279244ed6f4c458597bdcf335ab36f/inference-anemoi-by_epoch-epoch_000-step_001000.ckpt + label: interpolator_ICON-REA-L + steps: 0/120/1 + config: resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml + forecaster: + checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 + config: resources/inference/configs/sgm-multidataset-forecaster-global-ich1-oper.yaml + steps: 0/120/6 + extra_requirements: + - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + extra_requirements: + - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + - anemoi-datasets==0.5.35 + + +baselines: + - baseline: + baseline_id: ICON-CH2-EPS + label: ICON-CH2-ctrl + root: /scratch/mch/cmerker/ICON-CH2-EPS + steps: 0/120/6 + +truth: + label: KENDA-CH1 + root: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-v1.0.zarr + +stratification: + regions: + - jura + - mittelland + - voralpen + - alpennordhang + - innerealpentaeler + - alpensuedseite + root: /scratch/mch/bhendj/regions/Prognoseregionen_LV95_20220517 + +locations: + output_root: output/ + +profile: + executor: slurm + global_resources: + gpus: 16 + default_resources: + slurm_partition: "postproc" + cpus_per_task: 1 + mem_mb_per_cpu: 1800 + runtime: "1h" + gpus: 0 + jobs: 50 + batch_rules: + plot_forecast_frame: 32 diff --git a/debugging_summary_interpolator_2.txt b/debugging_summary_interpolator_2.txt new file mode 100644 index 00000000..f2bee469 --- /dev/null +++ b/debugging_summary_interpolator_2.txt @@ -0,0 +1,195 @@ +Debugging summary: running the new ICON-REA-L interpolator in evalML on Balfrin +================================================================================ + +Interpolator checkpoint: https://mlflow.ecmwf.int/#/experiments/456/runs/f9279244ed6f4c458597bdcf335ab36f + Local path: /scratch/mch/miccatta/ICON_interpolator_checkpoints/checkpoint_stage-C-interpolator-n320-6hto1h-reduced-variables/f9279244ed6f4c458597bdcf335ab36f/inference-anemoi-by_epoch-epoch_000-step_001000.ckpt +Forecaster checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 +anemoi-inference patch: https://github.com/ecmwf/anemoi-inference/issues/482 (commit e369b1a) + +The interpolator checkpoint was trained on Santis. Running it on Balfrin required +fixing 10 issues. Issues 1, 5, 6, 7 are Balfrin-specific (cross-site portability). +Issues 2, 3, 4, 8, 9, 10 affect both Santis and Balfrin. + +Status as of 2026-04-16 17:30: inference runs to completion (all 20 interpolation +windows, GRIB output written). The remaining failure is in the evalML verification +step (verif_metrics), not in inference itself. See issue 10. + + +Issue 1: Checkpoint not accessible via MLflow [Balfrin-only] +------------------------------------------------------------ +The MLflow script resolved the checkpoint URL to a path on miccatta's home dir: + /users/miccatta/symlinks-scratch/anemoi-outputs/checkpoint_stage-C-interpolator-n320-6hto1h-reduced-variables/... +This path does not exist on Balfrin (likely a broken symlink or permissions issue). + +Fix: Use the local checkpoint path directly in the evalML config: + /scratch/mch/miccatta/ICON_interpolator_checkpoints/... + + +Issue 2: Bad git hash for anemoi-datasets in checkpoint metadata [Both] +----------------------------------------------------------------------- +The auto-extracted requirements from the checkpoint contained a bogus git commit +hash for anemoi-datasets (d2e9f8c7...) that doesn't exist in the public repo. + +Root cause: https://github.com/ecmwf/anemoi-utils/issues/284 +When .venv is inside a git repo, gather_provenance_info() incorrectly records the +parent repo's SHA for almost all packages. Fix PR: https://github.com/ecmwf/anemoi-utils/pull/285 + +Fix: Add `anemoi-datasets==0.5.35` to the interpolator's extra_requirements in +the evalML config, which overrides the broken git hash with the correct PyPI release. + + +Issue 3: Runner renamed in anemoi-inference [Both] +-------------------------------------------------- +The anemoi-inference version from the #482 patch (commit e369b1a) does not have a +`time-interpolator` runner. It was renamed to `time-multi-interpolator`. + +Available runners: default, external-graph, time-multi-interpolator, parallel, +plugin, simple, testing, no-model. + +Fix: Created a new inference config (sgm-interpolator-global_trimedge_multi.yaml) +based on sgm-interpolator-global_trimedge.yaml with: + runner: time_multi_interpolator + + +Issue 4: patch_metadata path convention in inference config [Both] +------------------------------------------------------------------ +The evalML workflow copies files from resources/inference/metadata/ into the run's +resources/ directory. So the inference config must reference patch files as: + patch_metadata: resources/sgm-interpolator-ich1-oper-patch.yaml +NOT: + patch_metadata: resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml + +Fix: Corrected the path in the inference config. + + +Issue 5: Training dataset paths from Santis not available on Balfrin [Balfrin-only] +----------------------------------------------------------------------------------- +The checkpoint metadata embeds the training dataset paths from Santis: + /capstor/store/mch/msopr/ml/datasets/mch-realch1-fdb-1km-2005-2025-1h-pl13-ifsnames-1h-precip-v1.0.zarr + /capstor/store/mch/msopr/ml/datasets/aifs-ea-an-oper-0001-mars-n320-1979-2024-1h-v2-with-era51.zarr + +/capstor/store/mch is not mounted on Balfrin. The same datasets exist under /store_new/mch/... + +Fix: Created a patch_metadata file (resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml) +that remaps the dataset paths -- following the same pattern as the forecaster's +sgm-multidataset-ich1-oper-patch.yaml. + + +Issue 6: Training datasets don't cover experiment dates (2025) [Balfrin-only] +----------------------------------------------------------------------------- +The original training datasets (mch-realch1-fdb-..., aifs-ea-...) only have data +through 2024-12-31, but the experiment starts 2025-03-01. The constant forcings +loader requires the forecast date to exist in the dataset, even for time-invariant +fields (z, lsm). + +Fix: In the patch_metadata, swapped to datasets that cover 2025: + mch-ich1-1km-2024-2025-1h-pl13-ifsnames-v1.0.zarr (LAM, 1h) + aifs-od-an-oper-0001-mars-n320-2016-2025-6h-v1-combined-land.zarr (global, 6h) + +Note: On Santis this may also be an issue if the training datasets end at 2024-12-31. + + +Issue 7: Frequency mismatch between LAM (1h) and global (6h) datasets [Balfrin-only] +------------------------------------------------------------------------------------- +The replacement global dataset (aifs-od-...) is 6-hourly, but the checkpoint +metadata specifies frequency=1h. The dataset open call fails with: + "Requested frequency 1h is not a multiple of the dataset frequency 6:00:00" + +This only affects constant forcings (z, lsm) -- the main input comes from GRIB files. + +Fix: Override frequency to 6h in the patch_metadata at the correct nesting level: + config.dataloader.test.datasets.data.dataset_config.frequency: 6h + +Important: the frequency field must be inside dataset_config (alongside dataset), +NOT alongside start/end (which are at the dataloader level). The deep merge +applies at the dataset_config level. + + +Issue 8: No source0/trimedge_mask in checkpoint [Both] +------------------------------------------------------ +The inference config (copied from the CO2 interpolator) referenced a +source0/trimedge_mask supporting array for pre/post-processing of boundary points. +This checkpoint was NOT trained with edge trimming -- the array doesn't exist. + +Available supporting arrays: latitudes, longitudes, lam_0/cutout_mask, +global/cutout_mask, source0/latitudes, source0/longitudes, source1/latitudes, +source1/longitudes. + +Fix: Removed both the extract_mask pre-processor and assign_mask post-processor +that referenced source0/trimedge_mask from the inference config. + + +Issue 9: GRIB output template mismatch [Both] +---------------------------------------------- +Two sub-issues: + +a) The LAM output used COSMO templates (templates_index_cosmo.yaml) which match on + grid=0.02 or grid=0.01. This checkpoint outputs on an ICON-CH1 grid. + Fix: Switched to templates_index_icon.yaml. + +b) Even with ICON templates, the template lookup failed because the output variables + use IFS naming (10u, 2t, msl...) but the ICON templates match on ICON param names + (U_10M, T_2M, PMSL...). The forecaster works because its patch_metadata includes + a variables_metadata section that maps IFS names to ICON GRIB params. + Fix: Added the full variables_metadata section (copied from the forecaster's + sgm-multidataset-ich1-oper-patch.yaml) to the interpolator's patch_metadata. + + +Issue 10: Assertion error in verbose output printing [Both] +----------------------------------------------------------- +With verbosity=1, the _print_output_tensor method asserts that the first tensor +dimension is 1 or multi_step_input (2), but the multi-output interpolator produces +6 time steps. This is a bug in anemoi-inference (the verbose printing code doesn't +account for multi-output interpolators). + +Fix: Set verbosity: 0 in the inference config to skip the debug tensor printing. + +Note: This should be reported as a bug to anemoi-inference. + + +CURRENT STATUS: Verification step failure (not an inference issue) +------------------------------------------------------------------ +The interpolator inference now runs to completion (all 20 windows, 120h lead time, +hourly output). GRIB files are written for both LAM and IFS grids. + +The remaining failure is in evalML's verif_metrics step: + AttributeError: 'Dataset' object has no attribute 'ref_time' +The verification reader can't parse the output GRIB files -- likely missing +reference time metadata in the GRIB encoding. This needs investigation in the +evalML verification code or the GRIB encoding configuration. + + +Files created/modified +---------------------- +New files: + resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml + - Inference config for the new interpolator + - runner: time_multi_interpolator + - No trimedge pre/post-processors + - ICON templates instead of COSMO + - verbosity: 0 + + resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml + - Checkpoint metadata patch for Balfrin + - Remaps dataset paths from /capstor/store/mch to /store_new/mch + - Uses 2025-covering datasets for constant forcings + - Overrides frequency to 6h (for the 6h global dataset) + - Full variables_metadata for IFS-to-ICON param name mapping + + config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml + - evalML experiment config for Balfrin + - Local checkpoint path + - anemoi-datasets==0.5.35 override + + config/forecasters-ich1-oper-try-interpolator_initial.yaml + - The initial config before any fixes (for reference) + + config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml + - Config for running on Santis (no Balfrin-specific path remapping) + - Includes fixes for issues 2, 3 (universal issues) + + debugging_summary_interpolator.txt + - First version of this summary (covers issues 1-6) + + debugging_summary_interpolator_2.txt + - This file (covers all 10 issues + current status) diff --git a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml new file mode 100644 index 00000000..0bbb5fbc --- /dev/null +++ b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml @@ -0,0 +1,82 @@ +runner: time_multi_interpolator + +input: + cutout: + - lam_0: + grib: + path: forecaster/20* + namer: &namer + rules: + - - shortName: T + - t_{level} + - - shortName: U + - u_{level} + - - shortName: V + - v_{level} + - - shortName: W + - w_{level} + - - shortName: QV + - q_{level} + - - shortName: FI + - z_{level} + - - shortName: PMSL + - msl + - - shortName: FIS + - z + - - shortName: PS + - sp + - - shortName: T_2M + - 2t + - - shortName: TD_2M + - 2d + - - shortName: T_G + - skt + - - shortName: U_10M + - 10u + - - shortName: V_10M + - 10v + - - shortName: FR_LAND + - lsm + - - shortName: TOT_PREC + - tp + - global: + grib: + path: forecaster/ifs* + namer: *namer + +constant_forcings: + test: + use_original_paths: true + +patch_metadata: resources/sgm-interpolator-ich1-oper-patch.yaml + +output: + tee: + - grib: + path: grib/{dateTime}_{step:03}.grib + encoding: + typeOfGeneratingProcess: 2 + templates: + samples: resources/templates_index_icon.yaml + post_processors: + - extract_mask: # removes global points + mask: "lam_0/cutout_mask" + as_slice: true + + - grib: + path: grib/ifs-{dateTime}_{step:03}.grib + encoding: + typeOfGeneratingProcess: 2 + templates: + samples: resources/templates_index_ifs.yaml + post_processors: + - extract_mask: # removes lam points + mask: "lam_0/cutout_mask" + as_slice: true + inverse: true + - assign_mask: # fill local/global overlapping points with nan + mask: "global/cutout_mask" + +verbosity: 0 +allow_nans: true +output_frequency: "1h" diff --git a/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml b/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml new file mode 100644 index 00000000..b1dde369 --- /dev/null +++ b/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml @@ -0,0 +1,241 @@ +config: + dataloader: + test: + datasets: + data: + dataset_config: + dataset: + cutout: + - dataset: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-ifsnames-v1.0.zarr + - dataset: /store_new/mch/msopr/ml/datasets/aifs-od-an-oper-0001-mars-n320-2016-2025-6h-v1-combined-land.zarr + frequency: 6h + start: null + end: null + +dataset: + data: + constant_fields: [z, lsm] + variables_metadata: + 10u: + mars: + date: 20050101 + levtype: sfc + param: U_10M + step: 12 + time: 0 + 10v: + mars: + date: 20050101 + levtype: sfc + param: V_10M + step: 12 + time: 0 + 2d: + mars: + date: 20050101 + levtype: sfc + param: TD_2M + step: 12 + time: 0 + 2t: + mars: + date: 20050101 + levtype: sfc + param: T_2M + step: 12 + time: 0 + cos_julian_day: + computed_forcing: true + constant_in_time: false + cos_latitude: + computed_forcing: true + constant_in_time: true + cos_local_time: + computed_forcing: true + constant_in_time: false + cos_longitude: + computed_forcing: true + constant_in_time: true + insolation: + computed_forcing: true + constant_in_time: false + lsm: + constant_in_time: true + mars: + date: 20050101 + levtype: sfc + param: FR_LAND + step: 0 + time: 12 + msl: + mars: + date: 20050101 + levtype: sfc + param: PMSL + step: 12 + time: 0 + q_100: + mars: {date: 20050101, levelist: 100, levtype: pl, param: QV, step: 12, time: 0} + q_1000: + mars: {date: 20050101, levelist: 1000, levtype: pl, param: QV, step: 12, time: 0} + q_150: + mars: {date: 20050101, levelist: 150, levtype: pl, param: QV, step: 12, time: 0} + q_200: + mars: {date: 20050101, levelist: 200, levtype: pl, param: QV, step: 12, time: 0} + q_250: + mars: {date: 20050101, levelist: 250, levtype: pl, param: QV, step: 12, time: 0} + q_300: + mars: {date: 20050101, levelist: 300, levtype: pl, param: QV, step: 12, time: 0} + q_400: + mars: {date: 20050101, levelist: 400, levtype: pl, param: QV, step: 12, time: 0} + q_50: + mars: {date: 20050101, levelist: 50, levtype: pl, param: QV, step: 12, time: 0} + q_500: + mars: {date: 20050101, levelist: 500, levtype: pl, param: QV, step: 12, time: 0} + q_700: + mars: {date: 20050101, levelist: 700, levtype: pl, param: QV, step: 12, time: 0} + q_850: + mars: {date: 20050101, levelist: 850, levtype: pl, param: QV, step: 12, time: 0} + q_925: + mars: {date: 20050101, levelist: 925, levtype: pl, param: QV, step: 12, time: 0} + sin_julian_day: + computed_forcing: true + constant_in_time: false + sin_latitude: + computed_forcing: true + constant_in_time: true + sin_local_time: + computed_forcing: true + constant_in_time: false + sin_longitude: + computed_forcing: true + constant_in_time: true + sp: + mars: + date: 20050101 + levtype: sfc + param: PS + step: 12 + time: 0 + skt: + mars: + date: 20050101 + levtype: sfc + param: T_G + step: 12 + time: 0 + t_100: + mars: {date: 20050101, levelist: 100, levtype: pl, param: T, step: 12, time: 0} + t_1000: + mars: {date: 20050101, levelist: 1000, levtype: pl, param: T, step: 12, time: 0} + t_150: + mars: {date: 20050101, levelist: 150, levtype: pl, param: T, step: 12, time: 0} + t_200: + mars: {date: 20050101, levelist: 200, levtype: pl, param: T, step: 12, time: 0} + t_250: + mars: {date: 20050101, levelist: 250, levtype: pl, param: T, step: 12, time: 0} + t_300: + mars: {date: 20050101, levelist: 300, levtype: pl, param: T, step: 12, time: 0} + t_400: + mars: {date: 20050101, levelist: 400, levtype: pl, param: T, step: 12, time: 0} + t_50: + mars: {date: 20050101, levelist: 50, levtype: pl, param: T, step: 12, time: 0} + t_500: + mars: {date: 20050101, levelist: 500, levtype: pl, param: T, step: 12, time: 0} + t_700: + mars: {date: 20050101, levelist: 700, levtype: pl, param: T, step: 12, time: 0} + t_850: + mars: {date: 20050101, levelist: 850, levtype: pl, param: T, step: 12, time: 0} + t_925: + mars: {date: 20050101, levelist: 925, levtype: pl, param: T, step: 12, time: 0} + tp: + mars: + date: 20050101 + levtype: sfc + param: TOT_PREC + step: 12 + time: 0 + period: + - 1h + process: accumulation + u_100: + mars: {date: 20050101, levelist: 100, levtype: pl, param: U, step: 12, time: 0} + u_1000: + mars: {date: 20050101, levelist: 1000, levtype: pl, param: U, step: 12, time: 0} + u_150: + mars: {date: 20050101, levelist: 150, levtype: pl, param: U, step: 12, time: 0} + u_200: + mars: {date: 20050101, levelist: 200, levtype: pl, param: U, step: 12, time: 0} + u_250: + mars: {date: 20050101, levelist: 250, levtype: pl, param: U, step: 12, time: 0} + u_300: + mars: {date: 20050101, levelist: 300, levtype: pl, param: U, step: 12, time: 0} + u_400: + mars: {date: 20050101, levelist: 400, levtype: pl, param: U, step: 12, time: 0} + u_50: + mars: {date: 20050101, levelist: 50, levtype: pl, param: U, step: 12, time: 0} + u_500: + mars: {date: 20050101, levelist: 500, levtype: pl, param: U, step: 12, time: 0} + u_700: + mars: {date: 20050101, levelist: 700, levtype: pl, param: U, step: 12, time: 0} + u_850: + mars: {date: 20050101, levelist: 850, levtype: pl, param: U, step: 12, time: 0} + u_925: + mars: {date: 20050101, levelist: 925, levtype: pl, param: U, step: 12, time: 0} + v_100: + mars: {date: 20050101, levelist: 100, levtype: pl, param: V, step: 12, time: 0} + v_1000: + mars: {date: 20050101, levelist: 1000, levtype: pl, param: V, step: 12, time: 0} + v_150: + mars: {date: 20050101, levelist: 150, levtype: pl, param: V, step: 12, time: 0} + v_200: + mars: {date: 20050101, levelist: 200, levtype: pl, param: V, step: 12, time: 0} + v_250: + mars: {date: 20050101, levelist: 250, levtype: pl, param: V, step: 12, time: 0} + v_300: + mars: {date: 20050101, levelist: 300, levtype: pl, param: V, step: 12, time: 0} + v_400: + mars: {date: 20050101, levelist: 400, levtype: pl, param: V, step: 12, time: 0} + v_50: + mars: {date: 20050101, levelist: 50, levtype: pl, param: V, step: 12, time: 0} + v_500: + mars: {date: 20050101, levelist: 500, levtype: pl, param: V, step: 12, time: 0} + v_700: + mars: {date: 20050101, levelist: 700, levtype: pl, param: V, step: 12, time: 0} + v_850: + mars: {date: 20050101, levelist: 850, levtype: pl, param: V, step: 12, time: 0} + v_925: + mars: {date: 20050101, levelist: 925, levtype: pl, param: V, step: 12, time: 0} + z: + constant_in_time: true + mars: + date: 20050101 + levelist: null + levtype: sfc + param: FIS + step: 0 + time: 12 + z_100: + mars: {date: 20050101, levelist: 100, levtype: pl, param: FI, step: 12, time: 0} + z_1000: + mars: {date: 20050101, levelist: 1000, levtype: pl, param: FI, step: 12, time: 0} + z_150: + mars: {date: 20050101, levelist: 150, levtype: pl, param: FI, step: 12, time: 0} + z_200: + mars: {date: 20050101, levelist: 200, levtype: pl, param: FI, step: 12, time: 0} + z_250: + mars: {date: 20050101, levelist: 250, levtype: pl, param: FI, step: 12, time: 0} + z_300: + mars: {date: 20050101, levelist: 300, levtype: pl, param: FI, step: 12, time: 0} + z_400: + mars: {date: 20050101, levelist: 400, levtype: pl, param: FI, step: 12, time: 0} + z_50: + mars: {date: 20050101, levelist: 50, levtype: pl, param: FI, step: 12, time: 0} + z_500: + mars: {date: 20050101, levelist: 500, levtype: pl, param: FI, step: 12, time: 0} + z_700: + mars: {date: 20050101, levelist: 700, levtype: pl, param: FI, step: 12, time: 0} + z_850: + mars: {date: 20050101, levelist: 850, levtype: pl, param: FI, step: 12, time: 0} + z_925: + mars: {date: 20050101, levelist: 925, levtype: pl, param: FI, step: 12, time: 0} From f60a9ab71c107baf013cb52c789e3a5d51e545c6 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Wed, 22 Apr 2026 08:29:42 +0200 Subject: [PATCH 02/31] Renamed interpolator config and switch to hourly baseline --- ...interpolator_minimal_Claude.yaml => interpolators-ich1.yaml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename config/{forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml => interpolators-ich1.yaml} (98%) diff --git a/config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml b/config/interpolators-ich1.yaml similarity index 98% rename from config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml rename to config/interpolators-ich1.yaml index 74b98dac..ba6544d6 100644 --- a/config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml +++ b/config/interpolators-ich1.yaml @@ -34,7 +34,7 @@ baselines: baseline_id: ICON-CH2-EPS label: ICON-CH2-ctrl root: /scratch/mch/cmerker/ICON-CH2-EPS - steps: 0/120/6 + steps: 0/120/1 truth: label: KENDA-CH1 From 5f86d81877ea7abd43e3ade2c1eb9918b1c2b6ae Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Wed, 22 Apr 2026 08:32:03 +0200 Subject: [PATCH 03/31] Remove intermediate configs --- ...ich1-oper-try-interpolator_for-Santis.yaml | 72 ------------------- ...rs-ich1-oper-try-interpolator_initial.yaml | 68 ------------------ 2 files changed, 140 deletions(-) delete mode 100644 config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml delete mode 100644 config/forecasters-ich1-oper-try-interpolator_initial.yaml diff --git a/config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml b/config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml deleted file mode 100644 index 729bc100..00000000 --- a/config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# yaml-language-server: $schema=../workflow/tools/config.schema.json -description: | - Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) - driven by ICON-CH1 stage_E forecaster, using anemoi-inference patch from issue #482. - VERSION FOR SANTIS -- includes fixes for issues 2 (anemoi-datasets) and 3 (runner rename). - Does NOT include Balfrin-specific path remapping. - NOTE: requires sgm-interpolator-global_trimedge_multi.yaml inference config (runner: time_multi_interpolator). - NOTE: may also need dataset date-range fix (issue 6) if training datasets end at 2024-12-31 on Santis. - -dates: - start: 2025-03-01T00:00 - end: 2025-03-02T00:00 - frequency: 24h - - -runs: - - - interpolator: - inference_resources: - slurm_partition: normal-shared - checkpoint: https://mlflow.ecmwf.int/#/experiments/456/runs/f9279244ed6f4c458597bdcf335ab36f - label: interpolator_ICON-REA-L - steps: 0/120/1 - config: resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml - forecaster: - checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 - config: resources/inference/configs/sgm-multidataset-forecaster-global-ich1-oper.yaml - steps: 0/120/6 - extra_requirements: - - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 - extra_requirements: - - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 - - anemoi-datasets==0.5.35 - - -baselines: - - baseline: - baseline_id: ICON-CH2-EPS - label: ICON-CH2-ctrl - root: /scratch/mch/cmerker/ICON-CH2-EPS - steps: 0/120/6 - -truth: - label: KENDA-CH1 - root: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-v1.0.zarr - -stratification: - regions: - - jura - - mittelland - - voralpen - - alpennordhang - - innerealpentaeler - - alpensuedseite - root: /scratch/mch/bhendj/regions/Prognoseregionen_LV95_20220517 - -locations: - output_root: output/ - -profile: - executor: slurm - global_resources: - gpus: 16 - default_resources: - slurm_partition: "postproc" - cpus_per_task: 1 - mem_mb_per_cpu: 1800 - runtime: "1h" - gpus: 0 - jobs: 50 - batch_rules: - plot_forecast_frame: 32 diff --git a/config/forecasters-ich1-oper-try-interpolator_initial.yaml b/config/forecasters-ich1-oper-try-interpolator_initial.yaml deleted file mode 100644 index 7f11199f..00000000 --- a/config/forecasters-ich1-oper-try-interpolator_initial.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# yaml-language-server: $schema=../workflow/tools/config.schema.json -description: | - Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) - driven by ICON-CH1 stage_E forecaster, using anemoi-inference patch from issue #482. - INITIAL VERSION -- before any debugging fixes. - -dates: - start: 2025-03-01T00:00 - end: 2025-03-02T00:00 - frequency: 24h - - -runs: - - - interpolator: - inference_resources: - slurm_partition: normal-shared - checkpoint: https://mlflow.ecmwf.int/#/experiments/456/runs/f9279244ed6f4c458597bdcf335ab36f - label: interpolator_ICON-REA-L - steps: 0/120/1 - config: resources/inference/configs/sgm-interpolator-global_trimedge.yaml - forecaster: - checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 - config: resources/inference/configs/sgm-multidataset-forecaster-global-ich1-oper.yaml - steps: 0/120/6 - extra_requirements: - - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 - extra_requirements: - - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 - - -baselines: - - baseline: - baseline_id: ICON-CH2-EPS - label: ICON-CH2-ctrl - root: /scratch/mch/cmerker/ICON-CH2-EPS - steps: 0/120/6 - -truth: - label: KENDA-CH1 - root: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-v1.0.zarr - -stratification: - regions: - - jura - - mittelland - - voralpen - - alpennordhang - - innerealpentaeler - - alpensuedseite - root: /scratch/mch/bhendj/regions/Prognoseregionen_LV95_20220517 - -locations: - output_root: output/ - -profile: - executor: slurm - global_resources: - gpus: 16 - default_resources: - slurm_partition: "postproc" - cpus_per_task: 1 - mem_mb_per_cpu: 1800 - runtime: "1h" - gpus: 0 - jobs: 50 - batch_rules: - plot_forecast_frame: 32 From 5a243804c8c9e31669e01b3d2ed0ea9d5868531f Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Wed, 22 Apr 2026 14:01:54 +0200 Subject: [PATCH 04/31] Add accumulation post-processor to ensure that TOT_PREC is accumulated (as in baseline) --- .../configs/sgm-interpolator-global_trimedge_multi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml index 0bbb5fbc..67ba859e 100644 --- a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml +++ b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml @@ -62,6 +62,9 @@ output: - extract_mask: # removes global points mask: "lam_0/cutout_mask" as_slice: true + - accumulate_from_start_of_forecast: # accumulate tp from start of forecast + accumulations: + - tp - grib: path: grib/ifs-{dateTime}_{step:03}.grib @@ -76,6 +79,9 @@ output: inverse: true - assign_mask: # fill local/global overlapping points with nan mask: "global/cutout_mask" + - accumulate_from_start_of_forecast: # accumulate tp from start of forecast + accumulations: + - tp verbosity: 0 allow_nans: true From ba0029bba658a2444bef47f142ae8d1f705c23b5 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Wed, 22 Apr 2026 14:03:44 +0200 Subject: [PATCH 05/31] Update checkpoints and baselines --- config/interpolators-ich1.yaml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1.yaml index ba6544d6..1df7f2a7 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1.yaml @@ -1,7 +1,8 @@ # yaml-language-server: $schema=../workflow/tools/config.schema.json description: | Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) - driven by ICON-CH1 stage_E forecaster, using anemoi-inference patch from issue #482. + driven by ICON-CH1 stage_E forecaster with subgrid orography, using anemoi-inference patch from + issue #482. dates: start: 2025-03-01T00:00 @@ -10,16 +11,15 @@ dates: runs: - - interpolator: inference_resources: slurm_partition: normal-shared - checkpoint: /scratch/mch/miccatta/ICON_interpolator_checkpoints/checkpoint_stage-C-interpolator-n320-6hto1h-reduced-variables/f9279244ed6f4c458597bdcf335ab36f/inference-anemoi-by_epoch-epoch_000-step_001000.ckpt + checkpoint: /store_new/mch/msopr/ml/tmp/inference-last.ckpt label: interpolator_ICON-REA-L steps: 0/120/1 config: resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml forecaster: - checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 + checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/c30490b6ba064e4db03b430f3a2595ad config: resources/inference/configs/sgm-multidataset-forecaster-global-ich1-oper.yaml steps: 0/120/6 extra_requirements: @@ -33,8 +33,13 @@ baselines: - baseline: baseline_id: ICON-CH2-EPS label: ICON-CH2-ctrl - root: /scratch/mch/cmerker/ICON-CH2-EPS + root: /store_new/mch/msopr/ml/ICON-CH2-EPS steps: 0/120/1 + - baseline: + baseline_id: ICON-CH1-EPS + label: ICON-CH1-ctrl + root: /store_new/mch/msopr/ml/ICON-CH1-EPS + steps: 0/33/1 truth: label: KENDA-CH1 From 31fcb657b1a11e0ba78e8a819c9b2fca7e95249e Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Wed, 22 Apr 2026 14:04:14 +0200 Subject: [PATCH 06/31] Fix trailing whitespace --- config/interpolators-ich1.yaml | 2 +- .../configs/sgm-interpolator-global_trimedge_multi.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1.yaml index 1df7f2a7..0eeba0b9 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=../workflow/tools/config.schema.json description: | Evaluate skill of new time-interpolator (trained on ICON-REA-L, fine-tuned on KENDA-ICON-CH1) - driven by ICON-CH1 stage_E forecaster with subgrid orography, using anemoi-inference patch from + driven by ICON-CH1 stage_E forecaster with subgrid orography, using anemoi-inference patch from issue #482. dates: diff --git a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml index 67ba859e..37f7927d 100644 --- a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml +++ b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml @@ -63,7 +63,7 @@ output: mask: "lam_0/cutout_mask" as_slice: true - accumulate_from_start_of_forecast: # accumulate tp from start of forecast - accumulations: + accumulations: - tp - grib: @@ -80,7 +80,7 @@ output: - assign_mask: # fill local/global overlapping points with nan mask: "global/cutout_mask" - accumulate_from_start_of_forecast: # accumulate tp from start of forecast - accumulations: + accumulations: - tp verbosity: 0 From 91cdf6136c9ddef65de122a7130246d3b2d1acae Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Wed, 22 Apr 2026 15:59:41 +0200 Subject: [PATCH 07/31] Inlined debugging comments and remove debugging text file --- ...1.yaml => interpolators-ich1-balfrin.yaml} | 5 +- debugging_summary_interpolator_2.txt | 195 ------------------ ...gm-interpolator-global_trimedge_multi.yaml | 2 + .../sgm-interpolator-ich1-oper-patch.yaml | 5 + 4 files changed, 11 insertions(+), 196 deletions(-) rename config/{interpolators-ich1.yaml => interpolators-ich1-balfrin.yaml} (88%) delete mode 100644 debugging_summary_interpolator_2.txt diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1-balfrin.yaml similarity index 88% rename from config/interpolators-ich1.yaml rename to config/interpolators-ich1-balfrin.yaml index 0eeba0b9..45a195f5 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1-balfrin.yaml @@ -6,7 +6,7 @@ description: | dates: start: 2025-03-01T00:00 - end: 2025-03-02T00:00 + end: 2025-03-03T00:00 frequency: 24h @@ -14,6 +14,8 @@ runs: - interpolator: inference_resources: slurm_partition: normal-shared + # for checkpoints trained on a different HPC, using mlflow doesn't work due to difference in + # paths, so we directly specify the checkpoint path here checkpoint: /store_new/mch/msopr/ml/tmp/inference-last.ckpt label: interpolator_ICON-REA-L steps: 0/120/1 @@ -26,6 +28,7 @@ runs: - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 extra_requirements: - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 + # pinned anemoi-datasets because of ecmwf/anemoi-utils#284, can be removed when fixed - anemoi-datasets==0.5.35 diff --git a/debugging_summary_interpolator_2.txt b/debugging_summary_interpolator_2.txt deleted file mode 100644 index f2bee469..00000000 --- a/debugging_summary_interpolator_2.txt +++ /dev/null @@ -1,195 +0,0 @@ -Debugging summary: running the new ICON-REA-L interpolator in evalML on Balfrin -================================================================================ - -Interpolator checkpoint: https://mlflow.ecmwf.int/#/experiments/456/runs/f9279244ed6f4c458597bdcf335ab36f - Local path: /scratch/mch/miccatta/ICON_interpolator_checkpoints/checkpoint_stage-C-interpolator-n320-6hto1h-reduced-variables/f9279244ed6f4c458597bdcf335ab36f/inference-anemoi-by_epoch-epoch_000-step_001000.ckpt -Forecaster checkpoint: https://service.meteoswiss.ch/mlstore#/experiments/602/runs/fd63e17043014af59170c7beca516b95 -anemoi-inference patch: https://github.com/ecmwf/anemoi-inference/issues/482 (commit e369b1a) - -The interpolator checkpoint was trained on Santis. Running it on Balfrin required -fixing 10 issues. Issues 1, 5, 6, 7 are Balfrin-specific (cross-site portability). -Issues 2, 3, 4, 8, 9, 10 affect both Santis and Balfrin. - -Status as of 2026-04-16 17:30: inference runs to completion (all 20 interpolation -windows, GRIB output written). The remaining failure is in the evalML verification -step (verif_metrics), not in inference itself. See issue 10. - - -Issue 1: Checkpoint not accessible via MLflow [Balfrin-only] ------------------------------------------------------------- -The MLflow script resolved the checkpoint URL to a path on miccatta's home dir: - /users/miccatta/symlinks-scratch/anemoi-outputs/checkpoint_stage-C-interpolator-n320-6hto1h-reduced-variables/... -This path does not exist on Balfrin (likely a broken symlink or permissions issue). - -Fix: Use the local checkpoint path directly in the evalML config: - /scratch/mch/miccatta/ICON_interpolator_checkpoints/... - - -Issue 2: Bad git hash for anemoi-datasets in checkpoint metadata [Both] ------------------------------------------------------------------------ -The auto-extracted requirements from the checkpoint contained a bogus git commit -hash for anemoi-datasets (d2e9f8c7...) that doesn't exist in the public repo. - -Root cause: https://github.com/ecmwf/anemoi-utils/issues/284 -When .venv is inside a git repo, gather_provenance_info() incorrectly records the -parent repo's SHA for almost all packages. Fix PR: https://github.com/ecmwf/anemoi-utils/pull/285 - -Fix: Add `anemoi-datasets==0.5.35` to the interpolator's extra_requirements in -the evalML config, which overrides the broken git hash with the correct PyPI release. - - -Issue 3: Runner renamed in anemoi-inference [Both] --------------------------------------------------- -The anemoi-inference version from the #482 patch (commit e369b1a) does not have a -`time-interpolator` runner. It was renamed to `time-multi-interpolator`. - -Available runners: default, external-graph, time-multi-interpolator, parallel, -plugin, simple, testing, no-model. - -Fix: Created a new inference config (sgm-interpolator-global_trimedge_multi.yaml) -based on sgm-interpolator-global_trimedge.yaml with: - runner: time_multi_interpolator - - -Issue 4: patch_metadata path convention in inference config [Both] ------------------------------------------------------------------- -The evalML workflow copies files from resources/inference/metadata/ into the run's -resources/ directory. So the inference config must reference patch files as: - patch_metadata: resources/sgm-interpolator-ich1-oper-patch.yaml -NOT: - patch_metadata: resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml - -Fix: Corrected the path in the inference config. - - -Issue 5: Training dataset paths from Santis not available on Balfrin [Balfrin-only] ------------------------------------------------------------------------------------ -The checkpoint metadata embeds the training dataset paths from Santis: - /capstor/store/mch/msopr/ml/datasets/mch-realch1-fdb-1km-2005-2025-1h-pl13-ifsnames-1h-precip-v1.0.zarr - /capstor/store/mch/msopr/ml/datasets/aifs-ea-an-oper-0001-mars-n320-1979-2024-1h-v2-with-era51.zarr - -/capstor/store/mch is not mounted on Balfrin. The same datasets exist under /store_new/mch/... - -Fix: Created a patch_metadata file (resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml) -that remaps the dataset paths -- following the same pattern as the forecaster's -sgm-multidataset-ich1-oper-patch.yaml. - - -Issue 6: Training datasets don't cover experiment dates (2025) [Balfrin-only] ------------------------------------------------------------------------------ -The original training datasets (mch-realch1-fdb-..., aifs-ea-...) only have data -through 2024-12-31, but the experiment starts 2025-03-01. The constant forcings -loader requires the forecast date to exist in the dataset, even for time-invariant -fields (z, lsm). - -Fix: In the patch_metadata, swapped to datasets that cover 2025: - mch-ich1-1km-2024-2025-1h-pl13-ifsnames-v1.0.zarr (LAM, 1h) - aifs-od-an-oper-0001-mars-n320-2016-2025-6h-v1-combined-land.zarr (global, 6h) - -Note: On Santis this may also be an issue if the training datasets end at 2024-12-31. - - -Issue 7: Frequency mismatch between LAM (1h) and global (6h) datasets [Balfrin-only] -------------------------------------------------------------------------------------- -The replacement global dataset (aifs-od-...) is 6-hourly, but the checkpoint -metadata specifies frequency=1h. The dataset open call fails with: - "Requested frequency 1h is not a multiple of the dataset frequency 6:00:00" - -This only affects constant forcings (z, lsm) -- the main input comes from GRIB files. - -Fix: Override frequency to 6h in the patch_metadata at the correct nesting level: - config.dataloader.test.datasets.data.dataset_config.frequency: 6h - -Important: the frequency field must be inside dataset_config (alongside dataset), -NOT alongside start/end (which are at the dataloader level). The deep merge -applies at the dataset_config level. - - -Issue 8: No source0/trimedge_mask in checkpoint [Both] ------------------------------------------------------- -The inference config (copied from the CO2 interpolator) referenced a -source0/trimedge_mask supporting array for pre/post-processing of boundary points. -This checkpoint was NOT trained with edge trimming -- the array doesn't exist. - -Available supporting arrays: latitudes, longitudes, lam_0/cutout_mask, -global/cutout_mask, source0/latitudes, source0/longitudes, source1/latitudes, -source1/longitudes. - -Fix: Removed both the extract_mask pre-processor and assign_mask post-processor -that referenced source0/trimedge_mask from the inference config. - - -Issue 9: GRIB output template mismatch [Both] ----------------------------------------------- -Two sub-issues: - -a) The LAM output used COSMO templates (templates_index_cosmo.yaml) which match on - grid=0.02 or grid=0.01. This checkpoint outputs on an ICON-CH1 grid. - Fix: Switched to templates_index_icon.yaml. - -b) Even with ICON templates, the template lookup failed because the output variables - use IFS naming (10u, 2t, msl...) but the ICON templates match on ICON param names - (U_10M, T_2M, PMSL...). The forecaster works because its patch_metadata includes - a variables_metadata section that maps IFS names to ICON GRIB params. - Fix: Added the full variables_metadata section (copied from the forecaster's - sgm-multidataset-ich1-oper-patch.yaml) to the interpolator's patch_metadata. - - -Issue 10: Assertion error in verbose output printing [Both] ------------------------------------------------------------ -With verbosity=1, the _print_output_tensor method asserts that the first tensor -dimension is 1 or multi_step_input (2), but the multi-output interpolator produces -6 time steps. This is a bug in anemoi-inference (the verbose printing code doesn't -account for multi-output interpolators). - -Fix: Set verbosity: 0 in the inference config to skip the debug tensor printing. - -Note: This should be reported as a bug to anemoi-inference. - - -CURRENT STATUS: Verification step failure (not an inference issue) ------------------------------------------------------------------- -The interpolator inference now runs to completion (all 20 windows, 120h lead time, -hourly output). GRIB files are written for both LAM and IFS grids. - -The remaining failure is in evalML's verif_metrics step: - AttributeError: 'Dataset' object has no attribute 'ref_time' -The verification reader can't parse the output GRIB files -- likely missing -reference time metadata in the GRIB encoding. This needs investigation in the -evalML verification code or the GRIB encoding configuration. - - -Files created/modified ----------------------- -New files: - resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml - - Inference config for the new interpolator - - runner: time_multi_interpolator - - No trimedge pre/post-processors - - ICON templates instead of COSMO - - verbosity: 0 - - resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml - - Checkpoint metadata patch for Balfrin - - Remaps dataset paths from /capstor/store/mch to /store_new/mch - - Uses 2025-covering datasets for constant forcings - - Overrides frequency to 6h (for the 6h global dataset) - - Full variables_metadata for IFS-to-ICON param name mapping - - config/forecasters-ich1-oper-try-interpolator_minimal_Claude.yaml - - evalML experiment config for Balfrin - - Local checkpoint path - - anemoi-datasets==0.5.35 override - - config/forecasters-ich1-oper-try-interpolator_initial.yaml - - The initial config before any fixes (for reference) - - config/forecasters-ich1-oper-try-interpolator_for-Santis.yaml - - Config for running on Santis (no Balfrin-specific path remapping) - - Includes fixes for issues 2, 3 (universal issues) - - debugging_summary_interpolator.txt - - First version of this summary (covers issues 1-6) - - debugging_summary_interpolator_2.txt - - This file (covers all 10 issues + current status) diff --git a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml index 37f7927d..ae353d4d 100644 --- a/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml +++ b/resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml @@ -65,6 +65,7 @@ output: - accumulate_from_start_of_forecast: # accumulate tp from start of forecast accumulations: - tp + # here, the trimedge mask can be specified when available - grib: path: grib/ifs-{dateTime}_{step:03}.grib @@ -83,6 +84,7 @@ output: accumulations: - tp +# silenced due to bug in anemoi-inference for multi-step interpolators, can be removed when fixed verbosity: 0 allow_nans: true output_frequency: "1h" diff --git a/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml b/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml index b1dde369..1695812c 100644 --- a/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml +++ b/resources/inference/metadata/sgm-interpolator-ich1-oper-patch.yaml @@ -6,8 +6,11 @@ config: dataset_config: dataset: cutout: + # training datasets not available on system due to training on different system + # and/or training period not in dataset - dataset: /store_new/mch/msopr/ml/datasets/mch-ich1-1km-2024-2025-1h-pl13-ifsnames-v1.0.zarr - dataset: /store_new/mch/msopr/ml/datasets/aifs-od-an-oper-0001-mars-n320-2016-2025-6h-v1-combined-land.zarr + # avoid errors from frequency mismatch between LAM (1h) and global (6h) datasets when aligning datasets frequency: 6h start: null end: null @@ -15,6 +18,8 @@ config: dataset: data: constant_fields: [z, lsm] + # variables_metadata is necessary due to missing variables_metadata section in interpolator patch_metdata + # else mapping with grib templates fails variables_metadata: 10u: mars: From 0088c37e85d9745b47403a5cc0ddaa3ca07a8277 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 23 Apr 2026 08:52:49 +0200 Subject: [PATCH 08/31] move computation to inner loop to avoid dask graph bloat --- src/verification/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/verification/__init__.py b/src/verification/__init__.py index 97a62505..c6104a4d 100644 --- a/src/verification/__init__.py +++ b/src/verification/__init__.py @@ -216,12 +216,12 @@ def verify( score = xr.concat(score, dim="region") fcst_statistics = xr.concat(fcst_statistics, dim="region") obs_statistics = xr.concat(obs_statistics, dim="region") - statistics.append(xr.concat([fcst_statistics, obs_statistics], dim="source")) - scores.append(score) + param_statistics = xr.concat([fcst_statistics, obs_statistics], dim="source") + # Compute eagerly per parameter to prevent dask graph bloat + scores.append(_merge_metrics([score])) + statistics.append(_merge_metrics([param_statistics])) - scores = _merge_metrics(scores) - statistics = _merge_metrics(statistics) - out = xr.merge([scores, statistics], join="outer", compat="no_conflicts") + out = xr.merge(scores + statistics, join="outer", compat="no_conflicts") LOG.info("Computed metrics in %.2f seconds", time.time() - start) LOG.info("Metrics dataset: \n%s", out) return out From 9b07bd0f366ce3f5cc2e281f706a4bbf804280e4 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 23 Apr 2026 09:05:18 +0200 Subject: [PATCH 09/31] Use allocated CPU resources in dataset computation --- src/verification/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/verification/__init__.py b/src/verification/__init__.py index c6104a4d..9bc043b8 100644 --- a/src/verification/__init__.py +++ b/src/verification/__init__.py @@ -1,4 +1,5 @@ import logging +import os import time from pathlib import Path @@ -122,11 +123,11 @@ def _compute_statistics( return stats -def _merge_metrics(ds: xr.Dataset) -> xr.Dataset: +def _merge_metrics(ds: xr.Dataset, num_workers: int = 4) -> xr.Dataset: out = xr.merge(ds, compat="no_conflicts") if "ref_time" not in out.dims: out = out.expand_dims("ref_time").set_coords("ref_time") - out = out.compute(num_workers=4, scheduler="threads") + out = out.compute(num_workers=num_workers, scheduler="threads") return out @@ -147,6 +148,7 @@ def verify( obs_label: str, regions: list[str] | None = None, dim: list[str] | None = None, + num_workers: int | None = None, ) -> xr.Dataset: """ Compare two xarray Datasets (fcst and obs) and return pandas DataFrame with @@ -154,6 +156,12 @@ def verify( """ start = time.time() + if num_workers is None: + try: + num_workers = len(os.sched_getaffinity(0)) + except AttributeError: + num_workers = max((os.cpu_count() or 6) - 2, 1) + if dim is None: if "x" in fcst.dims and "y" in fcst.dims: dim = ["x", "y"] @@ -218,8 +226,8 @@ def verify( obs_statistics = xr.concat(obs_statistics, dim="region") param_statistics = xr.concat([fcst_statistics, obs_statistics], dim="source") # Compute eagerly per parameter to prevent dask graph bloat - scores.append(_merge_metrics([score])) - statistics.append(_merge_metrics([param_statistics])) + scores.append(_merge_metrics([score], num_workers=num_workers)) + statistics.append(_merge_metrics([param_statistics], num_workers=num_workers)) out = xr.merge(scores + statistics, join="outer", compat="no_conflicts") LOG.info("Computed metrics in %.2f seconds", time.time() - start) From 7b0b2477c8afb762d9bf587a5b88892afe35fdfb Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 23 Apr 2026 09:28:23 +0200 Subject: [PATCH 10/31] Update config/interpolators-ich1-balfrin.yaml Co-authored-by: Daniele Nerini --- config/interpolators-ich1-balfrin.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/interpolators-ich1-balfrin.yaml b/config/interpolators-ich1-balfrin.yaml index 45a195f5..73397e2b 100644 --- a/config/interpolators-ich1-balfrin.yaml +++ b/config/interpolators-ich1-balfrin.yaml @@ -30,9 +30,6 @@ runs: - git+https://github.com/ecmwf/anemoi-inference.git@e369b1a90313e9701db13f63364a467aa281cf36 # pinned anemoi-datasets because of ecmwf/anemoi-utils#284, can be removed when fixed - anemoi-datasets==0.5.35 - - -baselines: - baseline: baseline_id: ICON-CH2-EPS label: ICON-CH2-ctrl From 7fcbf8acc4e6a57007c238a9ae45b9b2cb85239b Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 23 Apr 2026 09:28:38 +0200 Subject: [PATCH 11/31] Update config/interpolators-ich1-balfrin.yaml Co-authored-by: Daniele Nerini --- config/interpolators-ich1-balfrin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/interpolators-ich1-balfrin.yaml b/config/interpolators-ich1-balfrin.yaml index 73397e2b..5cb8e175 100644 --- a/config/interpolators-ich1-balfrin.yaml +++ b/config/interpolators-ich1-balfrin.yaml @@ -17,7 +17,7 @@ runs: # for checkpoints trained on a different HPC, using mlflow doesn't work due to difference in # paths, so we directly specify the checkpoint path here checkpoint: /store_new/mch/msopr/ml/tmp/inference-last.ckpt - label: interpolator_ICON-REA-L + label: Varda-Single steps: 0/120/1 config: resources/inference/configs/sgm-interpolator-global_trimedge_multi.yaml forecaster: From 2d25a535a5ad8d3d64463aa29824bf8843a24f64 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 23 Apr 2026 09:31:51 +0200 Subject: [PATCH 12/31] renamed interpolator config --- .../{interpolators-ich1-balfrin.yaml => interpolators-ich1.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config/{interpolators-ich1-balfrin.yaml => interpolators-ich1.yaml} (100%) diff --git a/config/interpolators-ich1-balfrin.yaml b/config/interpolators-ich1.yaml similarity index 100% rename from config/interpolators-ich1-balfrin.yaml rename to config/interpolators-ich1.yaml From 24848f1c23d328ac60027a1f31bf6d7dbae90b74 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 28 Apr 2026 09:08:21 +0200 Subject: [PATCH 13/31] generalize the plot of meteograms to any location --- src/evalml/config.py | 21 ++++++++++++++++++ workflow/Snakefile | 36 +++++++++++++++++++------------ workflow/rules/plot.smk | 2 +- workflow/tools/config.schema.json | 36 +++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/src/evalml/config.py b/src/evalml/config.py index a4eb497b..26fb2346 100644 --- a/src/evalml/config.py +++ b/src/evalml/config.py @@ -208,6 +208,23 @@ class BaselineItem(BaseModel): baseline: BaselineConfig +class ShowcaseConfig(BaseModel): + """Configuration for the showcase workflow.""" + + meteograms: bool = Field( + default=True, + description="Whether to generate meteograms (time series plots at stations).", + ) + animations: bool = Field( + default=True, + description="Whether to generate forecast animations (GIFs per param and region).", + ) + stations: List[str] = Field( + default=["GVE", "KLO", "LUG"], + description="List of PeakWeather station IDs to generate meteograms for.", + ) + + class Locations(BaseModel): """Locations of data and services used in the workflow.""" @@ -318,6 +335,10 @@ class ConfigModel(BaseModel): stratification: Stratification locations: Locations profile: Profile + showcase: ShowcaseConfig = Field( + default_factory=ShowcaseConfig, + description="Settings for the showcase workflow.", + ) model_config = { "extra": "forbid", # fail on misspelled keys diff --git a/workflow/Snakefile b/workflow/Snakefile index fb0cbdf9..a9d67316 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -138,21 +138,29 @@ rule experiment_all: rule showcase_all: """Target rule for showcase workflow.""" input: - expand( - rules.make_forecast_animation.output, - init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], - run_id=CANDIDATES, - param=["T_2M", "SP_10M"], - region=["globe", "europe", "switzerland"], - showcase=EXPERIMENT_NAME, + ( + expand( + rules.make_forecast_animation.output, + init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], + run_id=CANDIDATES, + param=["T_2M", "SP_10M"], + region=["globe", "europe", "switzerland"], + showcase=EXPERIMENT_NAME, + ) + if config["showcase"]["animations"] + else [] ), - expand( - rules.plot_meteogram.output, - init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], - run_id=CANDIDATES, - param=["T_2M", "SP_10M"], - sta=["GVE", "KLO", "LUG"], - showcase=EXPERIMENT_NAME, + ( + expand( + rules.plot_meteogram.output, + init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], + run_id=CANDIDATES, + param=["T_2M", "SP_10M"], + sta=config["showcase"]["stations"], + showcase=EXPERIMENT_NAME, + ) + if config["showcase"]["meteograms"] + else [] ), diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 73badb2b..be483c16 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -38,7 +38,7 @@ rule plot_meteogram: resources: slurm_partition="postproc", cpus_per_task=1, - runtime="10m", + runtime="60m", params: ana_label=lambda wc: config["truth"]["label"], fcst_grib=lambda wc: ( diff --git a/workflow/tools/config.schema.json b/workflow/tools/config.schema.json index 20f65126..75663a79 100644 --- a/workflow/tools/config.schema.json +++ b/workflow/tools/config.schema.json @@ -465,6 +465,38 @@ "title": "Profile", "type": "object" }, + "ShowcaseConfig": { + "description": "Configuration for the showcase workflow.", + "properties": { + "meteograms": { + "default": true, + "description": "Whether to generate meteograms (time series plots at stations).", + "title": "Meteograms", + "type": "boolean" + }, + "animations": { + "default": true, + "description": "Whether to generate forecast animations (GIFs per param and region).", + "title": "Animations", + "type": "boolean" + }, + "stations": { + "default": [ + "GVE", + "KLO", + "LUG" + ], + "description": "List of PeakWeather station IDs to generate meteograms for.", + "items": { + "type": "string" + }, + "title": "Stations", + "type": "array" + } + }, + "title": "ShowcaseConfig", + "type": "object" + }, "Stratification": { "description": "Stratification settings for the analysis.", "properties": { @@ -589,6 +621,10 @@ }, "profile": { "$ref": "#/$defs/Profile" + }, + "showcase": { + "$ref": "#/$defs/ShowcaseConfig", + "description": "Settings for the showcase workflow." } }, "required": [ From f3e1c30cd3cc1ec4f206786d60f036f7173613b9 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Fri, 1 May 2026 17:12:04 +0200 Subject: [PATCH 14/31] config for regions and params --- src/evalml/config.py | 29 ++++++++++++++++++++++ src/plotting/__init__.py | 9 +++++++ src/plotting/compat.py | 11 ++++++-- workflow/Snakefile | 6 ++--- workflow/rules/common.smk | 21 ++++++++++++++++ workflow/rules/plot.smk | 10 ++++++++ workflow/scripts/plot_forecast_frame.mo.py | 27 ++++++++++++++++++-- 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/evalml/config.py b/src/evalml/config.py index 26fb2346..08610bf8 100644 --- a/src/evalml/config.py +++ b/src/evalml/config.py @@ -208,6 +208,22 @@ class BaselineItem(BaseModel): baseline: BaselineConfig +class RegionConfig(BaseModel): + """A custom map region defined by name, extent, and projection.""" + + name: str = Field(..., description="Name for the custom region (used as wildcard).") + extent: List[float] | None = Field( + None, + description="Geographic extent as [lon_min, lon_max, lat_min, lat_max] in PlateCarree coordinates. None means full globe.", + ) + projection: str = Field( + "orthographic", + description="Projection name (must be a key in plotting._PROJECTIONS, e.g. 'orthographic').", + ) + + model_config = {"extra": "forbid"} + + class ShowcaseConfig(BaseModel): """Configuration for the showcase workflow.""" @@ -219,10 +235,23 @@ class ShowcaseConfig(BaseModel): default=True, description="Whether to generate forecast animations (GIFs per param and region).", ) + params: List[str] = Field( + default=["T_2M", "SP_10M"], + description="List of parameters to generate animations and meteograms for.", + ) stations: List[str] = Field( default=["GVE", "KLO", "LUG"], description="List of PeakWeather station IDs to generate meteograms for.", ) + regions: List[str | RegionConfig] = Field( + default=["globe", "europe", "switzerland"], + description=( + "Regions to generate animations for. Each entry is either a named region " + "(e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, " + "or a custom region dict with 'name', optional 'extent' " + "[lon_min, lon_max, lat_min, lat_max], and optional 'projection'." + ), + ) class Locations(BaseModel): diff --git a/src/plotting/__init__.py b/src/plotting/__init__.py index ce5e4e63..810468ad 100644 --- a/src/plotting/__init__.py +++ b/src/plotting/__init__.py @@ -20,6 +20,15 @@ # Mapping of region names to their geographic extent and projection # extent [lon_min, lon_max, lat_min, lat_max] in PlateCarree coordinates +def get_projection(name: str) -> "ccrs.Projection": + """Look up a projection by name.""" + if name not in _PROJECTIONS: + raise ValueError( + f"Unknown projection {name!r}. Available: {list(_PROJECTIONS)}" + ) + return _PROJECTIONS[name] + + DOMAINS = { "globe": { "extent": None, # full globe view diff --git a/src/plotting/compat.py b/src/plotting/compat.py index 665287e0..7c4d14d3 100644 --- a/src/plotting/compat.py +++ b/src/plotting/compat.py @@ -18,8 +18,15 @@ def load_state_from_grib( fds = data_source.FileDataSource(datafiles=[str(file)]) ds = grib_decoder.load(fds, {"param": paramlist}) state = {} - lats = ds[paramlist[0]].lat.data.flatten() - lons = ds[paramlist[0]].lon.data.flatten() + ref_param = next((p for p in (paramlist or []) if p in ds), None) + if ref_param is None: + raise ValueError( + f"None of the requested params {paramlist} found in {file}. " + "The GRIB file may not contain these fields at this lead time " + "(e.g. accumulated fields like TOT_PREC are undefined at step 0)." + ) + lats = ds[ref_param].lat.data.flatten() + lons = ds[ref_param].lon.data.flatten() state["forecast_reference_time"] = reftime state["valid_time"] = reftime + pd.to_timedelta(lead_time_hours, unit="h") state["longitudes"] = lons diff --git a/workflow/Snakefile b/workflow/Snakefile index dd804911..3e564742 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -143,8 +143,8 @@ rule showcase_all: rules.make_forecast_animation.output, init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], run_id=CANDIDATES, - param=["T_2M", "SP_10M"], - region=["globe", "europe", "switzerland"], + param=config["showcase"]["params"], + region=list(SHOWCASE_REGIONS.keys()), showcase=EXPERIMENT_NAME, ) if config["showcase"]["animations"] @@ -155,7 +155,7 @@ rule showcase_all: rules.plot_meteogram.output, init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], run_id=CANDIDATES, - param=["T_2M", "SP_10M"], + param=config["showcase"]["params"], sta=config["showcase"]["stations"], showcase=EXPERIMENT_NAME, ) diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index e858c80f..bcb8c18f 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -80,6 +80,26 @@ def parse_regions(): return regions_txt +def parse_showcase_regions(): + """Parse showcase regions from config. + + Returns a dict mapping region name -> {extent, projection}. + Named regions (strings) have extent=None and projection=None, + meaning the plot script will fall back to the DOMAINS lookup. + Custom regions carry their explicit extent and projection. + """ + result = {} + for r in config["showcase"]["regions"]: + if isinstance(r, str): + result[r] = {"extent": None, "projection": None} + else: + result[r["name"]] = { + "extent": r.get("extent"), + "projection": r.get("projection", "orthographic"), + } + return result + + # ============================================================================ # Run entries configuration management # ============================================================================ @@ -294,6 +314,7 @@ def master_hash() -> str: REGIONS = parse_regions() +SHOWCASE_REGIONS = parse_showcase_regions() REFTIMES = parse_reference_times() RUN_CONFIGS = collect_all_runs() ENV_CONFIGS = collect_all_envs() diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 06a02526..5bb312be 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -92,6 +92,7 @@ rule plot_forecast_frame: / "data/runs/{run_id}/{init_time}/frames/frame_{leadtime}_{param}_{region}.png", wildcard_constraints: leadtime=r"\d+", # only digits + region="|".join(map(re.escape, SHOWCASE_REGIONS.keys())), resources: slurm_partition="postproc", cpus_per_task=1, @@ -100,12 +101,21 @@ rule plot_forecast_frame: grib_out_dir=lambda wc: ( Path(OUT_ROOT) / f"data/runs/{wc.run_id}/{wc.init_time}/grib" ).resolve(), + region_extra=lambda wc: ( + "--extent {} --projection {}".format( + " ".join(map(str, SHOWCASE_REGIONS[wc.region]["extent"])), + SHOWCASE_REGIONS[wc.region]["projection"], + ) + if SHOWCASE_REGIONS.get(wc.region, {}).get("extent") is not None + else "" + ), shell: """ export ECCODES_DEFINITION_PATH=$(realpath .venv/share/eccodes-cosmo-resources/definitions) python {input.script} \ --input {params.grib_out_dir} --date {wildcards.init_time} --outfn {output[0]} \ --param {wildcards.param} --leadtime {wildcards.leadtime} --region {wildcards.region} \ + {params.region_extra} # interactive editing (needs to set localrule: True and use only one core) # marimo edit {input.script} -- \ # --input {params.grib_out_dir} --date {wildcards.init_time} --outfn {output[0]}\ diff --git a/workflow/scripts/plot_forecast_frame.mo.py b/workflow/scripts/plot_forecast_frame.mo.py index b8592141..eee4fe04 100644 --- a/workflow/scripts/plot_forecast_frame.mo.py +++ b/workflow/scripts/plot_forecast_frame.mo.py @@ -15,6 +15,7 @@ def _(): import numpy as np from plotting import DOMAINS + from plotting import get_projection from plotting import StatePlotter from plotting.colormap_defaults import CMAP_DEFAULTS from plotting.compat import load_state_from_grib @@ -29,6 +30,7 @@ def _(): logging, np, DOMAINS, + get_projection, ccrs, ) @@ -53,6 +55,20 @@ def _(ArgumentParser, Path): parser.add_argument("--leadtime", type=str, help="leadtime") parser.add_argument("--param", type=str, help="parameter") parser.add_argument("--region", type=str, help="name of region") + parser.add_argument( + "--extent", + type=float, + nargs=4, + default=None, + metavar=("LON_MIN", "LON_MAX", "LAT_MIN", "LAT_MAX"), + help="custom geographic extent in PlateCarree coordinates; overrides DOMAINS lookup", + ) + parser.add_argument( + "--projection", + type=str, + default=None, + help="projection name (e.g. 'orthographic'); used only together with --extent", + ) args = parser.parse_args() grib_dir = Path(args.input) @@ -191,6 +207,7 @@ def _( StatePlotter, args, get_style, + get_projection, outfn, param, preprocess_field, @@ -205,11 +222,17 @@ def _( state["latitudes"], outfn.parent, ) + if args.extent is not None: + _projection = get_projection(args.projection or "orthographic") + _extent = args.extent + else: + _projection = DOMAINS[region]["projection"] + _extent = DOMAINS[region]["extent"] fig = plotter.init_geoaxes( nrows=1, ncols=1, - projection=DOMAINS[region]["projection"], - bbox=DOMAINS[region]["extent"], + projection=_projection, + bbox=_extent, name=region, size=(6, 6), ) From 51bb6fc16f582865122d99706b4d3edb290901ad Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Mon, 4 May 2026 09:55:13 +0200 Subject: [PATCH 15/31] update the schema --- workflow/tools/config.schema.json | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/workflow/tools/config.schema.json b/workflow/tools/config.schema.json index 75663a79..7537d296 100644 --- a/workflow/tools/config.schema.json +++ b/workflow/tools/config.schema.json @@ -465,6 +465,44 @@ "title": "Profile", "type": "object" }, + "RegionConfig": { + "additionalProperties": false, + "description": "A custom map region defined by name, extent, and projection.", + "properties": { + "name": { + "description": "Name for the custom region (used as wildcard).", + "title": "Name", + "type": "string" + }, + "extent": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Geographic extent as [lon_min, lon_max, lat_min, lat_max] in PlateCarree coordinates. None means full globe.", + "title": "Extent" + }, + "projection": { + "default": "orthographic", + "description": "Projection name (must be a key in plotting._PROJECTIONS, e.g. 'orthographic').", + "title": "Projection", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "RegionConfig", + "type": "object" + }, "ShowcaseConfig": { "description": "Configuration for the showcase workflow.", "properties": { @@ -480,6 +518,18 @@ "title": "Animations", "type": "boolean" }, + "params": { + "default": [ + "T_2M", + "SP_10M" + ], + "description": "List of parameters to generate animations and meteograms for.", + "items": { + "type": "string" + }, + "title": "Params", + "type": "array" + }, "stations": { "default": [ "GVE", @@ -492,6 +542,26 @@ }, "title": "Stations", "type": "array" + }, + "regions": { + "default": [ + "globe", + "europe", + "switzerland" + ], + "description": "Regions to generate animations for. Each entry is either a named region (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom region dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/RegionConfig" + } + ] + }, + "title": "Regions", + "type": "array" } }, "title": "ShowcaseConfig", From e2db0c9ba47fe6f9586e5902f0db395e41188b4a Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Mon, 4 May 2026 10:22:40 +0200 Subject: [PATCH 16/31] fix use a default when no params or regions are defined --- workflow/Snakefile | 4 ++-- workflow/rules/common.smk | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/workflow/Snakefile b/workflow/Snakefile index 3e564742..5dfab820 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -143,7 +143,7 @@ rule showcase_all: rules.make_forecast_animation.output, init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], run_id=CANDIDATES, - param=config["showcase"]["params"], + param=SHOWCASE_PARAMS, region=list(SHOWCASE_REGIONS.keys()), showcase=EXPERIMENT_NAME, ) @@ -155,7 +155,7 @@ rule showcase_all: rules.plot_meteogram.output, init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], run_id=CANDIDATES, - param=config["showcase"]["params"], + param=SHOWCASE_PARAMS, sta=config["showcase"]["stations"], showcase=EXPERIMENT_NAME, ) diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index bcb8c18f..1327819e 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -89,7 +89,7 @@ def parse_showcase_regions(): Custom regions carry their explicit extent and projection. """ result = {} - for r in config["showcase"]["regions"]: + for r in config.get("showcase", {}).get("regions", ["globe", "europe", "switzerland"]): if isinstance(r, str): result[r] = {"extent": None, "projection": None} else: @@ -315,6 +315,7 @@ def master_hash() -> str: REGIONS = parse_regions() SHOWCASE_REGIONS = parse_showcase_regions() +SHOWCASE_PARAMS = config.get("showcase", {}).get("params", ["T_2M", "SP_10M"]) REFTIMES = parse_reference_times() RUN_CONFIGS = collect_all_runs() ENV_CONFIGS = collect_all_envs() From 1144291eed774397afdeadbf93ff949a7d2fbff3 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Mon, 4 May 2026 10:52:08 +0200 Subject: [PATCH 17/31] linting --- workflow/rules/common.smk | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index 1327819e..0dd9c460 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -89,7 +89,9 @@ def parse_showcase_regions(): Custom regions carry their explicit extent and projection. """ result = {} - for r in config.get("showcase", {}).get("regions", ["globe", "europe", "switzerland"]): + for r in config.get("showcase", {}).get( + "regions", ["globe", "europe", "switzerland"] + ): if isinstance(r, str): result[r] = {"extent": None, "projection": None} else: From f2180a15cb9c3b467ed4ea0f987e459c586321d8 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Mon, 4 May 2026 17:32:05 +0200 Subject: [PATCH 18/31] add a showcase config example to a config --- config/interpolators-ich1.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1.yaml index 5cb8e175..417a0897 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1.yaml @@ -55,6 +55,22 @@ stratification: - alpensuedseite root: /scratch/mch/bhendj/regions/Prognoseregionen_LV95_20220517 +showcase: + params: + - T_2M + - SP_10M + - TOT_PREC + meteograms: false + animations: true + regions: + - europe + - switzerland + - name: alpine_arc + extent: [-16.0, 25.0, 30.0, 65.0] + projection: orthographic + + stations: [JUN] #, COV, GOR, WFJ, SAE, SAM, DAV, ZER, ANT, VSBAS, BRT, LTB, GOS, CEV, BIA] + locations: output_root: output/ From c26dd24d234b1fbb44eba74123941ec5b26d939d Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 5 May 2026 12:28:52 +0200 Subject: [PATCH 19/31] regions -> domains --- src/evalml/config.py | 6 +++--- workflow/rules/common.smk | 10 +++++----- workflow/rules/plot.smk | 2 +- workflow/tools/config.schema.json | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/evalml/config.py b/src/evalml/config.py index 08610bf8..9f305ec1 100644 --- a/src/evalml/config.py +++ b/src/evalml/config.py @@ -243,12 +243,12 @@ class ShowcaseConfig(BaseModel): default=["GVE", "KLO", "LUG"], description="List of PeakWeather station IDs to generate meteograms for.", ) - regions: List[str | RegionConfig] = Field( + domains: List[str | RegionConfig] = Field( default=["globe", "europe", "switzerland"], description=( - "Regions to generate animations for. Each entry is either a named region " + "Domains to generate animations for. Each entry is either a named domain " "(e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, " - "or a custom region dict with 'name', optional 'extent' " + "or a custom domain dict with 'name', optional 'extent' " "[lon_min, lon_max, lat_min, lat_max], and optional 'projection'." ), ) diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index 0dd9c460..e1a5a8b4 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -81,16 +81,16 @@ def parse_regions(): def parse_showcase_regions(): - """Parse showcase regions from config. + """Parse showcase domains from config. - Returns a dict mapping region name -> {extent, projection}. - Named regions (strings) have extent=None and projection=None, + Returns a dict mapping domain name -> {extent, projection}. + Named domains (strings) have extent=None and projection=None, meaning the plot script will fall back to the DOMAINS lookup. - Custom regions carry their explicit extent and projection. + Custom domains carry their explicit extent and projection. """ result = {} for r in config.get("showcase", {}).get( - "regions", ["globe", "europe", "switzerland"] + "domains", ["globe", "europe", "switzerland"] ): if isinstance(r, str): result[r] = {"extent": None, "projection": None} diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 29df048d..bd52d978 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -116,7 +116,7 @@ rule plot_forecast_frame: python {input.script} \ --input {params.grib_out_dir} --date {wildcards.init_time} --outfn {output[0]} \ --param {wildcards.param} --leadtime {wildcards.leadtime} --region {wildcards.region} \ - {params.region_extra} + {params.region_extra} \ --accu {params.accu} \ # interactive editing (needs to set localrule: True and use only one core) # marimo edit {input.script} -- \ diff --git a/workflow/tools/config.schema.json b/workflow/tools/config.schema.json index 7537d296..7ee2cb1f 100644 --- a/workflow/tools/config.schema.json +++ b/workflow/tools/config.schema.json @@ -543,13 +543,13 @@ "title": "Stations", "type": "array" }, - "regions": { + "domains": { "default": [ "globe", "europe", "switzerland" ], - "description": "Regions to generate animations for. Each entry is either a named region (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom region dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", + "description": "Domains to generate animations for. Each entry is either a named domain (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom domain dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", "items": { "anyOf": [ { @@ -560,7 +560,7 @@ } ] }, - "title": "Regions", + "title": "Domains", "type": "array" } }, From 2fe543ae0d166b97753dfca87e02362f67e80f2b Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 5 May 2026 20:24:48 +0200 Subject: [PATCH 20/31] fix for TOT_PREC (missing on step 0) --- src/data_input/__init__.py | 7 +++++-- workflow/rules/plot.smk | 12 +++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/data_input/__init__.py b/src/data_input/__init__.py index 71d99ca1..49956303 100644 --- a/src/data_input/__init__.py +++ b/src/data_input/__init__.py @@ -118,11 +118,14 @@ def load_fct_data_from_grib( ds[var] = da.rename({"z": da.attrs["vcoord_type"]}) ds = xr.merge([ds[p].rename(p) for p in ds], compat="no_conflicts") lead_times = np.array(steps, dtype="timedelta64[h]") - # Restrict to the requested lead times so that the TOT_PREC disaggregation + # Reindex to the requested lead times so that the TOT_PREC disaggregation # below operates on the correct step interval even if the GRIB contains # extra (e.g. hourly) steps beyond those requested — e.g. when consuming # output from an interpolator emulator or a baseline with sub-step output. - ds = ds.sel(lead_time=lead_times) + # reindex (rather than sel) fills missing steps with NaN instead of raising + # KeyError — needed because accumulated fields like TOT_PREC are absent at + # step 0 in the GRIB, and the NaN-fill logic below handles that case. + ds = ds.reindex(lead_time=lead_times) if "TOT_PREC" in ds.data_vars: ## Disaggregate TOT_PREC from cumulative-from-start (expected when the ## accumulate_from_start_of_forecast post-processor is enabled in diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index bd52d978..6d35c248 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -137,11 +137,17 @@ def get_leadtimes(wc): rule make_forecast_animation: localrule: True + wildcard_constraints: + param="|".join(map(re.escape, SHOWCASE_PARAMS)), + region="|".join(map(re.escape, SHOWCASE_REGIONS.keys())), input: - expand( + lambda wc: expand( rules.plot_forecast_frame.output, - leadtime=lambda wc: get_leadtimes(wc), - allow_missing=True, + run_id=wc.run_id, + init_time=wc.init_time, + param=wc.param, + region=wc.region, + leadtime=get_leadtimes(wc), ), output: OUT_ROOT From 68487bab5ff7b04d9b0e8e8f4f886d37fa3b1707 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 5 May 2026 20:28:03 +0200 Subject: [PATCH 21/31] update the extent of alpine arc --- config/interpolators-ich1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1.yaml index 417a0897..b89d5e09 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1.yaml @@ -66,7 +66,7 @@ showcase: - europe - switzerland - name: alpine_arc - extent: [-16.0, 25.0, 30.0, 65.0] + extent: [3.0, 17.0, 43.5, 48.5] projection: orthographic stations: [JUN] #, COV, GOR, WFJ, SAE, SAM, DAV, ZER, ANT, VSBAS, BRT, LTB, GOS, CEV, BIA] From 8e76a1bdaf57c0cb56cb7d140f3bc7295ab7b415 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 5 May 2026 20:38:22 +0200 Subject: [PATCH 22/31] add forgotten rename --- config/interpolators-ich1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1.yaml index b89d5e09..5fd24a73 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1.yaml @@ -62,7 +62,7 @@ showcase: - TOT_PREC meteograms: false animations: true - regions: + domains: - europe - switzerland - name: alpine_arc From f91cd6599fbe92a1c5a7edfa8f86cf197071e54e Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Thu, 7 May 2026 17:10:41 +0200 Subject: [PATCH 23/31] organize the configs --- config/interpolators-ich1.yaml | 21 ++++---- src/evalml/config.py | 40 ++++++++++---- workflow/Snakefile | 6 +-- workflow/rules/common.smk | 2 +- workflow/tools/config.schema.json | 87 ++++++++++++++++--------------- 5 files changed, 90 insertions(+), 66 deletions(-) diff --git a/config/interpolators-ich1.yaml b/config/interpolators-ich1.yaml index 5fd24a73..fc835e18 100644 --- a/config/interpolators-ich1.yaml +++ b/config/interpolators-ich1.yaml @@ -60,16 +60,17 @@ showcase: - T_2M - SP_10M - TOT_PREC - meteograms: false - animations: true - domains: - - europe - - switzerland - - name: alpine_arc - extent: [3.0, 17.0, 43.5, 48.5] - projection: orthographic - - stations: [JUN] #, COV, GOR, WFJ, SAE, SAM, DAV, ZER, ANT, VSBAS, BRT, LTB, GOS, CEV, BIA] + meteograms: + enabled: false + stations: [JUN] #, COV, GOR, WFJ, SAE, SAM, DAV, ZER, ANT, VSBAS, BRT, LTB, GOS, CEV, BIA] + animations: + enabled: true + domains: + - europe + - switzerland + - name: alpine_arc + extent: [3.0, 17.0, 43.5, 48.5] + projection: orthographic locations: output_root: output/ diff --git a/src/evalml/config.py b/src/evalml/config.py index 9f305ec1..7a031c1f 100644 --- a/src/evalml/config.py +++ b/src/evalml/config.py @@ -224,25 +224,26 @@ class RegionConfig(BaseModel): model_config = {"extra": "forbid"} -class ShowcaseConfig(BaseModel): - """Configuration for the showcase workflow.""" +class MeteogramConfig(BaseModel): + """Configuration for meteogram generation.""" - meteograms: bool = Field( + enabled: bool = Field( default=True, description="Whether to generate meteograms (time series plots at stations).", ) - animations: bool = Field( - default=True, - description="Whether to generate forecast animations (GIFs per param and region).", - ) - params: List[str] = Field( - default=["T_2M", "SP_10M"], - description="List of parameters to generate animations and meteograms for.", - ) stations: List[str] = Field( default=["GVE", "KLO", "LUG"], description="List of PeakWeather station IDs to generate meteograms for.", ) + + +class AnimationsConfig(BaseModel): + """Configuration for animation generation.""" + + enabled: bool = Field( + default=True, + description="Whether to generate forecast animations (GIFs per param and region).", + ) domains: List[str | RegionConfig] = Field( default=["globe", "europe", "switzerland"], description=( @@ -254,6 +255,23 @@ class ShowcaseConfig(BaseModel): ) +class ShowcaseConfig(BaseModel): + """Configuration for the showcase workflow.""" + + params: List[str] = Field( + default=["T_2M", "SP_10M"], + description="List of parameters to generate animations and meteograms for.", + ) + meteograms: MeteogramConfig = Field( + default_factory=MeteogramConfig, + description="Configuration for meteogram generation.", + ) + animations: AnimationsConfig = Field( + default_factory=AnimationsConfig, + description="Configuration for animation generation.", + ) + + class Locations(BaseModel): """Locations of data and services used in the workflow.""" diff --git a/workflow/Snakefile b/workflow/Snakefile index 5dfab820..655b9207 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -147,7 +147,7 @@ rule showcase_all: region=list(SHOWCASE_REGIONS.keys()), showcase=EXPERIMENT_NAME, ) - if config["showcase"]["animations"] + if config["showcase"]["animations"]["enabled"] else [] ), ( @@ -156,10 +156,10 @@ rule showcase_all: init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], run_id=CANDIDATES, param=SHOWCASE_PARAMS, - sta=config["showcase"]["stations"], + sta=config["showcase"]["meteograms"]["stations"], showcase=EXPERIMENT_NAME, ) - if config["showcase"]["meteograms"] + if config["showcase"]["meteograms"]["enabled"] else [] ), diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index e1a5a8b4..ae54ddab 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -89,7 +89,7 @@ def parse_showcase_regions(): Custom domains carry their explicit extent and projection. """ result = {} - for r in config.get("showcase", {}).get( + for r in config.get("showcase", {}).get("animations", {}).get( "domains", ["globe", "europe", "switzerland"] ): if isinstance(r, str): diff --git a/workflow/tools/config.schema.json b/workflow/tools/config.schema.json index 7ee2cb1f..0e83e64d 100644 --- a/workflow/tools/config.schema.json +++ b/workflow/tools/config.schema.json @@ -503,67 +503,72 @@ "title": "RegionConfig", "type": "object" }, - "ShowcaseConfig": { - "description": "Configuration for the showcase workflow.", + "MeteogramConfig": { + "description": "Configuration for meteogram generation.", "properties": { - "meteograms": { + "enabled": { "default": true, "description": "Whether to generate meteograms (time series plots at stations).", - "title": "Meteograms", - "type": "boolean" - }, - "animations": { - "default": true, - "description": "Whether to generate forecast animations (GIFs per param and region).", - "title": "Animations", + "title": "Enabled", "type": "boolean" }, - "params": { - "default": [ - "T_2M", - "SP_10M" - ], - "description": "List of parameters to generate animations and meteograms for.", - "items": { - "type": "string" - }, - "title": "Params", - "type": "array" - }, "stations": { - "default": [ - "GVE", - "KLO", - "LUG" - ], + "default": ["GVE", "KLO", "LUG"], "description": "List of PeakWeather station IDs to generate meteograms for.", - "items": { - "type": "string" - }, + "items": {"type": "string"}, "title": "Stations", "type": "array" + } + }, + "title": "MeteogramConfig", + "type": "object" + }, + "AnimationsConfig": { + "description": "Configuration for animation generation.", + "properties": { + "enabled": { + "default": true, + "description": "Whether to generate forecast animations (GIFs per param and region).", + "title": "Enabled", + "type": "boolean" }, "domains": { - "default": [ - "globe", - "europe", - "switzerland" - ], + "default": ["globe", "europe", "switzerland"], "description": "Domains to generate animations for. Each entry is either a named domain (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom domain dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", "items": { "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/$defs/RegionConfig" - } + {"type": "string"}, + {"$ref": "#/$defs/RegionConfig"} ] }, "title": "Domains", "type": "array" } }, + "title": "AnimationsConfig", + "type": "object" + }, + "ShowcaseConfig": { + "description": "Configuration for the showcase workflow.", + "properties": { + "params": { + "default": ["T_2M", "SP_10M"], + "description": "List of parameters to generate animations and meteograms for.", + "items": {"type": "string"}, + "title": "Params", + "type": "array" + }, + "meteograms": { + "$ref": "#/$defs/MeteogramConfig", + "default": {}, + "description": "Configuration for meteogram generation." + }, + "animations": { + "$ref": "#/$defs/AnimationsConfig", + "default": {}, + "description": "Configuration for animation generation." + } + }, "title": "ShowcaseConfig", "type": "object" }, From 8729776903f804fa4c9153669b3961a0e1f3a8c1 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Mon, 18 May 2026 11:23:44 +0200 Subject: [PATCH 24/31] fix linting --- workflow/rules/common.smk | 6 +- workflow/tools/config.schema.json | 121 +++++++++++++++++------------- 2 files changed, 72 insertions(+), 55 deletions(-) diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index ae54ddab..30a5b77e 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -89,8 +89,10 @@ def parse_showcase_regions(): Custom domains carry their explicit extent and projection. """ result = {} - for r in config.get("showcase", {}).get("animations", {}).get( - "domains", ["globe", "europe", "switzerland"] + for r in ( + config.get("showcase", {}) + .get("animations", {}) + .get("domains", ["globe", "europe", "switzerland"]) ): if isinstance(r, str): result[r] = {"extent": None, "projection": None} diff --git a/workflow/tools/config.schema.json b/workflow/tools/config.schema.json index f178b9d7..3d749361 100644 --- a/workflow/tools/config.schema.json +++ b/workflow/tools/config.schema.json @@ -1,5 +1,38 @@ { "$defs": { + "AnimationsConfig": { + "description": "Configuration for animation generation.", + "properties": { + "enabled": { + "default": true, + "description": "Whether to generate forecast animations (GIFs per param and region).", + "title": "Enabled", + "type": "boolean" + }, + "domains": { + "default": [ + "globe", + "europe", + "switzerland" + ], + "description": "Domains to generate animations for. Each entry is either a named domain (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom domain dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/RegionConfig" + } + ] + }, + "title": "Domains", + "type": "array" + } + }, + "title": "AnimationsConfig", + "type": "object" + }, "BaselineConfig": { "description": "Configuration for a single baseline to include in the verification.", "properties": { @@ -445,6 +478,32 @@ "title": "Locations", "type": "object" }, + "MeteogramConfig": { + "description": "Configuration for meteogram generation.", + "properties": { + "enabled": { + "default": true, + "description": "Whether to generate meteograms (time series plots at stations).", + "title": "Enabled", + "type": "boolean" + }, + "stations": { + "default": [ + "GVE", + "KLO", + "LUG" + ], + "description": "List of PeakWeather station IDs to generate meteograms for.", + "items": { + "type": "string" + }, + "title": "Stations", + "type": "array" + } + }, + "title": "MeteogramConfig", + "type": "object" + }, "Profile": { "description": "Workflow execution profile.", "properties": { @@ -521,70 +580,26 @@ "title": "RegionConfig", "type": "object" }, - "MeteogramConfig": { - "description": "Configuration for meteogram generation.", - "properties": { - "enabled": { - "default": true, - "description": "Whether to generate meteograms (time series plots at stations).", - "title": "Enabled", - "type": "boolean" - }, - "stations": { - "default": ["GVE", "KLO", "LUG"], - "description": "List of PeakWeather station IDs to generate meteograms for.", - "items": {"type": "string"}, - "title": "Stations", - "type": "array" - } - }, - "title": "MeteogramConfig", - "type": "object" - }, - "AnimationsConfig": { - "description": "Configuration for animation generation.", - "properties": { - "enabled": { - "default": true, - "description": "Whether to generate forecast animations (GIFs per param and region).", - "title": "Enabled", - "type": "boolean" - }, - "domains": { - "default": ["globe", "europe", "switzerland"], - "description": "Domains to generate animations for. Each entry is either a named domain (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom domain dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", - "items": { - "anyOf": [ - {"type": "string"}, - {"$ref": "#/$defs/RegionConfig"} - ] - }, - "title": "Domains", - "type": "array" - } - }, - "title": "AnimationsConfig", - "type": "object" - }, "ShowcaseConfig": { "description": "Configuration for the showcase workflow.", "properties": { "params": { - "default": ["T_2M", "SP_10M"], + "default": [ + "T_2M", + "SP_10M" + ], "description": "List of parameters to generate animations and meteograms for.", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "title": "Params", "type": "array" }, "meteograms": { - "$ref": "#/$defs/MeteogramConfig", - "default": {}, - "description": "Configuration for meteogram generation." + "$ref": "#/$defs/MeteogramConfig" }, "animations": { - "$ref": "#/$defs/AnimationsConfig", - "default": {}, - "description": "Configuration for animation generation." + "$ref": "#/$defs/AnimationsConfig" } }, "title": "ShowcaseConfig", From cc58dc7aaa01a612c3aa8a2fb6a69ea7916c20d9 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 19 May 2026 11:57:01 +0200 Subject: [PATCH 25/31] changes to process regions and stations at once --- workflow/Snakefile | 5 +- workflow/rules/plot.smk | 62 ++-- ...ast_frame.mo.py => plot_forecast_frame.py} | 0 workflow/scripts/plot_meteogram.mo.py | 293 ------------------ workflow/scripts/plot_meteogram.py | 217 +++++++++++++ 5 files changed, 251 insertions(+), 326 deletions(-) rename workflow/scripts/{plot_forecast_frame.mo.py => plot_forecast_frame.py} (100%) delete mode 100644 workflow/scripts/plot_meteogram.mo.py create mode 100644 workflow/scripts/plot_meteogram.py diff --git a/workflow/Snakefile b/workflow/Snakefile index 655b9207..dbc238db 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -152,7 +152,10 @@ rule showcase_all: ), ( expand( - rules.plot_meteogram.output, + str( + OUT_ROOT + / "results/{showcase}/{run_id}/{init_time}/{init_time}_{param}_{sta}.png" + ), init_time=[t.strftime("%Y%m%d%H%M") for t in REFTIMES], run_id=CANDIDATES, param=SHOWCASE_PARAMS, diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 6d35c248..74f20c7c 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -27,14 +27,16 @@ def _get_available_baselines(wc) -> list[dict[str, str]]: rule plot_meteogram: input: - script="workflow/scripts/plot_meteogram.mo.py", + script="workflow/scripts/plot_meteogram.py", inference_okfile=rules.inference_execute.output.okfile, truth=config["truth"]["root"], peakweather_dir=rules.data_download_obs_from_peakweather.output.root, output: - OUT_ROOT - / "results/{showcase}/{run_id}/{init_time}/{init_time}_{param}_{sta}.png", - # localrule: True + expand( + OUT_ROOT + / "results/{{showcase}}/{{run_id}}/{{init_time}}/{{init_time}}_{{param}}_{sta}.png", + sta=config["showcase"]["meteograms"]["stations"], + ), resources: slurm_partition="postproc", cpus_per_task=1, @@ -49,6 +51,10 @@ rule plot_meteogram: baseline_zarrs=lambda wc: [x["zarr"] for x in _get_available_baselines(wc)], baseline_steps=lambda wc: [x["steps"] for x in _get_available_baselines(wc)], baseline_labels=lambda wc: [x["label"] for x in _get_available_baselines(wc)], + outdir=lambda wc: str( + (Path(OUT_ROOT) / f"results/{wc.showcase}/{wc.run_id}/{wc.init_time}").resolve() + ), + stations=config["showcase"]["meteograms"]["stations"], shell: """ set -euo pipefail @@ -66,9 +72,9 @@ rule plot_meteogram: --analysis_label {params.ana_label:q} --peakweather {input.peakweather_dir:q} --date {wildcards.init_time:q} - --outfn {output[0]:q} + --outdir {params.outdir:q} --param {wildcards.param:q} - --station {wildcards.sta:q} + --stations {params.stations:q} ) for i in "${{!BASELINE_ZARRS[@]}}"; do @@ -78,51 +84,43 @@ rule plot_meteogram: done python {input.script} "${{CMD_ARGS[@]}}" - # interactive editing (needs to set localrule: True and use only one core) - # marimo edit {input.script} -- "${{CMD_ARGS[@]}}" """ rule plot_forecast_frame: input: - script="workflow/scripts/plot_forecast_frame.mo.py", + script="workflow/scripts/plot_forecast_frame.py", inference_okfile=rules.inference_execute.output.okfile, output: - OUT_ROOT - / "data/runs/{run_id}/{init_time}/frames/frame_{leadtime}_{param}_{region}.png", + expand( + OUT_ROOT + / "data/runs/{{run_id}}/{{init_time}}/frames/frame_{{leadtime}}_{{param}}_{region}.png", + region=list(SHOWCASE_REGIONS.keys()), + ), wildcard_constraints: leadtime=r"\d+", # only digits - region="|".join(map(re.escape, SHOWCASE_REGIONS.keys())), resources: slurm_partition="postproc", cpus_per_task=1, runtime="10m", params: - grib_out_dir=lambda wc: ( - Path(OUT_ROOT) / f"data/runs/{wc.run_id}/{wc.init_time}/grib" - ).resolve(), - region_extra=lambda wc: ( - "--extent {} --projection {}".format( - " ".join(map(str, SHOWCASE_REGIONS[wc.region]["extent"])), - SHOWCASE_REGIONS[wc.region]["projection"], - ) - if SHOWCASE_REGIONS.get(wc.region, {}).get("extent") is not None - else "" + grib_out_dir=lambda wc: str( + (Path(OUT_ROOT) / f"data/runs/{wc.run_id}/{wc.init_time}/grib").resolve() + ), + regions_json=json.dumps(SHOWCASE_REGIONS), + outdir=lambda wc: str( + (Path(OUT_ROOT) / f"data/runs/{wc.run_id}/{wc.init_time}/frames").resolve() ), accu=lambda wc: int(RUN_CONFIGS[wc.run_id]["steps"].split("/")[2]), shell: """ export ECCODES_DEFINITION_PATH=$(realpath .venv/share/eccodes-cosmo-resources/definitions) python {input.script} \ - --input {params.grib_out_dir} --date {wildcards.init_time} --outfn {output[0]} \ - --param {wildcards.param} --leadtime {wildcards.leadtime} --region {wildcards.region} \ - {params.region_extra} \ - --accu {params.accu} \ - # interactive editing (needs to set localrule: True and use only one core) - # marimo edit {input.script} -- \ - # --input {params.grib_out_dir} --date {wildcards.init_time} --outfn {output[0]}\ - # --param {wildcards.param} --leadtime {wildcards.leadtime} --region {wildcards.region}\ - # --accu {params.accu}\ + --input {params.grib_out_dir:q} --date {wildcards.init_time:q} \ + --param {wildcards.param:q} --leadtime {wildcards.leadtime:q} \ + --regions_json {params.regions_json:q} \ + --outdir {params.outdir:q} \ + --accu {params.accu} """ @@ -142,7 +140,7 @@ rule make_forecast_animation: region="|".join(map(re.escape, SHOWCASE_REGIONS.keys())), input: lambda wc: expand( - rules.plot_forecast_frame.output, + str(OUT_ROOT / "data/runs/{run_id}/{init_time}/frames/frame_{leadtime}_{param}_{region}.png"), run_id=wc.run_id, init_time=wc.init_time, param=wc.param, diff --git a/workflow/scripts/plot_forecast_frame.mo.py b/workflow/scripts/plot_forecast_frame.py similarity index 100% rename from workflow/scripts/plot_forecast_frame.mo.py rename to workflow/scripts/plot_forecast_frame.py diff --git a/workflow/scripts/plot_meteogram.mo.py b/workflow/scripts/plot_meteogram.mo.py deleted file mode 100644 index c26d2c24..00000000 --- a/workflow/scripts/plot_meteogram.mo.py +++ /dev/null @@ -1,293 +0,0 @@ -import marimo - -__generated_with = "0.19.6" -app = marimo.App(width="medium") - - -@app.cell -def _(): - from argparse import ArgumentParser - from datetime import datetime - from pathlib import Path - - import matplotlib.pyplot as plt - import numpy as np - from peakweather import PeakWeatherDataset - - from data_input import ( - parse_steps, - load_forecast_data, - load_truth_data, - ) - from verification.spatial import map_forecast_to_truth - - return ( - ArgumentParser, - Path, - PeakWeatherDataset, - datetime, - load_forecast_data, - load_truth_data, - map_forecast_to_truth, - np, - parse_steps, - plt, - ) - - -@app.cell -def _(ArgumentParser, Path, datetime, parse_steps): - parser = ArgumentParser() - - parser.add_argument( - "--forecast", type=str, default=None, help="Directory to forecast grib data" - ) - parser.add_argument( - "--forecast_steps", - type=parse_steps, - default="0/120/6", - help="Forecast steps in the format 'start/stop/step' (default: 0/120/6).", - ) - parser.add_argument( - "--forecast_label", - type=str, - default="forecast", - help="Label for forecast line in plot legend.", - ) - parser.add_argument( - "--baseline", - action="append", - type=str, - default=[], - help="Path to baseline zarr data (repeatable).", - ) - parser.add_argument( - "--baseline_steps", - action="append", - type=parse_steps, - default=[], - help=( - "Forecast steps in the format 'start/stop/step' for each baseline " - "(repeatable, must match --baseline count)." - ), - ) - parser.add_argument( - "--baseline_label", - action="append", - type=str, - default=[], - help="Label for each baseline line in plot legend (repeatable).", - ) - parser.add_argument( - "--analysis", type=str, default=None, help="Path to analysis zarr data" - ) - parser.add_argument( - "--analysis_label", - type=str, - default="truth", - help="Label for analysis line in plot legend.", - ) - parser.add_argument( - "--peakweather", type=str, default=None, help="Path to PeakWeather dataset" - ) - parser.add_argument("--date", type=str, default=None, help="reference datetime") - parser.add_argument("--outfn", type=str, help="output filename") - parser.add_argument("--param", type=str, help="parameter") - parser.add_argument("--station", type=str, help="station") - - args = parser.parse_args() - forecast_grib_dir = Path(args.forecast) - forecast_steps = args.forecast_steps - forecast_label = args.forecast_label - analysis_zarr = Path(args.analysis) - analysis_label = args.analysis_label - baseline_zarrs = [Path(path) for path in args.baseline] - baseline_steps = args.baseline_steps - baseline_labels = args.baseline_label - if len(baseline_zarrs) != len(baseline_steps): - raise ValueError( - "Mismatched baseline arguments: --baseline and --baseline_steps " - "must be provided the same number of times." - ) - if len(baseline_labels) != len(baseline_zarrs): - raise ValueError( - "Mismatched baseline arguments: --baseline and --baseline_label " - "must be provided the same number of times." - ) - peakweather_dir = Path(args.peakweather) - init_time = datetime.strptime(args.date, "%Y%m%d%H%M") - outfn = Path(args.outfn) - station = args.station - param = args.param - return ( - analysis_label, - analysis_zarr, - baseline_labels, - baseline_steps, - baseline_zarrs, - forecast_label, - forecast_steps, - forecast_grib_dir, - init_time, - outfn, - param, - peakweather_dir, - station, - ) - - -@app.cell -def _(np): - def preprocess_ds(ds, param: str): - ds = ds.copy() - # 10m wind speed - if param == "SP_10M": - ds[param] = np.sqrt(ds.U_10M**2 + ds.V_10M**2) - try: - units = ds["U_10M"].attrs["parameter"]["units"] - except KeyError: - units = None - ds[param].attrs["parameter"] = { - "shortName": "SP_10M", - "units": units, - "name": "10m wind speed", - } - ds = ds.drop_vars(["U_10M", "V_10M"]) - # wind speed from standard-level components - if param == "SP": - ds[param] = np.sqrt(ds.U**2 + ds.V**2) - units = ds.U.attrs["parameter"]["units"] - ds[param].attrs["parameter"] = { - "shortName": "SP", - "units": units, - "name": '"Wind speed', - } - ds = ds.drop_vars(["U", "V"]) - return ds.squeeze() - - return (preprocess_ds,) - - -@app.cell -def load_data( - analysis_zarr, - baseline_steps, - baseline_zarrs, - forecast_steps, - forecast_grib_dir, - init_time, - load_forecast_data, - load_truth_data, - param, - preprocess_ds, -): - if param == "SP_10M": - paramlist = ["U_10M", "V_10M"] - elif param == "SP": - paramlist = ["U", "V"] - else: - paramlist = [param] - - forecast_ds = load_forecast_data( - forecast_grib_dir, init_time, forecast_steps, paramlist - ) - forecast_ds = preprocess_ds(forecast_ds, param) - - steps = forecast_ds.lead_time.dt.total_seconds().values / 3600 - analysis_ds = load_truth_data(analysis_zarr, init_time, steps, paramlist) - analysis_ds = preprocess_ds(analysis_ds, param) - - baseline_ds_list = [ - preprocess_ds( - load_forecast_data(zarr, init_time, step, paramlist), - param, - ) - for zarr, step in zip(baseline_zarrs, baseline_steps) - ] - - return analysis_ds, baseline_ds_list, forecast_ds - - -@app.cell -def _(PeakWeatherDataset, peakweather_dir, station): - peakweather = PeakWeatherDataset(root=peakweather_dir) - stations = peakweather.stations_table - stations.index.names = ["values"] - station_ds = stations.to_xarray().sel(values=[station]) # keep singleton dim - station_ds = station_ds.rename({"latitude": "lat", "longitude": "lon"}) - station_ds = station_ds.set_coords(("lat", "lon", "station_name")) - station_ds = station_ds.drop_vars(list(station_ds.data_vars)) - station_ds - return (station_ds,) - - -@app.cell -def _(analysis_ds, baseline_ds_list, forecast_ds, station_ds, map_forecast_to_truth): - forecast_station_ds = map_forecast_to_truth(forecast_ds, station_ds) - analysis_station_ds = map_forecast_to_truth(analysis_ds, station_ds) - baseline_station_ds_list = [ - map_forecast_to_truth(ds, station_ds) for ds in baseline_ds_list - ] - return analysis_station_ds, baseline_station_ds_list, forecast_station_ds - - -@app.cell -def _( - analysis_label, - baseline_labels, - analysis_station_ds, - baseline_station_ds_list, - forecast_label, - forecast_ds, - forecast_station_ds, - init_time, - outfn, - param, - plt, - station, -): - fig, ax = plt.subplots() - - # truth - ax.plot( - analysis_station_ds["time"].values, - analysis_station_ds[param].values, - color="k", - ls="--", - label=analysis_label, - ) - # baselines - for i, (baseline_label, baseline_station_ds) in enumerate( - zip(baseline_labels, baseline_station_ds_list), start=1 - ): - ax.plot( - baseline_station_ds["time"].values, - baseline_station_ds[param].values, - color=f"C{i}", - label=f"{baseline_label}", - ) - # forecast - ax.plot( - forecast_station_ds["time"].values, - forecast_station_ds[param].values, - color="C0", - label=forecast_label, - ) - - ax.legend() - - param2plot = forecast_ds[param].attrs.get("parameter", {}) - short = param2plot.get("shortName", "") - units = param2plot.get("units", "") - name = param2plot.get("name", "") - - ax.set_ylabel(f"{short} ({units})" if short or units else "") - ax.set_title(f"{init_time} {name} at {station}") - - plt.savefig(outfn) - print(f"saved: {outfn}") - return - - -if __name__ == "__main__": - app.run() diff --git a/workflow/scripts/plot_meteogram.py b/workflow/scripts/plot_meteogram.py new file mode 100644 index 00000000..d0583127 --- /dev/null +++ b/workflow/scripts/plot_meteogram.py @@ -0,0 +1,217 @@ +import logging +from argparse import ArgumentParser +from datetime import datetime +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +from peakweather import PeakWeatherDataset + +from data_input import ( + parse_steps, + load_forecast_data, + load_truth_data, +) +from verification.spatial import map_forecast_to_truth + +LOG = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + + +def preprocess_ds(ds, param: str): + ds = ds.copy() + if param == "SP_10M": + ds[param] = np.sqrt(ds.U_10M**2 + ds.V_10M**2) + try: + units = ds["U_10M"].attrs["parameter"]["units"] + except KeyError: + units = None + ds[param].attrs["parameter"] = { + "shortName": "SP_10M", + "units": units, + "name": "10m wind speed", + } + ds = ds.drop_vars(["U_10M", "V_10M"]) + if param == "SP": + ds[param] = np.sqrt(ds.U**2 + ds.V**2) + units = ds.U.attrs["parameter"]["units"] + ds[param].attrs["parameter"] = { + "shortName": "SP", + "units": units, + "name": "Wind speed", + } + ds = ds.drop_vars(["U", "V"]) + return ds.squeeze() + + +def main(): + parser = ArgumentParser() + parser.add_argument( + "--forecast", type=str, default=None, help="Directory to forecast grib data" + ) + parser.add_argument( + "--forecast_steps", + type=parse_steps, + default="0/120/6", + help="Forecast steps in the format 'start/stop/step' (default: 0/120/6).", + ) + parser.add_argument( + "--forecast_label", + type=str, + default="forecast", + help="Label for forecast line in plot legend.", + ) + parser.add_argument( + "--baseline", + action="append", + type=str, + default=[], + help="Path to baseline zarr data (repeatable).", + ) + parser.add_argument( + "--baseline_steps", + action="append", + type=parse_steps, + default=[], + help=( + "Forecast steps in the format 'start/stop/step' for each baseline " + "(repeatable, must match --baseline count)." + ), + ) + parser.add_argument( + "--baseline_label", + action="append", + type=str, + default=[], + help="Label for each baseline line in plot legend (repeatable).", + ) + parser.add_argument( + "--analysis", type=str, default=None, help="Path to analysis zarr data" + ) + parser.add_argument( + "--analysis_label", + type=str, + default="truth", + help="Label for analysis line in plot legend.", + ) + parser.add_argument( + "--peakweather", type=str, default=None, help="Path to PeakWeather dataset" + ) + parser.add_argument("--date", type=str, default=None, help="reference datetime") + parser.add_argument("--outdir", type=str, help="output directory") + parser.add_argument("--param", type=str, help="parameter") + parser.add_argument("--stations", nargs="+", type=str, help="station IDs") + + args = parser.parse_args() + + forecast_grib_dir = Path(args.forecast) + forecast_steps = args.forecast_steps + forecast_label = args.forecast_label + analysis_zarr = Path(args.analysis) + analysis_label = args.analysis_label + baseline_zarrs = [Path(path) for path in args.baseline] + baseline_steps = args.baseline_steps + baseline_labels = args.baseline_label + if len(baseline_zarrs) != len(baseline_steps): + raise ValueError( + "Mismatched baseline arguments: --baseline and --baseline_steps " + "must be provided the same number of times." + ) + if len(baseline_labels) != len(baseline_zarrs): + raise ValueError( + "Mismatched baseline arguments: --baseline and --baseline_label " + "must be provided the same number of times." + ) + peakweather_dir = Path(args.peakweather) + init_time = datetime.strptime(args.date, "%Y%m%d%H%M") + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + param = args.param + stations = args.stations + + if param == "SP_10M": + paramlist = ["U_10M", "V_10M"] + elif param == "SP": + paramlist = ["U", "V"] + else: + paramlist = [param] + + # Load gridded data once — shared across all station plots + forecast_ds = load_forecast_data(forecast_grib_dir, init_time, forecast_steps, paramlist) + forecast_ds = preprocess_ds(forecast_ds, param) + + steps = [int(s) for s in forecast_ds.lead_time.dt.total_seconds().values / 3600] + analysis_ds = load_truth_data(analysis_zarr, init_time, steps, paramlist) + analysis_ds = preprocess_ds(analysis_ds, param) + + baseline_ds_list = [ + preprocess_ds( + load_forecast_data(zarr, init_time, step, paramlist), + param, + ) + for zarr, step in zip(baseline_zarrs, baseline_steps) + ] + + # Load station metadata once + peakweather = PeakWeatherDataset(root=peakweather_dir) + stations_table = peakweather.stations_table + stations_table.index.names = ["values"] + + param2plot = forecast_ds[param].attrs.get("parameter", {}) + short = param2plot.get("shortName", "") + units = param2plot.get("units", "") + name = param2plot.get("name", "") + + # Loop over stations — data is loaded once, mapping is per station + for station in stations: + station_ds = stations_table.to_xarray().sel(values=[station]) + station_ds = station_ds.rename({"latitude": "lat", "longitude": "lon"}) + station_ds = station_ds.set_coords(("lat", "lon", "station_name")) + station_ds = station_ds.drop_vars(list(station_ds.data_vars)) + + forecast_station_ds = map_forecast_to_truth(forecast_ds, station_ds) + analysis_station_ds = map_forecast_to_truth(analysis_ds, station_ds) + baseline_station_ds_list = [ + map_forecast_to_truth(ds, station_ds) for ds in baseline_ds_list + ] + + fig, ax = plt.subplots() + + ax.plot( + analysis_station_ds["time"].values, + analysis_station_ds[param].values, + color="k", + ls="--", + label=analysis_label, + ) + for i, (baseline_label, baseline_station_ds) in enumerate( + zip(baseline_labels, baseline_station_ds_list), start=1 + ): + ax.plot( + baseline_station_ds["time"].values, + baseline_station_ds[param].values, + color=f"C{i}", + label=baseline_label, + ) + ax.plot( + forecast_station_ds["time"].values, + forecast_station_ds[param].values, + color="C0", + label=forecast_label, + ) + + ax.legend() + ax.set_ylabel(f"{short} ({units})" if short or units else "") + ax.set_title(f"{init_time} {name} at {station}") + + outfn = outdir / f"{init_time.strftime('%Y%m%d%H%M')}_{param}_{station}.png" + plt.savefig(outfn) + plt.close(fig) + LOG.info(f"saved: {outfn}") + + +if __name__ == "__main__": + main() From c1f758bda33f0c37919987ad153b60f52941ce99 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Mon, 18 May 2026 11:23:44 +0200 Subject: [PATCH 26/31] fix linting --- workflow/rules/common.smk | 6 +- workflow/scripts/plot_forecast_frame.py | 353 ++++++++---------------- workflow/tools/config.schema.json | 121 ++++---- 3 files changed, 184 insertions(+), 296 deletions(-) diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index ae54ddab..30a5b77e 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -89,8 +89,10 @@ def parse_showcase_regions(): Custom domains carry their explicit extent and projection. """ result = {} - for r in config.get("showcase", {}).get("animations", {}).get( - "domains", ["globe", "europe", "switzerland"] + for r in ( + config.get("showcase", {}) + .get("animations", {}) + .get("domains", ["globe", "europe", "switzerland"]) ): if isinstance(r, str): result[r] = {"extent": None, "projection": None} diff --git a/workflow/scripts/plot_forecast_frame.py b/workflow/scripts/plot_forecast_frame.py index e370136d..ad1c559d 100644 --- a/workflow/scripts/plot_forecast_frame.py +++ b/workflow/scripts/plot_forecast_frame.py @@ -1,110 +1,107 @@ -import marimo - -__generated_with = "0.16.5" -app = marimo.App(width="medium") - - -@app.cell -def _(): - import logging - from argparse import ArgumentParser - from pathlib import Path - - import cartopy.crs as ccrs - import earthkit.plots as ekp - import numpy as np - - from plotting import DOMAINS - from plotting import get_projection - from plotting import StatePlotter - from plotting.colormap_defaults import CMAP_DEFAULTS - from plotting.compat import load_state_from_grib - - return ( - ArgumentParser, - CMAP_DEFAULTS, - Path, - StatePlotter, - ekp, - load_state_from_grib, - logging, - np, - DOMAINS, - get_projection, - ccrs, - ) - - -@app.cell -def _(logging): - LOG = logging.getLogger(__name__) - LOG_FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - logging.basicConfig(level=logging.INFO, format=LOG_FMT) - return (LOG,) +import json +import logging +from argparse import ArgumentParser +from pathlib import Path + +import cartopy.crs as ccrs +import earthkit.meteo.thermo as ekm_thermo +import earthkit.meteo.wind as ekm_wind +import earthkit.plots as ekp +import numpy as np + +from plotting import DOMAINS +from plotting import get_projection +from plotting import StatePlotter +from plotting.colormap_defaults import CMAP_DEFAULTS +from plotting.compat import load_state_from_grib + +LOG = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + + +def get_style(param, units_override=None, accu=1): + """Get style and colormap settings for the plot.""" + lookup = f"{param}_{accu}H" if param == "TOT_PREC" else param + cfg = CMAP_DEFAULTS[lookup] + units = units_override if units_override is not None else cfg.get("units", "") + return { + "style": ekp.styles.Style( + levels=cfg.get("bounds", cfg.get("levels", None)), + extend="both", + units=units, + colors=cfg.get("colors", None), + ), + "norm": cfg.get("norm", None), + "cmap": cfg.get("cmap", None), + "levels": cfg.get("levels", None), + "vmin": cfg.get("vmin", None), + "vmax": cfg.get("vmax", None), + "colors": cfg.get("colors", None), + } + + +def _m_to_mm(arr): + return arr * 1000 + + +def preprocess_field(param: str, state: dict): + """ + - Temperatures: K -> °C + - Wind speed: sqrt(u^2 + v^2) + - Precipitation: m -> mm + Returns: (field_array, units_override or None) + """ + fields = state["fields"] + if param in ("T_2M", "TD_2M", "T", "TD"): + return ekm_thermo.kelvin_to_celsius(fields[param]), "°C" + if param == "SP_10M": + return ekm_wind.speed(fields["U_10M"], fields["V_10M"]), "m/s" + if param == "SP": + return ekm_wind.speed(fields["U"], fields["V"]), "m/s" + if param == "TOT_PREC": + return np.maximum(_m_to_mm(fields[param]), 0), "mm" + return fields[param], None -@app.cell -def _(ArgumentParser, Path): +def main(): parser = ArgumentParser() - - parser.add_argument( - "--input", type=str, default=None, help="Directory to grib data" - ) + parser.add_argument("--input", type=str, default=None, help="Directory to grib data") parser.add_argument("--date", type=str, default=None, help="reference datetime") - parser.add_argument("--outfn", type=str, help="output filename") parser.add_argument("--leadtime", type=str, help="leadtime") parser.add_argument("--param", type=str, help="parameter") - parser.add_argument("--region", type=str, help="name of region") - parser.add_argument( - "--extent", - type=float, - nargs=4, - default=None, - metavar=("LON_MIN", "LON_MAX", "LAT_MIN", "LAT_MAX"), - help="custom geographic extent in PlateCarree coordinates; overrides DOMAINS lookup", - ) parser.add_argument( - "--projection", + "--regions_json", type=str, - default=None, - help="projection name (e.g. 'orthographic'); used only together with --extent", - ) - parser.add_argument( - "--accu", type=int, default=1, help="accumulation period in hours" + help="JSON dict mapping region name -> {extent, projection}", ) + parser.add_argument("--outdir", type=str, help="output directory") + parser.add_argument("--accu", type=int, default=1, help="accumulation period in hours") args = parser.parse_args() grib_dir = Path(args.input) init_time = args.date - outfn = Path(args.outfn) lead_time = args.leadtime param = args.param - region = args.region + regions = json.loads(args.regions_json) + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) accu = args.accu - return ( - args, - accu, - grib_dir, - init_time, - lead_time, - outfn, - param, - region, - ) - -@app.cell -def _(accu, grib_dir, init_time, lead_time, load_state_from_grib, param): - # load grib file - grib_file = grib_dir / f"{init_time}_{lead_time}.grib" if param == "SP_10M": paramlist = ["U_10M", "V_10M"] elif param == "SP": paramlist = ["U", "V"] else: paramlist = [param] + + # Load grib once — shared across all region plots + grib_file = grib_dir / f"{init_time}_{lead_time}.grib" state = load_state_from_grib(grib_file, paramlist=paramlist) - # tp is accumulated from start of forecast; de-accumulate to get the period [lt-accu, lt] + + # tp is accumulated from start of forecast; de-accumulate to get period [lt-accu, lt] if param == "TOT_PREC": prev_lt = int(lead_time) - accu if prev_lt > 0: @@ -114,168 +111,42 @@ def _(accu, grib_dir, init_time, lead_time, load_state_from_grib, param): state["fields"]["TOT_PREC"] - prev_state["fields"]["TOT_PREC"][: len(state["fields"]["TOT_PREC"])] ) - return (state,) - -@app.cell -def _(CMAP_DEFAULTS, ekp): - def get_style(param, units_override=None, accu=1): - """Get style and colormap settings for the plot. - Needed because cmap/norm does not work in Style(colors=cmap), - still needs to be passed as arguments to tripcolor()/tricontourf(). - """ - lookup = f"{param}_{accu}H" if param == "TOT_PREC" else param - cfg = CMAP_DEFAULTS[lookup] - units = units_override if units_override is not None else cfg.get("units", "") - return { - "style": ekp.styles.Style( - levels=cfg.get("bounds", cfg.get("levels", None)), - extend="both", - units=units, - colors=cfg.get("colors", None), - ), - "norm": cfg.get("norm", None), - "cmap": cfg.get("cmap", None), - "levels": cfg.get("levels", None), - "vmin": cfg.get("vmin", None), - "vmax": cfg.get("vmax", None), - "colors": cfg.get("colors", None), - } - - return (get_style,) - - -@app.cell -def _(LOG, np): - """Preprocess fields with pint-based unit conversion and derived quantities.""" - try: - import pint # type: ignore - - _ureg = pint.UnitRegistry() - - def _k_to_c(arr): - # robust conversion with pint, fallback if dtype unsupported - try: - return (_ureg.Quantity(arr, _ureg.kelvin).to(_ureg.degC)).magnitude - except Exception: - return arr - 273.15 - - def _ms_to_knots(arr): - # robust conversion with pint, fallback if dtype unsupported - try: - return ( - _ureg.Quantity(arr, _ureg.meter / _ureg.second).to(_ureg.knot) - ).magnitude - except Exception: - return arr * 1.943844 - - def _m_to_mm(arr): - # robust conversion with pint, fallback if dtype unsupported - try: - return (_ureg.Quantity(arr, _ureg.meter).to(_ureg.millimeter)).magnitude - except Exception: - return arr * 1000 - - except Exception: - LOG.warning("pint not available; falling back hardcoded conversions") - - def _k_to_c(arr): - return arr - 273.15 - - def _ms_to_knots(arr): - return arr * 1.943844 - - def _m_to_mm(arr): - return arr * 1000 - - def preprocess_field(param: str, state: dict): - """ - - Temperatures: K -> °C - - Wind speed: sqrt(u^2 + v^2) - - Precipitation: m -> mm - Returns: (field_array, units_override or None) - """ - fields = state["fields"] - # temperature variables - if param in ("T_2M", "TD_2M", "T", "TD"): - return _k_to_c(fields[param]), "°C" - # 10m wind speed (allow legacy 'uv' alias) - if param == "SP_10M": - u = fields["U_10M"] - v = fields["V_10M"] - return np.sqrt(u**2 + v**2), "m/s" - # wind speed from standard-level components - if param == "SP": - u = fields["U"] - v = fields["V"] - return np.sqrt(u**2 + v**2), "m/s" - if param == "TOT_PREC": - return np.maximum(_m_to_mm(fields[param]), 0), "mm" - # default: passthrough - return fields[param], None - - return (preprocess_field,) - - -@app.cell -def _( - LOG, - StatePlotter, - accu, - args, - get_style, - get_projection, - outfn, - param, - preprocess_field, - region, - state, - DOMAINS, - ccrs, -): - # plot individual fields - plotter = StatePlotter( - state["longitudes"], - state["latitudes"], - outfn.parent, - ) - if args.extent is not None: - _projection = get_projection(args.projection or "orthographic") - _extent = args.extent - else: - _projection = DOMAINS[region]["projection"] - _extent = DOMAINS[region]["extent"] - fig = plotter.init_geoaxes( - nrows=1, - ncols=1, - projection=_projection, - bbox=_extent, - name=region, - size=(6, 6), - ) - subplot = fig.add_map(row=0, column=0) - - # preprocess field (unit conversion, derived quantities) + # Preprocess field once — shared across all region plots field, units_override = preprocess_field(param, state) - - plotter.plot_field( - subplot, field, **get_style(args.param, units_override, accu=accu) - ) - subplot.ax.add_geometries( - state["lam_envelope"], - edgecolor="black", - facecolor="none", - crs=ccrs.PlateCarree(), - ) validtime = state["valid_time"].strftime("%Y%m%d%H%M") - # leadtime = int(state["lead_time"].total_seconds() // 3600) - - fig.title(f"{param}, time: {validtime}") - fig.save(outfn, bbox_inches="tight", dpi=200) - LOG.info(f"saved: {outfn}") - return + for region_name, region_cfg in regions.items(): + plotter = StatePlotter(state["longitudes"], state["latitudes"], outdir) + if region_cfg.get("extent") is not None: + projection = get_projection(region_cfg.get("projection") or "orthographic") + extent = region_cfg["extent"] + else: + projection = DOMAINS[region_name]["projection"] + extent = DOMAINS[region_name]["extent"] + fig = plotter.init_geoaxes( + nrows=1, + ncols=1, + projection=projection, + bbox=extent, + name=region_name, + size=(6, 6), + ) + subplot = fig.add_map(row=0, column=0) + + plotter.plot_field(subplot, field, **get_style(param, units_override, accu=accu)) + subplot.ax.add_geometries( + state["lam_envelope"], + edgecolor="black", + facecolor="none", + crs=ccrs.PlateCarree(), + ) + fig.title(f"{param}, time: {validtime}") + + outfn = outdir / f"frame_{lead_time}_{param}_{region_name}.png" + fig.save(outfn, bbox_inches="tight", dpi=200) + LOG.info(f"saved: {outfn}") if __name__ == "__main__": - app.run() + main() diff --git a/workflow/tools/config.schema.json b/workflow/tools/config.schema.json index f178b9d7..3d749361 100644 --- a/workflow/tools/config.schema.json +++ b/workflow/tools/config.schema.json @@ -1,5 +1,38 @@ { "$defs": { + "AnimationsConfig": { + "description": "Configuration for animation generation.", + "properties": { + "enabled": { + "default": true, + "description": "Whether to generate forecast animations (GIFs per param and region).", + "title": "Enabled", + "type": "boolean" + }, + "domains": { + "default": [ + "globe", + "europe", + "switzerland" + ], + "description": "Domains to generate animations for. Each entry is either a named domain (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom domain dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/RegionConfig" + } + ] + }, + "title": "Domains", + "type": "array" + } + }, + "title": "AnimationsConfig", + "type": "object" + }, "BaselineConfig": { "description": "Configuration for a single baseline to include in the verification.", "properties": { @@ -445,6 +478,32 @@ "title": "Locations", "type": "object" }, + "MeteogramConfig": { + "description": "Configuration for meteogram generation.", + "properties": { + "enabled": { + "default": true, + "description": "Whether to generate meteograms (time series plots at stations).", + "title": "Enabled", + "type": "boolean" + }, + "stations": { + "default": [ + "GVE", + "KLO", + "LUG" + ], + "description": "List of PeakWeather station IDs to generate meteograms for.", + "items": { + "type": "string" + }, + "title": "Stations", + "type": "array" + } + }, + "title": "MeteogramConfig", + "type": "object" + }, "Profile": { "description": "Workflow execution profile.", "properties": { @@ -521,70 +580,26 @@ "title": "RegionConfig", "type": "object" }, - "MeteogramConfig": { - "description": "Configuration for meteogram generation.", - "properties": { - "enabled": { - "default": true, - "description": "Whether to generate meteograms (time series plots at stations).", - "title": "Enabled", - "type": "boolean" - }, - "stations": { - "default": ["GVE", "KLO", "LUG"], - "description": "List of PeakWeather station IDs to generate meteograms for.", - "items": {"type": "string"}, - "title": "Stations", - "type": "array" - } - }, - "title": "MeteogramConfig", - "type": "object" - }, - "AnimationsConfig": { - "description": "Configuration for animation generation.", - "properties": { - "enabled": { - "default": true, - "description": "Whether to generate forecast animations (GIFs per param and region).", - "title": "Enabled", - "type": "boolean" - }, - "domains": { - "default": ["globe", "europe", "switzerland"], - "description": "Domains to generate animations for. Each entry is either a named domain (e.g. 'globe', 'europe', 'switzerland') defined in plotting.DOMAINS, or a custom domain dict with 'name', optional 'extent' [lon_min, lon_max, lat_min, lat_max], and optional 'projection'.", - "items": { - "anyOf": [ - {"type": "string"}, - {"$ref": "#/$defs/RegionConfig"} - ] - }, - "title": "Domains", - "type": "array" - } - }, - "title": "AnimationsConfig", - "type": "object" - }, "ShowcaseConfig": { "description": "Configuration for the showcase workflow.", "properties": { "params": { - "default": ["T_2M", "SP_10M"], + "default": [ + "T_2M", + "SP_10M" + ], "description": "List of parameters to generate animations and meteograms for.", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "title": "Params", "type": "array" }, "meteograms": { - "$ref": "#/$defs/MeteogramConfig", - "default": {}, - "description": "Configuration for meteogram generation." + "$ref": "#/$defs/MeteogramConfig" }, "animations": { - "$ref": "#/$defs/AnimationsConfig", - "default": {}, - "description": "Configuration for animation generation." + "$ref": "#/$defs/AnimationsConfig" } }, "title": "ShowcaseConfig", From 03679df9e3f62f60b9fe59ac2514977582f6cc83 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 19 May 2026 13:36:38 +0200 Subject: [PATCH 27/31] use earthkit.meteo --- workflow/scripts/plot_meteogram.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workflow/scripts/plot_meteogram.py b/workflow/scripts/plot_meteogram.py index d0583127..47c597c9 100644 --- a/workflow/scripts/plot_meteogram.py +++ b/workflow/scripts/plot_meteogram.py @@ -3,8 +3,8 @@ from datetime import datetime from pathlib import Path +import earthkit.meteo.wind as ekm_wind import matplotlib.pyplot as plt -import numpy as np from peakweather import PeakWeatherDataset from data_input import ( @@ -24,7 +24,7 @@ def preprocess_ds(ds, param: str): ds = ds.copy() if param == "SP_10M": - ds[param] = np.sqrt(ds.U_10M**2 + ds.V_10M**2) + ds[param] = ekm_wind.speed(ds.U_10M, ds.V_10M) try: units = ds["U_10M"].attrs["parameter"]["units"] except KeyError: @@ -36,7 +36,7 @@ def preprocess_ds(ds, param: str): } ds = ds.drop_vars(["U_10M", "V_10M"]) if param == "SP": - ds[param] = np.sqrt(ds.U**2 + ds.V**2) + ds[param] = ekm_wind.speed(ds.U, ds.V) units = ds.U.attrs["parameter"]["units"] ds[param].attrs["parameter"] = { "shortName": "SP", From e9e5e569b34bbf231c0779f2a7226a2d9f1caae8 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Tue, 19 May 2026 22:04:39 +0200 Subject: [PATCH 28/31] add logging from rules --- workflow/rules/plot.smk | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 74f20c7c..a3536867 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -37,6 +37,8 @@ rule plot_meteogram: / "results/{{showcase}}/{{run_id}}/{{init_time}}/{{init_time}}_{{param}}_{sta}.png", sta=config["showcase"]["meteograms"]["stations"], ), + log: + OUT_ROOT / "logs/{showcase}/{run_id}/{init_time}/plot_meteogram_{param}.log", resources: slurm_partition="postproc", cpus_per_task=1, @@ -83,7 +85,7 @@ rule plot_meteogram: CMD_ARGS+=(--baseline_label "${{BASELINE_LABELS[$i]}}") done - python {input.script} "${{CMD_ARGS[@]}}" + python {input.script} "${{CMD_ARGS[@]}}" > {log} 2>&1 """ @@ -99,6 +101,8 @@ rule plot_forecast_frame: ), wildcard_constraints: leadtime=r"\d+", # only digits + log: + OUT_ROOT / "logs/{run_id}/{init_time}/plot_forecast_frame_{leadtime}_{param}.log", resources: slurm_partition="postproc", cpus_per_task=1, @@ -120,7 +124,7 @@ rule plot_forecast_frame: --param {wildcards.param:q} --leadtime {wildcards.leadtime:q} \ --regions_json {params.regions_json:q} \ --outdir {params.outdir:q} \ - --accu {params.accu} + --accu {params.accu} > {log} 2>&1 """ From 0311f52db62b93f423501e1f6447ba16b80f98a9 Mon Sep 17 00:00:00 2001 From: Carlos Osuna Date: Wed, 27 May 2026 15:15:26 +0200 Subject: [PATCH 29/31] removing a redirect --- workflow/rules/inference.smk | 4 +++- workflow/rules/plot.smk | 9 ++++++--- workflow/scripts/plot_meteogram.py | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/workflow/rules/inference.smk b/workflow/rules/inference.smk index 87782c38..5a6f0a75 100644 --- a/workflow/rules/inference.smk +++ b/workflow/rules/inference.smk @@ -334,4 +334,6 @@ rule inference_execute: ' ) >{log} 2>&1 """ - # fmt: on + + +# fmt: on diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 4212c743..b3e89b78 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -54,7 +54,9 @@ rule plot_meteogram: baseline_steps=lambda wc: [x["steps"] for x in _get_available_baselines(wc)], baseline_labels=lambda wc: [x["label"] for x in _get_available_baselines(wc)], outdir=lambda wc: str( - (Path(OUT_ROOT) / f"results/{wc.showcase}/{wc.run_id}/{wc.init_time}").resolve() + ( + Path(OUT_ROOT) / f"results/{wc.showcase}/{wc.run_id}/{wc.init_time}" + ).resolve() ), stations=config["showcase"]["meteograms"]["stations"], shell: @@ -85,7 +87,7 @@ rule plot_meteogram: CMD_ARGS+=(--baseline_label "${{BASELINE_LABELS[$i]}}") done - python {input.script} "${{CMD_ARGS[@]}}" > {log} 2>&1 + python {input.script} "${{CMD_ARGS[@]}}" """ @@ -102,7 +104,8 @@ rule plot_forecast_frame: wildcard_constraints: leadtime=r"\d+", # only digits log: - OUT_ROOT / "logs/{run_id}/{init_time}/plot_forecast_frame_{leadtime}_{param}.log", + OUT_ROOT + / "logs/{run_id}/{init_time}/plot_forecast_frame_{leadtime}_{param}.log", resources: slurm_partition="postproc", cpus_per_task=1, diff --git a/workflow/scripts/plot_meteogram.py b/workflow/scripts/plot_meteogram.py index 47c597c9..ff501298 100644 --- a/workflow/scripts/plot_meteogram.py +++ b/workflow/scripts/plot_meteogram.py @@ -140,7 +140,9 @@ def main(): paramlist = [param] # Load gridded data once — shared across all station plots - forecast_ds = load_forecast_data(forecast_grib_dir, init_time, forecast_steps, paramlist) + forecast_ds = load_forecast_data( + forecast_grib_dir, init_time, forecast_steps, paramlist + ) forecast_ds = preprocess_ds(forecast_ds, param) steps = [int(s) for s in forecast_ds.lead_time.dt.total_seconds().values / 3600] From b87dcf649d54ff7e78cd4d59f67591fe5da3bfa8 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 28 May 2026 11:24:30 +0200 Subject: [PATCH 30/31] Fix: read baselines from grib instead of zarr for meteograms (#164) ### Summary of changes * pass on grib root instead of baseline zarr * load_forecasts already knows how to handle grib root --- workflow/rules/plot.smk | 22 +++++------ .../scripts/inference_extract_requirements.py | 2 +- workflow/scripts/plot_forecast_frame.py | 10 ++++- workflow/scripts/plot_meteogram.py | 39 +++++++++++-------- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index b3e89b78..76dc7b56 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -10,18 +10,16 @@ import pandas as pd def _get_available_baselines(wc) -> list[dict[str, str]]: - """Get all available baseline zarr datasets for the given init time.""" + """Get all available baseline datasets for the given init time.""" baselines = [] for baseline_id in BASELINE_CONFIGS: root = BASELINE_CONFIGS[baseline_id].get("root") steps = BASELINE_CONFIGS[baseline_id].get("steps") label = BASELINE_CONFIGS[baseline_id].get("label", baseline_id) - year = wc.init_time[2:4] - baseline_zarr = f"{root}/FCST{year}.zarr" - if Path(baseline_zarr).exists(): - baselines.append({"zarr": baseline_zarr, "steps": steps, "label": label}) + if root and Path(root).exists(): + baselines.append({"root": root, "steps": steps, "label": label}) if not baselines: - raise ValueError(f"No baseline zarr found for init time {wc.init_time}") + raise ValueError(f"No baseline data found for init time {wc.init_time}") return baselines @@ -50,7 +48,7 @@ rule plot_meteogram: ).resolve(), fcst_steps=lambda wc: RUN_CONFIGS[wc.run_id]["steps"], fcst_label=lambda wc: RUN_CONFIGS[wc.run_id]["label"], - baseline_zarrs=lambda wc: [x["zarr"] for x in _get_available_baselines(wc)], + baseline_roots=lambda wc: [x["root"] for x in _get_available_baselines(wc)], baseline_steps=lambda wc: [x["steps"] for x in _get_available_baselines(wc)], baseline_labels=lambda wc: [x["label"] for x in _get_available_baselines(wc)], outdir=lambda wc: str( @@ -64,7 +62,7 @@ rule plot_meteogram: set -euo pipefail export ECCODES_DEFINITION_PATH=$(realpath .venv/share/eccodes-cosmo-resources/definitions) - BASELINE_ZARRS=({params.baseline_zarrs:q}) + BASELINE_ROOTS=({params.baseline_roots:q}) BASELINE_STEPS=({params.baseline_steps:q}) BASELINE_LABELS=({params.baseline_labels:q}) @@ -81,13 +79,13 @@ rule plot_meteogram: --stations {params.stations:q} ) - for i in "${{!BASELINE_ZARRS[@]}}"; do - CMD_ARGS+=(--baseline "${{BASELINE_ZARRS[$i]}}") + for i in "${{!BASELINE_ROOTS[@]}}"; do + CMD_ARGS+=(--baseline "${{BASELINE_ROOTS[$i]}}") CMD_ARGS+=(--baseline_steps "${{BASELINE_STEPS[$i]}}") CMD_ARGS+=(--baseline_label "${{BASELINE_LABELS[$i]}}") done - python {input.script} "${{CMD_ARGS[@]}}" + python {input.script} "${{CMD_ARGS[@]}}" >{log} 2>&1 """ @@ -127,7 +125,7 @@ rule plot_forecast_frame: --param {wildcards.param:q} --leadtime {wildcards.leadtime:q} \ --regions_json {params.regions_json:q} \ --outdir {params.outdir:q} \ - --accu {params.accu} + --accu {params.accu} >{log} 2>&1 """ diff --git a/workflow/scripts/inference_extract_requirements.py b/workflow/scripts/inference_extract_requirements.py index 12e3e29a..d67271d6 100644 --- a/workflow/scripts/inference_extract_requirements.py +++ b/workflow/scripts/inference_extract_requirements.py @@ -20,7 +20,7 @@ BASE_DEPENDENCIES = [ "anemoi-inference", "eccodes==2.39.1", - "eccodes-cosmo-resources-python", + "eccodes-cosmo-resources-python==2.38.3.1", ] # Packages emitted in the output even when only found in provenance (not overrides). diff --git a/workflow/scripts/plot_forecast_frame.py b/workflow/scripts/plot_forecast_frame.py index 260d51ea..2077d05b 100644 --- a/workflow/scripts/plot_forecast_frame.py +++ b/workflow/scripts/plot_forecast_frame.py @@ -90,6 +90,11 @@ def main(): outdir.mkdir(parents=True, exist_ok=True) accu = args.accu + LOG.info( + "Plotting forecast frame: param=%s, init_time=%s, lead_time=%s, regions=%s", + param, init_time, lead_time, list(regions.keys()), + ) + if param == "SP_10M": paramlist = ["U_10M", "V_10M"] elif param == "SP": @@ -99,6 +104,7 @@ def main(): # Load grib once — shared across all region plots grib_file = grib_dir / f"{init_time}_{lead_time}.grib" + LOG.info("Loading grib file %s", grib_file) state = load_state_from_grib(grib_file, paramlist=paramlist) # tp is accumulated from start of forecast; de-accumulate to get period [lt-accu, lt] @@ -106,6 +112,7 @@ def main(): prev_lt = int(lead_time) - accu if prev_lt > 0: prev_grib_file = grib_dir / f"{init_time}_{prev_lt:03d}.grib" + LOG.info("De-accumulating TOT_PREC: loading previous grib file %s", prev_grib_file) prev_state = load_state_from_grib(prev_grib_file, paramlist=paramlist) state["fields"]["TOT_PREC"] = ( state["fields"]["TOT_PREC"] @@ -117,6 +124,7 @@ def main(): validtime = state["valid_time"].strftime("%Y%m%d%H%M") for region_name, region_cfg in regions.items(): + LOG.info("Plotting region %s", region_name) plotter = StatePlotter(state["longitudes"], state["latitudes"], outdir) if region_cfg.get("extent") is not None: projection = get_projection(region_cfg.get("projection") or "orthographic") @@ -147,7 +155,7 @@ def main(): outfn = outdir / f"frame_{lead_time}_{param}_{region_name}.png" fig.save(outfn, bbox_inches="tight", dpi=200) - LOG.info(f"saved: {outfn}") + LOG.info("saved: %s", outfn) if __name__ == "__main__": diff --git a/workflow/scripts/plot_meteogram.py b/workflow/scripts/plot_meteogram.py index ff501298..1c39b9ad 100644 --- a/workflow/scripts/plot_meteogram.py +++ b/workflow/scripts/plot_meteogram.py @@ -3,7 +3,6 @@ from datetime import datetime from pathlib import Path -import earthkit.meteo.wind as ekm_wind import matplotlib.pyplot as plt from peakweather import PeakWeatherDataset @@ -24,7 +23,7 @@ def preprocess_ds(ds, param: str): ds = ds.copy() if param == "SP_10M": - ds[param] = ekm_wind.speed(ds.U_10M, ds.V_10M) + ds[param] = (ds.U_10M**2 + ds.V_10M**2) ** 0.5 try: units = ds["U_10M"].attrs["parameter"]["units"] except KeyError: @@ -36,7 +35,7 @@ def preprocess_ds(ds, param: str): } ds = ds.drop_vars(["U_10M", "V_10M"]) if param == "SP": - ds[param] = ekm_wind.speed(ds.U, ds.V) + ds[param] = (ds.U**2 + ds.V**2) ** 0.5 units = ds.U.attrs["parameter"]["units"] ds[param].attrs["parameter"] = { "shortName": "SP", @@ -69,7 +68,7 @@ def main(): action="append", type=str, default=[], - help="Path to baseline zarr data (repeatable).", + help="Path to baseline data root (repeatable).", ) parser.add_argument( "--baseline_steps", @@ -89,7 +88,7 @@ def main(): help="Label for each baseline line in plot legend (repeatable).", ) parser.add_argument( - "--analysis", type=str, default=None, help="Path to analysis zarr data" + "--analysis", type=str, default=None, help="Path to analysis data root" ) parser.add_argument( "--analysis_label", @@ -110,17 +109,17 @@ def main(): forecast_grib_dir = Path(args.forecast) forecast_steps = args.forecast_steps forecast_label = args.forecast_label - analysis_zarr = Path(args.analysis) + analysis_root = Path(args.analysis) analysis_label = args.analysis_label - baseline_zarrs = [Path(path) for path in args.baseline] + baseline_roots = [Path(path) for path in args.baseline] baseline_steps = args.baseline_steps baseline_labels = args.baseline_label - if len(baseline_zarrs) != len(baseline_steps): + if len(baseline_roots) != len(baseline_steps): raise ValueError( "Mismatched baseline arguments: --baseline and --baseline_steps " "must be provided the same number of times." ) - if len(baseline_labels) != len(baseline_zarrs): + if len(baseline_labels) != len(baseline_roots): raise ValueError( "Mismatched baseline arguments: --baseline and --baseline_label " "must be provided the same number of times." @@ -132,6 +131,11 @@ def main(): param = args.param stations = args.stations + LOG.info( + "Plotting meteogram: param=%s, stations=%s, init_time=%s", + param, stations, init_time, + ) + if param == "SP_10M": paramlist = ["U_10M", "V_10M"] elif param == "SP": @@ -140,24 +144,26 @@ def main(): paramlist = [param] # Load gridded data once — shared across all station plots + LOG.info("Loading forecast data from %s", forecast_grib_dir) forecast_ds = load_forecast_data( forecast_grib_dir, init_time, forecast_steps, paramlist ) forecast_ds = preprocess_ds(forecast_ds, param) steps = [int(s) for s in forecast_ds.lead_time.dt.total_seconds().values / 3600] - analysis_ds = load_truth_data(analysis_zarr, init_time, steps, paramlist) + LOG.info("Loading analysis data from %s", analysis_root) + analysis_ds = load_truth_data(analysis_root, init_time, steps, paramlist) analysis_ds = preprocess_ds(analysis_ds, param) - baseline_ds_list = [ - preprocess_ds( - load_forecast_data(zarr, init_time, step, paramlist), - param, + baseline_ds_list = [] + for root, step, label in zip(baseline_roots, baseline_steps, baseline_labels): + LOG.info("Loading baseline '%s' from %s", label, root) + baseline_ds_list.append( + preprocess_ds(load_forecast_data(root, init_time, step, paramlist), param) ) - for zarr, step in zip(baseline_zarrs, baseline_steps) - ] # Load station metadata once + LOG.info("Loading station metadata from %s", peakweather_dir) peakweather = PeakWeatherDataset(root=peakweather_dir) stations_table = peakweather.stations_table stations_table.index.names = ["values"] @@ -169,6 +175,7 @@ def main(): # Loop over stations — data is loaded once, mapping is per station for station in stations: + LOG.info("Plotting station %s (%d/%d)", station, stations.index(station) + 1, len(stations)) station_ds = stations_table.to_xarray().sel(values=[station]) station_ds = station_ds.rename({"latitude": "lat", "longitude": "lon"}) station_ds = station_ds.set_coords(("lat", "lon", "station_name")) From 8631f782175cbaa3c26dc23b95f1b1f44e144f58 Mon Sep 17 00:00:00 2001 From: Jonas Bhend Date: Thu, 28 May 2026 11:27:04 +0200 Subject: [PATCH 31/31] linting --- workflow/rules/inference.smk | 2 -- workflow/rules/plot.smk | 8 ++++---- workflow/scripts/plot_forecast_frame.py | 10 ++++++++-- workflow/scripts/plot_meteogram.py | 11 +++++++++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/workflow/rules/inference.smk b/workflow/rules/inference.smk index 5a6f0a75..d2b8ef1e 100644 --- a/workflow/rules/inference.smk +++ b/workflow/rules/inference.smk @@ -334,6 +334,4 @@ rule inference_execute: ' ) >{log} 2>&1 """ - - # fmt: on diff --git a/workflow/rules/plot.smk b/workflow/rules/plot.smk index 76dc7b56..b07ab7d3 100644 --- a/workflow/rules/plot.smk +++ b/workflow/rules/plot.smk @@ -85,7 +85,7 @@ rule plot_meteogram: CMD_ARGS+=(--baseline_label "${{BASELINE_LABELS[$i]}}") done - python {input.script} "${{CMD_ARGS[@]}}" >{log} 2>&1 + python {input.script} "${{CMD_ARGS[@]}}" >{log} 2>&1 """ @@ -99,11 +99,11 @@ rule plot_forecast_frame: / "data/runs/{{run_id}}/{{init_time}}/frames/frame_{{leadtime}}_{{param}}_{region}.png", region=list(SHOWCASE_REGIONS.keys()), ), - wildcard_constraints: - leadtime=r"\d+", # only digits log: OUT_ROOT / "logs/{run_id}/{init_time}/plot_forecast_frame_{leadtime}_{param}.log", + wildcard_constraints: + leadtime=r"\d+", # only digits resources: slurm_partition="postproc", cpus_per_task=1, @@ -125,7 +125,7 @@ rule plot_forecast_frame: --param {wildcards.param:q} --leadtime {wildcards.leadtime:q} \ --regions_json {params.regions_json:q} \ --outdir {params.outdir:q} \ - --accu {params.accu} >{log} 2>&1 + --accu {params.accu} >{log} 2>&1 """ diff --git a/workflow/scripts/plot_forecast_frame.py b/workflow/scripts/plot_forecast_frame.py index 2077d05b..cc470fbc 100644 --- a/workflow/scripts/plot_forecast_frame.py +++ b/workflow/scripts/plot_forecast_frame.py @@ -92,7 +92,10 @@ def main(): LOG.info( "Plotting forecast frame: param=%s, init_time=%s, lead_time=%s, regions=%s", - param, init_time, lead_time, list(regions.keys()), + param, + init_time, + lead_time, + list(regions.keys()), ) if param == "SP_10M": @@ -112,7 +115,10 @@ def main(): prev_lt = int(lead_time) - accu if prev_lt > 0: prev_grib_file = grib_dir / f"{init_time}_{prev_lt:03d}.grib" - LOG.info("De-accumulating TOT_PREC: loading previous grib file %s", prev_grib_file) + LOG.info( + "De-accumulating TOT_PREC: loading previous grib file %s", + prev_grib_file, + ) prev_state = load_state_from_grib(prev_grib_file, paramlist=paramlist) state["fields"]["TOT_PREC"] = ( state["fields"]["TOT_PREC"] diff --git a/workflow/scripts/plot_meteogram.py b/workflow/scripts/plot_meteogram.py index 1c39b9ad..24baf3bb 100644 --- a/workflow/scripts/plot_meteogram.py +++ b/workflow/scripts/plot_meteogram.py @@ -133,7 +133,9 @@ def main(): LOG.info( "Plotting meteogram: param=%s, stations=%s, init_time=%s", - param, stations, init_time, + param, + stations, + init_time, ) if param == "SP_10M": @@ -175,7 +177,12 @@ def main(): # Loop over stations — data is loaded once, mapping is per station for station in stations: - LOG.info("Plotting station %s (%d/%d)", station, stations.index(station) + 1, len(stations)) + LOG.info( + "Plotting station %s (%d/%d)", + station, + stations.index(station) + 1, + len(stations), + ) station_ds = stations_table.to_xarray().sel(values=[station]) station_ds = station_ds.rename({"latitude": "lat", "longitude": "lon"}) station_ds = station_ds.set_coords(("lat", "lon", "station_name"))