|
34 | 34 |
|
35 | 35 | from __future__ import annotations |
36 | 36 |
|
| 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 | + |
37 | 50 | import json |
38 | 51 | import logging |
39 | 52 | import multiprocessing as mp |
@@ -277,6 +290,38 @@ def convergence_study(out_dir: Path = OUT_DIR) -> Path: |
277 | 290 | RMSE_FLAG_THRESHOLD = 1e-5 # normalized-RMSE above this -> non-exponential decay |
278 | 291 |
|
279 | 292 |
|
| 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 | + |
280 | 325 | def _make_validity_input(height: "ureg.Quantity", factor: str, multiplier: float): |
281 | 326 | """LIBRA-Pi-like input with height `height` (area fixed at the nominal |
282 | 327 | 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: |
334 | 379 | inp = _make_validity_input( |
335 | 380 | spec["height_m"] * ureg.m, spec["factor"], spec["multiplier"] |
336 | 381 | ) |
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, |
344 | 389 | ) |
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) |
350 | 390 |
|
351 | 391 |
|
352 | 392 | def generate_validity_data( |
@@ -393,6 +433,254 @@ def generate_validity_data( |
393 | 433 | return csv_path |
394 | 434 |
|
395 | 435 |
|
| 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 | + |
396 | 684 | if __name__ == "__main__": |
397 | 685 | logging.basicConfig( |
398 | 686 | level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" |
|
0 commit comments