Skip to content

Commit 8fe6ae4

Browse files
committed
0D validity study
1 parent 979e81e commit 8fe6ae4

6 files changed

Lines changed: 328 additions & 20 deletions

File tree

examples/sparging_standard.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,23 @@
2323

2424
# standard_input = get_sim_input_standard()
2525
my_input = get_sim_input_LIBRA_Pi()
26+
# my_func = my_input.h_l
27+
# my_input.h_l = lambda z: my_func(z) * 9.4
2628

2729

2830
print(f"Pi = {my_input.get_Pi_number():.2f}")
2931
print(f"steady state c_T2 = {my_input.get_c_T2_SS():.2e}")
3032
print(f"Bo = {my_input.get_Bo():.2e}")
3133

34+
tau_ave = my_input.get_tau_ave()
35+
print(f"tau_ave = {tau_ave:~.2e}")
36+
3237
my_input.c_T2_init = 3e-11 * ureg.molT2 / ureg.m**3
3338

3439

3540
my_simulation = Simulation(
3641
my_input,
37-
t_final=3 * ureg.days,
42+
t_final=5 * tau_ave,
3843
dispersion_on=True,
3944
constant_profiles=False,
4045
)
@@ -55,15 +60,15 @@
5560
"h_l",
5661
]
5762

58-
tau_pred = my_input.get_tau()
59-
dt = (tau_pred * 0.02).to(
63+
tau_pred = my_input.get_tau_ave()
64+
dt = (tau_pred * 0.005).to(
6065
"s"
6166
) # 2% of tau_pred gives 1% error on tau compared to fine mesh
62-
dx = my_input.dx_from_Pe(2) # grid Pe = 2
63-
my_input.K_s *= 1e-2
67+
dx = my_input.dx_from_Pe(0.1) # grid Pe = 2
68+
my_input.K_s *= 0.36
6469
print(f"dx={dx:~.2e}, dt={dt:~.2e}")
6570
t_start = time.perf_counter()
66-
output = my_simulation.solve(dt=dt, dx=dx, verbose=True)
71+
output = my_simulation.solve(dt=dt, dx=dx, verbose=False)
6772
elapsed = time.perf_counter() - t_start
6873
print(f"solve() took {elapsed:.1f} s")
6974

paper/generate_paper_data.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,29 @@
2121
spatial profiles), plus, for a manual post-run sanity check, the inventory decay
2222
series of the finest temporal run of each regime.
2323
24+
Also exposes `generate_validity_data`, which produces the data for the 0D
25+
approximation validity study (see its docstring below).
26+
2427
Run with::
2528
2629
python paper/generate_paper_data.py
2730
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/``.
2933
"""
3034

3135
from __future__ import annotations
3236

3337
import json
3438
import logging
39+
import multiprocessing as mp
3540
import time
3641
import warnings
3742
from datetime import datetime
3843
from pathlib import Path
3944

4045
import numpy as np
46+
import pandas as pd
4147

4248
from sparging import get_sim_input_LIBRA_Pi, Simulation, ureg
4349
from sparging import helpers
@@ -231,6 +237,162 @@ def convergence_study(out_dir: Path = OUT_DIR) -> Path:
231237
return out_path
232238

233239

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+
234396
if __name__ == "__main__":
235397
logging.basicConfig(
236398
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"

src/sparging/input_examples.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ def get_sim_input_LIBRA_Pi() -> SimulationInput:
110110
"""
111111
geom = ColumnGeometry(
112112
area=0.13 * ureg.m**2,
113-
height=0.93 * ureg.m,
113+
# height=0.93 * ureg.m,
114+
height=0.43 * ureg.m,
114115
nozzle_diameter=0.002 * ureg.m,
115116
nb_nozzle=4 * ureg.dimensionless,
116117
)

src/sparging/inputs.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,22 +172,23 @@ def get_tau(self) -> pint.Quantity:
172172
"""characteristic time of the sparger under the small partial pressure (SPP) approximation"""
173173
return (self.eps_l0 / (self.h_l0 * self.a_0)).to("seconds")
174174

