mahf708 commited on
Commit
a6fbbee
·
verified ·
1 Parent(s): 2ec1b12

checkpoints, kit and exporter

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ kit/forcing/v3.LR.historical_0101.aigo.eam.h0.2015-01.nc filter=lfs diff=lfs merge=lfs -text
37
+ kit/forcing/v3.LR.historical_0101.aigo.eam.h0.2015-02.nc filter=lfs diff=lfs merge=lfs -text
38
+ kit/forcing-aerofrz/aero.2015-01.nc filter=lfs diff=lfs merge=lfs -text
39
+ kit/forcing-aerofrz/aero.2015-02.nc filter=lfs diff=lfs merge=lfs -text
40
+ kit/ic/E05-FT-S01.restart.2015-01-03T12.nc filter=lfs diff=lfs merge=lfs -text
E05-FT-S01/best_inference_ckpt.tar ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:086629bcecc824a6a4c21bb08051a972f3d34b9ed0fcc002587f86bfbadcb3d2
3
+ size 1824999519
E11-FT-S01/best_inference_ckpt.tar ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f50c886354f30076309b53fbb41e6aef33202a0aa32a774de45e08ae36cf9a3e
3
+ size 341473131
README.md CHANGED
@@ -1,3 +1,61 @@
1
  ---
2
  license: bsd-3-clause
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: bsd-3-clause
3
+ tags:
4
+ - climate
5
+ - emulator
6
+ - e3sm
7
+ - ace
8
+ - samudra
9
  ---
