initial UQ and Claude interface

#2
by odunbar - opened
Files changed (30) hide show
  1. .cache/known_methods_snapshot.json +10 -1
  2. .claude/settings.json +79 -0
  3. .claude/skills/leaderboard-manager/SKILL.md +200 -0
  4. .claude/skills/leaderboard-manager/references/add-method.md +138 -0
  5. .claude/skills/leaderboard-manager/references/add-uq-data.md +149 -0
  6. .claude/skills/leaderboard-manager/references/assess.md +84 -0
  7. .claude/skills/leaderboard-manager/references/manage-display.md +218 -0
  8. .claude/skills/leaderboard-manager/references/move-between-boards.md +116 -0
  9. .claude/skills/leaderboard-manager/references/populate-metadata.md +146 -0
  10. .gitattributes +1 -0
  11. .gitignore +34 -3
  12. CLAUDE.md +185 -0
  13. README.md +120 -0
  14. data/ces-eki-dmc_results/ces-eki-dmc_l63_ensemble_results_2026-06-15_minimal.nc +3 -0
  15. data/ces-eki-dmc_results/ces-eki-dmc_l96_ensemble_results_2026-06-15_minimal.nc +3 -0
  16. data/ces-eki-dmc_results/ces-eki-dmc_l96_nn_forcing_ensemble_results_2026-06-15_minimal.nc +3 -0
  17. data/ces-eki-dmc_results/ces-eki-dmc_l96_spatial_forcing_ensemble_results_2026-06-15_minimal.nc +3 -0
  18. requirements.txt +4 -1
  19. src/common/leaderboard.py +660 -0
  20. src/common/method_registry.py +7 -1
  21. src/data_store.py +238 -34
  22. src/media/posterior_ribbons_20_13_k5.png +3 -0
  23. src/media/posterior_ribbons_const-force_12_1_k3.png +3 -0
  24. src/media/posterior_ribbons_flux-force_80_1_k3.png +3 -0
  25. src/media/posterior_ribbons_vec-force_65_1_k3.png +3 -0
  26. src/pages/MethodDetails.py +25 -18
  27. src/pages/OptimizationLeaderboard.py +35 -0
  28. src/pages/RawData.py +4 -2
  29. src/pages/UQLeaderboard.py +40 -0
  30. src/streamlit_app.py +92 -313
.cache/known_methods_snapshot.json CHANGED
@@ -22,7 +22,8 @@
22
  "family": "Kalman",
23
  "aliases": [
24
  "iekf",
25
- "gnsl"
 
26
  ]
27
  },
28
  "uki": {
@@ -48,6 +49,14 @@
48
  "aliases": [
49
  "hm"
50
  ]
 
 
 
 
 
 
 
 
51
  }
52
  },
53
  "observed_methods": [
 
22
  "family": "Kalman",
23
  "aliases": [
24
  "iekf",
25
+ "gnsl",
26
+ "gnki"
27
  ]
28
  },
29
  "uki": {
 
49
  "aliases": [
50
  "hm"
51
  ]
52
+ },
53
+ "ces-eki-dmc": {
54
+ "abbreviation": "CES-EKI-DMC",
55
+ "Method": "Calibrate Emulate Sample (EKI-DataMisfitController)",
56
+ "family": "calibrate_then_emulate",
57
+ "aliases": [
58
+ "ces-eki-dmc"
59
+ ]
60
  }
61
  },
