|
21 | 21 | spatial profiles), plus, for a manual post-run sanity check, the inventory decay |
22 | 22 | series of the finest temporal run of each regime. |
23 | 23 |
|
| 24 | +Also exposes `generate_validity_data`, which produces the data for the 0D |
| 25 | +approximation validity study (see its docstring below). |
| 26 | +
|
24 | 27 | Run with:: |
25 | 28 |
|
26 | 29 | python paper/generate_paper_data.py |
27 | 30 |
|
28 | | -Output is written to ``paper/runs/convergence_study/``. |
| 31 | +Output is written to ``paper/runs/convergence_study/`` and |
| 32 | +``paper/runs/0D_validity_study/``. |
29 | 33 | """ |
30 | 34 |
|
31 | 35 | from __future__ import annotations |
32 | 36 |
|
33 | 37 | import json |
34 | 38 | import logging |
| 39 | +import multiprocessing as mp |
35 | 40 | import time |
36 | 41 | import warnings |
37 | 42 | from datetime import datetime |
38 | 43 | from pathlib import Path |
39 | 44 |
|
40 | 45 | import numpy as np |
| 46 | +import pandas as pd |
41 | 47 |
|
42 | 48 | from sparging import get_sim_input_LIBRA_Pi, Simulation, ureg |
43 | 49 | from sparging import helpers |
@@ -231,6 +237,162 @@ def convergence_study(out_dir: Path = OUT_DIR) -> Path: |
231 | 237 | return out_path |
232 | 238 |
|
233 | 239 |
|
| 240 | +# --------------------------------------------------------------------------- |
| 241 | +# 0D approximation validity study |
| 242 | +# --------------------------------------------------------------------------- |
| 243 | +""" |
| 244 | +Quantifies when the 0D analytical approximation of the extraction time |
| 245 | +(SimulationInput.get_tau / get_tau_ave) is valid, by comparing it against the |
| 246 | +full 1D ARD model over a sampled space, and correlating the disagreement with |
| 247 | +the dimensionless groups governing each simplifying assumption: Pi (negligible |
| 248 | +interfacial partial pressure / solubility), G_mix_pred (perfectly mixed |
| 249 | +liquid) and G_P (constant hydrodynamic parameters along z). |
| 250 | +
|
| 251 | +Two things are varied, log-uniformly over +-DECADES around nominal LIBRA-Pi |
| 252 | +values: the product h_l*K_s (via a multiplier applied to a coin-flip choice of |
| 253 | +h_l or K_s -- Pi depends on the product, tau_pred depends only on h_l, so |
| 254 | +comparing the two populations tells apart Pi-controlled behaviour from a pure |
| 255 | +h_l effect), and the tank height H (area held fixed). This is a numerical- |
| 256 | +validity study, not a LIBRA-Pi operating study: sampled values may be |
| 257 | +unphysical for the real experiment, that is fine and intended. |
| 258 | +
|
| 259 | +Run with:: |
| 260 | +
|
| 261 | + python -c "from paper.generate_paper_data import generate_validity_data; generate_validity_data()" |
| 262 | +
|
| 263 | +Output is written to ``paper/runs/0D_validity_study/``. |
| 264 | +""" |
| 265 | + |
| 266 | +OUT_DIR_VALIDITY = Path("paper/runs/0D_validity_study") |
| 267 | + |
| 268 | +N_SAMPLES = 300 |
| 269 | +SEED = 42 |
| 270 | +DECADES = 2.0 # log-uniform sampling range (+-) for both the h_l*K_s multiplier and H |
| 271 | +T_FINAL_IN_TAU = 2 # simulate 2x tau_pred_ave -- enough to see the decay |
| 272 | +DT_FRACTION_OF_TAU = 0.01 # dt = tau_pred_ave * this |
| 273 | +MESH_PE = 2 # mesh Peclet number for dx (matches examples/sparging_standard.py) |
| 274 | +MIN_CELLS = 20 # floor on n_cells: dx_from_Pe(MESH_PE) does not scale with H, so |
| 275 | +# small-H samples would otherwise plan for very few mesh cells |
| 276 | +OTHER_GROUP_THRESHOLD = 0.1 # "other groups < 0.1" highlighting rule used in the plots |
| 277 | +RMSE_FLAG_THRESHOLD = 1e-5 # normalized-RMSE above this -> non-exponential decay |
| 278 | + |
| 279 | + |
| 280 | +def _make_validity_input(height: "ureg.Quantity", factor: str, multiplier: float): |
| 281 | + """LIBRA-Pi-like input with height `height` (area fixed at the nominal |
| 282 | + value) and h_l or K_s scaled by `multiplier`. Both the profile (h_l) and |
| 283 | + scalar (K_s) overrides are read fresh by every downstream getter and by |
| 284 | + Simulation.solve(), so the change is automatically consistent everywhere |
| 285 | + (ARD coefficients, tau_pred, Pi, ...).""" |
| 286 | + from sparging import ( |
| 287 | + SimulationInput, |
| 288 | + LIBRA_PI_GEOM, |
| 289 | + LIBRA_PI_MAT, |
| 290 | + LIBRA_PI_OPERATING_PARAMS, |
| 291 | + LIBRA_PI_SPARGING_PARAMS, |
| 292 | + ) |
| 293 | + |
| 294 | + geom = LIBRA_PI_GEOM.copy() |
| 295 | + geom.height = height |
| 296 | + inp = SimulationInput.from_parameters( |
| 297 | + geom, |
| 298 | + LIBRA_PI_MAT.copy(), |
| 299 | + LIBRA_PI_OPERATING_PARAMS.copy(), |
| 300 | + LIBRA_PI_SPARGING_PARAMS.copy(), |
| 301 | + ) |
| 302 | + inp.c_T2_init = C_T2_INIT |
| 303 | + if factor == "h_l": |
| 304 | + base_h_l = inp.h_l |
| 305 | + inp.h_l = lambda z, _f=base_h_l, _m=multiplier: _f(z) * _m |
| 306 | + else: |
| 307 | + inp.K_s = inp.K_s * multiplier |
| 308 | + return inp |
| 309 | + |
| 310 | + |
| 311 | +def _draw_sample_specs(n_samples: int, seed: int) -> list[dict]: |
| 312 | + """Draw the (multiplier, factor, height) sample space. Plain-dict specs |
| 313 | + (only picklable primitives) so they can be sent to a worker process.""" |
| 314 | + rng = np.random.default_rng(seed) |
| 315 | + H_nominal = get_sim_input_LIBRA_Pi().height.to("m").magnitude |
| 316 | + return [ |
| 317 | + { |
| 318 | + "sample_id": i, |
| 319 | + "multiplier": float(10 ** rng.uniform(-DECADES, DECADES)), |
| 320 | + "factor": str(rng.choice(["h_l", "K_s"])), |
| 321 | + "height_m": float(H_nominal * 10 ** rng.uniform(-DECADES, DECADES)), |
| 322 | + } |
| 323 | + for i in range(n_samples) |
| 324 | + ] |
| 325 | + |
| 326 | + |
| 327 | +def _run_one_sample(spec: dict) -> dict: |
| 328 | + """Build the input from `spec` (not a pre-built SimulationInput: profile |
| 329 | + closures aren't picklable, so a spawned worker process must rebuild the |
| 330 | + input itself), run the 1D ARD model once, and return one row of the |
| 331 | + validity dataset (see SimulationResults.validity_record).""" |
| 332 | + warnings.filterwarnings("ignore") # silence curve_fit / pint fit warnings |
| 333 | + |
| 334 | + inp = _make_validity_input( |
| 335 | + spec["height_m"] * ureg.m, spec["factor"], spec["multiplier"] |
| 336 | + ) |
| 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, |
| 344 | + ) |
| 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 | + |
| 351 | + |
| 352 | +def generate_validity_data( |
| 353 | + n_samples: int = N_SAMPLES, |
| 354 | + seed: int = SEED, |
| 355 | + n_workers: int = 4, |
| 356 | + out_dir: Path = OUT_DIR_VALIDITY, |
| 357 | +) -> Path: |
| 358 | + """Run the 0D-approximation validity study: one 1D ARD solve per sampled |
| 359 | + (h_l*K_s multiplier, height) point, in parallel, and write the results to |
| 360 | + a single CSV. Spawn-based multiprocessing: each worker rebuilds its own |
| 361 | + SimulationInput from a picklable spec, which sidesteps both the |
| 362 | + unpicklable profile closures and dolfinx/mpi4py process-fork hazards. |
| 363 | + """ |
| 364 | + out_dir.mkdir(parents=True, exist_ok=True) |
| 365 | + specs = _draw_sample_specs(n_samples, seed) |
| 366 | + |
| 367 | + t_start = time.time() |
| 368 | + with mp.get_context("spawn").Pool(n_workers) as pool: |
| 369 | + rows = list(pool.imap_unordered(_run_one_sample, specs)) |
| 370 | + elapsed = time.time() - t_start |
| 371 | + logger.info("0D validity study: %d samples in %.1f s", len(rows), elapsed) |
| 372 | + |
| 373 | + df = pd.DataFrame(rows).sort_values("sample_id").reset_index(drop=True) |
| 374 | + csv_path = out_dir / "validity_data.csv" |
| 375 | + df.to_csv(csv_path, index=False) |
| 376 | + |
| 377 | + with open(out_dir / "metadata.json", "w") as f: |
| 378 | + json.dump( |
| 379 | + { |
| 380 | + "git_commit": helpers.get_git_hash(), |
| 381 | + "date": datetime.now().isoformat(), |
| 382 | + "n_samples": len(df), |
| 383 | + "decades": DECADES, |
| 384 | + "t_final_in_tau": T_FINAL_IN_TAU, |
| 385 | + "dt_fraction_of_tau": DT_FRACTION_OF_TAU, |
| 386 | + "mesh_Pe": MESH_PE, |
| 387 | + "other_group_threshold": OTHER_GROUP_THRESHOLD, |
| 388 | + "rmse_flag_threshold": RMSE_FLAG_THRESHOLD, |
| 389 | + }, |
| 390 | + f, |
| 391 | + indent=2, |
| 392 | + ) |
| 393 | + return csv_path |
| 394 | + |
| 395 | + |
234 | 396 | if __name__ == "__main__": |
235 | 397 | logging.basicConfig( |
236 | 398 | level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" |
|
0 commit comments