Skip to content

Commit 4555fc4

Browse files
committed
sobol study
1 parent b529617 commit 4555fc4

1 file changed

Lines changed: 300 additions & 12 deletions

File tree

paper/generate_paper_data.py

Lines changed: 300 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@
3434

3535
from __future__ import annotations
3636

37+
import os
38+
39+
# Cap BLAS/OpenMP to one thread BEFORE numpy/dolfinx are imported. The studies
40+
# below fan out one (small, 1-D) solve per process with multiprocessing; without
41+
# this each worker's numpy/PETSc would spawn as many threads as there are cores,
42+
# oversubscribing the CPU (observed ~8x slowdown). setdefault -> an explicit user
43+
# setting still wins. Applies in spawned workers too (they re-import this module).
44+
for _v in (
45+
"OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
46+
"NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS",
47+
):
48+
os.environ.setdefault(_v, "1")
49+
3750
import json
3851
import logging
3952
import multiprocessing as mp
@@ -277,6 +290,38 @@ def convergence_study(out_dir: Path = OUT_DIR) -> Path:
277290
RMSE_FLAG_THRESHOLD = 1e-5 # normalized-RMSE above this -> non-exponential decay
278291

279292

293+
def _solve_and_record(
294+
inp,
295+
sample: dict,
296+
*,
297+
dt_fraction: float,
298+
t_final_in_tau: float,
299+
mesh_pe: float,
300+
min_cells: int,
301+
) -> dict:
302+
"""Discretise adaptively from the sample's own analytical time scale and mesh
303+
Peclet number, run the 1D ARD model once, and return one `validity_record`
304+
row. Shared by the 0D-validity and Sobol studies.
305+
306+
- dt = dt_fraction * tau_pred_ave
307+
- t_final = t_final_in_tau * tau_pred_ave
308+
- dx = dx_from_Pe(mesh_pe), floored at `min_cells` cells (dx_from_Pe
309+
does not scale with H, so small-H samples need a cell floor)
310+
"""
311+
tau_ave = inp.get_tau_ave()
312+
dt = (tau_ave * dt_fraction).to("s")
313+
t_final = (t_final_in_tau * tau_ave).to("s")
314+
n_cells = max(
315+
int(round((inp.height / inp.dx_from_Pe(mesh_pe)).to("dimensionless").magnitude)),
316+
min_cells,
317+
)
318+
dx = (inp.height / n_cells).to("m")
319+
320+
sim = Simulation(inp, t_final=t_final, dispersion_on=True, constant_profiles=False)
321+
out = sim.solve(dt=dt, dx=dx)
322+
return out.validity_record(sample=sample, t_0=0 * ureg.s, t_end=t_final)
323+
324+
280325
def _make_validity_input(height: "ureg.Quantity", factor: str, multiplier: float):
281326
"""LIBRA-Pi-like input with height `height` (area fixed at the nominal
282327
value) and h_l or K_s scaled by `multiplier`. Both the profile (h_l) and
@@ -334,19 +379,14 @@ def _run_one_sample(spec: dict) -> dict:
334379
inp = _make_validity_input(
335380
spec["height_m"] * ureg.m, spec["factor"], spec["multiplier"]
336381
)
337-
338-
tau_pred_ave = inp.get_tau_ave()
339-
dt = (tau_pred_ave * DT_FRACTION_OF_TAU).to("s")
340-
t_final = (T_FINAL_IN_TAU * tau_pred_ave).to("s")
341-
n_cells = max(
342-
int(round((inp.height / inp.dx_from_Pe(MESH_PE)).to("dimensionless").magnitude)),
343-
MIN_CELLS,
382+
return _solve_and_record(
383+
inp,
384+
spec,
385+
dt_fraction=DT_FRACTION_OF_TAU,
386+
t_final_in_tau=T_FINAL_IN_TAU,
387+
mesh_pe=MESH_PE,
388+
min_cells=MIN_CELLS,
344389
)
345-
dx = (inp.height / n_cells).to("m")
346-
347-
sim = Simulation(inp, t_final=t_final, dispersion_on=True, constant_profiles=False)
348-
out = sim.solve(dt=dt, dx=dx)
349-
return out.validity_record(sample=spec, t_0=0 * ureg.s, t_end=t_final)
350390

351391

352392
def generate_validity_data(
@@ -393,6 +433,254 @@ def generate_validity_data(
393433
return csv_path
394434

395435

436+
# ---------------------------------------------------------------------------
437+
# Sobol sensitivity study (operating / design inputs -> fitted tau)
438+
# ---------------------------------------------------------------------------
439+
"""
440+
Global (variance-based) sensitivity of the fitted extraction time tau_fitted to
441+
the four controllable inputs -- temperature, gas flow rate, top pressure and
442+
sparger nozzle diameter -- over the LIBRA-Pi design envelope. Total-order Sobol
443+
indices are estimated with the Saltelli scheme via scipy.stats.sobol_indices.
444+
445+
Design/evaluate/estimate are split: this module only generates the Saltelli
446+
design and evaluates the 1D ARD model at every design point (in parallel),
447+
writing one CSV row per run (same schema as the 0D-validity study, so the two
448+
datasets are interchangeable and reusable, e.g. to train a surrogate). The
449+
indices themselves are computed downstream in paper_plots.ipynb by feeding the
450+
precomputed outputs back to sobol_indices -- which also enables restarting a
451+
crashed batch and the n = 64/128/256/512 convergence check, both from the CSV.
452+
453+
Height and base area are held at their nominal LIBRA-Pi values; every other
454+
closure parameter (K_s, h_l, a, eps_g, u_g, E_g, E_l, ...) follows from the four
455+
sampled inputs through the correlation graph.
456+
457+
Run with::
458+
459+
python -c "from paper.generate_paper_data import sobol_study; sobol_study()"
460+
461+
Output is written to ``paper/runs/sobol_input_params/``.
462+
"""
463+
464+
OUT_DIR_SOBOL = Path("paper/runs/sobol_input_params")
465+
466+
SOBOL_N_BASE = 512 # base samples N (power of 2); total runs = N * (d + 2)
467+
SOBOL_SEED = 2024
468+
SOBOL_DT_FRACTION = 0.02 # dt = tau_pred_ave * this
469+
SOBOL_T_FINAL_IN_TAU = 2 # simulate 2x tau_pred_ave
470+
# prefix sizes for the convergence check (post-processing only -- any power-of-2
471+
# prefix of the N=512 design; the small ones are deliberately included to show the
472+
# estimator is noisy at low N and settles as N grows)
473+
SOBOL_CONV_SUBSETS = [8, 16, 32, 64, 128, 256, 512]
474+
475+
# The four sampled inputs, in Sobol-column order. Each is transformed from a
476+
# unit-hypercube coordinate u in [0, 1] by _transform below. Ranges span the
477+
# LIBRA-Pi design envelope; temperature is sampled uniformly in KELVIN.
478+
PARAM_SPACE = [
479+
{"name": "temperature", "csv": "temperature_K", "unit": "K",
480+
"min": 723.15, "max": 923.15, "scale": "uniform"}, # 450-650 degC
481+
{"name": "gas_flow", "csv": "gas_flow_sccm", "unit": "sccm",
482+
"min": 50.0, "max": 1000.0, "scale": "log"},
483+
{"name": "top_pressure", "csv": "top_pressure_atm", "unit": "atm",
484+
"min": 1.0, "max": 2.0, "scale": "uniform"},
485+
{"name": "nozzle_diameter", "csv": "nozzle_diameter_mm", "unit": "mm",
486+
"min": 0.5, "max": 5.0, "scale": "log"},
487+
]
488+
489+
490+
def _transform(u: float, spec: dict) -> float:
491+
"""Map a unit-hypercube coordinate u in [0, 1] to a physical value, either
492+
uniformly or log-uniformly between spec['min'] and spec['max']."""
493+
lo, hi = spec["min"], spec["max"]
494+
if spec["scale"] == "log":
495+
return float(10 ** (np.log10(lo) + u * (np.log10(hi) - np.log10(lo))))
496+
return float(lo + u * (hi - lo))
497+
498+
499+
def param_space_with(**overrides) -> list[dict]:
500+
"""Copy PARAM_SPACE, updating fields of the named parameters -- a compact way
501+
to define a study variant. e.g. to sample gas flow uniformly over 300-1000:
502+
param_space_with(gas_flow=dict(min=300, max=1000, scale="uniform"))
503+
"""
504+
out = []
505+
for sp in PARAM_SPACE:
506+
sp = dict(sp)
507+
if sp["name"] in overrides:
508+
sp.update(overrides[sp["name"]])
509+
out.append(sp)
510+
return out
511+
512+
513+
def _saltelli_design(n_base: int, seed: int, param_space: list[dict]) -> list[dict]:
514+
"""Build the Saltelli sample: matrices A and B from one 2d-dimensional
515+
scrambled Sobol sequence, plus d hybrid matrices AB_i (A with column i taken
516+
from B). Returns one picklable spec dict per model evaluation, tagged with
517+
its role so the outputs can be regrouped into f_A / f_B / f_AB downstream.
518+
"""
519+
from scipy.stats import qmc
520+
521+
d = len(param_space)
522+
pts = qmc.Sobol(d=2 * d, scramble=True, seed=seed).random(n_base)
523+
A, B = pts[:, :d], pts[:, d:]
524+
525+
specs = []
526+
527+
def _spec(role: str, base_j: int, var_i: int, u_row) -> None:
528+
params = {sp["csv"]: _transform(u_row[k], sp) for k, sp in enumerate(param_space)}
529+
specs.append({
530+
"sample_id": len(specs),
531+
"design_role": role, # "A", "B" or "AB"
532+
"base_index": base_j, # which base sample j (for A/B/AB_i pairing)
533+
"var_index": var_i, # AB: column swapped from B; A/B: -1
534+
**params,
535+
})
536+
537+
for j in range(n_base):
538+
_spec("A", j, -1, A[j])
539+
for j in range(n_base):
540+
_spec("B", j, -1, B[j])
541+
for i in range(d):
542+
for j in range(n_base):
543+
u = A[j].copy()
544+
u[i] = B[j, i]
545+
_spec("AB", j, i, u)
546+
return specs
547+
548+
549+
def _make_sobol_input(spec: dict):
550+
"""LIBRA-Pi-like input with the four sampled inputs applied (height and area
551+
stay nominal). Rebuilt inside the worker from picklable primitives."""
552+
from sparging import (
553+
SimulationInput,
554+
LIBRA_PI_GEOM,
555+
LIBRA_PI_MAT,
556+
LIBRA_PI_OPERATING_PARAMS,
557+
LIBRA_PI_SPARGING_PARAMS,
558+
)
559+
560+
geom = LIBRA_PI_GEOM.copy()
561+
geom.nozzle_diameter = spec["nozzle_diameter_mm"] * ureg.mm
562+
op = LIBRA_PI_OPERATING_PARAMS.copy()
563+
op.temperature = spec["temperature_K"] * ureg.K
564+
op.ndot_g0 = spec["gas_flow_sccm"] * ureg.sccm
565+
op.P_top = spec["top_pressure_atm"] * ureg.atm
566+
567+
inp = SimulationInput.from_parameters(
568+
geom, LIBRA_PI_MAT.copy(), op, LIBRA_PI_SPARGING_PARAMS.copy()
569+
)
570+
inp.c_T2_init = C_T2_INIT
571+
return inp
572+
573+
574+
def _run_one_sample_sobol(spec: dict) -> dict:
575+
"""Rebuild the input from `spec`, solve once, and return one CSV row
576+
(validity_record schema + design tags + per-sample solve time)."""
577+
warnings.filterwarnings("ignore") # silence curve_fit / pint fit warnings
578+
t0 = time.time()
579+
inp = _make_sobol_input(spec)
580+
rec = _solve_and_record(
581+
inp,
582+
spec,
583+
dt_fraction=SOBOL_DT_FRACTION,
584+
t_final_in_tau=SOBOL_T_FINAL_IN_TAU,
585+
mesh_pe=MESH_PE,
586+
min_cells=MIN_CELLS,
587+
)
588+
rec["solve_time_s"] = time.time() - t0
589+
return rec
590+
591+
592+
def sobol_study(
593+
n_base: int = SOBOL_N_BASE,
594+
seed: int = SOBOL_SEED,
595+
n_workers: int = 8,
596+
out_dir: Path = OUT_DIR_SOBOL,
597+
param_space: list[dict] = PARAM_SPACE,
598+
) -> Path:
599+
"""Generate the Saltelli design and evaluate the 1D ARD model at every point
600+
in parallel, writing sobol_data.csv + metadata.json. Prints each sample's
601+
solve time and a running estimate of the total time.
602+
603+
Pass a `param_space` (e.g. from `param_space_with(...)`) and a distinct
604+
`out_dir` to run a sampling-space variant without overwriting a previous run.
605+
"""
606+
import scipy
607+
608+
out_dir.mkdir(parents=True, exist_ok=True)
609+
specs = _saltelli_design(n_base, seed, param_space)
610+
n_total = len(specs)
611+
d = len(param_space)
612+
# convergence prefixes are post-processing only; keep those that fit in N
613+
conv_subsets = [s for s in SOBOL_CONV_SUBSETS if s <= n_base]
614+
logger.info(
615+
"Sobol study: d=%d inputs, N_base=%d -> %d runs on %d workers",
616+
d, n_base, n_total, n_workers,
617+
)
618+
619+
rows, times = [], []
620+
t_start = time.time()
621+
with mp.get_context("spawn").Pool(n_workers) as pool:
622+
for k, rec in enumerate(pool.imap_unordered(_run_one_sample_sobol, specs), 1):
623+
rows.append(rec)
624+
times.append(rec["solve_time_s"])
625+
avg = float(np.mean(times))
626+
eta_min = (n_total - k) * avg / n_workers / 60
627+
logger.info(
628+
"[%4d/%d] id=%4d %-2s var=%+d : %5.1fs (avg %.1fs, ETA %.0f min)",
629+
k, n_total, rec["sample_id"], rec["design_role"],
630+
rec["var_index"], rec["solve_time_s"], avg, eta_min,
631+
)
632+
elapsed = time.time() - t_start
633+
logger.info("Sobol study: %d runs in %.0f s (%.1f min)", n_total, elapsed, elapsed / 60)
634+
635+
df = pd.DataFrame(rows).sort_values("sample_id").reset_index(drop=True)
636+
csv_path = out_dir / "sobol_data.csv"
637+
df.to_csv(csv_path, index=False)
638+
639+
from sparging import LIBRA_PI_GEOM
640+
641+
base = get_sim_input_LIBRA_Pi()
642+
with open(out_dir / "metadata.json", "w") as f:
643+
json.dump(
644+
{
645+
"git_commit": helpers.get_git_hash(),
646+
"date": datetime.now().isoformat(),
647+
"description": (
648+
"Total-order Sobol sensitivity of tau_fitted to 4 operating/"
649+
"design inputs; Saltelli design, scipy.stats.sobol_indices."
650+
),
651+
"qoi": "tau_fitted_s",
652+
"estimator": "scipy.stats.sobol_indices (saltelli_2010)",
653+
"scipy_version": scipy.__version__,
654+
"n_base_samples": n_base,
655+
"d": d,
656+
"n_total_runs": n_total,
657+
"seed": seed,
658+
"convergence_subsets": conv_subsets,
659+
"param_space": [{**sp, "sobol_column": i} for i, sp in enumerate(param_space)],
660+
"fixed_params": {
661+
"height_m": float(base.height.to("m").magnitude),
662+
"area_m2": float(base.area.to("m**2").magnitude),
663+
"nb_nozzle": float(LIBRA_PI_GEOM.nb_nozzle.magnitude),
664+
},
665+
"discretization": {
666+
"dt_fraction_of_tau": SOBOL_DT_FRACTION,
667+
"t_final_in_tau": SOBOL_T_FINAL_IN_TAU,
668+
"mesh_Pe": MESH_PE,
669+
"min_cells": MIN_CELLS,
670+
},
671+
"design_note": (
672+
"rows tagged design_role in {A,B,AB} + base_index + var_index; "
673+
"f_AB[i] = A with column i replaced by B's column i"
674+
),
675+
"elapsed_s": elapsed,
676+
},
677+
f,
678+
indent=2,
679+
)
680+
logger.info("wrote %s and metadata.json", csv_path)
681+
return csv_path
682+
683+
396684
if __name__ == "__main__":
397685
logging.basicConfig(
398686
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"

0 commit comments

Comments
 (0)