62
  "observed_methods": [
.claude/settings.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
+ "permissions": {
4
+ "allow": [
5
+ "Bash(julia --project *)",
6
+ "Read(*)",
7
+ "FileWrite(src/*, test/*, docs/*, examples/*, ai/*)",
8
+ "Edit(src/*)",
9
+ "Write(src/*)",
10
+ "Grep",
11
+ "Glob",
12
+ "Bash(find *)",
13
+ "Bash(ls *)",
14
+ "Bash(tree *)",
15
+ "Bash(wc *)",
16
+ "Bash(head *)",
17
+ "Bash(tail *)",
18
+ "Bash(stat *)",
19
+ "Bash(grep *)",
20
+ "Bash(git log*)",
21
+ "Bash(git diff*)",
22
+ "Bash(git status*)",
23
+ "Bash(git show*)",
24
+ "Bash(git blame*)",
25
+ "Bash(git ls-files*)",
26
+ "Bash(git rev-parse*)",
27
+ "Bash(git describe*)",
28
+ "Bash(git shortlog*)",
29
+ "Bash(git tag -l*)",
30
+ "Bash(git tag --list*)",
31
+ "Bash(git stash list*)",
32
+ "Bash(git stash show*)",
33
+ "Bash(git remote -v*)",
34
+ "Bash(git remote show*)",
35
+ "Bash(git branch -l*)",
36
+ "Bash(git branch --list*)",
37
+ "Bash(git branch -a*)",
38
+ "Bash(git branch -r*)"
39
+ ],
40
+ "deny": [
41
+ "Read(./.env)",
42
+ "Bash(git commit*)",
43
+ "Bash(git add*)",
44
+ "Bash(git push*)",
45
+ "Bash(git pull*)",
46
+ "Bash(git merge*)",
47
+ "Bash(git rebase*)",
48
+ "Bash(git checkout*)",
49
+ "Bash(git switch*)",
50
+ "Bash(git restore*)",
51
+ "Bash(git reset*)",
52
+ "Bash(git cherry-pick*)",
53
+ "Bash(git clean*)",
54
+ "Bash(git rm*)"
55
+ ]
56
+ },
57
+ "hooks": {
58
+ "UserPromptSubmit": [
59
+ {
60
+ "hooks": [
61
+ {
62
+ "type": "command",
63
+ "command": "printf '\\n---\\nSKILL ROUTING: Review the available skills listed in the system context. If one clearly matches this task, invoke it via the Skill tool before proceeding. If no skill clearly matches, use AskUserQuestion to ask the user: (a) whether they would like to apply any of the listed skills to this task anyway, (b) whether they would like to create a new skill using the skill-creator skill β€” in which case propose a suggested skill name and one-line description tailored to this task β€” or (c) proceed without a skill. Also use AskUserQuestion to ask the user whether they would like to use subagents (e.g., Explore, Plan, general-purpose) for this task, and if so, how (e.g., for research, parallel work, or isolated execution). Do NOT proceed with the task until the user has answered.\\n'"
64
+ }
65
+ ]
66
+ }
67
+ ],
68
+ "Stop": [
69
+ {
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "command": "python3 -c \"import sys,json; d=json.load(sys.stdin); [sys.stdout.write(json.dumps(dict(decision='block',reason='If this task changed the repo structurally (added, removed, or renamed files; changed architecture, run/build commands, data layout, or method-registry/page conventions), ask the user whether to update root CLAUDE.md; otherwise stop normally.'))+chr(10))] if not d.get('stop_hook_active') else None\""
74
+ }
75
+ ]
76
+ }
77
+ ]
78
+ }
79
+ }
.claude/skills/leaderboard-manager/SKILL.md ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: leaderboard-manager
3
+ description: >
4
+ Manages the Calibration Benchmark Streamlit leaderboard dashboard β€” the repository at
5
+ calibration_benchmark/ that ranks parameter-calibration methods on forward-model-run
6
+ efficiency. Use this skill for ANY of the following tasks, even if the user doesn't use
7
+ the exact words below:
8
+
9
+ β€’ Adding a new method to the leaderboard (e.g. "add EKS to the dashboard", "register
10
+ new method", "wire up the new NetCDF results file", "include algorithm X")
11
+ β€’ Registering or replacing benchmark result data ("add new L96 results", "update the
12
+ data file", "point the app at the new .nc file")
13
+ β€’ Registering UQ ensemble-results data ("add CES-EKI-DMC to the UQ leaderboard",
14
+ "register UQ results", "wire up the new ensemble_results.nc", "add UQ data for L96")
15
+ β€’ Changing how the leaderboard table or figure is displayed ("change column order",
16
+ "rename a column", "show failure rate in the table", "update the chart colours",
17
+ "add a scoring mode", "tweak the Altair chart", "fix the x-axis ticks")
18
+ β€’ Moving or duplicating a method between the Optimization and UQ leaderboards ("put
19
+ TEKI on the UQ board too", "populate UQ leaderboard", "register UQ results")
20
+ β€’ Filling in or correcting method metadata ("add the citation for UKI", "fix the
21
+ summary text", "update the reference URL", "ETKI citation is wrong")
22
+ β€’ Assessing or auditing the leaderboard for bugs, staleness, or dead code ("is
23
+ the leaderboard stale?", "check for bugs", "what's broken", "audit the dashboard")
24
+
25
+ Always invoke this skill when the user is working on this Streamlit leaderboard
26
+ dashboard, even if they phrase the task as a general code edit.
27
+ ---
28
+
29
+ # Leaderboard Manager
30
+
31
+ This skill covers maintenance tasks for the Calibration Benchmark Streamlit dashboard.
32
+ Read the **shared model** section below (it applies to every task), then open the
33
+ reference file for the specific task.
34
+
35
+ ---
36
+
37
+ ## Task router
38
+
39
+ | User intent | Reference file to open |
40
+ |---|---|
41
+ | Add a method (+ its result NetCDF) | `references/add-method.md` |
42
+ | Register new UQ ensemble-results NC | `references/add-uq-data.md` |
43
+ | Change table columns, chart, scoring modes | `references/manage-display.md` |
44
+ | Move/duplicate a method to the UQ leaderboard | `references/move-between-boards.md` |
45
+ | Fill in or correct citation / summary / URL | `references/populate-metadata.md` |
46
+ | Assess for staleness / bugs | `references/assess.md` |
47
+
48
+ Open only the relevant file β€” the shared model here covers what all tasks need.
49
+
50
+ ---
51
+
52
+ ## Shared data model
53
+
54
+ ### Two leaderboards, one registry
55
+
56
+ | Leaderboard | Page | Data loader | Target coord | Data dict |
57
+ |---|---|---|---|---|
58
+ | Optimization | `src/pages/OptimizationLeaderboard.py` | `load_metric_store()` | `rmse_target` | `DATASET_FILES` |
59
+ | UQ | `src/pages/UQLeaderboard.py` | `load_uq_store()` β†’ `load_uq_budget_store()` | `uq_target` | `UQ_BUDGET_FILES` |
60
+
61
+ Both call `render_leaderboard()` from `src/common/leaderboard.py` β€” any change there
62
+ affects both boards simultaneously.
63
+
64
+ The UQ leaderboard uses a **different data pipeline** from the optimization one:
65
+ - Optimization: `DATASET_FILES` β†’ `_load_store()` reads NetCDF `metric` variable directly.
66
+ - UQ: `UQ_BUDGET_FILES` β†’ `load_uq_budget_store()` reads `output_coverage` and computes
67
+ budget-for-coverage on the fly. See `references/add-uq-data.md` for the schema.
68
+
69
+ ### Method identity chain
70
+
71
+ A method's identity flows through three layers that must stay in sync:
72
+
73
+ ```
74
+ NetCDF file
75
+ └─ algorithm_type dim (raw string, e.g. "gnki", "teki")
76
+ └─ normalize_method_name() β†’ lowercase alias
77
+ └─ canonicalize_method_name() β†’ canonical key (e.g. "iekf", "teki")
78
+ └─ KNOWN_METHODS[canonical] β†’ { abbreviation, Method, family, aliases }
79
+ └─ method_meta[abbreviation] β†’ { citation, url, summary }
80
+ ```
81
+
82
+ `KNOWN_METHODS` lives in `src/common/method_registry.py`.
83
+ `method_meta` lives in `src/pages/MethodDetails.py` (keyed by **abbreviation**, not the
84
+ canonical key).
85
+
86
+ ### Metric semantics
87
+
88
+ **Optimization leaderboard:**
89
+ - `metric` = mean forward-model runs to reach the RMSE target (lower is better).
90
+ - `metric == -1` encodes a **failed** run. `_load_store` strips these before averaging and
91
+ records their fraction as `failure_rate`.
92
+
93
+ **UQ leaderboard:**
94
+ - `metric` = mean budget N_ensΒ·k_iter to reach the coverage tolerance (lower is better).
95
+ - `NaN` (not -1) encodes a failed run β€” target never reached within k_iter range.
96
+ - `failure_rate` = % of seeds where coverage condition was never satisfied.
97
+ - See `references/add-uq-data.md` for details on the coverage computation.
98
+
99
+ ### Required NetCDF dimensions
100
+
101
+ **Optimization files:**
102
+ ```python
103
+ EXPECTED_DIMS = ("algorithm_type", "rmse_target", "ensemble_size", "random_seed")
104
+ ```
105
+
106
+ **UQ files** use a completely different schema β€” `output_coverage` variable, not `metric`.
107
+ See `references/add-uq-data.md` for the full spec.
108
+
109
+ ---
110
+
111
+ ## Critical wiring / gotchas
112
+
113
+ These traps apply to every task. Read them before touching any file.
114
+
115
+ **1. Method metadata is split across three files.**
116
+ Adding or editing a method requires changes in all three:
117
+ - `src/common/method_registry.py` β€” canonical key, abbreviation, family, aliases
118
+ - `src/pages/MethodDetails.py` β€” citation, URL, summary (in `method_meta` dict, keyed by abbreviation)
119
+ - `src/data_store.py` β€” `DATASET_FILES` (optimization) or `UQ_BUDGET_FILES` (UQ) data path registration
120
+
121
+ **2. Sidebar navigation is duplicated across five files.**
122
+ `st.sidebar.page_link(...)` blocks are copy-pasted in:
123
+ `streamlit_app.py`, `pages/OptimizationLeaderboard.py`, `pages/UQLeaderboard.py`,
124
+ `pages/MethodDetails.py`, `pages/RawData.py`.
125
+ Adding a new page means editing all five. (This is known tech debt.)
126
+
127
+ **3. `_load_store` has filename-based special-casing.**
128
+ `src/data_store.py` infers `algorithm_type` and `ensemble_size` from the **filename**
129
+ when they are missing from the NetCDF. This only works for filenames containing `"abc"` or
130
+ `"hm"`. Every other method must carry `algorithm_type` and `ensemble_size` as dimensions
131
+ in the NetCDF itself, or you must add a new branch in `_load_store`.
132
+
133
+ **4. `@st.cache_data` β€” clear the cache after data changes.**
134
+ `load_metric_store()`, `load_uq_store()`, and `load_uq_budget_store()` are all cached.
135
+ After registering a new file or changing `DATASET_FILES`/`UQ_BUDGET_FILES`, the user must
136
+ clear the Streamlit cache (top-right menu β†’ "Clear cache", or restart the app).
137
+
138
+ **5. `git lfs pull` is required before the first run.**
139
+ `data/*.nc` are Git LFS pointers (130 bytes) until pulled. The app will show no data if
140
+ LFS files haven't been fetched.
141
+
142
+ **6. Streamlit LaTeX rendering: `st.latex()` for display math, not `st.markdown()`.**
143
+ Streamlit's KaTeX parser does not render multi-line `$...$` blocks inside `st.markdown`.
144
+ For display-math equations, always use `st.latex(r"...")` (single-line raw string). For
145
+ surrounding text with inline math, `st.markdown(r"$x$")` works fine. Mixing them:
146
+ ```python
147
+ st.markdown(r"The condition is:")
148
+ st.latex(r"\left|S(q) - q\right| \leq c\sqrt{\frac{q(1-q)}{N_y}}")
149
+ st.markdown(r"where $c$ is the target-scaling level.")
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Known antipatterns
155
+
156
+ **`width="stretch"` is invalid Streamlit API.**
157
+ `st.dataframe` and `st.altair_chart` accept `use_container_width=True` (bool) or
158
+ `width=<int>` (pixels). Use `use_container_width=True` for full-width rendering.
159
+
160
+ **Hardcoded RMSE target list in `MethodDetails.py`.**
161
+ `target_options` must be derived from the loaded data:
162
+ ```python
163
+ sorted(slice_df["rmse_target"].astype(str).unique().tolist())
164
+ ```
165
+ A hardcoded list silently hides any target level present in the data but absent from the
166
+ list (e.g. ABC has a `2.0` target).
167
+
168
+ **`UQ_DATASET_FILES` is intentionally empty β€” do not add UQ files there.**
169
+ UQ data is loaded via `UQ_BUDGET_FILES` and `load_uq_budget_store()`, not via
170
+ `UQ_DATASET_FILES` / `_load_store()`. Adding a file to `UQ_DATASET_FILES` will have no
171
+ effect on the UQ leaderboard because `load_uq_store()` derives from the budget store only.
172
+
173
+ ---
174
+
175
+ ## Smoke-test after any change
176
+
177
+ ```bash
178
+ # From the repo root
179
+ source .venv/bin/activate
180
+ streamlit run src/streamlit_app.py
181
+ ```
182
+
183
+ Then visit `http://localhost:8501` and confirm:
184
+ - The new method (or updated display) appears on the relevant leaderboard.
185
+ - `.cache/known_methods_snapshot.json` β†’ `unmapped_observed_methods` is empty.
186
+ - The Methods page (`πŸ“˜ Methods`) shows the method with correct citation/summary.
187
+
188
+ If data files haven't been LFS-pulled the app will be blank β€” run `git lfs pull` first.
189
+
190
+ ---
191
+
192
+ ## Final step
193
+
194
+ After completing the task, offer the user a chance to improve the **`leaderboard-manager`**
195
+ skill itself using skill-creator. For example:
196
+
197
+ > "Would you like to improve the **leaderboard-manager** skill itself using skill-creator?
198
+ > You can share suggestions, or I can analyse patterns from this session β€” edge cases that
199
+ > came up, steps that were unclear, anything that felt awkward β€” to refine the skill for
200
+ > next time."
.claude/skills/leaderboard-manager/references/add-method.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Add a Method (+ its Data)
2
+
3
+ Use this checklist whenever a new calibration algorithm needs to appear on the
4
+ Optimization leaderboard. For the UQ leaderboard, complete this checklist first, then
5
+ follow `move-between-boards.md`.
6
+
7
+ ---
8
+
9
+ ## Pre-flight: understand the incoming NetCDF
10
+
11
+ Open the collaborator's file (e.g. from `From_Rob_6-10/`) and verify its structure:
12
+
13
+ ```python
14
+ import xarray as xr
15
+ ds = xr.open_dataset("path/to/file.nc")
16
+ print(ds) # check dimensions and variables
17
+ print(ds["metric"])
18
+ ```
19
+
20
+ The file must have:
21
+ - A `metric` variable (float; shape over the dims below).
22
+ - Dimensions: `algorithm_type`, `rmse_target`, `ensemble_size`, `random_seed`.
23
+ - `algorithm_type` is a string coordinate that names the algorithm.
24
+ - All dim names must match exactly (case-sensitive).
25
+
26
+ **If `algorithm_type` is missing from the NetCDF:**
27
+ The loader (`src/data_store.py:129–137`) only infers it from the filename for `"abc"` and
28
+ `"hm"` files. For any other method you must either:
29
+ (a) add the `algorithm_type` dim to the NetCDF before placing it in `data/`, or
30
+ (b) add a new `elif` branch in `_load_store()` analogous to the existing `abc`/`hm` block.
31
+
32
+ **If `ensemble_size` is missing:**
33
+ Same situation β€” currently only inferred for `"abc"` files (hardcoded to 1). Fix or add a
34
+ branch.
35
+
36
+ ---
37
+
38
+ ## Step 1 β€” Register the method in `src/common/method_registry.py`
39
+
40
+ Open `src/common/method_registry.py` and add an entry to `KNOWN_METHODS` (the dict starts
41
+ at line 7). Follow the existing pattern exactly:
42
+
43
+ ```python
44
+ "new-key": {
45
+ "abbreviation": "NEWABBR", # Short uppercase display name
46
+ "Method": "Full Method Name", # Human-readable name for tables
47
+ "family": "Kalman", # One of: Kalman | Bayesian | calibrate_then_emulate
48
+ "aliases": ["new-key", "alias2"], # All raw strings that may appear in NetCDF algorithm_type
49
+ },
50
+ ```
51
+
52
+ **Canonical key** (`"new-key"`): lowercase, hyphen-separated. Must match what
53
+ `canonicalize_method_name()` will resolve to from the NetCDF's `algorithm_type` strings.
54
+
55
+ **Aliases**: include every spelling variant that could appear in the raw NetCDF, both
56
+ lowercase and the exact case used. The `build_alias_lookup()` function (line 53) also
57
+ registers `canonical.upper()` automatically, but doesn't handle mixed-case; be thorough.
58
+
59
+ After saving, `ALIAS_TO_CANONICAL` is rebuilt at import time β€” no extra step needed.
60
+
61
+ ---
62
+
63
+ ## Step 2 β€” Add display metadata in `src/pages/MethodDetails.py`
64
+
65
+ Open `src/pages/MethodDetails.py`. Find the `method_meta` dict (line 40) and add an entry
66
+ keyed by the **abbreviation** (not the canonical key):
67
+
68
+ ```python
69
+ "NEWABBR": {
70
+ "citation": "Author et al., Journal, Year",
71
+ "url": "https://doi.org/...",
72
+ "summary": "One-sentence description of what this algorithm does.",
73
+ },
74
+ ```
75
+
76
+ If you don't have the citation/URL yet, use placeholder strings and follow up with
77
+ `populate-metadata.md`. The app will show "Citation pending" / a broken link if omitted,
78
+ but won't crash.
79
+
80
+ ---
81
+
82
+ ## Step 3 β€” Place the NetCDF and register it in `src/data_store.py`
83
+
84
+ 1. **Copy the file** into `data/` (or a subdirectory like `data/bayesian/`). Use
85
+ `git lfs track "data/**/*.nc"` if it isn't already tracked by LFS.
86
+
87
+ 2. **Add the path** to `DATASET_FILES` in `src/data_store.py` (starts at line 29):
88
+
89
+ ```python
90
+ DATASET_FILES = {
91
+ "L63": [
92
+ "l63_ensemble_results.nc",
93
+ ...
94
+ "my_subdir/new_method_l63.nc", # ← add here
95
+ ],
96
+ ...
97
+ }
98
+ ```
99
+
100
+ Keys (`"L63"`, `"L96"`, etc.) are the benchmark labels. Add the file to every benchmark
101
+ key it covers. Paths are relative to the `data/` directory.
102
+
103
+ ---
104
+
105
+ ## Step 4 β€” Verify
106
+
107
+ Run the smoke-test from `SKILL.md`:
108
+
109
+ ```bash
110
+ streamlit run src/streamlit_app.py
111
+ ```
112
+
113
+ Then check:
114
+
115
+ 1. **Leaderboard table** (Home page): the new method's abbreviation appears as a row.
116
+ 2. **Methods page** (`πŸ“˜ Methods`): the method is in the dropdown; citation/summary display.
117
+ 3. **Snapshot file**: open `.cache/known_methods_snapshot.json` after the app loads and
118
+ confirm:
119
+ - The new canonical key is in `known_methods`.
120
+ - It does **not** appear in `unmapped_observed_methods` (which lists any `algorithm_type`
121
+ strings from the data that didn't match any known alias).
122
+
123
+ If the method appears in `unmapped_observed_methods`, the raw `algorithm_type` in the
124
+ NetCDF doesn't match any alias you registered. Add the exact string (check
125
+ `observed_methods` in the snapshot for the raw value) to the `aliases` list in
126
+ `method_registry.py` and reload.
127
+
128
+ ---
129
+
130
+ ## Common failure modes
131
+
132
+ | Symptom | Likely cause |
133
+ |---|---|
134
+ | Method missing from leaderboard table | `algorithm_type` in NetCDF doesn't match any alias in `KNOWN_METHODS` β€” check `unmapped_observed_methods` |
135
+ | Method shown as uppercase canonical key (e.g. `NEW-KEY`) | `method_registry.py` entry added but `abbreviation` not picked up β€” check `get_method_meta()` path |
136
+ | "Skipping … missing expected columns" warning | NetCDF lacks `algorithm_type` or `ensemble_size` dims and no special-case branch exists in `_load_store` |
137
+ | Old data still shown after file change | Streamlit cache β€” clear it (top-right menu or restart) |
138
+ | `data/*.nc` is 130 bytes | `git lfs pull` not run |
.claude/skills/leaderboard-manager/references/add-uq-data.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Add UQ Ensemble-Results Data
2
+
3
+ Use this reference when a collaborator provides new ensemble-results NetCDF files that
4
+ should appear on the UQ leaderboard. This pipeline is entirely separate from the
5
+ optimization leaderboard's `DATASET_FILES` / `_load_store()` path.
6
+
7
+ ---
8
+
9
+ ## How the UQ pipeline works
10
+
11
+ The UQ leaderboard does not read a pre-computed `metric` variable. Instead it:
12
+
13
+ 1. Reads `output_coverage` from the NC file β€” the empirical marginal coverage fraction
14
+ `S(q)` at each iteration, ensemble size, and random seed.
15
+ 2. In `load_uq_budget_store()`, finds the minimum iteration k where
16
+ `|S(q) βˆ’ q| ≀ c·√(q(1βˆ’q)/N_y)` holds for **all three** quantiles
17
+ `q ∈ [0.15, 0.5, 0.85]` simultaneously.
18
+ 3. Budget = N_ens Β· k_iter; NaN when the condition is never satisfied.
19
+ 4. Aggregates over seeds: `mean_budget`, `mean_iters`, `failure_count`, `failure_rate`.
20
+ 5. `load_uq_store()` calls `load_uq_budget_store()` and exposes `mean_budget` as the
21
+ leaderboard `metric`.
22
+
23
+ ---
24
+
25
+ ## Required NC file schema
26
+
27
+ The file must carry:
28
+
29
+ | Variable / coordinate | Dims / shape | Notes |
30
+ |---|---|---|
31
+ | `output_coverage` | `(coverage_quantile, k_iter, ensemble_size, random_seed)` | Empirical coverage fractions in [0, 1] |
32
+ | `coverage_quantile` | `(n_cov_q,)` | Must include 0.15, 0.5, and 0.85 (exactly) |
33
+ | `k_iter` | `(n_k,)` | Iteration indices (1-indexed integers) |
34
+ | `ensemble_size` | `(n_ens,)` | Ensemble sizes (integers) |
35
+ | `random_seed` | `(n_seeds,)` | Seed indices |
36
+ | `target_scaling` | `(n_ts,)` | The c values, e.g. [1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5] |
37
+ | `output_dim` | scalar or dimension | N_y β€” number of output dimensions |
38
+
39
+ **Check the file structure before registering:**
40
+ ```python
41
+ import xarray as xr
42
+ ds = xr.open_dataset("data/ces-eki-dmc_results/my_file.nc")
43
+ print(ds)
44
+ print("coverage_quantile:", ds["coverage_quantile"].values)
45
+ print("target_scaling:", ds["target_scaling"].values)
46
+ print("output_dim:", ds.sizes.get("output_dim") or ds["output_dim"].values)
47
+ ```
48
+
49
+ Confirm that 0.15, 0.5, and 0.85 are all present in `coverage_quantile`. If they are
50
+ absent, the budget computation will silently use the nearest available quantile β€” check
51
+ via `np.argmin(np.abs(cov_q - 0.15))` etc.
52
+
53
+ ---
54
+
55
+ ## Step 1 β€” Place the file in `data/`
56
+
57
+ Place the NC file in `data/ces-eki-dmc_results/` (or an appropriate subdirectory).
58
+ Ensure it is tracked by Git LFS:
59
+
60
+ ```bash
61
+ git lfs track "data/**/*.nc" # already set up; just confirm
62
+ git add data/ces-eki-dmc_results/my_file.nc
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Step 2 β€” Register in `UQ_BUDGET_FILES`
68
+
69
+ Open `src/data_store.py` and add the file to `UQ_BUDGET_FILES`:
70
+
71
+ ```python
72
+ UQ_BUDGET_FILES: dict[str, list[tuple[str, str]]] = {
73
+ "L63": [
74
+ ("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l63_ensemble_results_2026-06-15.nc"),
75
+ ],
76
+ "L96": [
77
+ ("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l96_ensemble_results_2026-06-15.nc"),
78
+ ("new-method", "new-method_results/new_method_l96.nc"), # ← add here
79
+ ],
80
+ ...
81
+ }
82
+ ```
83
+
84
+ Each entry is a `(algorithm_type, relative_path)` tuple. `algorithm_type` must match an
85
+ entry in `KNOWN_METHODS` (or its aliases list) β€” see `add-method.md` for how to register
86
+ a new method.
87
+
88
+ Paths are relative to the `data/` directory.
89
+
90
+ ---
91
+
92
+ ## Step 3 β€” Ensure the method is registered
93
+
94
+ The `algorithm_type` string in the tuple must resolve through the method identity chain
95
+ to a known canonical key. If this is a new method:
96
+
97
+ 1. Add an entry to `KNOWN_METHODS` in `src/common/method_registry.py` (see `add-method.md`).
98
+ 2. Add display metadata in `src/pages/MethodDetails.py` `method_meta` dict.
99
+
100
+ If the method is already on the optimization leaderboard, its registry entry exists β€”
101
+ no changes to `method_registry.py` or `MethodDetails.py` are needed.
102
+
103
+ ---
104
+
105
+ ## Step 4 β€” Clear cache and verify
106
+
107
+ Clear the Streamlit cache (top-right menu β†’ "Clear cache", or restart the app).
108
+
109
+ Then check:
110
+
111
+ 1. **UQ Leaderboard** (`🎯 UQ Leaderboard`): the method appears in the ranked table at
112
+ target levels where coverage was achievable. At very tight tolerances (low c), all
113
+ seeds may fail β€” this is expected due to grid coarseness (N_y output dims means
114
+ coverage fractions are multiples of 1/N_y).
115
+
116
+ 2. **Iterations chart**: "Mean Iterations for Coverage vs Ensemble Size" should show a
117
+ line for the new method.
118
+
119
+ 3. **Failure rate chart**: "Failure Rate of Hitting Target ..." should show bars for
120
+ the new method. 100% failure at c=1.0 is often expected for small N_y.
121
+
122
+ 4. **Snapshot**: `.cache/known_methods_snapshot.json` β†’ `unmapped_observed_methods`
123
+ should not include the new method's algorithm_type string.
124
+
125
+ ---
126
+
127
+ ## Failure-rate expectations by N_y
128
+
129
+ With N_y output dimensions, coverage fractions are multiples of 1/N_y. The first c level
130
+ that permits convergence depends on N_y:
131
+
132
+ | N_y | First achievable c | Reason |
133
+ |---|---|---|
134
+ | 9 (L63) | β‰ˆ 1.5 | tol(q=0.5) = 0.25; nearest coverage 3/9 = 0.333 satisfies |0.333βˆ’0.5| ≀ 0.25 |
135
+ | 80 (L96) | β‰ˆ 1.0 | finer grid; easier to satisfy all three quantiles |
136
+
137
+ Expect 100% failure at the tightest c levels for small N_y. This is data behavior, not a
138
+ pipeline bug.
139
+
140
+ ---
141
+
142
+ ## What NOT to do
143
+
144
+ - **Do not add UQ files to `UQ_DATASET_FILES`** β€” that dict is intentionally empty. The
145
+ UQ leaderboard only reads from `UQ_BUDGET_FILES` via `load_uq_budget_store()`.
146
+ - **Do not rename `rmse_target` to `uq_target`** β€” UQ files do not use a `uq_target`
147
+ dimension at all; the target-scaling levels come from the `target_scaling` coordinate.
148
+ - **Do not put a `metric` variable in the UQ NC file** β€” `load_uq_budget_store()` ignores
149
+ it; the metric is computed from `output_coverage`.
.claude/skills/leaderboard-manager/references/assess.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Assess for Staleness / Bugs
2
+
3
+ Use this checklist when the user asks for an audit, bug check, or staleness review of the
4
+ leaderboard dashboard. Read all relevant source files first (in parallel), then work
5
+ through each item below and report findings grouped by severity.
6
+
7
+ ---
8
+
9
+ ## Files to read
10
+
11
+ Read these in a single parallel batch before starting the checklist:
12
+
13
+ - `src/common/method_registry.py` β€” `KNOWN_METHODS`, aliases
14
+ - `src/common/leaderboard.py` β€” `render_leaderboard()`
15
+ - `src/data_store.py` β€” `DATASET_FILES`, `UQ_DATASET_FILES`, `_load_store()`
16
+ - `src/streamlit_app.py` β€” home page wiring
17
+ - `src/pages/UQLeaderboard.py`
18
+ - `src/pages/MethodDetails.py` β€” `method_meta` dict, target list
19
+ - `src/pages/RawData.py`
20
+ - `.cache/known_methods_snapshot.json` β€” observed vs known methods
21
+
22
+ ---
23
+
24
+ ## Checklist
25
+
26
+ ### API correctness
27
+
28
+ - [ ] **`width="stretch"` in `st.dataframe` or `st.altair_chart`** β€” invalid parameter.
29
+ Should be `use_container_width=True`. Grep for `width="stretch"` across `src/`.
30
+
31
+ ### Data integrity
32
+
33
+ - [ ] **Methods in `KNOWN_METHODS` with no entry in `DATASET_FILES`** β€” registered but
34
+ will never appear on any leaderboard (no data). Check each canonical key in
35
+ `KNOWN_METHODS` against the file paths listed in `DATASET_FILES`; confirm at least one
36
+ registered file would produce rows for that `algorithm_type`.
37
+ - [ ] **`.cache/known_methods_snapshot.json` β†’ `unmapped_observed_methods` not empty** β€”
38
+ means a NetCDF carries an `algorithm_type` string that has no entry in `KNOWN_METHODS`
39
+ (or its aliases list). Those rows silently fall through with a raw uppercase fallback
40
+ abbreviation and `family = "Kalman"` default.
41
+ - [ ] **`DATASET_FILES` references a file path that doesn't exist on disk** β€” `_load_store`
42
+ emits a `warnings.warn` and skips it silently; run
43
+ `python -c "from src.data_store import load_metric_store; load_metric_store()"` and
44
+ watch for warnings, or just `ls` the registered paths.
45
+
46
+ ### Display correctness
47
+
48
+ - [ ] **Hardcoded `target_options` in `MethodDetails.py`** β€” should be derived from data:
49
+ `sorted(slice_df["rmse_target"].astype(str).unique().tolist())`. A hardcoded list
50
+ silently hides any target present in the data but absent from the list.
51
+ - [ ] **`method_meta` entries with placeholder citations or `example.com` URLs** β€”
52
+ grep for `example.com` and `placeholder` / `citation to be added` in `MethodDetails.py`.
53
+ See `references/populate-metadata.md` for confirmed citations.
54
+
55
+ ### Dead code
56
+
57
+ - [ ] **Unused columns written to `merged` in `_load_store`** β€” e.g. any column created
58
+ but never read downstream. Check columns added after the `dropna` step in `data_store.py`.
59
+ - [ ] **Session-state guards that are always `True`** β€” e.g.
60
+ `if st.session_state.get("show_home", True):` where nothing ever sets the key to `False`.
61
+ These wrap live code in an unreachable conditional.
62
+ - [ ] **Dead functions** β€” any function in `data_store.py` or elsewhere that is defined but
63
+ never called. Check for `def build_*` or similar remnants of superseded pipelines.
64
+
65
+ ### Sidebar nav consistency
66
+
67
+ - [ ] **All four files have identical sidebar `page_link` blocks** β€” the block is
68
+ copy-pasted in `streamlit_app.py`, `UQLeaderboard.py`, `MethodDetails.py`, `RawData.py`.
69
+ Confirm they are identical (same links, same icons, same order).
70
+
71
+ ---
72
+
73
+ ## Reporting format
74
+
75
+ Group findings by severity:
76
+
77
+ - **Bug (functional impact)** β€” something that causes incorrect output, a runtime error,
78
+ or silently hides data (e.g. hardcoded targets, invalid Streamlit API).
79
+ - **Staleness** β€” registry entries, metadata, or cached snapshots that no longer match
80
+ current code or data (e.g. placeholder citations, phantom method entries).
81
+ - **Dead code** β€” functions, columns, or conditionals that do nothing.
82
+ - **Informational** β€” minor inconsistencies that don't affect correctness.
83
+
84
+ For each finding, include: file, line number(s), description, and recommended fix.
.claude/skills/leaderboard-manager/references/manage-display.md ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Manage Display β€” Table, Chart, and Scoring
2
+
3
+ This file covers every display surface in the leaderboard dashboard and how to change it.
4
+ The key architectural fact: **both the Optimization and UQ leaderboards share a single
5
+ renderer** (`render_leaderboard()` in `src/common/leaderboard.py`). Any change to that
6
+ function affects both boards simultaneously. Method-specific charts live in
7
+ `src/pages/MethodDetails.py`.
8
+
9
+ ---
10
+
11
+ ## Leaderboard table
12
+
13
+ ### Column visibility and order
14
+
15
+ The columns shown depend on whether "All" or a specific benchmark is selected. Both lists
16
+ are defined in `src/common/leaderboard.py`:
17
+
18
+ - **All-benchmarks view** (`table_column_order`, line 296–305):
19
+ ```python
20
+ ["Placement", "abbreviation", "Method", "family", "Score",
21
+ "Targets Used", "Ensemble Sizes Used", "Benchmarks Used"]
22
+ ```
23
+ - **Single-benchmark view** (`table_column_order`, line 307–319):
24
+ ```python
25
+ ["Placement", "abbreviation", "Method", "family", "Score",
26
+ "Mean Forward Model Runs", "Minimum Forward Model Runs",
27
+ "Optimal Ensemble Size", "Targets Used", "Ensemble Sizes Used", "Benchmarks Used"]
28
+ ```
29
+
30
+ To **show or hide** a column, add/remove it from the relevant list.
31
+ To **reorder** columns, rearrange entries within the list.
32
+
33
+ ### Column labels and formatting
34
+
35
+ Column display names and number formats are set in `st.dataframe(... column_config=...)`
36
+ at lines 326–339. Each column has an entry like:
37
+
38
+ ```python
39
+ "Mean Forward Model Runs": st.column_config.NumberColumn(
40
+ "Mean Forward Model Runs", format="%.4f"
41
+ ),
42
+ "Score": st.column_config.ProgressColumn(
43
+ "Score (0–100)", min_value=0.0, max_value=100.0, format="%.1f"
44
+ ),
45
+ ```
46
+
47
+ To **rename** a displayed column header, change the string argument (second positional arg)
48
+ in the `column_config` entry. The dict key must still match the DataFrame column name.
49
+
50
+ To **add a new column to the table**, you also need to produce it in `build_scored_table()`
51
+ (line 111) β€” add it to the `.agg()` call or compute it after the groupby, then add a
52
+ `column_config` entry and include it in `table_column_order`.
53
+
54
+ ### Adding `failure_rate` to the leaderboard table
55
+
56
+ `failure_rate` is available in `metric_store` but not surfaced in the scored table.
57
+ To add it:
58
+ 1. In `build_scored_table()`, add it to the `.groupby(...).agg(...)` call, e.g.:
59
+ `**{"Mean Failure Rate (%)": ("failure_rate", "mean")}`.
60
+ 2. Add a `column_config` entry (e.g. `st.column_config.NumberColumn(..., format="%.1f")`).
61
+ 3. Add `"Mean Failure Rate (%)"` to `table_column_order` where desired.
62
+
63
+ ---
64
+
65
+ ## Scoring modes
66
+
67
+ The four scoring modes are defined in `scoring_options` (line 82):
68
+ ```python
69
+ scoring_options = [
70
+ "Mean Forward Model Runs",
71
+ "Minimum Forward Model Runs",
72
+ "Smallest Optimal Ensemble Size",
73
+ "Custom Blend",
74
+ ]
75
+ ```
76
+
77
+ Each mode maps to a sort key and a score column assignment in `build_scored_table()` at
78
+ lines 185–203. To **add a new scoring mode**, add its name to `scoring_options` and add
79
+ an `elif` branch in the scoring logic block that sets `scored_df["Score"]`,
80
+ `sort_columns`, and `ascending`.
81
+
82
+ The "Custom Blend" slider weight (lines 357–368) is keyed to the session-state key
83
+ `k_weight` β€” no change needed when adding a non-blend mode.
84
+
85
+ ---
86
+
87
+ ## Leaderboard line chart
88
+
89
+ The chart that appears in single-benchmark view ("Mean Forward Model Runs vs Ensemble
90
+ Size") is at lines 387–396 (inside the `if selected_benchmark != "All":` guard at line
91
+ 376):
92
+
93
+ ```python
94
+ ens_ticks = sorted(chart_df["ensemble_size"].unique().tolist())
95
+ chart = (
96
+ alt.Chart(chart_df)
97
+ .mark_line(point=True)
98
+ .encode(
99
+ x=alt.X(
100
+ "ensemble_size:Q",
101
+ title="Ensemble Size",
102
+ axis=alt.Axis(values=ens_ticks, format="d"), # integer ticks only
103
+ ),
104
+ y=alt.Y("mean_forward_runs:Q", title="Mean Forward Model Runs"),
105
+ color=alt.Color("abbreviation:N", title="Method"),
106
+ tooltip=["abbreviation", "ensemble_size",
107
+ alt.Tooltip("mean_forward_runs:Q", format=".4f")],
108
+ )
109
+ )
110
+ st.altair_chart(chart, use_container_width=True)
111
+ ```
112
+
113
+ The `axis=alt.Axis(values=ens_ticks, format="d")` pattern is important: without it,
114
+ Altair may interpolate non-integer ticks (e.g. 5.5, 10.5) on the ensemble-size axis.
115
+ Always derive `ens_ticks` from the actual data rather than hardcoding.
116
+
117
+ Common changes:
118
+ - **Log scale on Y**: add `scale=alt.Scale(type="log")` inside `alt.Y(...)`.
119
+ - **Add failure rate as a second Y-axis or tooltip**: add `failure_rate` to `chart_df`
120
+ groupby and include it in `tooltip`.
121
+ - **Change mark type** (e.g. bars): replace `mark_line(point=True)` with `mark_bar()`.
122
+ - **Color palette**: add `.configure_range(category={"scheme": "tableau10"})` after the
123
+ chart construction.
124
+
125
+ The chart only renders when a single benchmark is selected (`if selected_benchmark != "All":`
126
+ guard at line 376).
127
+
128
+ ---
129
+
130
+ ## Method Details page surfaces
131
+
132
+ The per-method page (`src/pages/MethodDetails.py`) has its own controls.
133
+
134
+ ### Target level radio
135
+
136
+ Line 111:
137
+ ```python
138
+ target_options = ["1.0", "1.1", "1.2"]
139
+ selected_target = st.radio("RMSE Target Level", options=target_options, horizontal=True)
140
+ ```
141
+
142
+ These are **hardcoded strings**. If the benchmark gains new target levels, update this
143
+ list. A more robust approach is to derive them from the data:
144
+ ```python
145
+ target_options = sorted(metric_store["rmse_target"].astype(str).unique().tolist())
146
+ ```
147
+
148
+ ### Per-method special-case charts
149
+
150
+ The HM (History Matching) failure-analysis chart (lines 122–138) is the canonical
151
+ example of a per-method detail chart:
152
+
153
+ ```python
154
+ if sel == "HM":
155
+ st.markdown("### Failure Analysis")
156
+ chart = (alt.Chart(best_table_view).mark_bar().encode(...))
157
+ st.altair_chart(chart, use_container_width=True)
158
+ ```
159
+
160
+ To **add a chart for another method**, add an `elif sel == "ABBR":` block immediately
161
+ after the `if sel == "HM":` block, following the same pattern.
162
+
163
+ To **generalize** (show failure rate for all methods when non-zero), replace the `if sel
164
+ == "HM":` guard with a condition on the data, e.g.:
165
+ ```python
166
+ if best_table_view["failure_rate"].max() > 0:
167
+ # render the failure chart for any method that has failures
168
+ ```
169
+
170
+ ### Scaling chart (ensemble size vs forward-model runs per benchmark)
171
+
172
+ The `"Scaling by Benchmark"` chart is already generic across all methods. To modify
173
+ encoding, change the `.encode()` arguments analogously to the leaderboard chart above.
174
+
175
+ ---
176
+
177
+ ## UQ-specific charts (`budget_store` parameter)
178
+
179
+ The UQ leaderboard passes a `budget_store` DataFrame to `render_leaderboard()` via the
180
+ `budget_store` keyword argument. This enables two additional chart sections rendered in
181
+ `src/common/leaderboard.py` after the main performance chart:
182
+
183
+ 1. **"Mean Iterations for Coverage vs Ensemble Size"** β€” line chart of `mean_iters` vs
184
+ ensemble size. Uses the same integer-tick pattern as the main chart.
185
+
186
+ 2. **"Failure Rate of Hitting Target ..."** β€” grouped bar chart with `ensemble_size:O`
187
+ on the x-axis (ordinal, not quantitative) and `mean_failure_rate:Q` on y. The title
188
+ reflects the currently selected target level.
189
+
190
+ To **add or modify a UQ chart**, edit the `if budget_store is not None` block in
191
+ `src/common/leaderboard.py`. The `budget_store` DataFrame has columns: `benchmark`,
192
+ `algorithm_type`, `abbreviation`, `family`, `uq_target`, `ensemble_size`, `mean_budget`,
193
+ `mean_iters`, `failure_count`, `failure_rate`, `n_seeds`.
194
+
195
+ **Bar chart integer x-axis:** Use `ensemble_size:O` (ordinal) with an explicit sort list
196
+ and `axis=alt.Axis(labelAngle=0)` to suppress diagonal labels:
197
+ ```python
198
+ ens_ticks_fail = sorted(failure_df["ensemble_size"].unique().tolist())
199
+ x=alt.X(
200
+ "ensemble_size:O",
201
+ title="Ensemble Size",
202
+ sort=[str(e) for e in ens_ticks_fail],
203
+ axis=alt.Axis(labelAngle=0),
204
+ )
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Display change checklist
210
+
211
+ 1. Edit the relevant file (`leaderboard.py` for both leaderboards; `MethodDetails.py` for
212
+ per-method surfaces).
213
+ 2. If adding a column: produce it in the aggregation AND add a `column_config` entry AND
214
+ add it to `table_column_order`.
215
+ 3. Restart or clear Streamlit cache to pick up Python changes (code changes take effect on
216
+ restart; cache only matters for data changes).
217
+ 4. Smoke-test on both the "All" view and a single-benchmark view to catch the different
218
+ `table_column_order` branches.
.claude/skills/leaderboard-manager/references/move-between-boards.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Move or Duplicate a Method Between Leaderboards
2
+
3
+ This file covers wiring a method's results onto the UQ leaderboard, or removing it from
4
+ one board while keeping it on the other. The key insight is that **the method registry
5
+ and the MethodDetails metadata are shared** β€” you never need to touch `method_registry.py`
6
+ or `MethodDetails.py` for this task. It is purely a **data registration** operation.
7
+
8
+ ---
9
+
10
+ ## How the two boards relate
11
+
12
+ ```
13
+ method_registry.py ← shared; controls identity on BOTH boards
14
+ pages/MethodDetails.py ← shared; controls the Methods page for BOTH boards
15
+ data_store.py
16
+ DATASET_FILES ← Optimization board only (rmse_target, metric variable)
17
+ UQ_BUDGET_FILES ← UQ board only (output_coverage variable, budget pipeline)
18
+ ```
19
+
20
+ A method is "on" a leaderboard if and only if a NetCDF containing its `algorithm_type`
21
+ is registered in the corresponding data dict.
22
+
23
+ **Critical difference:** the two boards use entirely different NetCDF schemas and loading
24
+ pipelines. Do not try to use the same NC file for both boards.
25
+
26
+ ---
27
+
28
+ ## UQ vs optimization NC schemas
29
+
30
+ | Aspect | Optimization (`DATASET_FILES`) | UQ (`UQ_BUDGET_FILES`) |
31
+ |---|---|---|
32
+ | Key variable | `metric` (pre-computed runs) | `output_coverage` (coverage fractions) |
33
+ | Target coord | `rmse_target` dimension | `target_scaling` coordinate (not a dim) |
34
+ | Failure encoding | `metric == -1` | NaN (never reached) |
35
+ | Loader | `_load_store()` | `load_uq_budget_store()` computes budget on the fly |
36
+
37
+ For the full UQ NC schema, see `references/add-uq-data.md`.
38
+
39
+ ---
40
+
41
+ ## Duplicate a method (appear on both boards)
42
+
43
+ "Duplicate" means the method shows up on the Optimization **and** UQ leaderboards.
44
+
45
+ 1. Obtain (or produce) a UQ-schema NC file for the method (must have `output_coverage`,
46
+ `coverage_quantile`, `k_iter`, `ensemble_size`, `random_seed`, `target_scaling`,
47
+ `output_dim` β€” see `references/add-uq-data.md` for the full spec).
48
+ 2. Place it in `data/` (e.g. `data/method_results/method_l63_ensemble_results.nc`).
49
+ 3. Add the path to `UQ_BUDGET_FILES` in `src/data_store.py`:
50
+
51
+ ```python
52
+ UQ_BUDGET_FILES: dict[str, list[tuple[str, str]]] = {
53
+ "L63": [
54
+ ("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l63_ensemble_results_2026-06-15.nc"),
55
+ ("new-method", "new-method_results/new_method_l63.nc"), # ← add here
56
+ ],
57
+ ...
58
+ }
59
+ ```
60
+
61
+ The method's optimization results in `DATASET_FILES` remain untouched.
62
+ No changes to `method_registry.py`, `MethodDetails.py`, or `UQLeaderboard.py` are needed.
63
+
64
+ **Clear the Streamlit cache** after editing `data_store.py` β€” all three loaders are cached.
65
+
66
+ ---
67
+
68
+ ## Move a method (UQ only, remove from optimization)
69
+
70
+ "Move" means the method should appear on the UQ leaderboard **and not** the Optimization
71
+ leaderboard.
72
+
73
+ 1. Complete the duplicate steps above (register UQ NC in `UQ_BUDGET_FILES`).
74
+ 2. Remove the method's path(s) from `DATASET_FILES` in `src/data_store.py`.
75
+
76
+ The registry entry and `method_meta` in `MethodDetails.py` stay β€” the Methods page still
77
+ shows the method, but performance data comes only from `load_uq_store()`. The method won't
78
+ appear in the optimization leaderboard table.
79
+
80
+ ---
81
+
82
+ ## Populate the UQ leaderboard for a new benchmark (first time)
83
+
84
+ When a benchmark key is absent from `UQ_BUDGET_FILES`, that benchmark has no UQ data.
85
+ Add the key and the NC file tuple β€” see the "Duplicate" section above. The UQ page calls
86
+ `render_leaderboard(load_uq_store(), ...)` which automatically includes all registered
87
+ benchmarks; no page-level changes needed.
88
+
89
+ ---
90
+
91
+ ## Verify
92
+
93
+ After editing `UQ_BUDGET_FILES` and clearing the cache:
94
+
95
+ 1. Visit the **UQ Leaderboard** page (`🎯 UQ Leaderboard` in the sidebar).
96
+ 2. Confirm the method appears in the ranked table for the benchmark you registered.
97
+ 3. Check `.cache/known_methods_snapshot.json` β†’ `unmapped_observed_methods` is empty for
98
+ the new method.
99
+ 4. Expect 100% failure at tight c values (e.g. c=1.0) for benchmarks with small N_y β€”
100
+ see `references/add-uq-data.md` for the coverage-coarseness explanation.
101
+
102
+ If the UQ leaderboard still shows "No metric data found" after clearing cache, confirm:
103
+ - The NC path in `UQ_BUDGET_FILES` is correct relative to `data/`.
104
+ - The file has been `git lfs pull`-ed (not a 130-byte pointer stub).
105
+ - `output_coverage` is present in the file and the `coverage_quantile` coordinate
106
+ includes 0.15, 0.5, and 0.85.
107
+
108
+ ---
109
+
110
+ ## What does NOT need changing
111
+
112
+ - `src/common/leaderboard.py` β€” fully generic; handles both leaderboards via parameters.
113
+ - `src/pages/UQLeaderboard.py` β€” passes `budget_store=load_uq_budget_store()` already.
114
+ - `src/common/method_registry.py` β€” no change if the method is already registered.
115
+ - `src/pages/MethodDetails.py` β€” no change; the Methods page is shared.
116
+ - The five sidebar nav blocks β€” no new pages are being added.
.claude/skills/leaderboard-manager/references/populate-metadata.md ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Populate Method and Data Metadata
2
+
3
+ This file covers filling in or correcting text that appears about a method in the
4
+ dashboard β€” citations, reference URLs, and summary descriptions β€” as well as understanding
5
+ where benchmark (dataset) labels come from.
6
+
7
+ ---
8
+
9
+ ## EKP documentation β€” fetch warning
10
+
11
+ The canonical citation source for Kalman-family methods is:
12
+ `https://clima.github.io/EnsembleKalmanProcesses.jl/dev/`
13
+
14
+ **This URL frequently times out in WebFetch.** If it does, fall back to the confirmed
15
+ citations below rather than retrying repeatedly.
16
+
17
+ ### Confirmed citations (verified in prior sessions)
18
+
19
+ | Abbreviation | Citation | DOI / URL |
20
+ |---|---|---|
21
+ | TEKI | Chada et al., SIAM/ASA J. UQ, 2020 | https://doi.org/10.1137/17M114402X |
22
+ | IEKF | Iglesias, Inverse Problems, 2016 | https://doi.org/10.1088/0266-5611/32/2/025002 |
23
+ | CES-EKI-DMC | Cleary et al., J. Comput. Phys., 2021 | https://doi.org/10.1016/j.jcp.2020.109716 |
24
+ | ETKI | Schillings & Stuart, Numer. Math., 2017 | link to EKP docs (sub-page DOI unconfirmed) |
25
+ | UKI | Huang, Huang & Stuart, Physica D, 2022 | link to EKP docs (sub-page DOI unconfirmed) |
26
+ | HM | Williamson et al. 2013; King et al. 2025 | β€” |
27
+
28
+ Use `https://clima.github.io/EnsembleKalmanProcesses.jl/dev/` as the URL for ETKI and UKI
29
+ until their sub-page DOIs are confirmed.
30
+
31
+ ---
32
+
33
+ ---
34
+
35
+ ## The metadata split
36
+
37
+ Method information lives in **two separate places** that serve different purposes:
38
+
39
+ | File | Key used | What it stores |
40
+ |---|---|---|
41
+ | `src/common/method_registry.py` | canonical key (e.g. `"uki"`) | Abbreviation, full method name, family, aliases β€” used for **data processing and identity** |
42
+ | `src/pages/MethodDetails.py` `method_meta` dict | abbreviation (e.g. `"UKI"`) | Citation, URL, summary β€” used for **display copy only** |
43
+
44
+ Both must be kept in sync. When you edit one, check the other. The most common mistake is
45
+ adding a method to `method_registry.py` without adding a corresponding `method_meta`
46
+ entry β€” the app won't crash, but the Methods page will show "Citation pending" and a
47
+ broken link.
48
+
49
+ ---
50
+
51
+ ## Editing method display copy
52
+
53
+ Open `src/pages/MethodDetails.py`. The `method_meta` dict starts at line 40:
54
+
55
+ ```python
56
+ method_meta = {
57
+ "TEKI": {
58
+ "citation": "Chada et al., SIAM/ASA J. UQ, 2020",
59
+ "url": "https://doi.org/10.1137/17M114402X",
60
+ "summary": "EKI variant with Tikhonov regularization for stability.",
61
+ },
62
+ ...
63
+ }
64
+ ```
65
+
66
+ **To update a citation or URL**, find the entry by abbreviation and edit the values.
67
+ **To add a missing entry**, add a new key matching the `abbreviation` in `method_registry.py`.
68
+
69
+ The three fields:
70
+ - `citation` β€” free-form string; typically "Author et al., Journal, Year" or a short title.
71
+ - `url` β€” a full `https://...` URL. Displayed as an "Open reference" button.
72
+ - `summary` β€” one or two sentences. Keep it short enough to fit in a narrow column.
73
+
74
+ If you don't have a value yet, use `"(citation pending)"` / `"https://example.com"` /
75
+ `"(summary pending)"` as placeholders so the app renders gracefully.
76
+
77
+ ---
78
+
79
+ ## Remaining placeholder backlog
80
+
81
+ Most entries have been filled in. The following still need real values:
82
+
83
+ | Abbreviation | Field | Status |
84
+ |---|---|---|
85
+ | `ABC` | url | `"https://example.com/abc"` β€” no canonical URL identified yet |
86
+ | `HM` | url | `"https://example.com/hm"` β€” no canonical URL identified yet |
87
+ | `ETKI` | url | Points to EKP docs index β€” sub-page DOI not yet confirmed |
88
+ | `UKI` | url | Points to EKP docs index β€” sub-page DOI not yet confirmed |
89
+
90
+ When filling in `ABC` or `HM` URLs, check with the collaborator for the preferred
91
+ reference (History Matching has many implementations; the right URL depends on which
92
+ variant is used here).
93
+
94
+ ---
95
+
96
+ ## Editing method identity / naming
97
+
98
+ If you need to correct the full method name, family classification, or abbreviation (the
99
+ short label that appears in table columns), edit `KNOWN_METHODS` in
100
+ `src/common/method_registry.py`:
101
+
102
+ ```python
103
+ "uki": {
104
+ "abbreviation": "UKI", # Short label in table columns
105
+ "Method": "Unscented Kalman Inversion", # Full name in Method column
106
+ "family": "Kalman", # Kalman | Bayesian | calibrate_then_emulate
107
+ "aliases": ["uki"], # Raw algorithm_type strings from NetCDF
108
+ },
109
+ ```
110
+
111
+ **If you change `abbreviation`**: also update the corresponding key in `method_meta` in
112
+ `MethodDetails.py` (the keys must match). The app looks up `method_meta[abbreviation]`.
113
+
114
+ **If you change `family`**: the "Family" column in the leaderboard table updates
115
+ automatically β€” no other change needed.
116
+
117
+ **If you add an alias**: new alias strings start matching immediately after reload. Use
118
+ this when a collaborator's NetCDF uses a variant spelling not currently in the alias list.
119
+
120
+ ---
121
+
122
+ ## Benchmark / dataset labels
123
+
124
+ Benchmark names (`L63`, `L96`, `L96_NN_FORCING`, `L96_SPATIAL_FORCING`) come from the
125
+ keys of `DATASET_FILES` in `src/data_store.py`. They appear:
126
+ - In the "Benchmark" selectbox on both leaderboards.
127
+ - In the `benchmark` column of every row in `metric_store`.
128
+ - As chart axis labels on the Methods detail page ("Scaling by Benchmark").
129
+
130
+ To **rename a benchmark label**, change its key in `DATASET_FILES` (and `UQ_DATASET_FILES`
131
+ if present). The label propagates everywhere automatically because it's read from the data
132
+ at runtime. There is no separate label registry.
133
+
134
+ To **add a new benchmark**, see `add-method.md` β†’ Step 3 (the same pattern applies even
135
+ without a new method β€” just add a new benchmark key with its file list).
136
+
137
+ ---
138
+
139
+ ## Verify
140
+
141
+ After editing metadata:
142
+ 1. Restart the app (code changes don't require cache clearing, only data changes do).
143
+ 2. Open the `πŸ“˜ Methods` page and select the edited method.
144
+ 3. Confirm the citation text, URL button target, and summary box show the new values.
145
+ 4. Check no `method_meta` key is misspelled (the abbreviation in `method_meta` must match
146
+ `KNOWN_METHODS[...]["abbreviation"]` exactly, including capitalisation).
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* 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
  *.nc 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
  *.nc filter=lfs diff=lfs merge=lfs -text
37
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,3 +1,34 @@
1
- *.pyc
2
- .venv
3
- __pycache__
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Emacs
2
+ *~
3
+ \#*\#
4
+ .\#*
5
+ *.elc
6
+
7
+ # Python
8
+ *.py[cod]
9
+ *.so
10
+ __pycache__/
11
+ *.egg-info/
12
+ .eggs/
13
+ dist/
14
+ build/
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+
19
+ # Virtual environments
20
+ .venv/
21
+ venv/
22
+ env/
23
+
24
+ # Streamlit
25
+ .streamlit/secrets.toml
26
+
27
+ # App cache
28
+ .cache/
29
+
30
+ # Jupyter
31
+ .ipynb_checkpoints/
32
+
33
+ # OS
34
+ .DS_Store
CLAUDE.md ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Calibration Benchmark
2
+
3
+ ## Project
4
+ Streamlit dashboard hosting a **calibration / UQ leaderboard**. Compares parameter-calibration
5
+ methods on Lorenz-63/96 dynamical-system benchmarks by ranking them on *forward-model run*
6
+ efficiency (cost to hit a target across ensemble sizes). Two leaderboards:
7
+
8
+ - **Optimization leaderboard** (home page) β€” race to reach an RMSE target.
9
+ - **UQ leaderboard** (`pages/UQLeaderboard.py`) β€” budget-for-coverage metric: N_ensΒ·k_iter to
10
+ satisfy a marginal-coverage condition at quantiles [0.15, 0.5, 0.85] for a given tolerance
11
+ scaling c. Data comes from ensemble-results NetCDF files registered in `UQ_BUDGET_FILES`.
12
+
13
+ Deployed as a Hugging Face Space via Docker (port 8501).
14
+
15
+ **Key metric (optimization):** `metric` = mean forward-model runs to reach an RMSE target
16
+ (lower is better). `metric == -1` encodes a failed run.
17
+
18
+ **Key metric (UQ):** `metric` = mean budget N_ensΒ·k_iter to reach marginal-coverage
19
+ tolerance `c·√(q(1βˆ’q)/N_y)` for all q ∈ [0.15, 0.5, 0.85] simultaneously (lower = better).
20
+ `uq_target` = the scaling c ∈ {1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5}. NaN encodes failure
21
+ (target never reached within the k_iter range).
22
+
23
+ ## Quick start
24
+
25
+ ```sh
26
+ # First-time setup
27
+ python3.11 -m venv .venv
28
+ source .venv/bin/activate
29
+ pip install -r requirements.txt
30
+ git lfs pull # required β€” data/*.nc are LFS pointers; app shows nothing without this
31
+
32
+ # Run
33
+ streamlit run src/streamlit_app.py
34
+ ```
35
+
36
+ Docker:
37
+ ```sh
38
+ docker build -t calibration-benchmark .
39
+ docker run -p 8501:8501 calibration-benchmark # β†’ http://localhost:8501
40
+ ```
41
+
42
+ ## File map
43
+
44
+ ```
45
+ calibration_benchmark/
46
+ β”œβ”€β”€ src/
47
+ β”‚ β”œβ”€β”€ streamlit_app.py Entrypoint + Home/"Optimization Leaderboard"
48
+ β”‚ β”œβ”€β”€ data_store.py load_metric_store() / load_uq_store() / load_uq_budget_store()
49
+ β”‚ β”‚ β†’ @st.cache_data DataFrames; DATASET_FILES, UQ_BUDGET_FILES
50
+ β”‚ β”œβ”€β”€ common/
51
+ β”‚ β”‚ β”œβ”€β”€ leaderboard.py render_leaderboard() β€” shared scored-table + chart pipeline
52
+ β”‚ β”‚ β”‚ optional budget_store param adds budget/iters chart section
53
+ β”‚ β”‚ └── method_registry.py KNOWN_METHODS registry + name canonicalization
54
+ β”‚ └── pages/
55
+ β”‚ β”œβ”€β”€ UQLeaderboard.py 🎯 UQ Leaderboard β€” calls render_leaderboard(load_uq_store())
56
+ β”‚ β”œβ”€β”€ MethodDetails.py πŸ“˜ Methods β€” per-method charts + metadata
57
+ β”‚ └── RawData.py 🧾 Get Data β€” raw table + CSV export
58
+ β”œβ”€β”€ data/ NetCDF result files (git-LFS β€” run `git lfs pull`)
59
+ β”‚ β”œβ”€β”€ *.nc Kalman results (TEKI, ETKI, IEKF)
60
+ β”‚ β”œβ”€β”€ bayesian/ ABC + HM results
61
+ β”‚ β”œβ”€β”€ UKI_results/ UKI results
62
+ β”‚ └── ces-eki-dmc_results/ CES-EKI-DMC ensemble-results files (UQ source)
63
+ β”‚ β”œβ”€β”€ ces-eki-dmc_l63_ensemble_results_2026-06-15.nc
64
+ β”‚ β”œβ”€β”€ ces-eki-dmc_l96_ensemble_results_2026-06-15.nc
65
+ β”‚ β”œβ”€β”€ ces-eki-dmc_l96_nn_forcing_ensemble_results_2026-06-15.nc
66
+ β”‚ └── ces-eki-dmc_l96_spatial_forcing_ensemble_results_2026-06-15.nc
67
+ β”œβ”€β”€ examples/ lorenz_demo.ipynb + lorenz.py (reference, not app code)
68
+ β”œβ”€β”€ From_Rob_6-10/ Raw collaborator data drop β€” NOT read by the app
69
+ β”œβ”€β”€ .cache/ known_methods_snapshot.json (auto-generated, safe to delete)
70
+ β”œβ”€β”€ .streamlit/config.toml showSidebarNavigation = false
71
+ β”œβ”€β”€ Dockerfile python:3.13.5-slim; ENTRYPOINT streamlit run src/...
72
+ β”œβ”€β”€ requirements.txt altair, pandas, streamlit, xarray, netcdf4, h5netcdf, jax, gpjax
73
+ β”œβ”€β”€ test_parse.py Ad-hoc NetCDF parse check (not a test suite)
74
+ └── test_failures.py Ad-hoc failure-count check (not a test suite)
75
+ ```
76
+
77
+ ## Data model
78
+
79
+ ### Optimization store (`load_metric_store()`)
80
+
81
+ | Field | Meaning |
82
+ |---|---|
83
+ | `benchmark` | `L63`, `L96`, `L96_NN_FORCING`, `L96_SPATIAL_FORCING` |
84
+ | `algorithm_type` | Canonical method key (e.g. `teki`) |
85
+ | `algorithm_alias` | Normalized raw name from NetCDF |
86
+ | `abbreviation` | Display name (e.g. `TEKI`) |
87
+ | `family` | `Kalman` Β· `Bayesian` Β· `calibrate_then_emulate` |
88
+ | `rmse_target` | Target RMSE level |
89
+ | `ensemble_size` | Ensemble / particle count |
90
+ | `metric` | Mean forward-model runs over `random_seed` (lower = better; βˆ’1 = failed) |
91
+ | `failure_rate` | % seeds that failed (`metric == -1`) |
92
+
93
+ `EXPECTED_DIMS = ("algorithm_type", "rmse_target", "ensemble_size", "random_seed")`
94
+
95
+ ### UQ store (`load_uq_store()`)
96
+
97
+ Derived from `load_uq_budget_store()` β€” same schema as the optimization store but with
98
+ `uq_target` in place of `rmse_target`, and `metric` = mean budget (N_ensΒ·k_iter). Failure
99
+ rate = % of seeds that never satisfied the coverage condition (NaN metric, not βˆ’1).
100
+
101
+ ### Budget store (`load_uq_budget_store()`)
102
+
103
+ | Field | Meaning |
104
+ |---|---|
105
+ | `benchmark` | `L63`, `L96`, `L96_NN_FORCING`, `L96_SPATIAL_FORCING` |
106
+ | `algorithm_type` | Canonical method key |
107
+ | `abbreviation` / `Method` / `family` | From method registry |
108
+ | `uq_target` | Target-scaling level c |
109
+ | `ensemble_size` | Ensemble size N |
110
+ | `mean_budget` | Mean NΒ·k_iter over converged seeds (NaN if all failed) |
111
+ | `mean_iters` | Mean k_iter over converged seeds |
112
+ | `failure_count` | # seeds that never reached the target |
113
+ | `failure_rate` | % seeds that never reached the target |
114
+ | `n_seeds` | Total seeds (20) |
115
+
116
+ Source NC files are registered in `UQ_BUDGET_FILES` in `src/data_store.py`. Each file must
117
+ carry `output_coverage (coverage_quantile, k_iter, ensemble_size, random_seed)`,
118
+ `target_scaling`, and `output_dim`.
119
+
120
+ `UQ_COVERAGE_QUANTILES = [0.15, 0.5, 0.85]` β€” the three quantile levels used for the
121
+ all-quantiles-satisfied condition.
122
+
123
+ All three stores are `@st.cache_data` β€” clear Streamlit cache to pick up new data files.
124
+
125
+ ## Method registry (`src/common/method_registry.py`)
126
+
127
+ | Canonical | Abbreviation | Family | Aliases |
128
+ |---|---|---|---|
129
+ | `teki` | TEKI | Kalman | β€” |
130
+ | `etki` | ETKI | Kalman | β€” |
131
+ | `iekf` | IEKF | Kalman | gnsl, gnki |
132
+ | `uki` | UKI | Kalman | β€” |
133
+ | `abc` | ABC | Bayesian | β€” |
134
+ | `hm` | HM | Bayesian | β€” |
135
+ | `ces-eki-dmc` | CES-EKI-DMC | calibrate_then_emulate | β€” |
136
+
137
+ ## How to extend
138
+
139
+ ### Add a method
140
+ 1. Add entry to `KNOWN_METHODS` in `src/common/method_registry.py`
141
+ (keys: `abbreviation`, `Method`, `family`, `aliases`).
142
+ 2. Add detail-page metadata to the inline `method_meta` dict in `src/pages/MethodDetails.py`
143
+ keyed by **abbreviation** (e.g. `"TEKI"`). ⚠️ Metadata is split across both files.
144
+ 3. Place result NetCDF(s) in `data/` and add paths to `DATASET_FILES` in `src/data_store.py`.
145
+
146
+ ### Add an optimization benchmark dataset
147
+ Add a `"BENCHMARK_NAME": [list_of_nc_paths]` entry to `DATASET_FILES` in `src/data_store.py`.
148
+ Paths are relative to the `data/` directory.
149
+
150
+ ### Add UQ data
151
+ Add an `(algorithm_type, nc_path)` tuple to the appropriate benchmark key in `UQ_BUDGET_FILES`
152
+ in `src/data_store.py`. The NC file must carry:
153
+ - `output_coverage` with dims `(coverage_quantile, k_iter, ensemble_size, random_seed)`
154
+ - coordinates `coverage_quantile`, `k_iter`, `ensemble_size`, `target_scaling`
155
+ - dimension `output_dim`
156
+
157
+ `load_uq_budget_store()` and `load_uq_store()` will pick it up automatically after a
158
+ Streamlit cache clear. No changes to `UQ_DATASET_FILES` (kept empty) are needed.
159
+
160
+ The budget/iters chart and coverage-failure bar chart in the UQ leaderboard are rendered via
161
+ the `budget_store` parameter of `render_leaderboard()` in `src/common/leaderboard.py`.
162
+
163
+ ### Add a page
164
+ 1. Create `src/pages/NewPage.py` β€” copy the sidebar nav block + `data_store` import shim
165
+ from an existing page (e.g. `RawData.py`).
166
+ 2. Add `st.sidebar.page_link("pages/NewPage.py", ...)` to **all four** existing files
167
+ (`streamlit_app.py`, `UQLeaderboard.py`, `MethodDetails.py`, `RawData.py`). Sidebar nav
168
+ is duplicated β€” there is no central nav module.
169
+
170
+ ## Known gotchas
171
+ - **`git lfs pull` is mandatory** before first run β€” without it `data/*.nc` are 130-byte stubs.
172
+ - **Sidebar nav is not centralized** β€” adding/renaming pages requires editing four files
173
+ (`streamlit_app.py`, `UQLeaderboard.py`, `MethodDetails.py`, `RawData.py`).
174
+ - **Method metadata is split** β€” `method_registry.py` (scoring) and `pages/MethodDetails.py`
175
+ `method_meta` dict (display copy) must both be updated when adding a new method.
176
+ - **`build_leaderboard()` in `data_store.py` is dead code** β€” both leaderboards are rendered
177
+ via `render_leaderboard()` in `src/common/leaderboard.py`.
178
+ - **No CI / no formal test suite** β€” `test_parse.py` and `test_failures.py` are manual scripts.
179
+ - **Cross-page linking** uses `st.query_params["method"]` (URL param = abbreviation).
180
+ - **`UQ_DATASET_FILES` is intentionally empty** β€” UQ data is loaded via `UQ_BUDGET_FILES`
181
+ and the `load_uq_budget_store()` pipeline, not via `_load_store()`.
182
+ - **UQ failure β‰  optimization failure** β€” in the UQ store, NaN metric means coverage target
183
+ never reached (no `metric == -1` sentinel); `failure_rate` is computed differently.
184
+ - **`render_leaderboard` budget_store param** β€” must be passed explicitly from
185
+ `UQLeaderboard.py`; `leaderboard.py` is not aware of UQ-specific data paths.
README.md CHANGED
@@ -17,3 +17,123 @@ Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :hear
17
 
18
  If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
  forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
  forums](https://discuss.streamlit.io).
20
+
21
+ ## Path to running the app locally (without docker)
22
+ Install the huggingface authentication (via `hf`) add security key
23
+
24
+ Use `python (e.g., 3.11)`
25
+
26
+ Then call
27
+ ```
28
+ python3.11 -m venv .venv
29
+ pip install -r requirements.txt
30
+ ```
31
+
32
+ As the data is just a pointer to the repository, install, and pull the large files
33
+ ```
34
+ sudo apt install git-lfs
35
+ git lfs pull
36
+ ```
37
+ then run the streamlit app
38
+ ```
39
+ streamlit run src/streamlit_app.py
40
+ ```
41
+
42
+ ## Via docker (untested)
43
+
44
+ install docker
45
+ ```
46
+ sudo snap install docker
47
+ docker build -t calibration-benchmark .
48
+ docker run -p 8501:8501 calibration-benchmark
49
+ ```
50
+ Open
51
+ ```
52
+ http://localhost:8501
53
+ ```
54
+
55
+ ## Adding new data files (git LFS)
56
+
57
+ All `*.nc` files in `data/` are tracked via git LFS (configured in `.gitattributes`).
58
+ To add a new result file:
59
+
60
+ **Step 1 β€” Ensure git LFS is installed and initialised**
61
+ ```
62
+ sudo apt install git-lfs # or: brew install git-lfs
63
+ git lfs install
64
+ ```
65
+
66
+ **Step 2 β€” Copy the file into `data/`**
67
+ ```
68
+ cp /path/to/your_results.nc data/your_results.nc
69
+ ```
70
+ Sub-directories are fine (`data/UKI_results/`, `data/bayesian/`, etc.).
71
+
72
+ **Step 3 β€” Stage and commit β€” LFS handles the rest**
73
+ ```
74
+ git add data/your_results.nc
75
+ git commit -m "Add <method> results for <benchmark>"
76
+ ```
77
+ Git LFS intercepts the add automatically because `.gitattributes` already contains
78
+ `*.nc filter=lfs diff=lfs merge=lfs -text`. You can verify the file is tracked with:
79
+ ```
80
+ git lfs ls-files | grep your_results.nc
81
+ ```
82
+
83
+ **Step 4 β€” Register the file in the app**
84
+
85
+ Open `src/data_store.py` and add the path to `DATASET_FILES` (optimization) or
86
+ `UQ_DATASET_FILES` (UQ leaderboard):
87
+ ```python
88
+ DATASET_FILES = {
89
+ "L96": [
90
+ "existing_file.nc",
91
+ "your_results.nc", # <-- add here
92
+ ],
93
+ ...
94
+ }
95
+ ```
96
+ Paths are relative to the `data/` directory.
97
+
98
+ **Step 5 β€” Push**
99
+ ```
100
+ git push
101
+ ```
102
+ LFS objects are pushed to the LFS store automatically alongside the pointer commit.
103
+ Collaborators get the data with `git lfs pull` after cloning or fetching.
104
+
105
+ ---
106
+
107
+ ## Contributing via a Hugging Face pull request
108
+
109
+ Hugging Face Spaces uses a non-standard PR workflow: you create the PR online first, then push to the branch it creates.
110
+
111
+ **Step 1 β€” Clone the repo (if you haven't already)**
112
+ ```
113
+ git clone https://huggingface.co/spaces/<owner>/<space-name>
114
+ cd <space-name>
115
+ ```
116
+
117
+ **Step 2 β€” Create and work on a local branch**
118
+ ```
119
+ git checkout -b <local-branch-name>
120
+ ```
121
+ Make your changes, then commit them:
122
+ ```
123
+ git add <changed-files>
124
+ git commit -m "Your commit message"
125
+ ```
126
+
127
+ **Step 3 β€” Open the pull request online**
128
+
129
+ Go to the Space on huggingface.co β†’ **Community** tab β†’ **New Pull Request**.
130
+ Fill in the title and description and submit. Hugging Face will create a remote PR branch
131
+ (shown on the PR page, e.g. `refs/pr/1` or a named branch).
132
+
133
+ **Step 4 β€” Push your local branch to the PR branch**
134
+ ```
135
+ git push --set-upstream origin <local-branch-name>:<pr-branch-name>
136
+ ```
137
+ Replace `<pr-branch-name>` with the branch name shown on the HF PR page (often `refs/pr/N`).
138
+
139
+ The PR page will update automatically once the push lands.
data/ces-eki-dmc_results/ces-eki-dmc_l63_ensemble_results_2026-06-15_minimal.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ea0c583aa3b7ffd39d8b00ab476b4fe7fcf3b15b54e0e15377cbc2245385d8e7
3
+ size 287190
data/ces-eki-dmc_results/ces-eki-dmc_l96_ensemble_results_2026-06-15_minimal.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eda2163d7dfc7e4a851aea5d6055b1f6cc1e1c4ec8496cf4072679739101fd39
3
+ size 123030
data/ces-eki-dmc_results/ces-eki-dmc_l96_nn_forcing_ensemble_results_2026-06-15_minimal.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d36761e0de6abb50ddd3054d2adf6100a6e730ba9ea8e2240ed7dd6a2c970892
3
+ size 150390
data/ces-eki-dmc_results/ces-eki-dmc_l96_spatial_forcing_ensemble_results_2026-06-15_minimal.nc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a402f8dc6d39b34bb1243e878bb34505846fe176a7e21a1024a8c1727d55dc1
3
+ size 177750
requirements.txt CHANGED
@@ -1,3 +1,4 @@
 
1
  altair
2
  pandas
3
  streamlit
@@ -5,4 +6,6 @@ jax
5
  gpjax
6
  matplotlib
7
  xarray
8
- netCDF4
 
 
 
1
+ hf
2
  altair
3
  pandas
4
  streamlit
 
6
  gpjax
7
  matplotlib
8
  xarray
9
+ netcdf4
10
+ h5netcdf
11
+ h5py
src/common/leaderboard.py ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared leaderboard renderer for the Calibration Benchmark dashboard.
2
+
3
+ Extracts the scored-table pipeline from the home page so both the Optimization
4
+ leaderboard (home) and the UQ leaderboard (pages/UQLeaderboard.py) can reuse
5
+ it without duplicating code.
6
+
7
+ Usage::
8
+
9
+ from common.leaderboard import render_leaderboard
10
+
11
+ render_leaderboard(
12
+ metric_store,
13
+ target_col="rmse_target",
14
+ target_label="RMSE Target Level",
15
+ title="Optimization Leaderboard",
16
+ state_prefix="opt",
17
+ raw_page="pages/RawData.py",
18
+ )
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import altair as alt
23
+ import pandas as pd
24
+ import streamlit as st
25
+
26
+ # Stable categorical palette (Vega tableau10). Methods are assigned colors by their
27
+ # sorted position in the full available-method list, so toggling visibility never
28
+ # reassigns colors to other methods.
29
+ _METHOD_PALETTE = [
30
+ "#4c78a8", "#f58518", "#e45756", "#72b7b2", "#54a24b",
31
+ "#eeca3b", "#b279a2", "#ff9da6", "#9d755d", "#bab0ac",
32
+ ]
33
+
34
+
35
+ def render_leaderboard(
36
+ metric_store: pd.DataFrame,
37
+ *,
38
+ target_col: str,
39
+ target_label: str,
40
+ title: str,
41
+ state_prefix: str,
42
+ raw_page: str | None = None,
43
+ show_failure_panel: bool = False,
44
+ show_scoring_modes: bool = True,
45
+ canonical_target_levels: list[float] | None = None,
46
+ budget_store: pd.DataFrame | None = None,
47
+ ) -> None:
48
+ """Render a scored leaderboard backed by *metric_store*.
49
+
50
+ Parameters
51
+ ----------
52
+ metric_store:
53
+ DataFrame produced by ``load_metric_store()`` or ``load_uq_store()``.
54
+ Must contain at least the columns ``benchmark``, ``algorithm_type``,
55
+ ``abbreviation``, ``Method``, ``family``, ``ensemble_size``, ``metric``,
56
+ ``failure_rate``, and *target_col*.
57
+ target_col:
58
+ Name of the target-coordinate column, e.g. ``"rmse_target"`` or
59
+ ``"uq_target"``.
60
+ target_label:
61
+ Human-readable label for the target-level radio control,
62
+ e.g. ``"RMSE Target Level"`` or ``"UQ Target Level"``.
63
+ title:
64
+ Leaderboard section header text.
65
+ state_prefix:
66
+ Short string used to namespace ``st.session_state`` keys so multiple
67
+ leaderboard pages keep independent control state. Use ``"opt"`` for
68
+ the Optimization leaderboard and ``"uq"`` for the UQ leaderboard.
69
+ raw_page:
70
+ Optional Streamlit page path for an "Open Raw Data" link shown at the
71
+ bottom. Pass ``None`` to suppress the link.
72
+ show_failure_panel:
73
+ If ``True``, render a grouped-bar failure-rate chart below the main
74
+ performance chart.
75
+ canonical_target_levels:
76
+ If provided, the target-level selector always offers exactly these
77
+ values (as strings) regardless of what is present in the data. Use
78
+ this to pin the UQ leaderboard to its fixed set of target-scaling
79
+ levels even when some have 100 % failure.
80
+ budget_store:
81
+ Optional DataFrame produced by ``load_uq_budget_store()``. When
82
+ provided, an additional "Budget and Iterations for Coverage" section is
83
+ rendered below the main performance chart, showing mean budget
84
+ (N_ensΒ·k_iter, solid lines) and mean iterations (k_iter, dashed lines)
85
+ vs ensemble size, followed by a coverage-failure-rate bar chart.
86
+ """
87
+ st.header(title)
88
+
89
+ if metric_store.empty:
90
+ st.warning("No metric data found. Expected NetCDF files in `data/` with a `metric` variable.")
91
+ return
92
+
93
+ # Derived column name for the string version of the target coordinate
94
+ target_str_col = f"{target_col}_str"
95
+
96
+ benchmark_values = sorted(metric_store["benchmark"].unique().tolist())
97
+ benchmark_options = ["All"] + benchmark_values
98
+ selected_benchmark = st.selectbox("Benchmark", options=benchmark_options, index=0)
99
+
100
+ filtered = (
101
+ metric_store.copy()
102
+ if selected_benchmark == "All"
103
+ else metric_store[metric_store["benchmark"] == selected_benchmark].copy()
104
+ )
105
+ filtered[target_str_col] = filtered[target_col].astype(str)
106
+
107
+ if canonical_target_levels is not None:
108
+ target_options = ["All targets"] + [str(float(t)) for t in canonical_target_levels]
109
+ else:
110
+ target_options = ["All targets"] + sorted(
111
+ metric_store[target_col].astype(str).unique().tolist()
112
+ )
113
+
114
+ scoring_options = [
115
+ "Mean Forward Model Runs",
116
+ "Minimum Forward Model Runs",
117
+ "Smallest Optimal Ensemble Size",
118
+ "Custom Blend",
119
+ ]
120
+
121
+ # Session-state keys namespaced by state_prefix so two leaderboard pages
122
+ # don't share control state.
123
+ k_target = f"{state_prefix}_selected_target"
124
+ k_scoring = f"{state_prefix}_scoring_mode"
125
+ k_weight = f"{state_prefix}_fwdruns_weight_percent"
126
+ k_methods = f"{state_prefix}_selected_methods"
127
+
128
+ current_target = st.session_state.get(k_target, "All targets")
129
+ if current_target not in target_options:
130
+ current_target = target_options[0]
131
+
132
+ current_scoring_mode = st.session_state.get(k_scoring, "Mean Forward Model Runs")
133
+ if current_scoring_mode not in scoring_options:
134
+ current_scoring_mode = scoring_options[0]
135
+
136
+ current_fwdruns_weight_percent = int(st.session_state.get(k_weight, 80))
137
+ current_fwdruns_weight_percent = max(0, min(100, current_fwdruns_weight_percent))
138
+
139
+ selected_target = current_target
140
+ scoring_mode = current_scoring_mode
141
+ fwdruns_weight = current_fwdruns_weight_percent / 100.0
142
+ ensemble_weight = 1.0 - fwdruns_weight
143
+
144
+ # Available methods for the current benchmark selection; used to populate the
145
+ # multiselect and to prune any stale saved selections when the benchmark changes.
146
+ available_methods = sorted(filtered["abbreviation"].dropna().unique().tolist())
147
+ saved_methods = st.session_state.get(k_methods, available_methods)
148
+ valid_saved = [m for m in saved_methods if m in available_methods]
149
+ st.session_state[k_methods] = valid_saved if valid_saved else available_methods
150
+
151
+ # Stable color scale: domain covers ALL methods so colors don't shift when a
152
+ # subset is displayed.
153
+ color_domain = available_methods
154
+ color_range = [_METHOD_PALETTE[i % len(_METHOD_PALETTE)] for i in range(len(available_methods))]
155
+ method_color = alt.Color(
156
+ "abbreviation:N",
157
+ title="Method",
158
+ scale=alt.Scale(domain=color_domain, range=color_range),
159
+ )
160
+
161
+ def build_scored_table(input_df: pd.DataFrame, add_rank: bool = True) -> pd.DataFrame:
162
+ ranking_source = (
163
+ input_df
164
+ if selected_target == "All targets"
165
+ else input_df[input_df[target_str_col] == selected_target]
166
+ )
167
+ if ranking_source.empty:
168
+ return ranking_source
169
+
170
+ # Failure rate from every row (NaN metric rows carry failure_rate=100)
171
+ failure_agg = ranking_source.groupby(
172
+ ["algorithm_type", "abbreviation", "Method", "family"], as_index=False
173
+ ).agg(**{"Mean Failure Rate (%)": ("failure_rate", "mean")})
174
+
175
+ # Metric stats only from runs that reached the target (non-NaN metric)
176
+ valid_rows = ranking_source.dropna(subset=["metric"])
177
+ if valid_rows.empty:
178
+ return pd.DataFrame()
179
+
180
+ scored_df = valid_rows.groupby(
181
+ ["algorithm_type", "abbreviation", "Method", "family"], as_index=False
182
+ ).agg(
183
+ **{"Mean Forward Model Runs": ("metric", "mean")},
184
+ **{"Minimum Forward Model Runs": ("metric", "min")},
185
+ **{"Targets Used": (target_str_col, "nunique")},
186
+ **{"Ensemble Sizes Used": ("ensemble_size", "nunique")},
187
+ )
188
+
189
+ best_per_target = (
190
+ valid_rows.sort_values(["algorithm_type", target_str_col, "metric", "ensemble_size"])
191
+ .groupby(["algorithm_type", "abbreviation", "Method", "family", target_str_col], as_index=False)
192
+ .first()[
193
+ [
194
+ "algorithm_type",
195
+ "abbreviation",
196
+ "Method",
197
+ "family",
198
+ target_str_col,
199
+ "ensemble_size",
200
+ ]
201
+ ]
202
+ )
203
+
204
+ optimal_ensemble = best_per_target.groupby(
205
+ ["algorithm_type", "abbreviation", "Method", "family"], as_index=False
206
+ ).agg(**{"Optimal Ensemble Size": ("ensemble_size", "mean")})
207
+
208
+ scored_df = scored_df.merge(
209
+ optimal_ensemble,
210
+ on=["algorithm_type", "abbreviation", "Method", "family"],
211
+ how="left",
212
+ )
213
+ scored_df = scored_df.merge(
214
+ failure_agg,
215
+ on=["algorithm_type", "abbreviation", "Method", "family"],
216
+ how="left",
217
+ )
218
+
219
+ scored_df["Optimal Ensemble Size"] = scored_df["Optimal Ensemble Size"].round(2)
220
+ scored_df["Mean Forward Model Runs"] = scored_df["Mean Forward Model Runs"].round(4)
221
+ scored_df["Minimum Forward Model Runs"] = scored_df["Minimum Forward Model Runs"].round(4)
222
+ scored_df["Mean Failure Rate (%)"] = scored_df["Mean Failure Rate (%)"].round(1)
223
+
224
+ mean_runs_min = scored_df["Mean Forward Model Runs"].min()
225
+ mean_runs_max = scored_df["Mean Forward Model Runs"].max()
226
+ if mean_runs_max > mean_runs_min:
227
+ scored_df["mean_runs_score"] = (
228
+ 100.0 * (mean_runs_max - scored_df["Mean Forward Model Runs"]) / (mean_runs_max - mean_runs_min)
229
+ )
230
+ else:
231
+ scored_df["mean_runs_score"] = 100.0
232
+
233
+ minimum_runs_min = scored_df["Minimum Forward Model Runs"].min()
234
+ minimum_runs_max = scored_df["Minimum Forward Model Runs"].max()
235
+ if minimum_runs_max > minimum_runs_min:
236
+ scored_df["minimum_runs_score"] = (
237
+ 100.0 * (minimum_runs_max - scored_df["Minimum Forward Model Runs"]) / (minimum_runs_max - minimum_runs_min)
238
+ )
239
+ else:
240
+ scored_df["minimum_runs_score"] = 100.0
241
+
242
+ ens_min = scored_df["Optimal Ensemble Size"].min()
243
+ ens_max = scored_df["Optimal Ensemble Size"].max()
244
+ if ens_max > ens_min:
245
+ scored_df["ensemble_score"] = (
246
+ 100.0 * (ens_max - scored_df["Optimal Ensemble Size"]) / (ens_max - ens_min)
247
+ )
248
+ else:
249
+ scored_df["ensemble_score"] = 100.0
250
+
251
+ if scoring_mode == "Mean Forward Model Runs":
252
+ scored_df["Score"] = scored_df["mean_runs_score"]
253
+ sort_columns = ["Mean Forward Model Runs", "Optimal Ensemble Size", "abbreviation"]
254
+ ascending = [True, True, True]
255
+ elif scoring_mode == "Minimum Forward Model Runs":
256
+ scored_df["Score"] = scored_df["minimum_runs_score"]
257
+ sort_columns = ["Minimum Forward Model Runs", "Optimal Ensemble Size", "abbreviation"]
258
+ ascending = [True, True, True]
259
+ elif scoring_mode == "Smallest Optimal Ensemble Size":
260
+ scored_df["Score"] = scored_df["ensemble_score"]
261
+ sort_columns = ["Optimal Ensemble Size", "Mean Forward Model Runs", "abbreviation"]
262
+ ascending = [True, True, True]
263
+ else:
264
+ scored_df["Score"] = (
265
+ fwdruns_weight * scored_df["mean_runs_score"]
266
+ + ensemble_weight * scored_df["ensemble_score"]
267
+ )
268
+ sort_columns = ["Score", "Mean Forward Model Runs", "Optimal Ensemble Size", "abbreviation"]
269
+ ascending = [False, True, True, True]
270
+
271
+ scored_df = scored_df.sort_values(sort_columns, ascending=ascending).reset_index(drop=True)
272
+
273
+ if add_rank:
274
+ scored_df["Rank"] = scored_df.index + 1
275
+ scored_df["Placement"] = scored_df["Rank"].apply(
276
+ lambda rank: f"{ {1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰'}.get(rank, '')} #{rank}".strip()
277
+ )
278
+
279
+ return scored_df
280
+
281
+ if selected_benchmark == "All":
282
+ benchmark_scores = []
283
+ for benchmark_name in benchmark_values:
284
+ benchmark_df = metric_store[metric_store["benchmark"] == benchmark_name].copy()
285
+ benchmark_df[target_str_col] = benchmark_df[target_col].astype(str)
286
+ scored = build_scored_table(benchmark_df, add_rank=False)
287
+ if scored.empty:
288
+ continue
289
+ scored["benchmark"] = benchmark_name
290
+ benchmark_scores.append(scored)
291
+
292
+ if benchmark_scores:
293
+ combined_scores = (
294
+ benchmark_scores[0].copy()
295
+ if len(benchmark_scores) == 1
296
+ else pd.concat(benchmark_scores, ignore_index=True)
297
+ )
298
+ leaderboard_df = combined_scores.groupby(
299
+ ["algorithm_type", "abbreviation", "Method", "family"], as_index=False
300
+ ).agg(
301
+ Score=("Score", "mean"),
302
+ **{"Mean Forward Model Runs": ("Mean Forward Model Runs", "mean")},
303
+ **{"Minimum Forward Model Runs": ("Minimum Forward Model Runs", "mean")},
304
+ **{"Optimal Ensemble Size": ("Optimal Ensemble Size", "mean")},
305
+ **{"Targets Used": ("Targets Used", "mean")},
306
+ **{"Ensemble Sizes Used": ("Ensemble Sizes Used", "mean")},
307
+ **{"Benchmarks Used": ("benchmark", "nunique")},
308
+ **{"Mean Failure Rate (%)": ("Mean Failure Rate (%)", "mean")},
309
+ )
310
+
311
+ leaderboard_df["Mean Forward Model Runs"] = leaderboard_df["Mean Forward Model Runs"].round(4)
312
+ leaderboard_df["Minimum Forward Model Runs"] = leaderboard_df["Minimum Forward Model Runs"].round(4)
313
+ leaderboard_df["Optimal Ensemble Size"] = leaderboard_df["Optimal Ensemble Size"].round(2)
314
+ leaderboard_df["Targets Used"] = leaderboard_df["Targets Used"].round().astype(int)
315
+ leaderboard_df["Ensemble Sizes Used"] = leaderboard_df["Ensemble Sizes Used"].round().astype(int)
316
+ leaderboard_df["Mean Failure Rate (%)"] = leaderboard_df["Mean Failure Rate (%)"].round(1)
317
+ leaderboard_df = leaderboard_df.sort_values(
318
+ ["Score", "Mean Forward Model Runs", "abbreviation"], ascending=[False, True, True]
319
+ ).reset_index(drop=True)
320
+ leaderboard_df["Rank"] = leaderboard_df.index + 1
321
+ leaderboard_df["Placement"] = leaderboard_df["Rank"].apply(
322
+ lambda rank: f"{ {1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰'}.get(rank, '')} #{rank}".strip()
323
+ )
324
+ else:
325
+ leaderboard_df = pd.DataFrame(
326
+ columns=[
327
+ "Placement",
328
+ "abbreviation",
329
+ "Method",
330
+ "family",
331
+ "Mean Forward Model Runs",
332
+ "Minimum Forward Model Runs",
333
+ "Score",
334
+ "Optimal Ensemble Size",
335
+ "Targets Used",
336
+ "Ensemble Sizes Used",
337
+ "Benchmarks Used",
338
+ "Mean Failure Rate (%)",
339
+ ]
340
+ )
341
+ else:
342
+ leaderboard_df = build_scored_table(filtered, add_rank=True)
343
+ leaderboard_df["Benchmarks Used"] = 1
344
+
345
+ if scoring_mode == "Mean Forward Model Runs":
346
+ score_basis = "normalized mean of best forward model runs over selected target levels (lower is better)"
347
+ elif scoring_mode == "Minimum Forward Model Runs":
348
+ score_basis = "normalized minimum of forward model runs over selected targets and ensemble sizes (lower is better)"
349
+ elif scoring_mode == "Smallest Optimal Ensemble Size":
350
+ score_basis = "normalized mean optimal ensemble size over selected target levels (lower is better)"
351
+ else:
352
+ score_basis = (
353
+ "weighted blend of normalized forward-model-runs score and normalized ensemble-size score "
354
+ f"(forward-runs weight {fwdruns_weight:.0%}, ensemble-size weight {ensemble_weight:.0%})"
355
+ )
356
+
357
+ if selected_benchmark == "All":
358
+ score_basis = f"{score_basis}; in All mode, each method's final score is the mean of its per-benchmark scores"
359
+
360
+ # Controls expander β€” always shown so users can change target even when the
361
+ # current selection yields all failures.
362
+ with st.expander("Scoring & Target Controls", expanded=False):
363
+ st.radio(
364
+ target_label,
365
+ options=target_options,
366
+ horizontal=True,
367
+ key=k_target,
368
+ )
369
+
370
+ if show_scoring_modes:
371
+ st.radio(
372
+ "Scoring Method",
373
+ options=scoring_options,
374
+ horizontal=True,
375
+ key=k_scoring,
376
+ )
377
+
378
+ if st.session_state.get(k_scoring, "Mean Forward Model Runs") == "Custom Blend":
379
+ st.slider(
380
+ "Blend Weight: Forward Runs vs Ensemble Size",
381
+ min_value=0,
382
+ max_value=100,
383
+ step=5,
384
+ key=k_weight,
385
+ help=(
386
+ "Higher forward-runs weight prioritizes fewer model evaluations; "
387
+ "higher ensemble-size weight prioritizes smaller ensembles."
388
+ ),
389
+ )
390
+
391
+ st.multiselect(
392
+ "Methods to display in charts",
393
+ options=available_methods,
394
+ key=k_methods,
395
+ )
396
+
397
+ selected_methods = st.session_state.get(k_methods, available_methods)
398
+ if not selected_methods:
399
+ selected_methods = available_methods
400
+
401
+ if leaderboard_df.empty:
402
+ st.warning(
403
+ "All runs failed to reach the target at this selection. "
404
+ "See the failure rate chart below."
405
+ if show_failure_panel
406
+ else "No rows available for the current benchmark/target selection."
407
+ )
408
+ else:
409
+ if selected_benchmark == "All":
410
+ table_column_order = [
411
+ "Placement",
412
+ "abbreviation",
413
+ "Method",
414
+ "family",
415
+ "Score",
416
+ "Mean Failure Rate (%)",
417
+ "Targets Used",
418
+ "Ensemble Sizes Used",
419
+ "Benchmarks Used",
420
+ ]
421
+ else:
422
+ table_column_order = [
423
+ "Placement",
424
+ "abbreviation",
425
+ "Method",
426
+ "family",
427
+ "Score",
428
+ "Mean Forward Model Runs",
429
+ "Minimum Forward Model Runs",
430
+ "Mean Failure Rate (%)",
431
+ "Optimal Ensemble Size",
432
+ "Targets Used",
433
+ "Ensemble Sizes Used",
434
+ "Benchmarks Used",
435
+ ]
436
+
437
+ st.subheader(f"Ranked Leaderboard β€” {selected_benchmark}")
438
+ st.dataframe(
439
+ leaderboard_df,
440
+ hide_index=True,
441
+ use_container_width=True,
442
+ column_config={
443
+ "Placement": st.column_config.TextColumn("Placement"),
444
+ "family": st.column_config.TextColumn("Family"),
445
+ "Method": st.column_config.TextColumn("Method"),
446
+ "abbreviation": st.column_config.TextColumn("Abbrev."),
447
+ "Mean Forward Model Runs": st.column_config.NumberColumn("Mean Forward Model Runs", format="%.4f"),
448
+ "Minimum Forward Model Runs": st.column_config.NumberColumn("Minimum Forward Model Runs", format="%.4f"),
449
+ "Score": st.column_config.ProgressColumn("Score (0-100)", min_value=0.0, max_value=100.0, format="%.1f"),
450
+ "Optimal Ensemble Size": st.column_config.NumberColumn("Mean Optimal Ensemble Size", format="%.2f"),
451
+ "Mean Failure Rate (%)": st.column_config.NumberColumn("Mean Failure Rate (%)", format="%.1f"),
452
+ "Targets Used": st.column_config.NumberColumn("Targets Used", format="%d"),
453
+ "Ensemble Sizes Used": st.column_config.NumberColumn("Ensemble Sizes Used", format="%d"),
454
+ "Benchmarks Used": st.column_config.NumberColumn("Benchmarks Used", format="%d"),
455
+ },
456
+ column_order=table_column_order,
457
+ )
458
+
459
+ st.info(
460
+ f"Score is a normalized 0–100 ranking based on **{score_basis}**. "
461
+ "For Mean/Minimum forward-runs scoring, values are computed from all selected metric target levels "
462
+ "and all ensemble sizes after averaging over random seeds."
463
+ )
464
+
465
+ if selected_benchmark != "All":
466
+ st.subheader("Mean Forward Model Runs vs Ensemble Size")
467
+ chart_source = (
468
+ filtered
469
+ if selected_target == "All targets"
470
+ else filtered[filtered[target_str_col] == selected_target]
471
+ )
472
+ chart_source = chart_source[chart_source["abbreviation"].isin(selected_methods)]
473
+ chart_df = chart_source.dropna(subset=["metric"]).groupby(
474
+ ["abbreviation", "ensemble_size"], as_index=False
475
+ ).agg(mean_forward_runs=("metric", "mean"))
476
+
477
+ all_ens_combos = chart_source[["abbreviation", "ensemble_size"]].drop_duplicates()
478
+ ens_ticks = sorted(all_ens_combos["ensemble_size"].unique().tolist()) if not all_ens_combos.empty else []
479
+
480
+ if not chart_df.empty:
481
+ _ok = chart_df[["abbreviation", "ensemble_size"]].assign(_ok=True)
482
+ fail_df = all_ens_combos.merge(_ok, on=["abbreviation", "ensemble_size"], how="left")
483
+ fail_df = fail_df[fail_df["_ok"].isna()].drop(columns="_ok").assign(mean_forward_runs=0.0)
484
+ else:
485
+ fail_df = all_ens_combos.assign(mean_forward_runs=0.0)
486
+
487
+ all_failed = chart_df.empty
488
+ chart_layers = []
489
+ if not chart_df.empty:
490
+ chart_layers.append(
491
+ alt.Chart(chart_df)
492
+ .mark_line(point=True)
493
+ .encode(
494
+ x=alt.X(
495
+ "ensemble_size:Q",
496
+ title="Ensemble Size",
497
+ axis=alt.Axis(values=ens_ticks, format="d"),
498
+ ),
499
+ y=alt.Y("mean_forward_runs:Q", title="Mean Forward Model Runs"),
500
+ color=method_color,
501
+ tooltip=["abbreviation", "ensemble_size", alt.Tooltip("mean_forward_runs:Q", format=".4f")],
502
+ )
503
+ )
504
+ if not fail_df.empty:
505
+ y_fwd = (
506
+ alt.Y("mean_forward_runs:Q", title="Mean Forward Model Runs", scale=alt.Scale(domain=[0, 1]))
507
+ if all_failed
508
+ else alt.Y("mean_forward_runs:Q", title="Mean Forward Model Runs")
509
+ )
510
+ chart_layers.append(
511
+ alt.Chart(fail_df)
512
+ .mark_point(shape="cross", angle=45, size=200, filled=True, opacity=1.0)
513
+ .encode(
514
+ x=alt.X(
515
+ "ensemble_size:Q",
516
+ title="Ensemble Size",
517
+ axis=alt.Axis(values=ens_ticks, format="d"),
518
+ ),
519
+ y=y_fwd,
520
+ color=method_color,
521
+ tooltip=[
522
+ alt.Tooltip("abbreviation:N", title="Method"),
523
+ alt.Tooltip("ensemble_size:Q", title="Ensemble Size"),
524
+ alt.Tooltip("mean_forward_runs:Q", title="Value (all failed)"),
525
+ ],
526
+ )
527
+ )
528
+ if chart_layers:
529
+ st.altair_chart(alt.layer(*chart_layers), use_container_width=True)
530
+
531
+ # Mean-iterations-for-coverage section (UQ only, when budget_store provided)
532
+ if budget_store is not None and not budget_store.empty and selected_benchmark != "All":
533
+ bf = budget_store[budget_store["benchmark"] == selected_benchmark].copy()
534
+ bf[target_str_col] = bf[target_col].astype(str)
535
+ if selected_target != "All targets":
536
+ bf = bf[bf[target_str_col] == selected_target]
537
+ bf = bf[bf["abbreviation"].isin(selected_methods)]
538
+
539
+ iters_df = (
540
+ bf[["abbreviation", "ensemble_size", "mean_iters"]]
541
+ .dropna(subset=["mean_iters"])
542
+ .groupby(["abbreviation", "ensemble_size"], as_index=False)
543
+ .agg(mean_iters=("mean_iters", "mean"))
544
+ )
545
+ all_ens_combos_iters = bf[["abbreviation", "ensemble_size"]].drop_duplicates()
546
+ if not all_ens_combos_iters.empty:
547
+ iters_ticks = sorted(all_ens_combos_iters["ensemble_size"].unique().tolist())
548
+
549
+ if not iters_df.empty:
550
+ _ok_iters = iters_df[["abbreviation", "ensemble_size"]].assign(_ok=True)
551
+ fail_df_iters = all_ens_combos_iters.merge(_ok_iters, on=["abbreviation", "ensemble_size"], how="left")
552
+ fail_df_iters = fail_df_iters[fail_df_iters["_ok"].isna()].drop(columns="_ok").assign(mean_iters=0.0)
553
+ else:
554
+ fail_df_iters = all_ens_combos_iters.assign(mean_iters=0.0)
555
+
556
+ all_failed_iters = iters_df.empty
557
+ st.subheader("Mean Iterations for Coverage vs Ensemble Size")
558
+ iters_layers = []
559
+ if not iters_df.empty:
560
+ iters_layers.append(
561
+ alt.Chart(iters_df)
562
+ .mark_line(point=True)
563
+ .encode(
564
+ x=alt.X(
565
+ "ensemble_size:Q",
566
+ title="Ensemble Size",
567
+ axis=alt.Axis(values=iters_ticks, format="d"),
568
+ ),
569
+ y=alt.Y("mean_iters:Q", title="Mean Iterations"),
570
+ color=method_color,
571
+ tooltip=[
572
+ alt.Tooltip("abbreviation:N", title="Method"),
573
+ alt.Tooltip("ensemble_size:Q", title="Ensemble Size"),
574
+ alt.Tooltip("mean_iters:Q", format=".2f", title="Mean Iterations"),
575
+ ],
576
+ )
577
+ )
578
+ if not fail_df_iters.empty:
579
+ y_iters = (
580
+ alt.Y("mean_iters:Q", title="Mean Iterations", scale=alt.Scale(domain=[0, 1]))
581
+ if all_failed_iters
582
+ else alt.Y("mean_iters:Q", title="Mean Iterations")
583
+ )
584
+ iters_layers.append(
585
+ alt.Chart(fail_df_iters)
586
+ .mark_point(shape="cross", angle=45, size=200, filled=True, opacity=1.0)
587
+ .encode(
588
+ x=alt.X(
589
+ "ensemble_size:Q",
590
+ title="Ensemble Size",
591
+ axis=alt.Axis(values=iters_ticks, format="d"),
592
+ ),
593
+ y=y_iters,
594
+ color=method_color,
595
+ tooltip=[
596
+ alt.Tooltip("abbreviation:N", title="Method"),
597
+ alt.Tooltip("ensemble_size:Q", title="Ensemble Size"),
598
+ alt.Tooltip("mean_iters:Q", title="Value (all failed)"),
599
+ ],
600
+ )
601
+ )
602
+ if iters_layers:
603
+ st.altair_chart(alt.layer(*iters_layers), use_container_width=True)
604
+
605
+ # Failure panel β€” rendered regardless of whether the scored table has rows
606
+ if show_failure_panel and selected_benchmark != "All":
607
+ failure_source = (
608
+ filtered
609
+ if selected_target == "All targets"
610
+ else filtered[filtered[target_str_col] == selected_target]
611
+ )
612
+ failure_source = failure_source[failure_source["abbreviation"].isin(selected_methods)]
613
+ if not failure_source.empty:
614
+ failure_df = failure_source.groupby(
615
+ ["abbreviation", "ensemble_size"], as_index=False
616
+ ).agg(mean_failure_rate=("failure_rate", "mean"))
617
+ failure_df = failure_df.sort_values("ensemble_size")
618
+
619
+ target_str = (
620
+ "All Targets"
621
+ if selected_target == "All targets"
622
+ else f"Target {selected_target}"
623
+ )
624
+ st.subheader(f"Failure Rate of Hitting {target_str}")
625
+ ens_ticks_fail = sorted(failure_df["ensemble_size"].unique().tolist())
626
+ failure_chart = (
627
+ alt.Chart(failure_df)
628
+ .mark_bar()
629
+ .encode(
630
+ x=alt.X(
631
+ "ensemble_size:O",
632
+ title="Ensemble Size",
633
+ sort=[str(e) for e in ens_ticks_fail],
634
+ axis=alt.Axis(labelAngle=0),
635
+ ),
636
+ xOffset=alt.XOffset("abbreviation:N"),
637
+ y=alt.Y(
638
+ "mean_failure_rate:Q",
639
+ title="Failure Rate (%)",
640
+ scale=alt.Scale(domain=[0, 100]),
641
+ ),
642
+ color=method_color,
643
+ tooltip=[
644
+ alt.Tooltip("abbreviation:N", title="Method"),
645
+ alt.Tooltip("ensemble_size:O", title="Ensemble Size"),
646
+ alt.Tooltip("mean_failure_rate:Q", format=".1f", title="Failure Rate (%)"),
647
+ ],
648
+ )
649
+ )
650
+ ceiling_line = (
651
+ alt.Chart(alt.Data(values=[{}]))
652
+ .mark_rule(color="grey", strokeDash=[4, 4])
653
+ .encode(y=alt.datum(100))
654
+ )
655
+ st.altair_chart(failure_chart + ceiling_line, use_container_width=True)
656
+
657
+ st.caption("Top 3 are shown as podium spots; remaining methods are directly comparable via normalized score.")
658
+
659
+ if raw_page is not None:
660
+ st.page_link(raw_page, label="Open Raw Data & CSV Export", icon="🧾")
src/common/method_registry.py CHANGED
@@ -21,7 +21,7 @@ KNOWN_METHODS = {
21
  "abbreviation": "IEKF",
22
  "Method": "Iterative Ensemble Kalman Filter",
23
  "family": "Kalman",
24
- "aliases": ["iekf", "gnsl"],
25
  },
26
  "uki": {
27
  "abbreviation": "UKI",
@@ -41,6 +41,12 @@ KNOWN_METHODS = {
41
  "family": "Bayesian",
42
  "aliases": ["hm"],
43
  },
 
 
 
 
 
 
44
  }
45
 
46
 
 
21
  "abbreviation": "IEKF",
22
  "Method": "Iterative Ensemble Kalman Filter",
23
  "family": "Kalman",
24
+ "aliases": ["iekf", "gnsl", "gnki"],
25
  },
26
  "uki": {
27
  "abbreviation": "UKI",
 
41
  "family": "Bayesian",
42
  "aliases": ["hm"],
43
  },
44
+ "ces-eki-dmc": {
45
+ "abbreviation": "CES-EKI-DMC",
46
+ "Method": "Calibrate Emulate Sample (EKI-DataMisfitController)",
47
+ "family": "calibrate_then_emulate",
48
+ "aliases": ["ces-eki-dmc"]
49
+ }
50
  }
51
 
52
 
src/data_store.py CHANGED
@@ -2,7 +2,8 @@ from __future__ import annotations
2
 
3
  import importlib
4
  from pathlib import Path
5
- import warnings
 
6
  import pandas as pd
7
  import streamlit as st
8
 
@@ -22,6 +23,10 @@ except ModuleNotFoundError:
22
  )
23
 
24
 
 
 
 
 
25
  DATASET_FILES = {
26
  "L63": [
27
  "l63_ensemble_results.nc",
@@ -45,13 +50,66 @@ DATASET_FILES = {
45
  ],
46
  }
47
 
 
 
48
 
49
 
50
- EXPECTED_DIMS = ("algorithm_type", "rmse_target", "ensemble_size", "random_seed")
 
 
 
 
 
 
 
 
 
 
 
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- @st.cache_data(show_spinner=False)
54
- def load_metric_store() -> pd.DataFrame:
55
  xr = importlib.import_module("xarray")
56
 
57
  project_root = Path(__file__).resolve().parent.parent
@@ -59,7 +117,7 @@ def load_metric_store() -> pd.DataFrame:
59
 
60
  records: list[pd.DataFrame] = []
61
 
62
- for benchmark_name, filenames in DATASET_FILES.items():
63
  for filename in filenames:
64
  file_path = data_dir / filename
65
  if not file_path.exists():
@@ -72,44 +130,53 @@ def load_metric_store() -> pd.DataFrame:
72
  continue
73
 
74
  metric = dataset["metric"]
75
-
 
 
 
 
 
 
76
  # Track failures (metric == -1) as a percentage
77
  failures_count = (metric == -1).sum(dim="random_seed")
78
 
79
  failure_rate = (failures_count / dataset.sizes["random_seed"]) * 100
80
-
81
- # Filter out failed calibrations (metric == -1)
82
  metric = metric.where(metric != -1)
83
-
84
  metric_mean = metric.mean(dim="random_seed", skipna=True)
85
  df = metric_mean.to_dataframe(name="metric").reset_index()
86
-
87
  df_failures = failure_rate.to_dataframe(name="failure_rate").reset_index()
88
  df["failure_rate"] = df_failures["failure_rate"]
89
 
90
  df["benchmark"] = benchmark_name
91
-
92
  if "algorithm_type" not in df.columns:
93
  if "abc" in file_path.name:
94
  df["algorithm_type"] = "abc"
95
  elif "hm" in file_path.name:
96
  df["algorithm_type"] = "hm"
97
-
98
  if "ensemble_size" not in df.columns:
99
  if "abc" in file_path.name:
100
  df["ensemble_size"] = 1
101
-
102
- missing_cols = [c for c in ["benchmark", "algorithm_type", "rmse_target", "ensemble_size", "metric", "failure_rate"] if c not in df.columns]
 
103
  if missing_cols:
104
  st.warning(
105
  f"Skipping {file_path.name}: metric dataframe is missing expected columns {missing_cols}"
106
  )
107
  continue
108
 
109
- records.append(df[["benchmark", "algorithm_type", "rmse_target", "ensemble_size", "metric", "failure_rate"]])
110
 
111
  if not records:
112
- return pd.DataFrame(columns=["benchmark", "algorithm_type", "rmse_target", "ensemble_size", "metric", "failure_rate"])
 
 
113
 
114
  merged = pd.concat(records, ignore_index=True)
115
  merged["algorithm_alias"] = merged["algorithm_type"].map(normalize_method_name)
@@ -117,10 +184,11 @@ def load_metric_store() -> pd.DataFrame:
117
  merged["ensemble_size"] = pd.to_numeric(merged["ensemble_size"], errors="coerce")
118
  merged["metric"] = pd.to_numeric(merged["metric"], errors="coerce")
119
  merged["failure_rate"] = pd.to_numeric(merged["failure_rate"], errors="coerce").fillna(0.0)
120
- merged = merged.dropna(subset=["ensemble_size", "metric"])
 
 
 
121
  merged["ensemble_size"] = merged["ensemble_size"].astype(int)
122
- merged["forward_model_runs"] = merged["metric"]
123
-
124
  merged["abbreviation"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("abbreviation"))
125
  merged["Method"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("Method"))
126
  merged["family"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("family"))
@@ -136,23 +204,159 @@ def load_metric_store() -> pd.DataFrame:
136
  return merged
137
 
138
 
139
- def build_leaderboard(metric_store: pd.DataFrame) -> pd.DataFrame:
140
- if metric_store.empty:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  return pd.DataFrame(
142
  columns=[
143
- "family",
144
- "Method",
145
- "abbreviation",
146
- "benchmark",
147
- "Forward Model Runs",
148
- "Optimal Ensemble Size",
149
- "Target Level",
150
  ]
151
  )
152
-
153
- best_idx = metric_store.groupby(["benchmark", "algorithm_type"])["metric"].idxmin()
154
- best = metric_store.loc[best_idx].copy()
155
- best = best.rename(columns={"metric": "Forward Model Runs", "rmse_target": "Target Level", "ensemble_size": "Optimal Ensemble Size"})
156
- return best[["family", "Method", "abbreviation", "benchmark", "Forward Model Runs", "Optimal Ensemble Size", "Target Level"]].sort_values(
157
- ["benchmark", "Forward Model Runs"], ascending=[True, True]
 
158
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import importlib
4
  from pathlib import Path
5
+ import warnings
6
+ import numpy as np
7
  import pandas as pd
8
  import streamlit as st
9
 
 
23
  )
24
 
25
 
26
+ # ---------------------------------------------------------------------------
27
+ # Optimization (RMSE) datasets
28
+ # ---------------------------------------------------------------------------
29
+
30
  DATASET_FILES = {
31
  "L63": [
32
  "l63_ensemble_results.nc",
 
50
  ],
51
  }
52
 
53
+ EXPECTED_DIMS_OPT = ("algorithm_type", "rmse_target", "ensemble_size", "random_seed")
54
+ EXPECTED_DIMS_UQ = ("random_seed", "ensemble_size", "k_iter", "coverage_quantile")
55
 
56
 
57
+ # ---------------------------------------------------------------------------
58
+ # Uncertainty Quantification datasets
59
+ # ---------------------------------------------------------------------------
60
+ # Add entries here when UQ result NetCDF files are available. Each file must
61
+ # carry a ``metric`` variable and a ``uq_target`` coordinate (in place of the
62
+ # ``rmse_target`` used by the optimization files). The rest of the schema
63
+ # (algorithm_type, ensemble_size, random_seed) is identical.
64
+ #
65
+ # Example:
66
+ # UQ_DATASET_FILES = {
67
+ # "L63": ["uq/l63_uq_results.nc"],
68
+ # "L96": ["uq/l96_uq_results.nc"],
69
+ # }
70
 
71
+ UQ_DATASET_FILES: dict[str, list[str]] = {}
72
+
73
+ # Canonical UQ target-scaling levels always offered in the UQ leaderboard selector.
74
+ UQ_TARGET_LEVELS: list[float] = [1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5]
75
+
76
+ # Budget-for-coverage source files: maps benchmark β†’ list of (algorithm_type, nc_path) pairs.
77
+ # Each NC must carry output_coverage (coverage_quantile, k_iter, ensemble_size, random_seed),
78
+ # target_scaling, and output_dim.
79
+ UQ_BUDGET_FILES: dict[str, list[tuple[str, str]]] = {
80
+ "L63": [("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l63_ensemble_results_2026-06-15_minimal.nc")],
81
+ "L96": [("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l96_ensemble_results_2026-06-15_minimal.nc")],
82
+ "L96_NN_FORCING": [("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l96_nn_forcing_ensemble_results_2026-06-15_minimal.nc")],
83
+ "L96_SPATIAL_FORCING": [("ces-eki-dmc", "ces-eki-dmc_results/ces-eki-dmc_l96_spatial_forcing_ensemble_results_2026-06-15_minimal.nc")],
84
+ }
85
+
86
+ # Quantile levels used for the all-quantiles-satisfied budget-for-coverage condition.
87
+ # Budget = N_ens Β· k_iter where first k s.t. |S(q)βˆ’q| ≀ c·√(q(1βˆ’q)/N_y) for ALL q below.
88
+ UQ_COVERAGE_QUANTILES: list[float] = [0.15, 0.5, 0.85]
89
+
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Private loader β€” shared by both public load_* functions
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _load_store(dataset_files: dict[str, list[str]], target_col: str, *, drop_nan_metric: bool = True) -> pd.DataFrame:
97
+ """Load and merge NetCDF metric files into a long-format DataFrame.
98
+
99
+ Parameters
100
+ ----------
101
+ dataset_files:
102
+ Mapping of benchmark name β†’ list of relative NetCDF paths under
103
+ ``data/``.
104
+ target_col:
105
+ Name of the target-coordinate dimension in the NetCDF files, e.g.
106
+ ``"rmse_target"`` or ``"uq_target"``.
107
+ """
108
+ if not dataset_files:
109
+ return pd.DataFrame(
110
+ columns=["benchmark", "algorithm_type", target_col, "ensemble_size", "metric", "failure_rate"]
111
+ )
112
 
 
 
113
  xr = importlib.import_module("xarray")
114
 
115
  project_root = Path(__file__).resolve().parent.parent
 
117
 
118
  records: list[pd.DataFrame] = []
119
 
120
+ for benchmark_name, filenames in dataset_files.items():
121
  for filename in filenames:
122
  file_path = data_dir / filename
123
  if not file_path.exists():
 
130
  continue
131
 
132
  metric = dataset["metric"]
133
+ if not set(metric.dims).issubset(EXPECTED_DIMS_OPT):
134
+ warnings.warn(
135
+ f"{file_path.name}: metric dims {metric.dims} contain unexpected "
136
+ f"dimensions not in EXPECTED_DIMS_OPT {EXPECTED_DIMS_OPT} β€” skipping"
137
+ )
138
+ continue
139
+
140
  # Track failures (metric == -1) as a percentage
141
  failures_count = (metric == -1).sum(dim="random_seed")
142
 
143
  failure_rate = (failures_count / dataset.sizes["random_seed"]) * 100
144
+
145
+ # Filter out failed runs (metric == -1)
146
  metric = metric.where(metric != -1)
147
+
148
  metric_mean = metric.mean(dim="random_seed", skipna=True)
149
  df = metric_mean.to_dataframe(name="metric").reset_index()
150
+
151
  df_failures = failure_rate.to_dataframe(name="failure_rate").reset_index()
152
  df["failure_rate"] = df_failures["failure_rate"]
153
 
154
  df["benchmark"] = benchmark_name
155
+
156
  if "algorithm_type" not in df.columns:
157
  if "abc" in file_path.name:
158
  df["algorithm_type"] = "abc"
159
  elif "hm" in file_path.name:
160
  df["algorithm_type"] = "hm"
161
+
162
  if "ensemble_size" not in df.columns:
163
  if "abc" in file_path.name:
164
  df["ensemble_size"] = 1
165
+
166
+ required_cols = ["benchmark", "algorithm_type", target_col, "ensemble_size", "metric", "failure_rate"]
167
+ missing_cols = [c for c in required_cols if c not in df.columns]
168
  if missing_cols:
169
  st.warning(
170
  f"Skipping {file_path.name}: metric dataframe is missing expected columns {missing_cols}"
171
  )
172
  continue
173
 
174
+ records.append(df[required_cols])
175
 
176
  if not records:
177
+ return pd.DataFrame(
178
+ columns=["benchmark", "algorithm_type", target_col, "ensemble_size", "metric", "failure_rate"]
179
+ )
180
 
181
  merged = pd.concat(records, ignore_index=True)
182
  merged["algorithm_alias"] = merged["algorithm_type"].map(normalize_method_name)
 
184
  merged["ensemble_size"] = pd.to_numeric(merged["ensemble_size"], errors="coerce")
185
  merged["metric"] = pd.to_numeric(merged["metric"], errors="coerce")
186
  merged["failure_rate"] = pd.to_numeric(merged["failure_rate"], errors="coerce").fillna(0.0)
187
+ if drop_nan_metric:
188
+ merged = merged.dropna(subset=["ensemble_size", "metric"])
189
+ else:
190
+ merged = merged.dropna(subset=["ensemble_size"])
191
  merged["ensemble_size"] = merged["ensemble_size"].astype(int)
 
 
192
  merged["abbreviation"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("abbreviation"))
193
  merged["Method"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("Method"))
194
  merged["family"] = merged["algorithm_type"].map(lambda method: get_method_meta(method).get("family"))
 
204
  return merged
205
 
206
 
207
+ # ---------------------------------------------------------------------------
208
+ # Public cached loaders
209
+ # ---------------------------------------------------------------------------
210
+
211
+ @st.cache_data(show_spinner=False)
212
+ def load_metric_store() -> pd.DataFrame:
213
+ """Load the optimization (RMSE) metric store from ``DATASET_FILES``."""
214
+ return _load_store(DATASET_FILES, "rmse_target")
215
+
216
+
217
+ @st.cache_data(show_spinner=False)
218
+ def load_uq_store() -> pd.DataFrame:
219
+ """Load the UQ metric store derived from budget-for-coverage computations.
220
+
221
+ Returns a DataFrame compatible with ``render_leaderboard`` where ``metric``
222
+ is the mean budget (N_ensΒ·k_iter) to satisfy the coverage condition across
223
+ all three quantiles in ``UQ_COVERAGE_QUANTILES``. ``uq_target`` is the
224
+ target-scaling level c. Failure means the coverage target was never
225
+ reached within the k_iter range.
226
+ """
227
+ budget_df = load_uq_budget_store()
228
+ if budget_df.empty:
229
  return pd.DataFrame(
230
  columns=[
231
+ "benchmark", "algorithm_type", "algorithm_alias", "abbreviation",
232
+ "Method", "family", "uq_target", "ensemble_size", "metric", "failure_rate",
 
 
 
 
 
233
  ]
234
  )
235
+ df = budget_df.copy()
236
+ df["metric"] = df["mean_budget"]
237
+ df["algorithm_alias"] = df["algorithm_type"].map(normalize_method_name)
238
+ df["Method"] = df["algorithm_type"].map(
239
+ lambda m: get_method_meta(canonicalize_method_name(normalize_method_name(m))).get(
240
+ "Method", m.upper()
241
+ )
242
  )
243
+ return df[
244
+ [
245
+ "benchmark", "algorithm_type", "algorithm_alias", "abbreviation",
246
+ "Method", "family", "uq_target", "ensemble_size", "metric", "failure_rate",
247
+ ]
248
+ ]
249
+
250
+
251
+ @st.cache_data(show_spinner=False)
252
+ def load_uq_budget_store() -> pd.DataFrame:
253
+ """Compute budget-for-coverage and iterations-for-coverage from ensemble result NC files.
254
+
255
+ For each (target_scaling c, ensemble_size N, random_seed):
256
+ Find the minimum k_iter s.t. |S(q) βˆ’ q| ≀ c·√(q(1βˆ’q)/N_y) for ALL q in
257
+ UQ_COVERAGE_QUANTILES. Budget = N Β· k_iter; NaN when target never reached.
258
+
259
+ Returns a DataFrame with columns:
260
+ benchmark, algorithm_type, abbreviation, family, uq_target,
261
+ ensemble_size, mean_budget, mean_iters, failure_count, failure_rate, n_seeds
262
+ """
263
+ xr = importlib.import_module("xarray")
264
+
265
+ project_root = Path(__file__).resolve().parent.parent
266
+ data_dir = project_root / "data"
267
+
268
+ bq_vals = np.array(UQ_COVERAGE_QUANTILES)
269
+ records: list[dict] = []
270
+
271
+ for benchmark_name, method_files in UQ_BUDGET_FILES.items():
272
+ for algorithm_type, filename in method_files:
273
+ file_path = data_dir / filename
274
+ if not file_path.exists():
275
+ warnings.warn(f"UQ budget file {file_path} does not exist")
276
+ continue
277
+
278
+ with xr.open_dataset(file_path) as ds:
279
+ if "output_coverage" not in ds:
280
+ warnings.warn(f"{file_path.name}: missing output_coverage")
281
+ continue
282
+
283
+ if set(ds["output_coverage"].dims) != set(EXPECTED_DIMS_UQ):
284
+ warnings.warn(
285
+ f"{file_path.name}: output_coverage dims {ds['output_coverage'].dims} "
286
+ f"do not match EXPECTED_DIMS_UQ {EXPECTED_DIMS_UQ} β€” skipping"
287
+ )
288
+ continue
289
+
290
+ cov_q = ds["coverage_quantile"].values # (n_cov_q,)
291
+ k_vals = ds["k_iter"].values # (n_k,) 1-indexed
292
+ ens_vals = ds["ensemble_size"].values # (n_ens,)
293
+ ts_vals = ds["target_scaling"].values # (n_ts,)
294
+ # xarray drops dimensions that no variable uses; read output_dim
295
+ # directly from the underlying netCDF4 file to handle minimal files.
296
+ nc4 = importlib.import_module("netCDF4")
297
+ with nc4.Dataset(file_path) as _nc:
298
+ if "output_dim" not in _nc.dimensions:
299
+ warnings.warn(f"{file_path.name}: missing output_dim dimension")
300
+ continue
301
+ n_y = len(_nc.dimensions["output_dim"])
302
+ # dims: (coverage_quantile, k_iter, ensemble_size, random_seed)
303
+ cov_np = ds["output_coverage"].values
304
+
305
+ n_rng = cov_np.shape[3]
306
+
307
+ # Indices into coverage_quantile for [0.15, 0.5, 0.85]
308
+ bq_idx = [int(np.argmin(np.abs(cov_q - q))) for q in bq_vals]
309
+
310
+ for si, c in enumerate(ts_vals):
311
+ tol = c * np.sqrt(bq_vals * (1.0 - bq_vals) / n_y)
312
+
313
+ for ei, ens_f in enumerate(ens_vals):
314
+ N_ens = int(ens_f)
315
+ budgets = np.full(n_rng, np.nan)
316
+ kiters = np.full(n_rng, np.nan)
317
+
318
+ for ri in range(n_rng):
319
+ for ki, kv in enumerate(k_vals):
320
+ s_q = cov_np[bq_idx, ki, ei, ri]
321
+ if np.any(np.isnan(s_q)):
322
+ continue
323
+ if np.all(np.abs(s_q - bq_vals) <= tol):
324
+ budgets[ri] = N_ens * float(kv)
325
+ kiters[ri] = float(kv)
326
+ break
327
+
328
+ valid_b = budgets[~np.isnan(budgets)]
329
+ valid_k = kiters[~np.isnan(kiters)]
330
+ n_fail = n_rng - len(valid_b)
331
+ records.append(
332
+ {
333
+ "benchmark": benchmark_name,
334
+ "algorithm_type": algorithm_type,
335
+ "uq_target": float(c),
336
+ "ensemble_size": N_ens,
337
+ "mean_budget": float(np.mean(valid_b)) if len(valid_b) > 0 else np.nan,
338
+ "mean_iters": float(np.mean(valid_k)) if len(valid_k) > 0 else np.nan,
339
+ "failure_count": n_fail,
340
+ "failure_rate": 100.0 * n_fail / n_rng,
341
+ "n_seeds": n_rng,
342
+ }
343
+ )
344
+
345
+ if not records:
346
+ return pd.DataFrame(
347
+ columns=[
348
+ "benchmark", "algorithm_type", "abbreviation", "family",
349
+ "uq_target", "ensemble_size", "mean_budget", "mean_iters",
350
+ "failure_count", "failure_rate", "n_seeds",
351
+ ]
352
+ )
353
+
354
+ df = pd.DataFrame(records)
355
+
356
+ def _meta(m: str, key: str, fallback: str) -> str:
357
+ return get_method_meta(canonicalize_method_name(normalize_method_name(m))).get(key, fallback)
358
+
359
+ df["abbreviation"] = df["algorithm_type"].map(lambda m: _meta(m, "abbreviation", m.upper()))
360
+ df["family"] = df["algorithm_type"].map(lambda m: _meta(m, "family", "Kalman"))
361
+ df["Method"] = df["algorithm_type"].map(lambda m: _meta(m, "Method", m.upper()))
362
+ return df
src/media/posterior_ribbons_20_13_k5.png ADDED

Git LFS Details

  • SHA256: a20370c35c70c07857dc47237fc5f2f83fed117352a908e30a5e51efd60b64b6
  • Pointer size: 131 Bytes
  • Size of remote file: 113 kB
src/media/posterior_ribbons_const-force_12_1_k3.png ADDED

Git LFS Details

  • SHA256: 4fc26312d47c448a9a8dc1a6ecee59814cc2d7fce77cf0fc04648efdd7e1a67d
  • Pointer size: 131 Bytes
  • Size of remote file: 194 kB
src/media/posterior_ribbons_flux-force_80_1_k3.png ADDED

Git LFS Details

  • SHA256: 10043b9fc650de268ea24614c7beb6056ca5850159f2c70c85eeb434b59d81bf
  • Pointer size: 131 Bytes
  • Size of remote file: 296 kB
src/media/posterior_ribbons_vec-force_65_1_k3.png ADDED

Git LFS Details

  • SHA256: 1f9b165d98e5386d737c57df079f1e9a470caf17ab4e805901b06a3682542afb
  • Pointer size: 131 Bytes
  • Size of remote file: 296 kB
src/pages/MethodDetails.py CHANGED
@@ -15,6 +15,8 @@ st.set_page_config(page_title="Method Details", page_icon="πŸ“˜", layout="wide")
15
  # Sidebar navigation
16
  st.sidebar.title("Navigation")
17
  st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
 
 
18
  st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
19
  st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
20
 
@@ -38,24 +40,24 @@ abbrs = methods_df["abbreviation"].tolist()
38
  # Metadata dictionary (can be moved to a JSON/YAML later)
39
  method_meta = {
40
  "TEKI": {
41
- "citation": "Chada et al., SIAM/ASA J. UQ, 2018",
42
  "url": "https://doi.org/10.1137/17M114402X",
43
- "summary": "EKI variant with Tikhonov regularization for stability.",
44
  },
45
  "ETKI": {
46
- "citation": "Ensemble transform variant (citation to be added)",
47
- "url": "https://example.com/etki",
48
- "summary": "Transform-based Kalman inversion using ensemble-space updates.",
49
  },
50
  "IEKF": {
51
- "citation": "Iterative ensemble Kalman filter (citation to be added)",
52
- "url": "https://example.com/iekf",
53
- "summary": "Iterative Kalman updates for nonlinear inverse problems.",
54
  },
55
  "UKI": {
56
- "citation": "Unscented transform-based inversion (placeholder)",
57
- "url": "https://example.com/uki",
58
- "summary": "Uses sigma points to propagate uncertainty without linearization.",
59
  },
60
  "ABC": {
61
  "citation": "Approximate Bayesian Calibration",
@@ -63,10 +65,15 @@ method_meta = {
63
  "summary": "Sample without exact likelihoods until error falls below a target convergence.",
64
  },
65
  "HM": {
66
- "citation": "History Matching",
67
  "url": "https://example.com/hm",
68
  "summary": "Iterative constraint of parameter space using wave reductions.",
69
  },
 
 
 
 
 
70
  }
71
 
72
  # Selection UI (defaults to query param if valid)
@@ -102,7 +109,7 @@ st.markdown("**Benchmark Slice**")
102
  slice_df = metric_store[metric_store["abbreviation"] == sel].copy()
103
  slice_df = slice_df.sort_values(["benchmark", "rmse_target", "ensemble_size"])
104
 
105
- target_options = ["1.0", "1.1", "1.2"]
106
  selected_target = st.radio("RMSE Target Level", options=target_options, horizontal=True)
107
 
108
  best_table_view = slice_df[slice_df["rmse_target"].astype(str) == selected_target]
@@ -111,7 +118,7 @@ best_idx = best_table_view.groupby("benchmark")["metric"].idxmin()
111
  best_ensemble_df = best_table_view.loc[best_idx, ["benchmark", "rmse_target", "ensemble_size", "metric", "failure_rate"]].rename(
112
  columns={"metric": "Mean Forward Model Runs", "failure_rate": "Failure Rate (%)", "ensemble_size": "Optimal Ensemble Size", "rmse_target": "RMSE Target"}
113
  )
114
- st.dataframe(best_ensemble_df, use_container_width=True, hide_index=True)
115
 
116
  if sel == "HM":
117
  st.markdown("### Failure Analysis")
@@ -145,7 +152,7 @@ st.markdown("**Best by Benchmark**")
145
  st.dataframe(
146
  best_by_benchmark,
147
  hide_index=True,
148
- width="stretch",
149
  column_config={
150
  "benchmark": st.column_config.TextColumn("Benchmark"),
151
  "Best Mean Forward Model Runs": st.column_config.NumberColumn("Best Mean Forward Model Runs", format="%.4f"),
@@ -169,13 +176,13 @@ chart = (
169
  tooltip=["benchmark", "ensemble_size", alt.Tooltip("mean_forward_runs:Q", format=".4f")],
170
  )
171
  )
172
- st.altair_chart(chart, width="stretch")
173
 
174
  st.markdown("**All Averaged Configurations for Method**")
175
  st.dataframe(
176
  slice_df[["benchmark", "algorithm_alias", "rmse_target", "ensemble_size", "metric"]],
177
  hide_index=True,
178
- width="stretch",
179
  column_config={
180
  "benchmark": st.column_config.TextColumn("Benchmark"),
181
  "algorithm_alias": st.column_config.TextColumn("Source Alias"),
@@ -185,4 +192,4 @@ st.dataframe(
185
  },
186
  )
187
 
188
- st.page_link("streamlit_app.py", label="β¬… Back to Leaderboard", icon="↩️")
 
15
  # Sidebar navigation
16
  st.sidebar.title("Navigation")
17
  st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
18
+ st.sidebar.page_link("pages/OptimizationLeaderboard.py", label="Optimization Leaderboard", icon="πŸ“Š")
19
+ st.sidebar.page_link("pages/UQLeaderboard.py", label="UQ Leaderboard", icon="🎯")
20
  st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
21
  st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
22
 
 
40
  # Metadata dictionary (can be moved to a JSON/YAML later)
41
  method_meta = {
42
  "TEKI": {
43
+ "citation": "Chada et al., SIAM/ASA J. UQ, 2020",
44
  "url": "https://doi.org/10.1137/17M114402X",
45
+ "summary": "EKI variant with Tikhonov regularization that adds a penalty term to prevent ensemble collapse and improve stability on nonlinear problems.",
46
  },
47
  "ETKI": {
48
+ "citation": "Schillings & Stuart, Numer. Math., 2017",
49
+ "url": "https://clima.github.io/EnsembleKalmanProcesses.jl/dev/",
50
+ "summary": "Ensemble Transform Kalman Inversion β€” applies an ensemble-space transform update that preserves the ensemble mean while reducing variance inflation.",
51
  },
52
  "IEKF": {
53
+ "citation": "Iglesias, Inverse Problems, 2016",
54
+ "url": "https://doi.org/10.1088/0266-5611/32/2/025002",
55
+ "summary": "Regularizing iterative ensemble Kalman method; repeatedly refines the ensemble around a regularized Gauss-Newton step for nonlinear inverse problems.",
56
  },
57
  "UKI": {
58
+ "citation": "Huang, Huang & Stuart, Physica D, 2022",
59
+ "url": "https://clima.github.io/EnsembleKalmanProcesses.jl/dev/",
60
+ "summary": "Unscented Kalman Inversion β€” propagates a deterministic set of sigma points through the forward model to estimate mean and covariance without linearization.",
61
  },
62
  "ABC": {
63
  "citation": "Approximate Bayesian Calibration",
 
65
  "summary": "Sample without exact likelihoods until error falls below a target convergence.",
66
  },
67
  "HM": {
68
+ "citation": "Williamson et al. 2013; King et al. 2025",
69
  "url": "https://example.com/hm",
70
  "summary": "Iterative constraint of parameter space using wave reductions.",
71
  },
72
+ "CES-EKI-DMC": {
73
+ "citation": "Cleary et al., J. Comput. Phys., 2021",
74
+ "url": "https://doi.org/10.1016/j.jcp.2020.109716",
75
+ "summary": "Calibrate-Emulate-Sample: uses EKI with a DataMisfitController to select training points, builds a GP emulator of the forward model, then samples the posterior via MCMC.",
76
+ },
77
  }
78
 
79
  # Selection UI (defaults to query param if valid)
 
109
  slice_df = metric_store[metric_store["abbreviation"] == sel].copy()
110
  slice_df = slice_df.sort_values(["benchmark", "rmse_target", "ensemble_size"])
111
 
112
+ target_options = sorted(slice_df["rmse_target"].astype(str).unique().tolist())
113
  selected_target = st.radio("RMSE Target Level", options=target_options, horizontal=True)
114
 
115
  best_table_view = slice_df[slice_df["rmse_target"].astype(str) == selected_target]
 
118
  best_ensemble_df = best_table_view.loc[best_idx, ["benchmark", "rmse_target", "ensemble_size", "metric", "failure_rate"]].rename(
119
  columns={"metric": "Mean Forward Model Runs", "failure_rate": "Failure Rate (%)", "ensemble_size": "Optimal Ensemble Size", "rmse_target": "RMSE Target"}
120
  )
121
+ st.dataframe(best_ensemble_df, hide_index=True, use_container_width=True)
122
 
123
  if sel == "HM":
124
  st.markdown("### Failure Analysis")
 
152
  st.dataframe(
153
  best_by_benchmark,
154
  hide_index=True,
155
+ use_container_width=True,
156
  column_config={
157
  "benchmark": st.column_config.TextColumn("Benchmark"),
158
  "Best Mean Forward Model Runs": st.column_config.NumberColumn("Best Mean Forward Model Runs", format="%.4f"),
 
176
  tooltip=["benchmark", "ensemble_size", alt.Tooltip("mean_forward_runs:Q", format=".4f")],
177
  )
178
  )
179
+ st.altair_chart(chart, use_container_width=True)
180
 
181
  st.markdown("**All Averaged Configurations for Method**")
182
  st.dataframe(
183
  slice_df[["benchmark", "algorithm_alias", "rmse_target", "ensemble_size", "metric"]],
184
  hide_index=True,
185
+ use_container_width=True,
186
  column_config={
187
  "benchmark": st.column_config.TextColumn("Benchmark"),
188
  "algorithm_alias": st.column_config.TextColumn("Source Alias"),
 
192
  },
193
  )
194
 
195
+ st.page_link("pages/OptimizationLeaderboard.py", label="β¬… Back to Optimization Leaderboard", icon="↩️")
src/pages/OptimizationLeaderboard.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sys
3
+
4
+ import streamlit as st
5
+
6
+ try:
7
+ from data_store import load_metric_store
8
+ from common.leaderboard import render_leaderboard
9
+ except ModuleNotFoundError:
10
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
11
+ from data_store import load_metric_store
12
+ from common.leaderboard import render_leaderboard
13
+
14
+ st.set_page_config(page_title="Optimization Leaderboard", page_icon="πŸ“Š", layout="wide")
15
+
16
+ # Sidebar navigation
17
+ st.sidebar.title("Navigation")
18
+ st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
19
+ st.sidebar.page_link("pages/OptimizationLeaderboard.py", label="Optimization Leaderboard", icon="πŸ“Š")
20
+ st.sidebar.page_link("pages/UQLeaderboard.py", label="UQ Leaderboard", icon="🎯")
21
+ st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
22
+ st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
23
+
24
+ st.markdown(r"**Optimization target.** A run succeeds when the normalized weighted distance from the ensemble-mean forward-model output to the true data falls at or below the (selected in the target controls) RMSE target level $\tau$:")
25
+ st.latex(r"\frac{1}{N_y}\bigl(y - G(\bar{\theta})\bigr)^\top \Gamma^{-1}\bigl(y - G(\bar{\theta})\bigr) \leq \tau")
26
+ st.markdown(r"where $y$ is the true observation, $G(\bar{\theta})$ is the forward-model output at the final ensemble mean $\bar{\theta}$, and $\Gamma$ is the observation noise covariance, and $N_y = \dim(y)$.")
27
+
28
+ render_leaderboard(
29
+ load_metric_store(),
30
+ target_col="rmse_target",
31
+ target_label="RMSE Target Level",
32
+ title="Optimization Leaderboard",
33
+ state_prefix="opt",
34
+ raw_page="pages/RawData.py",
35
+ )
src/pages/RawData.py CHANGED
@@ -14,6 +14,8 @@ st.set_page_config(page_title="Raw Data", page_icon="🧾", layout="wide")
14
  # Sidebar navigation
15
  st.sidebar.title("Navigation")
16
  st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
 
 
17
  st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
18
  st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
19
 
@@ -47,7 +49,7 @@ st.dataframe(
47
  ]
48
  ],
49
  hide_index=True,
50
- width="stretch",
51
  column_config={
52
  "benchmark": st.column_config.TextColumn("Benchmark"),
53
  "abbreviation": st.column_config.TextColumn("Abbrev."),
@@ -69,4 +71,4 @@ st.download_button(
69
  mime="text/csv",
70
  )
71
 
72
- st.page_link("streamlit_app.py", label="β¬… Back to Leaderboard", icon="↩️")
 
14
  # Sidebar navigation
15
  st.sidebar.title("Navigation")
16
  st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
17
+ st.sidebar.page_link("pages/OptimizationLeaderboard.py", label="Optimization Leaderboard", icon="πŸ“Š")
18
+ st.sidebar.page_link("pages/UQLeaderboard.py", label="UQ Leaderboard", icon="🎯")
19
  st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
20
  st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
21
 
 
49
  ]
50
  ],
51
  hide_index=True,
52
+ use_container_width=True,
53
  column_config={
54
  "benchmark": st.column_config.TextColumn("Benchmark"),
55
  "abbreviation": st.column_config.TextColumn("Abbrev."),
 
71
  mime="text/csv",
72
  )
73
 
74
+ st.page_link("pages/OptimizationLeaderboard.py", label="β¬… Back to Optimization Leaderboard", icon="↩️")
src/pages/UQLeaderboard.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sys
3
+
4
+ import streamlit as st
5
+
6
+ try:
7
+ from data_store import load_uq_store, load_uq_budget_store, UQ_TARGET_LEVELS
8
+ from common.leaderboard import render_leaderboard
9
+ except ModuleNotFoundError:
10
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
11
+ from data_store import load_uq_store, load_uq_budget_store, UQ_TARGET_LEVELS
12
+ from common.leaderboard import render_leaderboard
13
+
14
+ st.set_page_config(page_title="UQ Leaderboard", page_icon="🎯", layout="wide")
15
+
16
+ # Sidebar navigation
17
+ st.sidebar.title("Navigation")
18
+ st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
19
+ st.sidebar.page_link("pages/OptimizationLeaderboard.py", label="Optimization Leaderboard", icon="πŸ“Š")
20
+ st.sidebar.page_link("pages/UQLeaderboard.py", label="UQ Leaderboard", icon="🎯")
21
+ st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
22
+ st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
23
+
24
+ st.markdown(r"**UQ target.** Marginal coverage, $\widetilde{S}(q)$ measures the fraction of output dimensions where the true observation $y$ falls below the $q$-quantile of the posterior predictive distribution. This effectively treats each output dimension as an independent trial, so we also whitened the outputs and data by $\Gamma$, Though not notated, we also truncate to retain 99% of the variance:")
25
+ st.latex(r"\widetilde{S}(q) = \frac{1}{N_y}\sum_{n=1}^{N_y} \mathbb{I}\!\left(\mathrm{cdf}_{\,\Gamma^{-1/2}\mathrm{post}}\!\left(\Gamma^{-1/2}y\right) \leq q\right)")
26
+ st.markdown(r"A run succeeds when **all three of the target quantiles** $q \in \{0.15, 0.50, 0.85\}$ simultaneously satisfy")
27
+ st.latex(r"\left|\widetilde{S}(q) - q\right| \leq \alpha_c(q), \qquad \alpha_c(q) = c\sqrt{\frac{q(1-q)}{N_y}}")
28
+ st.markdown(r"The boundary $\alpha_c(q)$ adapts the race-goal tolerance for different quantiles, as some are easier to estimate than others with finite samples. while $c$ is the target-scaling level that can be selected in the controls below, and $N_y = \dim(\Gamma^{-1/2}y)$ (and after truncation).")
29
+
30
+ render_leaderboard(
31
+ load_uq_store(),
32
+ target_col="uq_target",
33
+ target_label="UQ Target (coverage tolerance scaling)",
34
+ title="Uncertainty Quantification Leaderboard",
35
+ state_prefix="uq",
36
+ show_failure_panel=True,
37
+ show_scoring_modes=False,
38
+ canonical_target_levels=UQ_TARGET_LEVELS,
39
+ budget_store=load_uq_budget_store(),
40
+ )
src/streamlit_app.py CHANGED
@@ -1,324 +1,103 @@
1
- import altair as alt
2
- import importlib
3
- import pandas as pd
4
  import streamlit as st
 
5
 
6
- st.set_page_config(layout="wide")
7
-
8
- if __package__:
9
- data_store = importlib.import_module(f"{__package__}.data_store")
10
- else:
11
- data_store = importlib.import_module("data_store")
12
-
13
- load_metric_store = data_store.load_metric_store
14
-
15
- st.title("Calibration Benchmark")
16
 
17
  # Sidebar navigation
18
  st.sidebar.title("Navigation")
19
  st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
 
 
20
  st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
21
  st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
22
 
23
-
24
- show_home = st.session_state.get("show_home", True)
25
- if show_home:
26
- st.header("Calibration Leaderboard")
27
-
28
- metric_store = load_metric_store()
29
- if metric_store.empty:
30
- st.warning("No metric data found. Expected NetCDF files in `data/` with a `metric` variable.")
31
- st.stop()
32
-
33
- benchmark_values = sorted(metric_store["benchmark"].unique().tolist())
34
- benchmark_options = ["All"] + benchmark_values
35
- default_benchmark_index = 0
36
- selected_benchmark = st.selectbox("Benchmark", options=benchmark_options, index=default_benchmark_index)
37
-
38
- filtered = metric_store.copy() if selected_benchmark == "All" else metric_store[metric_store["benchmark"] == selected_benchmark].copy()
39
- filtered["rmse_target_str"] = filtered["rmse_target"].astype(str)
40
-
41
- target_options = ["All targets"] + sorted(metric_store["rmse_target"].astype(str).unique().tolist())
42
- scoring_options = [
43
- "Mean Forward Model Runs",
44
- "Minimum Forward Model Runs",
45
- "Smallest Optimal Ensemble Size",
46
- "Custom Blend",
47
- ]
48
-
49
- current_target = st.session_state.get("selected_target", "All targets")
50
- if current_target not in target_options:
51
- current_target = target_options[0]
52
-
53
- current_scoring_mode = st.session_state.get("scoring_mode", "Mean Forward Model Runs")
54
- if current_scoring_mode not in scoring_options:
55
- current_scoring_mode = scoring_options[0]
56
-
57
- current_fwdruns_weight_percent = int(st.session_state.get("fwdruns_weight_percent", 80))
58
- current_fwdruns_weight_percent = max(0, min(100, current_fwdruns_weight_percent))
59
-
60
- selected_target = current_target
61
- scoring_mode = current_scoring_mode
62
- fwdruns_weight = current_fwdruns_weight_percent / 100.0
63
- ensemble_weight = 1.0 - fwdruns_weight
64
-
65
- def build_scored_table(input_df, add_rank=True):
66
- ranking_source = input_df if selected_target == "All targets" else input_df[input_df["rmse_target_str"] == selected_target]
67
- if ranking_source.empty:
68
- return ranking_source
69
-
70
- scored_df = ranking_source.groupby(["algorithm_type", "abbreviation", "Method", "family"], as_index=False).agg(
71
- **{"Mean Forward Model Runs": ("metric", "mean")},
72
- **{"Minimum Forward Model Runs": ("metric", "min")},
73
- **{"Targets Used": ("rmse_target_str", "nunique")},
74
- **{"Ensemble Sizes Used": ("ensemble_size", "nunique")},
75
- )
76
-
77
- best_per_target = (
78
- ranking_source.sort_values(["algorithm_type", "rmse_target_str", "metric", "ensemble_size"])
79
- .groupby(["algorithm_type", "abbreviation", "Method", "family", "rmse_target_str"], as_index=False)
80
- .first()[
81
- [
82
- "algorithm_type",
83
- "abbreviation",
84
- "Method",
85
- "family",
86
- "rmse_target_str",
87
- "ensemble_size",
88
- ]
89
- ]
90
- )
91
-
92
- optimal_ensemble = best_per_target.groupby(
93
- ["algorithm_type", "abbreviation", "Method", "family"], as_index=False
94
- ).agg(**{"Optimal Ensemble Size": ("ensemble_size", "mean")})
95
-
96
- scored_df = scored_df.merge(
97
- optimal_ensemble,
98
- on=["algorithm_type", "abbreviation", "Method", "family"],
99
- how="left",
100
- )
101
-
102
- scored_df["Optimal Ensemble Size"] = scored_df["Optimal Ensemble Size"].round(2)
103
- scored_df["Mean Forward Model Runs"] = scored_df["Mean Forward Model Runs"].round(4)
104
- scored_df["Minimum Forward Model Runs"] = scored_df["Minimum Forward Model Runs"].round(4)
105
-
106
- mean_runs_min = scored_df["Mean Forward Model Runs"].min()
107
- mean_runs_max = scored_df["Mean Forward Model Runs"].max()
108
- if mean_runs_max > mean_runs_min:
109
- scored_df["mean_runs_score"] = 100.0 * (mean_runs_max - scored_df["Mean Forward Model Runs"]) / (mean_runs_max - mean_runs_min)
110
- else:
111
- scored_df["mean_runs_score"] = 100.0
112
-
113
- minimum_runs_min = scored_df["Minimum Forward Model Runs"].min()
114
- minimum_runs_max = scored_df["Minimum Forward Model Runs"].max()
115
- if minimum_runs_max > minimum_runs_min:
116
- scored_df["minimum_runs_score"] = 100.0 * (minimum_runs_max - scored_df["Minimum Forward Model Runs"]) / (minimum_runs_max - minimum_runs_min)
117
- else:
118
- scored_df["minimum_runs_score"] = 100.0
119
-
120
- ens_min = scored_df["Optimal Ensemble Size"].min()
121
- ens_max = scored_df["Optimal Ensemble Size"].max()
122
- if ens_max > ens_min:
123
- scored_df["ensemble_score"] = 100.0 * (ens_max - scored_df["Optimal Ensemble Size"]) / (ens_max - ens_min)
124
- else:
125
- scored_df["ensemble_score"] = 100.0
126
-
127
- if scoring_mode == "Mean Forward Model Runs":
128
- scored_df["Score"] = scored_df["mean_runs_score"]
129
- sort_columns = ["Mean Forward Model Runs", "Optimal Ensemble Size", "abbreviation"]
130
- ascending = [True, True, True]
131
- elif scoring_mode == "Minimum Forward Model Runs":
132
- scored_df["Score"] = scored_df["minimum_runs_score"]
133
- sort_columns = ["Minimum Forward Model Runs", "Optimal Ensemble Size", "abbreviation"]
134
- ascending = [True, True, True]
135
- elif scoring_mode == "Smallest Optimal Ensemble Size":
136
- scored_df["Score"] = scored_df["ensemble_score"]
137
- sort_columns = ["Optimal Ensemble Size", "Mean Forward Model Runs", "abbreviation"]
138
- ascending = [True, True, True]
139
- else:
140
- scored_df["Score"] = (
141
- fwdruns_weight * scored_df["mean_runs_score"]
142
- + ensemble_weight * scored_df["ensemble_score"]
143
- )
144
- sort_columns = ["Score", "Mean Forward Model Runs", "Optimal Ensemble Size", "abbreviation"]
145
- ascending = [False, True, True, True]
146
-
147
- scored_df = scored_df.sort_values(sort_columns, ascending=ascending).reset_index(drop=True)
148
-
149
- if add_rank:
150
- scored_df["Rank"] = scored_df.index + 1
151
- scored_df["Placement"] = scored_df["Rank"].apply(
152
- lambda rank: f"{ {1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰'}.get(rank, '')} #{rank}".strip()
153
- )
154
-
155
- return scored_df
156
-
157
- if selected_benchmark == "All":
158
- benchmark_scores = []
159
- for benchmark_name in benchmark_values:
160
- benchmark_df = metric_store[metric_store["benchmark"] == benchmark_name].copy()
161
- benchmark_df["rmse_target_str"] = benchmark_df["rmse_target"].astype(str)
162
- scored = build_scored_table(benchmark_df, add_rank=False)
163
- if scored.empty:
164
- continue
165
- scored["benchmark"] = benchmark_name
166
- benchmark_scores.append(scored)
167
-
168
- if benchmark_scores:
169
- combined_scores = benchmark_scores[0].copy() if len(benchmark_scores) == 1 else pd.concat(benchmark_scores, ignore_index=True)
170
- leaderboard_df = combined_scores.groupby(["algorithm_type", "abbreviation", "Method", "family"], as_index=False).agg(
171
- Score=("Score", "mean"),
172
- **{"Mean Forward Model Runs": ("Mean Forward Model Runs", "mean")},
173
- **{"Minimum Forward Model Runs": ("Minimum Forward Model Runs", "mean")},
174
- **{"Optimal Ensemble Size": ("Optimal Ensemble Size", "mean")},
175
- **{"Targets Used": ("Targets Used", "mean")},
176
- **{"Ensemble Sizes Used": ("Ensemble Sizes Used", "mean")},
177
- **{"Benchmarks Used": ("benchmark", "nunique")},
178
- )
179
-
180
- leaderboard_df["Mean Forward Model Runs"] = leaderboard_df["Mean Forward Model Runs"].round(4)
181
- leaderboard_df["Minimum Forward Model Runs"] = leaderboard_df["Minimum Forward Model Runs"].round(4)
182
- leaderboard_df["Optimal Ensemble Size"] = leaderboard_df["Optimal Ensemble Size"].round(2)
183
- leaderboard_df["Targets Used"] = leaderboard_df["Targets Used"].round().astype(int)
184
- leaderboard_df["Ensemble Sizes Used"] = leaderboard_df["Ensemble Sizes Used"].round().astype(int)
185
- leaderboard_df = leaderboard_df.sort_values(["Score", "Mean Forward Model Runs", "abbreviation"], ascending=[False, True, True]).reset_index(drop=True)
186
- leaderboard_df["Rank"] = leaderboard_df.index + 1
187
- leaderboard_df["Placement"] = leaderboard_df["Rank"].apply(
188
- lambda rank: f"{ {1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰'}.get(rank, '')} #{rank}".strip()
189
- )
190
- else:
191
- leaderboard_df = pd.DataFrame(
192
- columns=[
193
- "Placement",
194
- "abbreviation",
195
- "Method",
196
- "family",
197
- "Mean Forward Model Runs",
198
- "Minimum Forward Model Runs",
199
- "Score",
200
- "Optimal Ensemble Size",
201
- "Targets Used",
202
- "Ensemble Sizes Used",
203
- "Benchmarks Used",
204
- ]
205
- )
206
- else:
207
- leaderboard_df = build_scored_table(filtered, add_rank=True)
208
- leaderboard_df["Benchmarks Used"] = 1
209
-
210
- if scoring_mode == "Mean Forward Model Runs":
211
- score_basis = "normalized mean of best forward model runs over selected target levels (lower is better)"
212
- elif scoring_mode == "Minimum Forward Model Runs":
213
- score_basis = "normalized minimum of forward model runs over selected targets and ensemble sizes (lower is better)"
214
- elif scoring_mode == "Smallest Optimal Ensemble Size":
215
- score_basis = "normalized mean optimal ensemble size over selected target levels (lower is better)"
216
- else:
217
- score_basis = (
218
- "weighted blend of normalized forward-model-runs score and normalized ensemble-size score "
219
- f"(forward-runs weight {fwdruns_weight:.0%}, ensemble-size weight {ensemble_weight:.0%})"
220
- )
221
-
222
- if selected_benchmark == "All":
223
- score_basis = f"{score_basis}; in All mode, each method's final score is the mean of its per-benchmark scores"
224
-
225
- if leaderboard_df.empty:
226
- st.warning("No rows available for the current benchmark/target selection.")
227
- st.stop()
228
-
229
- if selected_benchmark == "All":
230
- table_column_order = [
231
- "Placement",
232
- "abbreviation",
233
- "Method",
234
- "family",
235
- "Score",
236
- "Targets Used",
237
- "Ensemble Sizes Used",
238
- "Benchmarks Used",
239
- ]
240
- else:
241
- table_column_order = [
242
- "Placement",
243
- "abbreviation",
244
- "Method",
245
- "family",
246
- "Score",
247
- "Mean Forward Model Runs",
248
- "Minimum Forward Model Runs",
249
- "Optimal Ensemble Size",
250
- "Targets Used",
251
- "Ensemble Sizes Used",
252
- "Benchmarks Used",
253
- ]
254
-
255
- st.subheader(f"Ranked Leaderboard β€” {selected_benchmark}")
256
- st.dataframe(
257
- leaderboard_df,
258
- hide_index=True,
259
- width="stretch",
260
- column_config={
261
- "Placement": st.column_config.TextColumn("Placement"),
262
- "family": st.column_config.TextColumn("Family"),
263
- "Method": st.column_config.TextColumn("Method"),
264
- "abbreviation": st.column_config.TextColumn("Abbrev."),
265
- "Mean Forward Model Runs": st.column_config.NumberColumn("Mean Forward Model Runs", format="%.4f"),
266
- "Minimum Forward Model Runs": st.column_config.NumberColumn("Minimum Forward Model Runs", format="%.4f"),
267
- "Score": st.column_config.ProgressColumn("Score (0-100)", min_value=0.0, max_value=100.0, format="%.1f"),
268
- "Optimal Ensemble Size": st.column_config.NumberColumn("Mean Optimal Ensemble Size", format="%.2f"),
269
- "Targets Used": st.column_config.NumberColumn("Targets Used", format="%d"),
270
- "Ensemble Sizes Used": st.column_config.NumberColumn("Ensemble Sizes Used", format="%d"),
271
- "Benchmarks Used": st.column_config.NumberColumn("Benchmarks Used", format="%d"),
272
- },
273
- column_order=table_column_order,
274
  )
275
-
276
- with st.expander("Scoring & Target Controls", expanded=False):
277
- st.radio(
278
- "RMSE Target Level",
279
- options=target_options,
280
- horizontal=True,
281
- key="selected_target",
282
- )
283
-
284
- st.radio(
285
- "Scoring Method",
286
- options=scoring_options,
287
- horizontal=True,
288
- key="scoring_mode",
289
- )
290
-
291
- if st.session_state.get("scoring_mode", "Mean Forward Model Runs") == "Custom Blend":
292
- st.slider(
293
- "Blend Weight: Forward Runs vs Ensemble Size",
294
- min_value=0,
295
- max_value=100,
296
- step=5,
297
- key="fwdruns_weight_percent",
298
- help="Higher forward-runs weight prioritizes fewer model evaluations; higher ensemble-size weight prioritizes smaller ensembles.",
299
- )
300
-
301
- st.info(
302
- f"Score is a normalized 0–100 ranking based on **{score_basis}**. "
303
- "For Mean/Minimum forward-runs scoring, values are computed from all selected metric target levels and all ensemble sizes "
304
- "after averaging over random seeds."
305
  )
306
-
307
- if selected_benchmark != "All":
308
- st.subheader("Mean Forward Model Runs vs Ensemble Size")
309
- chart_source = filtered if selected_target == "All targets" else filtered[filtered["rmse_target_str"] == selected_target]
310
- chart_df = chart_source.groupby(["abbreviation", "ensemble_size"], as_index=False).agg(mean_forward_runs=("metric", "mean"))
311
- chart = (
312
- alt.Chart(chart_df)
313
- .mark_line(point=True)
314
- .encode(
315
- x=alt.X("ensemble_size:Q", title="Ensemble Size"),
316
- y=alt.Y("mean_forward_runs:Q", title="Mean Forward Model Runs"),
317
- color=alt.Color("abbreviation:N", title="Method"),
318
- tooltip=["abbreviation", "ensemble_size", alt.Tooltip("mean_forward_runs:Q", format=".4f")],
319
- )
320
- )
321
- st.altair_chart(chart, width="stretch")
322
- st.caption("Top 3 are shown as podium spots; remaining methods are directly comparable via normalized score.")
323
- st.page_link("pages/RawData.py", label="Open Raw Data & CSV Export", icon="🧾")
324
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
+ from pathlib import Path
3
 
4
+ st.set_page_config(page_title="Calibration Benchmark", page_icon="🏠", layout="wide")
 
 
 
 
 
 
 
 
 
5
 
6
  # Sidebar navigation
7
  st.sidebar.title("Navigation")
8
  st.sidebar.page_link("streamlit_app.py", label="Home", icon="🏠")
9
+ st.sidebar.page_link("pages/OptimizationLeaderboard.py", label="Optimization Leaderboard", icon="πŸ“Š")
10
+ st.sidebar.page_link("pages/UQLeaderboard.py", label="UQ Leaderboard", icon="🎯")
11
  st.sidebar.page_link("pages/MethodDetails.py", label="Methods", icon="πŸ“˜")
12
  st.sidebar.page_link("pages/RawData.py", label="Get Data", icon="🧾")
13
 
14
+ st.title("Calibration Benchmark")
15
+ st.markdown(
16
+ "A benchmark comparing parameter-calibration methods on chaotic dynamical systems. "
17
+ "Methods are ranked by **forward-model run efficiency** β€” how many forward-model "
18
+ "evaluations are needed, on average across random seeds, to reach a target accuracy."
19
+ )
20
+
21
+ st.divider()
22
+
23
+ col1, col2 = st.columns(2, gap="large")
24
+
25
+ with col1:
26
+ st.subheader("πŸ“Š Optimization Leaderboard")
27
+ st.markdown(
28
+ "Ranks methods by mean forward-model runs to reach an **RMSE target** on "
29
+ "Lorenz-63 and Lorenz-96 benchmarks. "
30
+ "Lower is better; failed runs are tracked separately as a failure rate."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  )
32
+ st.page_link("pages/OptimizationLeaderboard.py", label="Go to Optimization Leaderboard β†’")
33
+
34
+ with col2:
35
+ st.subheader("🎯 UQ Leaderboard")
36
+ st.markdown(
37
+ "Ranks methods by mean forward-model runs to reach an **uncertainty quantification "
38
+ "target**. Same metric and benchmarks as the Optimization Leaderboard, evaluated "
39
+ "at a UQ-specific convergence criterion."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  )
41
+ st.page_link("pages/UQLeaderboard.py", label="Go to UQ Leaderboard β†’")
42
+
43
+ st.divider()
44
+
45
+ st.subheader("Benchmarks")
46
+ st.markdown(
47
+ "Results are reported on four benchmark configurations of the [Lorenz system]"
48
+ "(https://en.wikipedia.org/wiki/Lorenz_system), a standard testbed for "
49
+ "data-assimilation and calibration algorithms:"
50
+ )
51
+
52
+ _media = Path(__file__).parent / "media"
53
+
54
+ st.markdown("- **L63** β€” Lorenz-63 β€” 3-variable chaotic attractor; learn 2 parameters, strongly nonlinear.")
55
+ st.image(
56
+ str(_media / "posterior_ribbons_20_13_k5.png"),
57
+ caption="Example prior-posterior & truth. L: Difference to true parameter. R: data-sample/output predictived distribution (state-mean [1:3], state-covariance (diag [4:6] and off-diag [7:9]))",
58
+ width=800,
59
+ )
60
+
61
+
62
+ st.markdown("- **L96** β€” Lorenz-96 (40-variable); learn 1-parameter constant forcing.")
63
+ st.image(
64
+ str(_media / "posterior_ribbons_const-force_12_1_k3.png"),
65
+ caption="Example prior-posterior & truth. L: Difference to true parameter. C: parameter-induced forcing of the L96 system. R: data-sample/output predictived distribution ([1:40] state-mean [41:80] state-std)",
66
+ width=1200,
67
+ )
68
+
69
+ st.markdown("- **L96_SPATIAL_FORCING** β€” Lorenz-96 (40-variable) with spatially-varying forcing; learn 40 parameters; moderately correlated prior.")
70
+ st.image(
71
+ str(_media / "posterior_ribbons_vec-force_65_1_k3.png"),
72
+ caption="Example prior-posterior & truth. L: Difference to true parameters. C: parameter-induced forcing of the L96 system. R: data-sample/output predictived distribution ([1:40] state-mean [41:80] state-std)",
73
+ width=1200,
74
+ )
75
+
76
+
77
+ st.markdown("- **L96_NN_FORCING** β€” Lorenz-96 (100-variable) with a neural-network forcing; Learn 61 parameters (weights and biases of the network). Reasonable prior given.")
78
+ st.image(
79
+ str(_media / "posterior_ribbons_flux-force_80_1_k3.png"),
80
+ caption="Example prior-posterior & truth. L: Difference to true parameters (weights). C: parameter-induced forcing of the L96 system. R: data-sample/output predictived distribution ([1:100] state-mean [101:200] state-std)",
81
+ width=1200,
82
+ )
83
+
84
+ st.subheader("Method families")
85
+ st.markdown(
86
+ "Methods are grouped into three families. See the **πŸ“˜ Methods** page for "
87
+ "citations and per-method performance charts."
88
+ )
89
+ families = {
90
+ "Kalman": "Ensemble Kalman variants (TEKI, ETKI, IEKF, UKI) β€” update an ensemble of parameter guesses via a linearized observation operator.",
91
+ "Bayesian": "Sampling-based approaches (ABC, HM) β€” explore parameter space without requiring gradient information.",
92
+ "Calibrate-then-emulate": "Two-stage pipelines (CES-EKI-DMC) β€” use an initial calibration phase to build a cheap emulator, then sample the posterior via MCMC.",
93
+ }
94
+ for family, desc in families.items():
95
+ st.markdown(f"- **{family}** β€” {desc}")
96
+
97
+ st.subheader("Key metric")
98
+ st.markdown(
99
+ "The reported metric is the **mean number of forward-model evaluations** required "
100
+ "to reach the target, averaged over random seeds. "
101
+ "A value of **βˆ’1** indicates a failed run (did not reach the target); "
102
+ "the **failure rate** shows the fraction of seeds that failed."
103
+ )