10
+
11
+ # E3SMv3 emulator checkpoints for the E3SM AI group hackathon, 25 September 2026
12
+
13
+ > **Not fit for forcing or scenario experiments.** Both checkpoints reproduce the E3SMv3 historical run well
14
+ > when the other half of the climate system is handed to them, but neither responds to a change in forcing
15
+ > physically: the atmosphere uses CO₂ as a clock, and the ocean reads its answer off its upward-longwave input.
16
+ > Use them to study how an emulator represents the climate, not what the climate does. Results from them show
17
+ > how the model *represents* forcing.
18
+
19
+ | Folder | Checkpoint | Fit for | Not fit for |
20
+ | --- | --- | --- | --- |
21
+ | `E05-FT-S01/` | `E05-FT.aug26.atm.A3_B16_C1_L0_O5_W0_X0.S01`, atmosphere, noise-conditioned SFNO (8 blocks × 384 channels), 1°, 6-hourly, SST prescribed | Historical runs with prescribed SST; looking inside the network | Changed CO₂ or SST, any forcing experiment; precipitation extremes (about 2× too frequent) |
22
+ | `E11-FT-S01/` | `E11-FT.aug26.ocn.A0_B16_C0_L0_O5_W0_X0.S01`, ocean, Samudra U-Net, 1°, 5-daily, surface fluxes prescribed | Replaying the historical run from reference fluxes; looking inside the network | Any change to the fluxes; ENSO studies; subsurface heat uptake |
23
+
24
+ These are the checkpoints the latents in
25
+ [E3SM-Project/aigs-hack-sep26-latents](https://huggingface.co/datasets/E3SM-Project/aigs-hack-sep26-latents)
26
+ were recorded from. E05-FT S01 is not the shipped atmosphere (S03): it is the one the probes behind the
27
+ "not fit for" column used.
28
+
29
+ ## Run an experiment yourself
30
+
31
+ The `kit/` folder holds what a 2015 atmosphere run needs besides the checkpoint: the initial condition
32
+ (2015-01-03 12:00), two months of forcing (the inputs only), and configs for the control and each intervention.
33
+ One run uses about 4 GB of GPU memory.
34
+
35
+ ```console
36
+ $ pip install "fme @ git+https://github.com/E3SM-Project/ace@e3sm/exps/hist-v2026.8.0"
37
+ $ hf download E3SM-Project/aigs-hack-sep26-models --local-dir aigs-models && cd aigs-models
38
+ $ python -m fme.ace.inference kit/configs/atm-co2x1.25.yaml
39
+ ```
40
+
41
+ | Config | Intervention |
42
+ | --- | --- |
43
+ | `atm-ctrl.yaml` | none |
44
+ | `atm-co2x1.25.yaml`, `atm-co2x0.8.yaml` | `overwrite.multiply_scalar.global_mean_co2` |
45
+ | `atm-p4K.yaml` | `overwrite.add_scalar.TS: 4.0` |
46
+ | `atm-aerofrz.yaml` | aerosol diagnostics swapped for their 1965 values (`kit/forcing-aerofrz/`) |
47
+
48
+ Every config sets `seed: 0`. Keep it: the model is stochastic, and a shared seed is what makes two runs
49
+ comparable node for node. Change the intervention by editing the `overwrite` block; make it longer with
50
+ `n_forward_steps` (the forcing covers January and February 2015).
51
+
52
+ To record the latents as well, and to steer a layer along a feature, use the exporter in the kit
53
+ (`kit/export_fme_latents.py`; it needs [xaig](https://pypi.org/project/xaig/) 0.2 or later in the same
54
+ environment):
55
+
56
+ ```console
57
+ $ python kit/export_fme_latents.py kit/configs/atm-ctrl.yaml --out my-run --steps 20 \
58
+ --steer 0:sae_L00.npz:142:3 # push SAE feature 142 of layer 0 by +3 at every node
59
+ ```
60
+
61
+ It reproduces the published latents bit for bit.
kit/configs/atm-aerofrz.yaml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_dir: output/atm-aerofrz
2
+ n_forward_steps: 20
3
+ forward_steps_in_memory: 20
4
+ checkpoint_path: E05-FT-S01/best_inference_ckpt.tar
5
+ logging:
6
+ log_to_screen: true
7
+ log_to_wandb: false
8
+ log_to_file: true
9
+ initial_condition:
10
+ path: kit/ic/E05-FT-S01.restart.2015-01-03T12.nc
11
+ engine: netcdf4
12
+ forcing_loader:
13
+ dataset:
14
+ merge:
15
+ - data_path: kit/forcing
16
+ file_pattern: v3.LR.historical_0101.aigo.eam.h0.*.nc
17
+ rename:
18
+ co2vmr: global_mean_co2
19
+ aerindexall: aerindexall_orig
20
+ colccn.3: colccn3_orig
21
+ reference_pressure_name: P0
22
+ - data_path: kit/forcing-aerofrz
23
+ file_pattern: aero.*.nc
24
+ rename:
25
+ aerindexall_F: aerindexall
26
+ colccn.3_F: colccn.3
27
+ num_data_workers: 2
28
+ data_writer:
29
+ save_prediction_files: true
30
+ save_monthly_files: false
31
+ names:
32
+ - PS
33
+ - TS
34
+ - Tat2m
35
+ - surface_precipitation_rate
36
+ - FLUT
37
+ - FSUTOA
38
+ - lwp
39
+ seed: 0
kit/configs/atm-co2x0.8.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_dir: output/atm-co2x0.8
2
+ n_forward_steps: 20
3
+ forward_steps_in_memory: 20
4
+ checkpoint_path: E05-FT-S01/best_inference_ckpt.tar
5
+ logging:
6
+ log_to_screen: true
7
+ log_to_wandb: false
8
+ log_to_file: true
9
+ initial_condition:
10
+ path: kit/ic/E05-FT-S01.restart.2015-01-03T12.nc
11
+ engine: netcdf4
12
+ forcing_loader:
13
+ dataset:
14
+ data_path: kit/forcing
15
+ file_pattern: v3.LR.historical_0101.aigo.eam.h0.*.nc
16
+ rename:
17
+ co2vmr: global_mean_co2
18
+ reference_pressure_name: P0
19
+ overwrite:
20
+ multiply_scalar:
21
+ global_mean_co2: 0.8
22
+ num_data_workers: 2
23
+ data_writer:
24
+ save_prediction_files: true
25
+ save_monthly_files: false
26
+ names:
27
+ - PS
28
+ - TS
29
+ - Tat2m
30
+ - surface_precipitation_rate
31
+ - FLUT
32
+ - FSUTOA
33
+ - lwp
34
+ seed: 0
kit/configs/atm-co2x1.25.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_dir: output/atm-co2x1.25
2
+ n_forward_steps: 20
3
+ forward_steps_in_memory: 20
4
+ checkpoint_path: E05-FT-S01/best_inference_ckpt.tar
5
+ logging:
6
+ log_to_screen: true
7
+ log_to_wandb: false
8
+ log_to_file: true
9
+ initial_condition:
10
+ path: kit/ic/E05-FT-S01.restart.2015-01-03T12.nc
11
+ engine: netcdf4
12
+ forcing_loader:
13
+ dataset:
14
+ data_path: kit/forcing
15
+ file_pattern: v3.LR.historical_0101.aigo.eam.h0.*.nc
16
+ rename:
17
+ co2vmr: global_mean_co2
18
+ reference_pressure_name: P0
19
+ overwrite:
20
+ multiply_scalar:
21
+ global_mean_co2: 1.25
22
+ num_data_workers: 2
23
+ data_writer:
24
+ save_prediction_files: true
25
+ save_monthly_files: false
26
+ names:
27
+ - PS
28
+ - TS
29
+ - Tat2m
30
+ - surface_precipitation_rate
31
+ - FLUT
32
+ - FSUTOA
33
+ - lwp
34
+ seed: 0
kit/configs/atm-ctrl.yaml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_dir: output/atm-ctrl
2
+ n_forward_steps: 20
3
+ forward_steps_in_memory: 20
4
+ checkpoint_path: E05-FT-S01/best_inference_ckpt.tar
5
+ logging:
6
+ log_to_screen: true
7
+ log_to_wandb: false
8
+ log_to_file: true
9
+ initial_condition:
10
+ path: kit/ic/E05-FT-S01.restart.2015-01-03T12.nc
11
+ engine: netcdf4
12
+ forcing_loader:
13
+ dataset:
14
+ data_path: kit/forcing
15
+ file_pattern: v3.LR.historical_0101.aigo.eam.h0.*.nc
16
+ rename:
17
+ co2vmr: global_mean_co2
18
+ reference_pressure_name: P0
19
+ num_data_workers: 2
20
+ data_writer:
21
+ save_prediction_files: true
22
+ save_monthly_files: false
23
+ names:
24
+ - PS
25
+ - TS
26
+ - Tat2m
27
+ - surface_precipitation_rate
28
+ - FLUT
29
+ - FSUTOA
30
+ - lwp
31
+ seed: 0
kit/configs/atm-p4K.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_dir: output/atm-p4K
2
+ n_forward_steps: 20
3
+ forward_steps_in_memory: 20
4
+ checkpoint_path: E05-FT-S01/best_inference_ckpt.tar
5
+ logging:
6
+ log_to_screen: true
7
+ log_to_wandb: false
8
+ log_to_file: true
9
+ initial_condition:
10
+ path: kit/ic/E05-FT-S01.restart.2015-01-03T12.nc
11
+ engine: netcdf4
12
+ forcing_loader:
13
+ dataset:
14
+ data_path: kit/forcing
15
+ file_pattern: v3.LR.historical_0101.aigo.eam.h0.*.nc
16
+ rename:
17
+ co2vmr: global_mean_co2
18
+ reference_pressure_name: P0
19
+ overwrite:
20
+ add_scalar:
21
+ TS: 4.0
22
+ num_data_workers: 2
23
+ data_writer:
24
+ save_prediction_files: true
25
+ save_monthly_files: false
26
+ names:
27
+ - PS
28
+ - TS
29
+ - Tat2m
30
+ - surface_precipitation_rate
31
+ - FLUT
32
+ - FSUTOA
33
+ - lwp
34
+ seed: 0
kit/export_fme_latents.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Record an fme (ACE / Samudra) run's latents as an xaig latent archive.
2
+
3
+ Runs ``fme.ace.inference`` (a YAML with ``initial_condition`` + ``forcing_loader``) or
4
+ ``fme.ace.inference.evaluator`` (a YAML with ``loader``) with forward hooks on the
5
+ network inside the stepper.
6
+
7
+ - SFNO (atmosphere): layer 0 is the input to the first block (encoder output plus
8
+ position embedding); layer L (1..num_layers) is the output of block L.
9
+ - Samudra U-Net (ocean): only the full-resolution levels, which xaig can map --
10
+ layer 0 is the first encoder block's output, layer 1 the last decoder block's.
11
+
12
+ Optionally *writes* a layer as well (steering): add ``alpha`` times a direction to
13
+ layer L at every step (or a window of steps). The direction is one channel, or one
14
+ feature of an xaig basis file (a Dictionary's unit direction scaled by its
15
+ ``input_scale``, so ``alpha`` is in activation units; a PCA component in layer units).
16
+
17
+ Network inputs are kept too, denormalized, exactly as the network saw them (so an
18
+ intervention on CO2 or a frozen flux shows up in ``reference.nc``).
19
+
20
+ Needs the model's environment (torch + fme) and xaig's archive writer, which is
21
+ pure numpy: point ``--xaig-src`` at a checkout's ``src`` when xaig is not installed.
22
+
23
+ python export_fme_latents.py atm-ctrl.yaml --out archives/atm-E05S01-ctrl --steps 20
24
+
25
+ The archive's ``experiment`` block records the checkpoint, the source config, the
26
+ seed, the intervention (anything under ``--experiment``), the steering, and the
27
+ recorded steps.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import datetime as dt
34
+ import json
35
+ import logging
36
+ import shutil
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+ import torch
42
+ import yaml
43
+
44
+ DEFAULT_FIELDS = [
45
+ "PS", "TS", "Tat2m", "Qat2m", "Uat10m", "Vat10m",
46
+ "surface_precipitation_rate", "FLUT", "FSUTOA", "FLUS", "FLDS", "FSNS",
47
+ "LHFLX", "SHFLX", "TAUX", "TAUY", "lwp", "lcc",
48
+ "U_1", "V_1", "T_1", "U_3", "V_3", "U_5", "V_5", "T_5", "STW_5", "U_7", "V_7", "STW_7",
49
+ ] # fmt: skip
50
+
51
+
52
+ DEFAULT_INPUTS = [
53
+ "SOLIN", "global_mean_co2", "aerindexall", "colccn.3", "OCNFRAC", "ICEFRAC", "LANDFRAC",
54
+ "PHIS", "TS", "FLUS", "FLDS", "FSNS", "LHFLX", "SHFLX", "TAUX", "TAUY", "sea_surface_fraction",
55
+ ] # fmt: skip
56
+ OCEAN_FIELDS = [
57
+ "sst", "ssh", "ocean_sea_ice_fraction", "iceVolumeTotal", "temperatureCoarsened_0",
58
+ "temperatureCoarsened_5", "temperatureCoarsened_10", "salinityCoarsened_0",
59
+ "velocityZonalCoarsened_0", "velocityMeridionalCoarsened_0",
60
+ ] # fmt: skip
61
+
62
+
63
+ def find_step_with_packer(stepper):
64
+ """The step object that packs named inputs into the network's channels."""
65
+ candidates = [stepper, getattr(stepper, "_step_obj", None)]
66
+ for obj in list(candidates):
67
+ if obj is not None:
68
+ candidates.extend(v for v in vars(obj).values() if hasattr(v, "in_packer"))
69
+ for obj in candidates:
70
+ if obj is not None and hasattr(obj, "in_packer") and hasattr(obj, "_normalizer"):
71
+ return obj
72
+ raise RuntimeError("could not find the step that packs the network inputs")
73
+
74
+
75
+ class SkipRescale:
76
+ """A pre-hook on an SFNO's decoder that rescales one input in the big-skip half only.
77
+
78
+ The decoder reads ``cat(block output, normalized inputs)``; this changes channel
79
+ ``embed_dim + index(name)`` from ``(x - mean) / std`` to ``(factor * x - mean) / std``,
80
+ so the skip sees the input scaled while every block saw it as it was."""
81
+
82
+ def __init__(self, stepper, net, name, factor):
83
+ if not getattr(net, "big_skip", False):
84
+ raise SystemExit("--skip-rescale needs an SFNO with a big skip")
85
+ step = find_step_with_packer(stepper)
86
+ packed = list(step.in_packer.names)
87
+ if name not in packed:
88
+ raise SystemExit(f"{name} is not a network input; inputs are {packed}")
89
+ self.channel = net.embed_dim + packed.index(name)
90
+ self.mean = float(step._normalizer.means[name])
91
+ self.std = float(step._normalizer.stds[name])
92
+ self.factor = factor
93
+ net.decoder.register_forward_pre_hook(self.hook)
94
+
95
+ def hook(self, module, args):
96
+ x = args[0].clone()
97
+ physical = x[:, self.channel] * self.std + self.mean
98
+ x[:, self.channel] = (self.factor * physical - self.mean) / self.std
99
+ return (x, *args[1:])
100
+
101
+
102
+ class InputCapture:
103
+ """A pre-hook on the network that keeps chosen inputs, denormalized, at recorded steps."""
104
+
105
+ def __init__(self, stepper, net_outer, names, recorder, n_field_times):
106
+ step = find_step_with_packer(stepper)
107
+ packed = list(step.in_packer.names)
108
+ missing = [n for n in names if n not in packed]
109
+ if missing:
110
+ logging.warning("inputs not among the network's inputs, skipped: %s", missing)
111
+ self.names = [n for n in names if n in packed]
112
+ self.index = {n: packed.index(n) for n in self.names}
113
+ self.mean = {n: float(step._normalizer.means[n]) for n in self.names}
114
+ self.std = {n: float(step._normalizer.stds[n]) for n in self.names}
115
+ self.rec = recorder
116
+ self.n_field_times = n_field_times
117
+ self.values = {}
118
+ net_outer.register_forward_pre_hook(self.hook) # after the recorder's counter
119
+
120
+ def hook(self, module, args):
121
+ k = self.rec.call
122
+ x = args[0]
123
+ if k >= self.n_field_times or x.shape[0] != 1:
124
+ return
125
+ for n in self.names:
126
+ v = x[0, self.index[n]].detach().float() * self.std[n] + self.mean[n]
127
+ v = v.reshape(-1).cpu().numpy()
128
+ if n not in self.values:
129
+ self.values[n] = np.full((self.n_field_times, v.size), np.nan, dtype=np.float32)
130
+ self.values[n][k] = v
131
+
132
+
133
+ def parse_args(argv=None):
134
+ p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
135
+ p.add_argument("config", help="fme inference YAML (initial_condition + forcing_loader)")
136
+ p.add_argument("--out", required=True, help="archive directory to write")
137
+ p.add_argument("--steps", type=int, required=True, help="forward steps to run")
138
+ p.add_argument("--every", type=int, default=1, help="record every Nth step")
139
+ p.add_argument("--layers", default="all", help="'all' or comma list, e.g. 0,4,8")
140
+ p.add_argument(
141
+ "--fields",
142
+ default=None,
143
+ help="output variables kept in reference.nc (default: a set per component)",
144
+ )
145
+ p.add_argument(
146
+ "--inputs",
147
+ default=",".join(DEFAULT_INPUTS),
148
+ help="network inputs kept in reference.nc, as the network saw them "
149
+ "(interventions included); named <name>_input when an output shares the name",
150
+ )
151
+ p.add_argument("--dtype", default="float16")
152
+ p.add_argument(
153
+ "--seed",
154
+ type=int,
155
+ default=0,
156
+ help="rollout seed; the noise-conditioned SFNO is stochastic, so runs meant "
157
+ "to be compared node by node must share it",
158
+ )
159
+ p.add_argument("--steer", help="L:BASIS.npz:FEATURE:ALPHA add alpha x a basis direction")
160
+ p.add_argument("--steer-channel", help="L:CHANNEL:AMOUNT add a constant to one channel")
161
+ p.add_argument(
162
+ "--steer-vector", help="L:VECTOR.npy:ALPHA add alpha x a saved (channels,) vector"
163
+ )
164
+ p.add_argument("--steer-window", default=None, help="FIRST:LAST forward steps to steer")
165
+ p.add_argument(
166
+ "--skip-rescale",
167
+ help="NAME:FACTOR SFNO with a big skip only: scale one input, in physical units, in the "
168
+ "copy of the inputs the skip hands the decoder, leaving what the blocks see alone",
169
+ )
170
+ p.add_argument("--model-name", default=None, help="manifest 'model' (default: run id)")
171
+ p.add_argument("--experiment", default="{}", help="JSON merged into the manifest's experiment")
172
+ p.add_argument("--xaig-src", default=None, help="path to an xaig checkout's src/")
173
+ p.add_argument("--keep-run", action="store_true", help="keep the fme experiment_dir")
174
+ return p.parse_args(argv)
175
+
176
+
177
+ def find_network(stepper):
178
+ """(kind, outermost module, network, taps): taps are (module, 'pre'|'post', label)."""
179
+ from fme.core.models.conditional_sfno.sfnonet import SphericalFourierNeuralOperatorNet
180
+
181
+ modules = list(stepper.modules.modules())
182
+ sfno = [m for m in modules if isinstance(m, SphericalFourierNeuralOperatorNet)]
183
+ if len(sfno) == 1:
184
+ net = sfno[0]
185
+ from fme.ace.registry.stochastic_sfno import NoiseConditionedModel
186
+
187
+ outer = [m for m in modules if isinstance(m, NoiseConditionedModel)]
188
+ taps = [(net.blocks[0], "pre", "encoder output (input to block 1)")]
189
+ taps += [(b, "post", f"SFNO block {i} output") for i, b in enumerate(net.blocks, 1)]
190
+ return "sfno", (outer[0] if outer else net), net, taps
191
+ from fme.ace.models.ocean.m2lines.samudra import Samudra
192
+
193
+ samudra = [m for m in modules if isinstance(m, Samudra)]
194
+ if len(samudra) == 1:
195
+ net = samudra[0]
196
+ taps = [
197
+ (
198
+ net.layers[0],
199
+ "post",
200
+ f"encoder level 1 output ({net.ch_width[0]} ch, full resolution)",
201
+ ),
202
+ (
203
+ net.layers[-2],
204
+ "post",
205
+ f"last decoder block output ({net.ch_width[0]} ch, full resolution)",
206
+ ),
207
+ ]
208
+ return "samudra", net, net, taps
209
+ raise RuntimeError("found neither one SFNO nor one Samudra in the stepper")
210
+
211
+
212
+ class Recorder:
213
+ """Forward hooks that record chosen layers at chosen steps and optionally steer one."""
214
+
215
+ def __init__(
216
+ self, outer, taps, layers, steps, every, staging: Path, dtype, steer=None, window=None
217
+ ):
218
+ self.outer = outer
219
+ self.taps = taps
220
+ self.n_layers = len(taps)
221
+ self.layers = (
222
+ list(range(self.n_layers)) if layers == "all" else [int(x) for x in layers.split(",")]
223
+ )
224
+ for layer in self.layers:
225
+ if not 0 <= layer < self.n_layers:
226
+ raise ValueError(f"layer {layer} not in 0..{self.n_layers - 1}")
227
+ self.recorded_steps = list(range(0, steps, every))
228
+ self.slot = {s: i for i, s in enumerate(self.recorded_steps)}
229
+ self.staging = staging
230
+ self.dtype = dtype
231
+ self.steer = steer # (layer, torch vector (C,))
232
+ self.window = window
233
+ self.arrays = {}
234
+ self.call = -1
235
+ self.shape = None
236
+
237
+ def _array(self, layer, x):
238
+ if layer not in self.arrays:
239
+ n_ch, h, w = x.shape[1:]
240
+ self.shape = (h, w)
241
+ path = self.staging / f"raw_{layer:02d}.npy"
242
+ self.arrays[layer] = np.lib.format.open_memmap(
243
+ path, mode="w+", dtype=self.dtype, shape=(len(self.recorded_steps), h * w, n_ch)
244
+ )
245
+ return self.arrays[layer]
246
+
247
+ def _steer(self, layer, x):
248
+ if self.steer is None or self.steer[0] != layer:
249
+ return x
250
+ if self.window is not None and not (self.window[0] <= self.call <= self.window[1]):
251
+ return x
252
+ vec = self.steer[1].to(device=x.device, dtype=x.dtype)
253
+ if vec.numel() < x.shape[1]: # a one-channel push, given only up to its channel
254
+ vec = torch.nn.functional.pad(vec, (0, x.shape[1] - vec.numel()))
255
+ if vec.numel() != x.shape[1]:
256
+ raise RuntimeError(
257
+ f"steering direction has {vec.numel()} channels, layer {layer} has {x.shape[1]}"
258
+ )
259
+ return x + vec.view(1, -1, 1, 1)
260
+
261
+ def _record(self, layer, x):
262
+ if layer in self.layers and self.call in self.slot:
263
+ if x.shape[0] != 1:
264
+ raise RuntimeError(f"recording needs batch 1, got {x.shape[0]}")
265
+ flat = x[0].detach().float().reshape(x.shape[1], -1).T # (nodes, channels), lat-major
266
+ self._array(layer, x)[self.slot[self.call]] = flat.cpu().numpy().astype(self.dtype)
267
+
268
+ def attach(self):
269
+ def count(module, args):
270
+ self.call += 1
271
+
272
+ self.outer.register_forward_pre_hook(count, prepend=True)
273
+ for layer, (module, kind, _) in enumerate(self.taps):
274
+ if kind == "pre":
275
+
276
+ def pre(module, args, layer=layer):
277
+ x = self._steer(layer, args[0])
278
+ self._record(layer, x)
279
+ return (x, *args[1:])
280
+
281
+ module.register_forward_pre_hook(pre)
282
+ else:
283
+
284
+ def post(module, args, out, layer=layer):
285
+ out = self._steer(layer, out)
286
+ self._record(layer, out)
287
+ return out
288
+
289
+ module.register_forward_hook(post)
290
+
291
+ def labels(self):
292
+ return [label for _, _, label in self.taps]
293
+
294
+
295
+ def steering_vector(args):
296
+ import torch
297
+
298
+ if sum(bool(x) for x in (args.steer, args.steer_channel, args.steer_vector)) > 1:
299
+ raise SystemExit("give one of --steer, --steer-channel, --steer-vector")
300
+ if args.steer_vector:
301
+ layer, path, alpha = args.steer_vector.split(":")
302
+ vec = float(alpha) * np.load(path).astype(np.float64).ravel()
303
+ info = {"layer": int(layer), "vector": Path(path).name, "by": float(alpha)}
304
+ return int(layer), torch.as_tensor(vec, dtype=torch.float32), info
305
+ if args.steer_channel:
306
+ layer, ch, amount = args.steer_channel.split(":")
307
+ vec = torch.zeros(int(ch) + 1) # padded to the layer's width at first use
308
+ vec[int(ch)] = float(amount)
309
+ return int(layer), vec, {"layer": int(layer), "channel": int(ch), "by": float(amount)}
310
+ if args.steer:
311
+ from xaig.daig.latent import load_basis
312
+
313
+ layer, path, feature, alpha = args.steer.split(":")
314
+ basis = load_basis(path)
315
+ feature, alpha = int(feature), float(alpha)
316
+ # a Dictionary's activation a adds a * input_scale of its unit direction to the
317
+ # layer; a PCA component is a unit direction in the layer's own units
318
+ scale = float(getattr(basis, "input_scale", 1.0))
319
+ vec = alpha * scale * np.asarray(basis.directions()[feature], dtype=np.float64)
320
+ info = {
321
+ "layer": int(layer),
322
+ "basis": Path(path).name,
323
+ "kind": basis.kind,
324
+ "feature": feature,
325
+ "by": alpha,
326
+ "input_scale": scale,
327
+ }
328
+ return int(layer), torch.as_tensor(vec, dtype=torch.float32), info
329
+ return None, None, None
330
+
331
+
332
+ def time_labels(ds):
333
+ """Valid-time labels (ISO, 4-digit year) of the prediction file, initial condition first."""
334
+ t = ds["valid_time"].values if "valid_time" in ds else ds["time"].values
335
+ t = np.asarray(t).reshape(-1)
336
+ return [x.strftime("%Y-%m-%dT%H:%M:%S") if hasattr(x, "strftime") else str(x)[:19] for x in t]
337
+
338
+
339
+ def main(argv=None):
340
+ args = parse_args(argv)
341
+ if args.xaig_src:
342
+ sys.path.insert(0, args.xaig_src)
343
+ import fme.ace.inference.evaluator as evaluator
344
+ import fme.ace.inference.inference as inference
345
+ import xarray as xr
346
+
347
+ from xaig.adapters.latent_archive import write_archive
348
+ from xaig.daig.grid import Grid
349
+
350
+ out = Path(args.out).resolve()
351
+ staging = out.parent / f".{out.name}.work"
352
+ if staging.exists():
353
+ shutil.rmtree(staging)
354
+ staging.mkdir(parents=True)
355
+ run_dir = staging / "fme"
356
+
357
+ cfg = yaml.safe_load(Path(args.config).read_text())
358
+ source_experiment_dir = cfg.get("experiment_dir")
359
+ cfg["experiment_dir"] = str(run_dir)
360
+ cfg["n_forward_steps"] = args.steps
361
+ cfg["seed"] = args.seed
362
+ cfg["forward_steps_in_memory"] = min(cfg.get("forward_steps_in_memory", 20), args.steps)
363
+ evaluating = "loader" in cfg
364
+ fields = None if args.fields is None else [f for f in args.fields.split(",") if f]
365
+ if fields is None:
366
+ import torch as _torch
367
+
368
+ ckpt_cfg = _torch.load(cfg["checkpoint_path"], map_location="cpu", weights_only=False)
369
+ out_names = ckpt_cfg["stepper"]["config"]["step"]["config"].get("out_names", [])
370
+ del ckpt_cfg
371
+ fields = OCEAN_FIELDS if "sst" in out_names else DEFAULT_FIELDS
372
+ cfg["data_writer"] = {
373
+ "save_prediction_files": bool(fields),
374
+ "save_monthly_files": False,
375
+ **({"names": fields} if fields else {}),
376
+ }
377
+ cfg.setdefault("logging", {})["log_to_wandb"] = False
378
+ run_yaml = staging / "run.yaml"
379
+ run_yaml.write_text(yaml.safe_dump(cfg, sort_keys=False))
380
+
381
+ state = {}
382
+ config_class = evaluator.InferenceEvaluatorConfig if evaluating else inference.InferenceConfig
383
+ original_load = config_class.load_stepper
384
+
385
+ def load_stepper_with_hooks(self):
386
+ stepper = original_load(self)
387
+ kind, outer, net, taps = find_network(stepper)
388
+ s_layer, s_vec, s_info = steering_vector(args)
389
+ window = tuple(int(v) for v in args.steer_window.split(":")) if args.steer_window else None
390
+ rec = Recorder(
391
+ outer, taps, args.layers, args.steps, args.every, staging, args.dtype,
392
+ steer=(s_layer, s_vec) if s_vec is not None else None, window=window,
393
+ ) # fmt: skip
394
+ rec.attach()
395
+ inputs = [n for n in args.inputs.split(",") if n]
396
+ if inputs:
397
+ state["inputs"] = InputCapture(stepper, outer, inputs, rec, args.steps + 1)
398
+ if args.skip_rescale:
399
+ if kind != "sfno":
400
+ raise SystemExit("--skip-rescale applies to an SFNO only")
401
+ name, factor = args.skip_rescale.rsplit(":", 1)
402
+ SkipRescale(stepper, net, name, float(factor))
403
+ state["skip"] = {"input": name, "factor": float(factor)}
404
+ state.update(rec=rec, steer=s_info, window=window, kind=kind)
405
+ return stepper
406
+
407
+ config_class.load_stepper = load_stepper_with_hooks
408
+ try:
409
+ (evaluator if evaluating else inference).main(str(run_yaml))
410
+ finally:
411
+ config_class.load_stepper = original_load
412
+
413
+ rec: Recorder = state["rec"]
414
+ if rec.call + 1 != args.steps:
415
+ logging.warning("model was called %d times for %d steps", rec.call + 1, args.steps)
416
+
417
+ pred = (
418
+ xr.open_dataset(run_dir / "autoregressive_predictions.nc", decode_timedelta=False)
419
+ if fields
420
+ else None
421
+ )
422
+ ic = xr.open_dataset(run_dir / "initial_condition.nc") if fields else None
423
+ if pred is None:
424
+ raise SystemExit(
425
+ "--fields '' is not supported yet: the grid and times come from the prediction file"
426
+ )
427
+ lat1 = pred["lat"].values
428
+ lon1 = pred["lon"].values
429
+ lon, lat = np.meshgrid(lon1, lat1)
430
+ ocean = state.get("kind") == "samudra"
431
+ mask = None
432
+ if ocean and "sst" in pred: # land holds no ocean: the first predicted SST says where
433
+ mask = np.isfinite(pred["sst"].values.reshape(-1, lat1.size * lon1.size)[0])
434
+ grid = Grid(lat=lat.ravel(), lon=lon.ravel(), shape=(lat1.size, lon1.size), mask=mask)
435
+ if rec.shape != (lat1.size, lon1.size):
436
+ raise SystemExit(
437
+ f"latent grid {rec.shape} differs from the output grid {(lat1.size, lon1.size)}"
438
+ )
439
+
440
+ all_times = time_labels(pred) # prediction valid times, one per step
441
+ ic_time = time_labels(ic)[0] if "valid_time" in ic or "time" in ic else None
442
+ if len(all_times) == args.steps and ic_time is not None:
443
+ field_times = [ic_time, *all_times]
444
+ else:
445
+ field_times = all_times
446
+ step_input_times = field_times[: args.steps] # the state each forward call started from
447
+ times = [step_input_times[s] for s in rec.recorded_steps]
448
+
449
+ field_arrays = {}
450
+ for name in fields:
451
+ if name not in pred:
452
+ logging.warning("field %s not in predictions; skipped", name)
453
+ continue
454
+ values = pred[name].values.reshape(-1, lat1.size * lon1.size) # (steps, nodes)
455
+ if len(field_times) == values.shape[0] + 1:
456
+ first = (
457
+ ic[name].values.reshape(1, -1)
458
+ if name in ic
459
+ else np.full((1, values.shape[1]), np.nan)
460
+ )
461
+ values = np.concatenate([first, values])
462
+ field_arrays[name] = values.astype(np.float32)
463
+ captured = state.get("inputs")
464
+ if captured is not None:
465
+ if len(field_times) != args.steps + 1:
466
+ raise SystemExit(
467
+ "input capture needs the initial condition's time in the prediction files"
468
+ )
469
+ for name, values in captured.values.items():
470
+ key = f"{name}_input" if name in field_arrays else name
471
+ field_arrays[key] = values
472
+
473
+ ckpt = cfg["checkpoint_path"]
474
+ run_id = Path(ckpt).parent.parent.name
475
+ experiment = {
476
+ "run_id": run_id,
477
+ "source_config": str(Path(args.config).resolve()),
478
+ "source_experiment_dir": source_experiment_dir,
479
+ "initial_condition": cfg.get("initial_condition", {}).get("path"),
480
+ "seed": cfg.get("seed"),
481
+ "recorded_steps": {"n": args.steps, "every": args.every},
482
+ "exported": dt.datetime.now().isoformat(timespec="seconds"),
483
+ **json.loads(args.experiment),
484
+ }
485
+ if state.get("skip"):
486
+ experiment["skip_rescale"] = state["skip"]
487
+ if state.get("steer"):
488
+ experiment["steer"] = {
489
+ **state["steer"],
490
+ **({"window": list(state["window"])} if state["window"] else {}),
491
+ }
492
+
493
+ labels = rec.labels()
494
+ layers = [(labels[i], rec.arrays[i]) for i in sorted(rec.arrays)]
495
+ if sorted(rec.arrays) != list(range(len(layers))):
496
+ # the reader indexes layers by position; keep the network's numbering in the label
497
+ layers = [(f"{labels[i]} [network layer {i}]", rec.arrays[i]) for i in sorted(rec.arrays)]
498
+ step_seconds = (
499
+ int(
500
+ round(
501
+ (np.datetime64(field_times[1]) - np.datetime64(field_times[0]))
502
+ / np.timedelta64(1, "s")
503
+ )
504
+ )
505
+ if len(field_times) > 1
506
+ else None
507
+ )
508
+ write_archive(
509
+ out,
510
+ grid=grid,
511
+ times=times,
512
+ layers=layers,
513
+ fields=field_arrays or None,
514
+ field_times=field_times if field_arrays else None,
515
+ model=args.model_name or run_id,
516
+ component="ocean" if ocean else "atmosphere",
517
+ checkpoint=ckpt,
518
+ calendar=str(pred["time"].attrs.get("calendar", "noleap")) if "time" in pred else "noleap",
519
+ timestep_seconds=step_seconds,
520
+ experiment=experiment,
521
+ dtype=args.dtype,
522
+ overwrite=True,
523
+ )
524
+ if args.keep_run:
525
+ shutil.move(str(run_dir), str(out / "_fme_run"))
526
+ shutil.rmtree(staging, ignore_errors=True)
527
+ print(
528
+ f"wrote {out}: {len(times)} times x {len(layers)} layers; fields: {', '.join(field_arrays)}"
529
+ )
530
+
531
+
532
+ if __name__ == "__main__":
533
+ main()
kit/forcing-aerofrz/aero.2015-01.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e4852019df49bfe240e0c2c0234a2ab234aaaa425bff081dbd57713528a83719
3
+ size 64297664
kit/forcing-aerofrz/aero.2015-02.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b5b3201db3d9654b2e18d421aafa298ee0b91001b1082f888f4840131ce143de
3
+ size 58076768
kit/forcing/v3.LR.historical_0101.aigo.eam.h0.2015-01.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:93c82a6f0211c593b7b26f5c503b2c8f16b8f4ba2b7252f97f26e98906526378
3
+ size 134716164
kit/forcing/v3.LR.historical_0101.aigo.eam.h0.2015-02.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:198700789f49c2ef215ca3bdc61531d7ad03ced68efc916a626312d474833881
3
+ size 121586871
kit/ic/E05-FT-S01.restart.2015-01-03T12.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e28123a6d0a4cfba3c701e35995ad744fe19cf6ad88caf8dd3a40c49024eebc
3
+ size 9896940