RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
design.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""make_design - the ONE canonical Saltelli design for the Tier-2/3 campaign.
3
4Run this ONCE, on ONE machine, and commit the output. Every machine in the
5campaign reads the same file; no machine ever generates its own.
6
7Two reasons this is not merely tidy:
8
9 1. Saltelli indices are computed from PAIRED rows. S_j pairs f(A_i) against
10 f(AB_i^(j)). If two machines independently sample, the pairing is
11 destroyed and the indices are silently WRONG -- not noisy, wrong.
12
13 2. Tier 3 is a maximin NESTED SUBSET of the Tier-2 design (plan S4/Tier 3).
14 A Sobol sequence is nested by construction: the first M base points of a
15 2^k sequence are themselves a balanced design. Latin hypercube is NOT --
16 it cannot be extended without rebuilding. The shared theta points between
17 LF and HF ARE the multi-fidelity discrepancy model, so they must coincide
18 exactly, which is why theta is referenced by a stable row_id.
19
20Design size: N base samples, p sampled parameters -> N*(p+2) rows per condition
21(matrices A, B, and AB_j for j = 1..p).
22
23Usage
24-----
25 python design.py --tier 2 --N 16 --out design/T2_design_v1.csv
26 python design.py --tier 3 --N 4 --nest-from design/T2_design_v1.csv \
27 --out design/T3_design_v1.csv
28
29Outputs a CSV with one row per model evaluation:
30 row_id, matrix, base_idx, param_j, condition, <key_1> ... <key_p>
31plus a sidecar .meta.json recording the seed, parameter version, git SHA and a
32SHA-256 of the design itself. run_ensemble.py refuses to run against a design
33whose hash does not match its sidecar.
34"""
35from __future__ import annotations
36
37import argparse
38import hashlib
39import json
40import subprocess
41import sys
42from pathlib import Path
43
44import numpy as np
45from scipy.stats import qmc, truncnorm
46
47HERE = Path(__file__).resolve().parent
48
49
50# ---------------------------------------------------------------- spec loading
51def load_spec(path: Path | None = None) -> dict:
52 """Load parameters.json (or a parameters.yaml sibling if pyyaml exists)."""
53 if path is None:
54 y = HERE / "parameters.yaml"
55 if y.exists():
56 try:
57 import yaml # optional
58 return yaml.safe_load(y.read_text(encoding="utf-8"))
59 except ImportError:
60 print(" note: parameters.yaml present but pyyaml missing; "
61 "using parameters.json", file=sys.stderr)
62 path = HERE / "parameters.json"
63 return json.loads(path.read_text(encoding="utf-8"))
64
65
66def active_params(spec: dict, tier: int) -> list[dict]:
67 """Parameters sampled at this tier.
68
69 Tier 2 -> tier==2 only. Tier 3 -> tier 2 AND 3: the HF design must vary
70 everything the LF design varied *plus* the dose-driven block, otherwise the
71 HF runs are not comparable to their LF partners.
72 """
73 want = {2} if tier == 2 else {2, 3}
74 ps = [p for p in spec["parameters"] if p["tier"] in want]
75 return sorted(ps, key=lambda p: p["id"])
76
77
78def apply_exclusions(params: list[dict], exclude: list[str]) -> list[dict]:
79 """Drop parameters this campaign holds FIXED rather than samples.
80
81 Kept out of active_params/parameters.json deliberately: an excluded
82 parameter is excluded *by this design*, not deleted from the model, and it
83 keeps its prior and its documentation. The exclusion is recorded in the
84 design's .meta.json, so a Sobol table can never be read as though the
85 parameter had been varied and found unimportant.
86
87 Rev 6 excludes i_mobile and v_mobile: bin_moment fixes them at 50 and 5
88 (they set the discrete/binned boundary, so sampling them would change the
89 DISCRETISATION between rows, not just the physics), and run_ensemble refuses
90 to run bin_moment against a design that still carries them.
91 """
92 if not exclude:
93 return params
94 keys = {q["key"] for q in params}
95 unknown = [e for e in exclude if e not in keys]
96 if unknown:
97 raise SystemExit(f"--exclude: not active at this tier: {unknown}\n"
98 f" active: {sorted(keys)}")
99 return [q for q in params if q["key"] not in set(exclude)]
100
101
102# ------------------------------------------------------------- prior transform
103def to_physical(u: np.ndarray, p: dict) -> np.ndarray:
104 """Map u ~ U[0,1)^n onto the parameter's prior support.
105
106 Every family is inverse-CDF so the Sobol sequence's low-discrepancy
107 structure survives the transform -- which is the whole point of using one.
108 """
109 lo, hi, fam = float(p["lo"]), float(p["hi"]), p["prior"]
110 u = np.clip(u, 1e-12, 1 - 1e-12)
111 if fam == "U":
112 return lo + (hi - lo) * u
113 if fam == "LN": # log-uniform
114 return 10.0 ** (np.log10(lo) + (np.log10(hi) - np.log10(lo)) * u)
115 if fam == "INT":
116 # uniform over integers; floor of a uniform on [lo, hi+1)
117 return np.floor(lo + (hi + 1 - lo) * u).clip(lo, hi).astype(int)
118 if fam == "TN":
119 mu = float(p["nominal"])
120 sig = (hi - lo) / 4.0 # +/-2 sigma spans the stated range
121 a, b = (lo - mu) / sig, (hi - mu) / sig
122 return truncnorm.ppf(u, a, b, loc=mu, scale=sig)
123 raise ValueError(f"unknown prior family {fam!r} for {p['key']}")
124
125
126# ------------------------------------------------------------- Saltelli matrix
127def saltelli_rows(N: int, params: list[dict], seed: int):
128 """Return (rows, meta) where rows is a list of dicts.
129
130 Layout per base index i: A_i, B_i, then AB_i^(j) for each j.
131 AB_i^(j) is A_i with column j replaced by B_i's column j.
132 """
133 p = len(params)
134 # One Sobol draw of dimension 2p, split into A | B. Drawing both halves
135 # from a single sequence keeps A and B mutually low-discrepancy, which is
136 # what the Saltelli estimator assumes.
137 sampler = qmc.Sobol(d=2 * p, scramble=True, seed=seed)
138 if N & (N - 1):
139 print(f" warning: N={N} is not a power of two; Sobol balance is "
140 f"degraded. Prefer 8/16/32/64.", file=sys.stderr)
141 draw = sampler.random(N)
142 U_A, U_B = draw[:, :p], draw[:, p:]
143
144 rows = []
145 rid = 0
146 for i in range(N):
147 variants = [("A", -1, U_A[i].copy()), ("B", -1, U_B[i].copy())]
148 for j in range(p):
149 ab = U_A[i].copy()
150 ab[j] = U_B[i][j]
151 variants.append(("AB", j, ab))
152 for matrix, j, u in variants:
153 row = {"row_id": rid, "matrix": matrix, "base_idx": i, "param_j": j}
154 for k, par in enumerate(params):
155 v = to_physical(np.array([u[k]]), par)[0]
156 row[par["key"]] = int(v) if par["prior"] == "INT" else float(v)
157 rows.append(row)
158 rid += 1
159 return rows
160
161
162def git_sha() -> str:
163 try:
164 return subprocess.check_output(["git", "rev-parse", "HEAD"],
165 cwd=str(HERE), stderr=subprocess.DEVNULL,
166 text=True).strip()
167 except Exception:
168 return "unknown"
169
170
171def main(argv=None):
172 ap = argparse.ArgumentParser(description=__doc__,
173 formatter_class=argparse.RawDescriptionHelpFormatter)
174 ap.add_argument("--tier", type=int, choices=(2, 3), required=True)
175 ap.add_argument("--N", type=int, default=16, help="base samples (power of 2)")
176 ap.add_argument("--seed", type=int, default=20260801)
177 ap.add_argument("--conditions", nargs="+", default=["N2", "N5", "I1"],
178 help="condition ids; the design is replicated across these")
179 ap.add_argument("--out", type=Path, required=True)
180 ap.add_argument("--spec", type=Path, default=None)
181 ap.add_argument("--exclude", nargs="*", default=[],
182 help="parameters this campaign HOLDS FIXED rather than "
183 "samples. Recorded in the .meta.json so the exclusion "
184 "travels with the design. Rev 6: i_mobile v_mobile.")
185 a = ap.parse_args(argv)
186
187 spec = load_spec(a.spec)
188 params = apply_exclusions(active_params(spec, a.tier), a.exclude)
189 p = len(params)
190
191 pending = [q["key"] for q in params if q.get("REVISION_PENDING")]
192 if pending:
193 print(f" *** {len(pending)} parameter(s) marked REVISION_PENDING: "
194 f"{', '.join(pending)}", file=sys.stderr)
195 print(" Their ranges predate the 2026-08-01 absorption-gate work. "
196 "Fix parameters.json before a production campaign.",
197 file=sys.stderr)
198
199 rows = saltelli_rows(a.N, params, a.seed)
200 per_cond = len(rows)
201
202 # replicate across conditions, keeping row_id globally unique and stable
203 out_rows = []
204 rid = 0
205 for cond in a.conditions:
206 for r in rows:
207 rr = dict(r)
208 rr["condition"] = cond
209 rr["row_id"] = rid
210 rr["cond_row_id"] = r["row_id"] # id WITHIN the condition block
211 rid += 1
212 out_rows.append(rr)
213
214 a.out.parent.mkdir(parents=True, exist_ok=True)
215 cols = (["row_id", "condition", "cond_row_id", "matrix", "base_idx", "param_j"]
216 + [q["key"] for q in params])
217 with a.out.open("w", encoding="utf-8", newline="") as fh:
218 fh.write(",".join(cols) + "\n")
219 for r in out_rows:
220 fh.write(",".join(
221 (f"{r[c]:.17g}" if isinstance(r[c], float) else str(r[c]))
222 for c in cols) + "\n")
223
224 digest = hashlib.sha256(a.out.read_bytes()).hexdigest()
225 meta = {
226 "tier": a.tier, "N": a.N, "p": p, "seed": a.seed,
227 "excluded": list(a.exclude),
228 "conditions": a.conditions,
229 "rows_per_condition": per_cond, "rows_total": len(out_rows),
230 "parameters": [q["key"] for q in params],
231 "parameters_version": spec.get("version"),
232 "revision_pending": pending,
233 "design_sha256": digest,
234 "git_sha": git_sha(),
235 "estimator": "Saltelli; S_j pairs A_i with AB_i^(j), S_T_j pairs B_i with AB_i^(j)",
236 }
237 a.out.with_suffix(".meta.json").write_text(json.dumps(meta, indent=2),
238 encoding="utf-8")
239 print(f" wrote {a.out} ({len(out_rows)} rows = "
240 f"{a.N}*({p}+2)*{len(a.conditions)} conditions)")
241 print(f" sha256 {digest[:16]}... p={p} N={a.N}")
242 print(f" per machine (4-way): ~{len(out_rows)//4} rows each")
243 return 0
244
245
246if __name__ == "__main__":
247 raise SystemExit(main())
np.ndarray to_physical(np.ndarray u, dict p)
Definition design.py:103
dict load_spec(Path|None path=None)
Definition design.py:51
list[dict] apply_exclusions(list[dict] params, list[str] exclude)
Definition design.py:78
saltelli_rows(int N, list[dict] params, int seed)
Definition design.py:127
list[dict] active_params(dict spec, int tier)
Definition design.py:66
str git_sha()
Definition design.py:162
main(argv=None)
Definition design.py:171