175-
def get_tau_ave(self) -> pint.Quantity:
176-
"""characteristic time computed from a, h_l and eps_l profiles integrated
177-
over the tank height, instead of evaluated at the bottom (z=0)."""
175+
def _height_integral(
176+
self, profile: Callable[[pint.Quantity], pint.Quantity], unit: str
177+
) -> pint.Quantity:
178+
"""integral of a z-dependent profile (evaluated in `unit`) over [0, height]."""
178179
from scipy.integrate import quad
179180

180-
def eps_l_integrand(z_m: float) -> float:
181-
return (1 - self.eps_g(z_m * ureg.m)).to("dimensionless").magnitude
182-
183-
def a_h_l_integrand(z_m: float) -> float:
184-
z = z_m * ureg.m
185-
return (self.a(z) * self.h_l(z)).to("1/s").magnitude
181+
def integrand(z_m: float) -> float:
182+
return profile(z_m * ureg.m).to(unit).magnitude
186183

187184
height_m = self.height.to("m").magnitude
188-
int_eps_l = quad(eps_l_integrand, 0, height_m)[0] * ureg.m
189-
int_a_h_l = quad(a_h_l_integrand, 0, height_m)[0] * ureg("m/s")
185+
return quad(integrand, 0, height_m)[0] * ureg(unit) * ureg.m
190186

187+
def get_tau_ave(self) -> pint.Quantity:
188+
"""characteristic time computed from a, h_l and eps_l profiles integrated
189+
over the tank height, instead of evaluated at the bottom (z=0)."""
190+
int_eps_l = self._height_integral(lambda z: 1 - self.eps_g(z), "dimensionless")
191+
int_a_h_l = self._height_integral(lambda z: self.a(z) * self.h_l(z), "1/s")
191192
return (int_eps_l / int_a_h_l).to("seconds")
192193

193194
def get_c_T2_SS(self) -> pint.Quantity:
@@ -207,6 +208,36 @@ def get_Pi_number(self) -> pint.Quantity:
207208
/ (self.eps_g0 * self.graph.nodes["v_g0"]["value"])
208209
).to("dimensionless")
209210

211+
def get_Pi_ave(self) -> pint.Quantity:
212+
"""Partial pressure number computed from a, h_l and u_g profiles integrated
213+
over the tank height, instead of evaluated at the bottom (z=0). Consistent
214+
with get_tau_ave() (same height-averaged a*h_l)."""
215+
int_a_h_l = self._height_integral(lambda z: self.a(z) * self.h_l(z), "1/s")
216+
int_u_g = self._height_integral(self.u_g, "m/s")
217+
a_h_l_ave = int_a_h_l / self.height
218+
u_g_ave = int_u_g / self.height
219+
return (
220+
self.K_s
221+
* (const_R * self.temperature)
222+
* self.height
223+
* a_h_l_ave
224+
/ u_g_ave
225+
).to("dimensionless")
226+
227+
def get_G_mix_pred(self) -> pint.Quantity:
228+
"""Predicted mixing number: ratio of the liquid dispersive mixing time
229+
scale (H^2/E_l) to the height-averaged extraction time tau_pred_ave.
230+
Governs the "perfectly mixed liquid" assumption. "Predicted" since it
231+
uses the analytical tau_pred_ave rather than a simulated/fitted tau."""
232+
return ((self.height**2 / self.E_l) / self.get_tau_ave()).to("dimensionless")
233+
234+
def get_G_P(self) -> pint.Quantity:
235+
"""Relative hydrostatic pressure variation along the tank height
236+
(P_bottom - P_top) / P_top. Governs the "constant hydrodynamic
237+
parameters along z" assumption."""
238+
P_top = self.P_l(self.height)
239+
return ((self.P_l0 - P_top) / P_top).to("dimensionless")
240+
210241
def get_dP_dx(self) -> pint.Quantity:
211242
"""
212243
returns 1/c_T * dP_T/dx in the SPP approximation (linearized around P_T = 0))

0 commit comments

Comments
 (0)