mantis-rtm 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mantis/__init__.py +5 -0
- mantis/_version.py +24 -0
- mantis/diff_lib.py +1937 -0
- mantis/generate_equation_system.py +1975 -0
- mantis/live_plot_lib.py +623 -0
- mantis/model.py +942 -0
- mantis/monitor_plots.py +171 -0
- mantis/monolithic_solver.py +904 -0
- mantis/parallel_video.py +523 -0
- mantis/plot_data_new.py +1363 -0
- mantis/solvers_numba.py +219 -0
- mantis_rtm-0.1.1.dev0.dist-info/METADATA +175 -0
- mantis_rtm-0.1.1.dev0.dist-info/RECORD +17 -0
- mantis_rtm-0.1.1.dev0.dist-info/WHEEL +5 -0
- mantis_rtm-0.1.1.dev0.dist-info/entry_points.txt +6 -0
- mantis_rtm-0.1.1.dev0.dist-info/licenses/LICENSE +661 -0
- mantis_rtm-0.1.1.dev0.dist-info/top_level.txt +1 -0
mantis/model.py
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
"""model.py
|
|
2
|
+
|
|
3
|
+
Unified, declarative DiageneticModel interface for MANTIS reaction-transport simulations.
|
|
4
|
+
|
|
5
|
+
Provides a clean object-oriented entry point that:
|
|
6
|
+
- Automatically resolves paths relative to the calling script.
|
|
7
|
+
- Centralizes species, phases, dynamic speciation, and default boundary conditions.
|
|
8
|
+
- Extracts pH from species.py (Hplus['bc']['pH']) as a single source of truth.
|
|
9
|
+
- Sets bioirrigation explicitly to zero by default.
|
|
10
|
+
- Provides built-in CLI argument parsing (--help, --solver, --video).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import importlib.util
|
|
17
|
+
import inspect
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pint
|
|
25
|
+
|
|
26
|
+
from mantis.diff_lib import (
|
|
27
|
+
VariableArray as CellVariable,
|
|
28
|
+
check_peclet_numbers,
|
|
29
|
+
compute_bio_irrigation_alpha,
|
|
30
|
+
compute_sigmoidal_db,
|
|
31
|
+
data_container,
|
|
32
|
+
diff_coeff,
|
|
33
|
+
get_l_mass,
|
|
34
|
+
get_total_delta,
|
|
35
|
+
make_grid2,
|
|
36
|
+
read_state,
|
|
37
|
+
save_data,
|
|
38
|
+
save_state,
|
|
39
|
+
)
|
|
40
|
+
from mantis.generate_equation_system import resolve_and_prepare_equations
|
|
41
|
+
from mantis.live_plot_lib import LivePlotter
|
|
42
|
+
from mantis.monolithic_solver import run_non_steady_state_solver_monolithic as run_solver
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DiageneticModelResult:
|
|
46
|
+
"""Encapsulates the output of a completed DiageneticModel simulation."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
mp: data_container,
|
|
51
|
+
c: data_container,
|
|
52
|
+
k: data_container,
|
|
53
|
+
species_list: List[str],
|
|
54
|
+
z: np.ndarray,
|
|
55
|
+
D_mol: data_container,
|
|
56
|
+
diagenetic_reactions: Any,
|
|
57
|
+
converged: str,
|
|
58
|
+
step: int,
|
|
59
|
+
total_time: float,
|
|
60
|
+
df: Any = None,
|
|
61
|
+
fqfn: Optional[str] = None,
|
|
62
|
+
):
|
|
63
|
+
self.mp = mp
|
|
64
|
+
self.c = c
|
|
65
|
+
self.k = k
|
|
66
|
+
self.species_list = species_list
|
|
67
|
+
self.z = z
|
|
68
|
+
self.D_mol = D_mol
|
|
69
|
+
self.diagenetic_reactions = diagenetic_reactions
|
|
70
|
+
self.converged = converged
|
|
71
|
+
self.step = step
|
|
72
|
+
self.total_time = total_time
|
|
73
|
+
self.df = df
|
|
74
|
+
self.fqfn = fqfn
|
|
75
|
+
|
|
76
|
+
def save_csv(self) -> Tuple[Any, str]:
|
|
77
|
+
"""Saves tabular concentration and rate data to CSV."""
|
|
78
|
+
df, fqfn = save_data(
|
|
79
|
+
self.mp, self.c, self.k, self.species_list, self.z, self.D_mol, self.diagenetic_reactions
|
|
80
|
+
)
|
|
81
|
+
self.df = df
|
|
82
|
+
self.fqfn = fqfn
|
|
83
|
+
return df, fqfn
|
|
84
|
+
|
|
85
|
+
def save_state(self, filepath: Optional[Union[str, Path]] = None) -> str:
|
|
86
|
+
"""Saves complete CellVariable array state to a compressed .npz archive."""
|
|
87
|
+
if filepath is None:
|
|
88
|
+
filepath = f"{self.mp.plot_name}_state.npz"
|
|
89
|
+
filepath_str = str(filepath)
|
|
90
|
+
save_state(self.c, filepath_str)
|
|
91
|
+
return filepath_str
|
|
92
|
+
|
|
93
|
+
def plot_summary(self, layout_file: Optional[Union[str, Path]] = None, outfile: Optional[str] = None) -> Optional[str]:
|
|
94
|
+
"""Renders the final summary plot PDF."""
|
|
95
|
+
import mantis.plot_data_new as pdn
|
|
96
|
+
layout = layout_file or getattr(self.mp, "layout_file", "plot_layout.py")
|
|
97
|
+
if not Path(layout).exists() and hasattr(self.mp, "_caller_dir"):
|
|
98
|
+
candidate = Path(self.mp._caller_dir) / Path(layout).name
|
|
99
|
+
if candidate.exists():
|
|
100
|
+
layout = str(candidate)
|
|
101
|
+
if Path(layout).exists() and self.df is not None:
|
|
102
|
+
pdf_out = outfile or f"{self.mp.plot_name}.pdf"
|
|
103
|
+
try:
|
|
104
|
+
plt_desc = pdn.load_layout_from_file(self.df, layout)
|
|
105
|
+
pdn.plot(self.df, self.mp.max_depth, outfile=pdf_out, show=False, plot_description=plt_desc)
|
|
106
|
+
return pdf_out
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"[DiageneticModel] Plot rendering skipped or failed: {e}")
|
|
109
|
+
return None
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DiageneticModel:
|
|
114
|
+
"""Declarative Diagenetic Model Runner."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
species: Union[str, Path] = "species.py",
|
|
119
|
+
reactions: Union[str, Path] = "reactions.py",
|
|
120
|
+
constants: Union[str, Path] = "reaction_constants.py",
|
|
121
|
+
equations: Optional[Union[str, Path]] = None,
|
|
122
|
+
limiters: Optional[Union[str, Path]] = None,
|
|
123
|
+
layout: Union[str, Path] = "plot_layout.py",
|
|
124
|
+
experiment: Optional[str] = None,
|
|
125
|
+
base_dir: Optional[Union[str, Path]] = None,
|
|
126
|
+
):
|
|
127
|
+
# 1. Determine caller directory and caller frame automatically
|
|
128
|
+
caller_frame = None
|
|
129
|
+
for frame_info in inspect.stack()[1:]:
|
|
130
|
+
frame_file = frame_info.filename
|
|
131
|
+
frame_name = Path(frame_file).name
|
|
132
|
+
if (
|
|
133
|
+
frame_name not in ("model.py", "__init__.py")
|
|
134
|
+
and "_pytest" not in frame_file
|
|
135
|
+
and "pluggy" not in frame_file
|
|
136
|
+
and frame_name not in ("pytest", "pytest.py")
|
|
137
|
+
):
|
|
138
|
+
caller_frame = frame_info
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
if base_dir is not None:
|
|
142
|
+
self.base_dir = Path(base_dir).resolve()
|
|
143
|
+
elif caller_frame is not None and caller_frame.filename != "<stdin>":
|
|
144
|
+
self.base_dir = Path(caller_frame.filename).resolve().parent
|
|
145
|
+
else:
|
|
146
|
+
self.base_dir = Path.cwd()
|
|
147
|
+
|
|
148
|
+
# 2. Resolve filenames relative to base_dir or cwd
|
|
149
|
+
self.species_file = self._resolve(species)
|
|
150
|
+
self.reactions_file = self._resolve(reactions)
|
|
151
|
+
self.constants_file = self._resolve(constants)
|
|
152
|
+
self.equations_file = self._resolve(equations) if equations else None
|
|
153
|
+
self.limiters_file = self._resolve(limiters) if limiters else None
|
|
154
|
+
self.layout_file = self._resolve(layout)
|
|
155
|
+
|
|
156
|
+
# Derive experiment name automatically from caller filename stem
|
|
157
|
+
if experiment is not None:
|
|
158
|
+
self.experiment = experiment
|
|
159
|
+
elif caller_frame is not None and caller_frame.filename not in ("<stdin>", "") and not Path(caller_frame.filename).name.startswith("pytest"):
|
|
160
|
+
self.experiment = Path(caller_frame.filename).stem
|
|
161
|
+
elif sys.argv and sys.argv[0] and not Path(sys.argv[0]).name.startswith("pytest"):
|
|
162
|
+
self.experiment = Path(sys.argv[0]).stem
|
|
163
|
+
else:
|
|
164
|
+
self.experiment = "diagenetic_run"
|
|
165
|
+
|
|
166
|
+
self.ureg = pint.UnitRegistry()
|
|
167
|
+
self.Q_ = self.ureg.Quantity
|
|
168
|
+
|
|
169
|
+
# Master parameter container
|
|
170
|
+
self.mp = data_container()
|
|
171
|
+
self.p_dict: Dict[str, Any] = {}
|
|
172
|
+
|
|
173
|
+
# Default parameters
|
|
174
|
+
self._init_defaults()
|
|
175
|
+
|
|
176
|
+
def _resolve(self, path_like: Union[str, Path]) -> Path:
|
|
177
|
+
p = Path(path_like)
|
|
178
|
+
if p.is_absolute() and p.exists():
|
|
179
|
+
return p
|
|
180
|
+
if (self.base_dir / p).exists():
|
|
181
|
+
return (self.base_dir / p).resolve()
|
|
182
|
+
if (Path.cwd() / p).exists():
|
|
183
|
+
return (Path.cwd() / p).resolve()
|
|
184
|
+
return (self.base_dir / p).resolve()
|
|
185
|
+
|
|
186
|
+
def _init_defaults(self) -> None:
|
|
187
|
+
"""Initializes default physical, geometric, and numerical solver parameters."""
|
|
188
|
+
Q_ = self.Q_
|
|
189
|
+
self.p_dict.update({
|
|
190
|
+
"experiment": self.experiment,
|
|
191
|
+
"plot_name": self.experiment,
|
|
192
|
+
"state_data": None,
|
|
193
|
+
"layout_file": str(self.layout_file),
|
|
194
|
+
"video_dt": None,
|
|
195
|
+
"video_dpi": 120,
|
|
196
|
+
"video_queue_size": 100,
|
|
197
|
+
"video_resume_threshold": 50,
|
|
198
|
+
"video_workers": None,
|
|
199
|
+
"start_time": 0,
|
|
200
|
+
# Geometry
|
|
201
|
+
"max_depth": 2.0, # meters
|
|
202
|
+
"initial_spacing": 0.0001,
|
|
203
|
+
"reaction_zone_spacing": 0.0001,
|
|
204
|
+
"max_spacing": 0.1,
|
|
205
|
+
"reaction_zone": (0.0, 0.1),
|
|
206
|
+
"display_length": 2.0,
|
|
207
|
+
# Environmental defaults
|
|
208
|
+
"temp": [10.0, 10.1],
|
|
209
|
+
"w": Q_("0.2 cm/yr").to("m/s").magnitude,
|
|
210
|
+
"advection": 0.0,
|
|
211
|
+
"phi": 0.8,
|
|
212
|
+
"POC_O2_ratio": 1.27,
|
|
213
|
+
# Monod defaults (bulk or phase corrected)
|
|
214
|
+
"K_O2": Q_("0.001 umol/cm^3").to("mol/m^3").magnitude,
|
|
215
|
+
"K_O2_TS2": Q_("0.001 umol/cm^3").to("mol/m^3").magnitude,
|
|
216
|
+
"K_TS2": Q_("0.1 umol/cm^3").to("mol/m^3").magnitude,
|
|
217
|
+
"K_SO4": Q_("0.9 umol/cm^3").to("mol/m^3").magnitude,
|
|
218
|
+
"K_Fe3_diss_red": Q_("10.4 umol/cm^3").to("mol/m^3").magnitude,
|
|
219
|
+
"K_Fe3": 1e-3,
|
|
220
|
+
# Benthic activity (bioirrigation explicitly disabled by default)
|
|
221
|
+
"BT0": 0.0,
|
|
222
|
+
"BT_depth": Q_("7.6 cm").to("meter").magnitude,
|
|
223
|
+
"BT_attenuation": Q_("2 cm").to("meter").magnitude,
|
|
224
|
+
"BI0": 0.0, # explicitly 0.0
|
|
225
|
+
"BI_depth": 0.0,
|
|
226
|
+
# Isotopes
|
|
227
|
+
"isotopes": True,
|
|
228
|
+
"SO4_d": 21.0,
|
|
229
|
+
"S0_d": 8.0,
|
|
230
|
+
"msr_alpha": 1.07,
|
|
231
|
+
"TS2_O2_alpha": 0.995,
|
|
232
|
+
"S0_O2_alpha": 1.0,
|
|
233
|
+
"dispro_hs_alpha": 0.993,
|
|
234
|
+
"dispro_SO4_hs_split": 3.0,
|
|
235
|
+
"h2s_hs_alpha": 0.99991542,
|
|
236
|
+
"VCDT": 0.044162589,
|
|
237
|
+
"K_epsilon_msr": 0.2,
|
|
238
|
+
"K_epsilon_TS2_O2": 0.01,
|
|
239
|
+
# Solver parameters
|
|
240
|
+
"solver": "monolithic",
|
|
241
|
+
"t_end": Q_("1 kyr").to("seconds").magnitude,
|
|
242
|
+
"dt_min": Q_("1 minute").to("seconds").magnitude,
|
|
243
|
+
"dt_init": Q_("1 month").to("seconds").magnitude,
|
|
244
|
+
"dt_max": Q_("1 year").to("seconds").magnitude,
|
|
245
|
+
"tolerance": 1e-12,
|
|
246
|
+
"dt_tolerance": 1e-12,
|
|
247
|
+
# Monolithic Newton Settings
|
|
248
|
+
"max_newton_iters": 8,
|
|
249
|
+
"newton_tol": 1e-3,
|
|
250
|
+
"newton_norm": "linf",
|
|
251
|
+
"newton_target_optimal": 4,
|
|
252
|
+
"newton_max_acceptable": 6,
|
|
253
|
+
"use_numerical_jacobian": False,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
def configure(self, **kwargs) -> DiageneticModel:
|
|
257
|
+
"""Sets or overrides model configuration parameters with automatic unit handling.
|
|
258
|
+
|
|
259
|
+
Supported parameters include:
|
|
260
|
+
- max_depth: e.g. "10 m" or 10.0
|
|
261
|
+
- t_end: e.g. "10 kyr" or seconds
|
|
262
|
+
- dt_max: e.g. "1 year"
|
|
263
|
+
- dt_init: e.g. "1 day"
|
|
264
|
+
- dt_min: e.g. "1 second"
|
|
265
|
+
- w: sedimentation rate, e.g. "0.2 cm/yr"
|
|
266
|
+
- phi: sediment porosity (float)
|
|
267
|
+
- pH: (float) will update Hplus boundary condition
|
|
268
|
+
- isotopes: bool
|
|
269
|
+
- bioturbation: dict with 'BT0', 'depth', 'attenuation'
|
|
270
|
+
- bioirrigation: dict with 'BI0', 'depth' (defaults to 0.0)
|
|
271
|
+
- bc_overrides: dict mapping species names to concentration/flux overrides
|
|
272
|
+
"""
|
|
273
|
+
Q_ = self.Q_
|
|
274
|
+
|
|
275
|
+
def _to_sec(val: Any) -> float:
|
|
276
|
+
if isinstance(val, str):
|
|
277
|
+
return Q_(val).to("seconds").magnitude
|
|
278
|
+
return float(val)
|
|
279
|
+
|
|
280
|
+
def _to_m(val: Any) -> float:
|
|
281
|
+
if isinstance(val, str):
|
|
282
|
+
return Q_(val).to("meter").magnitude
|
|
283
|
+
return float(val)
|
|
284
|
+
|
|
285
|
+
def _to_vel(val: Any) -> float:
|
|
286
|
+
if isinstance(val, str):
|
|
287
|
+
return Q_(val).to("m/s").magnitude
|
|
288
|
+
return float(val)
|
|
289
|
+
|
|
290
|
+
for key, val in kwargs.items():
|
|
291
|
+
if key == "max_depth":
|
|
292
|
+
self.p_dict["max_depth"] = _to_m(val)
|
|
293
|
+
elif key == "display_length":
|
|
294
|
+
self.p_dict["display_length"] = _to_m(val)
|
|
295
|
+
elif key == "t_end":
|
|
296
|
+
self.p_dict["t_end"] = _to_sec(val)
|
|
297
|
+
elif key == "dt_max":
|
|
298
|
+
self.p_dict["dt_max"] = _to_sec(val)
|
|
299
|
+
elif key == "dt_init":
|
|
300
|
+
self.p_dict["dt_init"] = _to_sec(val)
|
|
301
|
+
elif key == "dt_min":
|
|
302
|
+
self.p_dict["dt_min"] = _to_sec(val)
|
|
303
|
+
elif key == "w":
|
|
304
|
+
self.p_dict["w"] = _to_vel(val)
|
|
305
|
+
elif key == "bioturbation" and isinstance(val, dict):
|
|
306
|
+
if "BT0" in val:
|
|
307
|
+
b0 = val["BT0"]
|
|
308
|
+
self.p_dict["BT0"] = Q_(b0).to("m^2/second").magnitude if isinstance(b0, str) else float(b0)
|
|
309
|
+
if "depth" in val:
|
|
310
|
+
self.p_dict["BT_depth"] = _to_m(val["depth"])
|
|
311
|
+
if "attenuation" in val:
|
|
312
|
+
self.p_dict["BT_attenuation"] = _to_m(val["attenuation"])
|
|
313
|
+
elif key == "bioirrigation" and isinstance(val, dict):
|
|
314
|
+
if "BI0" in val:
|
|
315
|
+
b0 = val["BI0"]
|
|
316
|
+
self.p_dict["BI0"] = Q_(b0).to("1/second").magnitude if isinstance(b0, str) else float(b0)
|
|
317
|
+
if "depth" in val:
|
|
318
|
+
self.p_dict["BI_depth"] = _to_m(val["depth"])
|
|
319
|
+
elif key == "video" or key == "video_dt":
|
|
320
|
+
self.p_dict["video_dt"] = val
|
|
321
|
+
elif key == "pH":
|
|
322
|
+
self.p_dict["pH"] = float(val)
|
|
323
|
+
else:
|
|
324
|
+
self.p_dict[key] = val
|
|
325
|
+
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def parse_cli_args(self, args: Optional[List[str]] = None) -> None:
|
|
329
|
+
"""Parses CLI arguments if requested, supporting category inspection and option overrides."""
|
|
330
|
+
cur_solver = self.p_dict.get("solver", "monolithic")
|
|
331
|
+
cur_tol = self.p_dict.get("newton_tol", 1e-3)
|
|
332
|
+
cur_jac = self.p_dict.get("use_numerical_jacobian", False)
|
|
333
|
+
cur_max_iters = self.p_dict.get("max_newton_iters", 8)
|
|
334
|
+
cur_norm = self.p_dict.get("newton_norm", "linf")
|
|
335
|
+
cur_opt_iters = self.p_dict.get("newton_target_optimal", 4)
|
|
336
|
+
cur_max_acc_iters = self.p_dict.get("newton_max_acceptable", 6)
|
|
337
|
+
|
|
338
|
+
cur_video = self.p_dict.get("video_dt", "None")
|
|
339
|
+
cur_dpi = self.p_dict.get("video_dpi", 120)
|
|
340
|
+
|
|
341
|
+
cur_tend = self.p_dict.get("t_end", "None")
|
|
342
|
+
cur_dtmax = self.p_dict.get("dt_max", "None")
|
|
343
|
+
cur_dtinit = self.p_dict.get("dt_init", "None")
|
|
344
|
+
cur_dtmin = self.p_dict.get("dt_min", "None")
|
|
345
|
+
cur_max_depth = self.p_dict.get("max_depth", "None")
|
|
346
|
+
cur_disp_len = self.p_dict.get("display_length", "None")
|
|
347
|
+
|
|
348
|
+
solver_section = [
|
|
349
|
+
("--solver <backend>", cur_solver, "Backend solver choice ('monolithic' or 'coupled')"),
|
|
350
|
+
("--solver-tol <float>", cur_tol, "Newton convergence tolerance (e.g. 1e-3)"),
|
|
351
|
+
("--numerical-jacobian", cur_jac, "Use finite-difference Jacobian instead of SymPy analytical"),
|
|
352
|
+
("--max-newton-iters <int>", cur_max_iters, "Maximum Newton iterations allowed per timestep"),
|
|
353
|
+
("--newton-norm <choice>", cur_norm, "Residual norm formulation ('linf' or 'wrms')"),
|
|
354
|
+
("--newton-target-optimal <int>", cur_opt_iters, "Target Newton iterations for timestep expansion"),
|
|
355
|
+
("--newton-max-acceptable <int>", cur_max_acc_iters, "Max acceptable iterations before timestep reduction"),
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
video_section = [
|
|
359
|
+
("--video <interval>", cur_video, "Live video recording frame interval (e.g. '10 year', 'none')"),
|
|
360
|
+
("--video-dpi <int>", cur_dpi, "Resolution (DPI) of recorded video frames"),
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
run_section = [
|
|
364
|
+
("--t-end <duration>", f"{cur_tend} s" if isinstance(cur_tend, (int, float)) else str(cur_tend), "Total duration of simulation (e.g. '10 kyr')"),
|
|
365
|
+
("--dt-max <timestep>", f"{cur_dtmax} s" if isinstance(cur_dtmax, (int, float)) else str(cur_dtmax), "Maximum adaptive timestep cap (e.g. '1 year')"),
|
|
366
|
+
("--dt-init <timestep>", f"{cur_dtinit} s" if isinstance(cur_dtinit, (int, float)) else str(cur_dtinit), "Initial timestep size (e.g. '1 day')"),
|
|
367
|
+
("--dt-min <timestep>", f"{cur_dtmin} s" if isinstance(cur_dtmin, (int, float)) else str(cur_dtmin), "Minimum allowable timestep before aborting"),
|
|
368
|
+
("--max-depth <length>", f"{cur_max_depth} m" if isinstance(cur_max_depth, (int, float)) else str(cur_max_depth), "Total depth of sediment model column (e.g. '10 m')"),
|
|
369
|
+
("--display-length <length>", f"{cur_disp_len} m" if isinstance(cur_disp_len, (int, float)) else str(cur_disp_len), "Plotted column depth for video and PDF summaries"),
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
parser = argparse.ArgumentParser(
|
|
373
|
+
description=(
|
|
374
|
+
f"MANTIS Diagenetic Model: {self.experiment}\n\n"
|
|
375
|
+
"Interactive Category Query:\n"
|
|
376
|
+
" Passing category flags without arguments displays all configurable options and current defaults:\n"
|
|
377
|
+
" %(prog)s --solver Show all solver options and current settings\n"
|
|
378
|
+
" %(prog)s --video Show all video output options\n"
|
|
379
|
+
" %(prog)s --run Show all time, grid, and domain options\n"
|
|
380
|
+
),
|
|
381
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
382
|
+
add_help=True,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Solver options
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--solver",
|
|
388
|
+
nargs="?",
|
|
389
|
+
const="__SHOW__",
|
|
390
|
+
metavar="BACKEND",
|
|
391
|
+
help=f"Solver backend: 'monolithic' or 'coupled' (or query section: pass flag with no value)",
|
|
392
|
+
)
|
|
393
|
+
parser.add_argument(
|
|
394
|
+
"--solver-tol",
|
|
395
|
+
"--newton-tol",
|
|
396
|
+
nargs="?",
|
|
397
|
+
const="__SHOW__",
|
|
398
|
+
metavar="TOL",
|
|
399
|
+
help=f"Newton convergence tolerance (default: {cur_tol})",
|
|
400
|
+
)
|
|
401
|
+
parser.add_argument(
|
|
402
|
+
"--numerical-jacobian",
|
|
403
|
+
action="store_true",
|
|
404
|
+
help=f"Use finite-difference Jacobian instead of SymPy analytical (default: {cur_jac})",
|
|
405
|
+
)
|
|
406
|
+
parser.add_argument(
|
|
407
|
+
"--max-newton-iters",
|
|
408
|
+
type=int,
|
|
409
|
+
metavar="ITERS",
|
|
410
|
+
help=f"Max Newton iterations per step (default: {cur_max_iters})",
|
|
411
|
+
)
|
|
412
|
+
parser.add_argument(
|
|
413
|
+
"--newton-norm",
|
|
414
|
+
choices=["linf", "wrms"],
|
|
415
|
+
metavar="NORM",
|
|
416
|
+
help=f"Residual norm metric: 'linf' or 'wrms' (default: {cur_norm})",
|
|
417
|
+
)
|
|
418
|
+
parser.add_argument(
|
|
419
|
+
"--newton-target-optimal",
|
|
420
|
+
type=int,
|
|
421
|
+
metavar="ITERS",
|
|
422
|
+
help=f"Target iterations for timestep expansion (default: {cur_opt_iters})",
|
|
423
|
+
)
|
|
424
|
+
parser.add_argument(
|
|
425
|
+
"--newton-max-acceptable",
|
|
426
|
+
type=int,
|
|
427
|
+
metavar="ITERS",
|
|
428
|
+
help=f"Max iterations before timestep reduction (default: {cur_max_acc_iters})",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Video options
|
|
432
|
+
parser.add_argument(
|
|
433
|
+
"--video",
|
|
434
|
+
nargs="?",
|
|
435
|
+
const="__SHOW__",
|
|
436
|
+
metavar="INTERVAL",
|
|
437
|
+
help=f"Live video recording interval, e.g. '10 year' or 'none' (or query section: pass flag with no value)",
|
|
438
|
+
)
|
|
439
|
+
parser.add_argument(
|
|
440
|
+
"--video-dpi",
|
|
441
|
+
nargs="?",
|
|
442
|
+
const="__SHOW__",
|
|
443
|
+
metavar="DPI",
|
|
444
|
+
help=f"Video DPI resolution (default: {cur_dpi})",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Run / Domain options
|
|
448
|
+
parser.add_argument(
|
|
449
|
+
"--run",
|
|
450
|
+
nargs="?",
|
|
451
|
+
const="__SHOW__",
|
|
452
|
+
metavar="SETTINGS",
|
|
453
|
+
help="Query time and domain run settings (pass flag with no value)",
|
|
454
|
+
)
|
|
455
|
+
parser.add_argument(
|
|
456
|
+
"--t-end",
|
|
457
|
+
metavar="DURATION",
|
|
458
|
+
help=f"Simulation duration, e.g. '10 kyr' (default: {cur_tend} s)",
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"--dt-max",
|
|
462
|
+
metavar="TIMESTEP",
|
|
463
|
+
help=f"Maximum timestep, e.g. '1 year' (default: {cur_dtmax} s)",
|
|
464
|
+
)
|
|
465
|
+
parser.add_argument(
|
|
466
|
+
"--dt-init",
|
|
467
|
+
metavar="TIMESTEP",
|
|
468
|
+
help=f"Initial timestep, e.g. '1 day' (default: {cur_dtinit} s)",
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--dt-min",
|
|
472
|
+
metavar="TIMESTEP",
|
|
473
|
+
help=f"Minimum timestep, e.g. '1 second' (default: {cur_dtmin} s)",
|
|
474
|
+
)
|
|
475
|
+
parser.add_argument(
|
|
476
|
+
"--max-depth",
|
|
477
|
+
metavar="LENGTH",
|
|
478
|
+
help=f"Sediment column depth, e.g. '10 m' (default: {cur_max_depth} m)",
|
|
479
|
+
)
|
|
480
|
+
parser.add_argument(
|
|
481
|
+
"--display-length",
|
|
482
|
+
metavar="LENGTH",
|
|
483
|
+
help=f"Plotted display depth, e.g. '0.5 m' (default: {cur_disp_len} m)",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
parsed, _ = parser.parse_known_args(args)
|
|
487
|
+
|
|
488
|
+
# Check section queries first
|
|
489
|
+
def _print_section(title: str, rows: List[Tuple[str, Any, str]]) -> None:
|
|
490
|
+
print(f"\n[{self.experiment}] {title}:")
|
|
491
|
+
for flag, cur_val, desc in rows:
|
|
492
|
+
print(f" {flag:<30} : {str(cur_val):<12} ({desc})")
|
|
493
|
+
print()
|
|
494
|
+
|
|
495
|
+
show_solver = parsed.solver in ("__SHOW__", "help", "?", "show") or parsed.solver_tol == "__SHOW__"
|
|
496
|
+
show_video = parsed.video in ("__SHOW__", "help", "?", "show") or parsed.video_dpi == "__SHOW__"
|
|
497
|
+
show_run = parsed.run in ("__SHOW__", "help", "?", "show")
|
|
498
|
+
|
|
499
|
+
if show_solver or show_video or show_run:
|
|
500
|
+
if show_solver:
|
|
501
|
+
_print_section("Solver Configuration & Configurable Options", solver_section)
|
|
502
|
+
if show_video:
|
|
503
|
+
_print_section("Video Output Configuration & Configurable Options", video_section)
|
|
504
|
+
if show_run:
|
|
505
|
+
_print_section("Simulation Time & Domain Configuration & Configurable Options", run_section)
|
|
506
|
+
sys.exit(0)
|
|
507
|
+
|
|
508
|
+
# Apply solver overrides
|
|
509
|
+
if parsed.solver is not None:
|
|
510
|
+
if parsed.solver in ("monolithic", "coupled"):
|
|
511
|
+
self.p_dict["solver"] = parsed.solver
|
|
512
|
+
else:
|
|
513
|
+
parser.error(f"argument --solver: invalid choice: '{parsed.solver}' (choose from 'monolithic', 'coupled')")
|
|
514
|
+
|
|
515
|
+
if parsed.solver_tol is not None:
|
|
516
|
+
self.p_dict["newton_tol"] = float(parsed.solver_tol)
|
|
517
|
+
if parsed.numerical_jacobian:
|
|
518
|
+
self.p_dict["use_numerical_jacobian"] = True
|
|
519
|
+
if parsed.max_newton_iters is not None:
|
|
520
|
+
self.p_dict["max_newton_iters"] = parsed.max_newton_iters
|
|
521
|
+
if parsed.newton_norm is not None:
|
|
522
|
+
self.p_dict["newton_norm"] = parsed.newton_norm
|
|
523
|
+
if parsed.newton_target_optimal is not None:
|
|
524
|
+
self.p_dict["newton_target_optimal"] = parsed.newton_target_optimal
|
|
525
|
+
if parsed.newton_max_acceptable is not None:
|
|
526
|
+
self.p_dict["newton_max_acceptable"] = parsed.newton_max_acceptable
|
|
527
|
+
|
|
528
|
+
# Apply video overrides
|
|
529
|
+
if parsed.video is not None:
|
|
530
|
+
self.p_dict["video_dt"] = None if parsed.video.lower() in ("none", "null", "false") else parsed.video
|
|
531
|
+
if parsed.video_dpi is not None:
|
|
532
|
+
self.p_dict["video_dpi"] = int(parsed.video_dpi)
|
|
533
|
+
|
|
534
|
+
# Apply run / domain overrides
|
|
535
|
+
if parsed.t_end is not None:
|
|
536
|
+
self.configure(t_end=parsed.t_end)
|
|
537
|
+
if parsed.dt_max is not None:
|
|
538
|
+
self.configure(dt_max=parsed.dt_max)
|
|
539
|
+
if parsed.dt_init is not None:
|
|
540
|
+
self.configure(dt_init=parsed.dt_init)
|
|
541
|
+
if parsed.dt_min is not None:
|
|
542
|
+
self.configure(dt_min=parsed.dt_min)
|
|
543
|
+
if parsed.max_depth is not None:
|
|
544
|
+
self.configure(max_depth=parsed.max_depth)
|
|
545
|
+
if parsed.display_length is not None:
|
|
546
|
+
self.configure(display_length=parsed.display_length)
|
|
547
|
+
|
|
548
|
+
def _load_species_definitions(self) -> Dict[str, Any]:
|
|
549
|
+
"""Loads and parses the species definitions dictionary from species.py."""
|
|
550
|
+
spec = importlib.util.spec_from_file_location("species_mod", self.species_file)
|
|
551
|
+
if spec is None or spec.loader is None:
|
|
552
|
+
raise ImportError(f"Cannot load species file: {self.species_file}")
|
|
553
|
+
mod = importlib.util.module_from_spec(spec)
|
|
554
|
+
spec.loader.exec_module(mod)
|
|
555
|
+
if not hasattr(mod, "species") or not isinstance(mod.species, dict):
|
|
556
|
+
raise ValueError(f"Species file {self.species_file} must define a 'species' dictionary.")
|
|
557
|
+
self._species_mod = mod
|
|
558
|
+
return mod.species
|
|
559
|
+
|
|
560
|
+
def setup(self, plot_queue=None) -> Tuple[data_container, data_container, data_container, List[str], np.ndarray, data_container, Any]:
|
|
561
|
+
"""Assembles grid, species variables, transport coefficients, and compiles reaction equations."""
|
|
562
|
+
self.mp.update(self.p_dict)
|
|
563
|
+
self.mp._caller_dir = str(self.base_dir)
|
|
564
|
+
|
|
565
|
+
# 1. Parse species definitions
|
|
566
|
+
species_defs = self._load_species_definitions()
|
|
567
|
+
|
|
568
|
+
# 2. Extract pH from species definitions (e.g. Hplus or any species defining 'pH' under 'bc')
|
|
569
|
+
if "pH" not in self.p_dict:
|
|
570
|
+
for sp_k, s_meta in species_defs.items():
|
|
571
|
+
s_bc = s_meta.get("bc")
|
|
572
|
+
if isinstance(s_bc, dict) and "pH" in s_bc:
|
|
573
|
+
self.mp.pH = float(s_bc["pH"])
|
|
574
|
+
break
|
|
575
|
+
|
|
576
|
+
# 3. Compile or load reaction equations
|
|
577
|
+
prep_dict = dict(self.mp)
|
|
578
|
+
prep_dict["reactions"] = str(self.reactions_file)
|
|
579
|
+
prep_dict["species"] = str(self.species_file)
|
|
580
|
+
prep_dict["kinetic_constants"] = str(self.constants_file)
|
|
581
|
+
if self.limiters_file and self.limiters_file.exists():
|
|
582
|
+
prep_dict["limiters"] = str(self.limiters_file)
|
|
583
|
+
if self.equations_file:
|
|
584
|
+
prep_dict["equations"] = str(self.equations_file)
|
|
585
|
+
|
|
586
|
+
eq_mod, _rxn_p, _eq_p = resolve_and_prepare_equations(
|
|
587
|
+
prep_dict, experiment_name=self.experiment, base_dir=self.base_dir
|
|
588
|
+
)
|
|
589
|
+
self.mp.reactions_module = eq_mod
|
|
590
|
+
if hasattr(eq_mod, "get_limiters"):
|
|
591
|
+
self.mp.get_limiters = eq_mod.get_limiters
|
|
592
|
+
|
|
593
|
+
# 4. Load reaction constants
|
|
594
|
+
spec_rc = importlib.util.spec_from_file_location("rc_mod", self.constants_file)
|
|
595
|
+
if spec_rc is None or spec_rc.loader is None:
|
|
596
|
+
raise ImportError(f"Cannot load reaction constants file: {self.constants_file}")
|
|
597
|
+
mod_rc = importlib.util.module_from_spec(spec_rc)
|
|
598
|
+
spec_rc.loader.exec_module(mod_rc)
|
|
599
|
+
|
|
600
|
+
if hasattr(mod_rc, "get_reaction_constants"):
|
|
601
|
+
get_rc_fn = mod_rc.get_reaction_constants
|
|
602
|
+
else:
|
|
603
|
+
raise AttributeError(f"{self.constants_file} must define get_reaction_constants(pH, phi, k_values).")
|
|
604
|
+
|
|
605
|
+
# 5. Grid generation
|
|
606
|
+
mesh, z = make_grid2(
|
|
607
|
+
self.mp.max_depth,
|
|
608
|
+
self.mp.initial_spacing,
|
|
609
|
+
self.mp.reaction_zone_spacing,
|
|
610
|
+
self.mp.max_spacing,
|
|
611
|
+
self.mp.reaction_zone,
|
|
612
|
+
)
|
|
613
|
+
self.mp.mesh = mesh
|
|
614
|
+
self.mp.z = z
|
|
615
|
+
self.mp.grid_points = len(z)
|
|
616
|
+
self.mp.phi = CellVariable(name="porosity", mesh=mesh, value=self.mp.phi)
|
|
617
|
+
|
|
618
|
+
# 6. Evaluate k values
|
|
619
|
+
k = data_container()
|
|
620
|
+
_k1, k = get_rc_fn(self.mp.pH, self.mp.phi, k_values=k)
|
|
621
|
+
self.mp.k = k
|
|
622
|
+
|
|
623
|
+
# 7. Species lists & phase classification
|
|
624
|
+
species_transport: List[str] = []
|
|
625
|
+
species_all: List[str] = []
|
|
626
|
+
bc_map: Dict[str, Dict[str, Any]] = {}
|
|
627
|
+
D_mol = data_container()
|
|
628
|
+
c = data_container()
|
|
629
|
+
|
|
630
|
+
# Porosity volume ratios
|
|
631
|
+
phi_val = self.mp.phi.value if hasattr(self.mp.phi, "value") else self.mp.phi
|
|
632
|
+
self.mp.fac_s = phi_val / (1.0 - phi_val)
|
|
633
|
+
|
|
634
|
+
# Dynamic speciation & phase partition calculations from species_defs
|
|
635
|
+
Hplus = 10.0 ** (-float(self.mp.pH))
|
|
636
|
+
eval_ctx: Dict[str, Any] = {
|
|
637
|
+
"k": k,
|
|
638
|
+
"mp": self.mp,
|
|
639
|
+
"np": np,
|
|
640
|
+
"Hplus": Hplus,
|
|
641
|
+
"pH": float(self.mp.pH),
|
|
642
|
+
"fac_s": self.mp.fac_s,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
for sp_name, meta in species_defs.items():
|
|
646
|
+
# Check partition for mixed species (e.g. Fe2_total: dissolved, sorbed)
|
|
647
|
+
if "partition" in meta and isinstance(meta["partition"], dict):
|
|
648
|
+
for p_key, p_expr in meta["partition"].items():
|
|
649
|
+
val = float(eval(p_expr, eval_ctx))
|
|
650
|
+
setattr(self.mp, f"{sp_name}_{p_key}", val)
|
|
651
|
+
eval_ctx[f"{sp_name}_{p_key}"] = val
|
|
652
|
+
# Backwards compatibility alias for Fe2_total.dissolved -> Fe2_diss
|
|
653
|
+
if sp_name == "Fe2_total" and p_key == "dissolved":
|
|
654
|
+
self.mp.Fe2_diss = val
|
|
655
|
+
eval_ctx["Fe2_diss"] = val
|
|
656
|
+
elif sp_name == "Fe2_total" and p_key == "sorbed":
|
|
657
|
+
self.mp.Fe2_sorb = val * self.mp.fac_s
|
|
658
|
+
eval_ctx["Fe2_sorb"] = self.mp.Fe2_sorb
|
|
659
|
+
|
|
660
|
+
# Check fraction for equilibrium speciation (e.g. HS: Ka1 / (Hplus + Ka1))
|
|
661
|
+
if "fraction" in meta:
|
|
662
|
+
frac_ctx = dict(eval_ctx)
|
|
663
|
+
if "constants" in meta and isinstance(meta["constants"], dict):
|
|
664
|
+
for c_name, c_val in meta["constants"].items():
|
|
665
|
+
frac_ctx[c_name] = float(c_val)
|
|
666
|
+
if c_name.startswith("pK"):
|
|
667
|
+
base_c = c_name[1:]
|
|
668
|
+
frac_ctx[base_c] = 10.0 ** (-float(c_val))
|
|
669
|
+
f_val = float(eval(meta["fraction"], frac_ctx))
|
|
670
|
+
setattr(self.mp, f"{sp_name.lower()}_frac", f_val)
|
|
671
|
+
eval_ctx[f"{sp_name.lower()}_frac"] = f_val
|
|
672
|
+
|
|
673
|
+
# Ensure fallback defaults if not specified in species_defs
|
|
674
|
+
if not hasattr(self.mp, "Fe2_diss"):
|
|
675
|
+
self.mp.Fe2_diss = 1.0 / (1.0 + k.Fe2_p_eq) if hasattr(k, "Fe2_p_eq") else 1.0
|
|
676
|
+
if not hasattr(self.mp, "Fe2_sorb"):
|
|
677
|
+
self.mp.Fe2_sorb = (k.Fe2_p_eq * self.mp.fac_s * self.mp.Fe2_diss) if hasattr(k, "Fe2_p_eq") else 0.0
|
|
678
|
+
if not hasattr(self.mp, "hs_frac"):
|
|
679
|
+
Ka1 = 10.0 ** (-7.0)
|
|
680
|
+
self.mp.hs_frac = Ka1 / (Hplus + Ka1)
|
|
681
|
+
if not hasattr(self.mp, "h2s_frac"):
|
|
682
|
+
Ka1 = 10.0 ** (-7.0)
|
|
683
|
+
self.mp.h2s_frac = Hplus / (Hplus + Ka1)
|
|
684
|
+
|
|
685
|
+
# Initialize CellVariables
|
|
686
|
+
zeros = np.zeros(self.mp.grid_points)
|
|
687
|
+
T_profile = np.linspace(self.mp.temp[0], self.mp.temp[1], self.mp.grid_points)
|
|
688
|
+
|
|
689
|
+
# Sort species: included in transport vs diagnostic
|
|
690
|
+
for sp_name, meta in species_defs.items():
|
|
691
|
+
species_all.append(sp_name)
|
|
692
|
+
setattr(D_mol, sp_name, zeros.copy())
|
|
693
|
+
setattr(c, sp_name, CellVariable(name=sp_name, mesh=mesh, value=0.0, hasOld=True))
|
|
694
|
+
|
|
695
|
+
is_inc = meta.get("include", True)
|
|
696
|
+
is_iso = "isotope_of" in meta
|
|
697
|
+
|
|
698
|
+
if is_inc:
|
|
699
|
+
if not is_iso or self.mp.isotopes:
|
|
700
|
+
species_transport.append(sp_name)
|
|
701
|
+
|
|
702
|
+
# Build default boundary conditions
|
|
703
|
+
bc_meta = meta.get("bc")
|
|
704
|
+
if bc_meta and isinstance(bc_meta, dict):
|
|
705
|
+
top_val = bc_meta.get("top", 0.0)
|
|
706
|
+
if isinstance(top_val, str):
|
|
707
|
+
# convert flux or concentration units
|
|
708
|
+
q_val = self.Q_(top_val)
|
|
709
|
+
if "yr" in top_val or "year" in top_val or "second" in top_val:
|
|
710
|
+
top_val = q_val.to("mol/(m^2 * second)").magnitude
|
|
711
|
+
else:
|
|
712
|
+
top_val = q_val.to("mol/m^3").magnitude
|
|
713
|
+
bc_map[sp_name] = {
|
|
714
|
+
"top": top_val,
|
|
715
|
+
"type": bc_meta.get("type", "dissolved"),
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
# Apply any explicit user overrides to boundary conditions
|
|
719
|
+
bc_overrides = self.mp.get("bc_overrides", {})
|
|
720
|
+
for sp_k, ov in bc_overrides.items():
|
|
721
|
+
if sp_k in bc_map:
|
|
722
|
+
if isinstance(ov, dict):
|
|
723
|
+
bc_map[sp_k].update(ov)
|
|
724
|
+
else:
|
|
725
|
+
bc_map[sp_k]["top"] = float(ov)
|
|
726
|
+
|
|
727
|
+
# 8. Compute molecular diffusion coefficients from species recipes
|
|
728
|
+
# Import diff_lib module for diff_coeff functions
|
|
729
|
+
import mantis.diff_lib as diff_lib_mod
|
|
730
|
+
|
|
731
|
+
for sp_name, meta in species_defs.items():
|
|
732
|
+
diff_spec = meta.get("diffusion")
|
|
733
|
+
fn_name = meta.get("diff_coeff_function")
|
|
734
|
+
|
|
735
|
+
# Check if this species inherits diffusion from another species
|
|
736
|
+
if isinstance(diff_spec, str):
|
|
737
|
+
if hasattr(D_mol, diff_spec):
|
|
738
|
+
setattr(D_mol, sp_name, getattr(D_mol, diff_spec))
|
|
739
|
+
continue
|
|
740
|
+
|
|
741
|
+
# Determine function name from diff_coeff_function or dict 'type'
|
|
742
|
+
if fn_name is None and isinstance(diff_spec, dict):
|
|
743
|
+
fn_name = diff_spec.get("type", "diff_coeff")
|
|
744
|
+
|
|
745
|
+
if fn_name is not None:
|
|
746
|
+
# 1. Look up function in species.py module first, then diff_lib
|
|
747
|
+
diff_fn = None
|
|
748
|
+
if hasattr(self, "_species_mod") and hasattr(self._species_mod, fn_name):
|
|
749
|
+
diff_fn = getattr(self._species_mod, fn_name)
|
|
750
|
+
elif hasattr(diff_lib_mod, fn_name):
|
|
751
|
+
diff_fn = getattr(diff_lib_mod, fn_name)
|
|
752
|
+
|
|
753
|
+
if diff_fn is not None:
|
|
754
|
+
sig = inspect.signature(diff_fn)
|
|
755
|
+
kwargs = {}
|
|
756
|
+
if "T" in sig.parameters:
|
|
757
|
+
kwargs["T"] = T_profile
|
|
758
|
+
if "T_profile" in sig.parameters:
|
|
759
|
+
kwargs["T_profile"] = T_profile
|
|
760
|
+
if "temp" in sig.parameters:
|
|
761
|
+
kwargs["temp"] = T_profile
|
|
762
|
+
if "phi" in sig.parameters:
|
|
763
|
+
kwargs["phi"] = self.mp.phi
|
|
764
|
+
if isinstance(diff_spec, dict):
|
|
765
|
+
if "m0" in sig.parameters and "m0" in diff_spec:
|
|
766
|
+
kwargs["m0"] = diff_spec["m0"]
|
|
767
|
+
if "m1" in sig.parameters and "m1" in diff_spec:
|
|
768
|
+
kwargs["m1"] = diff_spec["m1"]
|
|
769
|
+
try:
|
|
770
|
+
d_val = diff_fn(**kwargs)
|
|
771
|
+
setattr(D_mol, sp_name, d_val)
|
|
772
|
+
except Exception as e:
|
|
773
|
+
raise RuntimeError(f"Failed to compute diffusion for '{sp_name}' with {fn_name}(): {e}")
|
|
774
|
+
else:
|
|
775
|
+
raise AttributeError(f"Diffusion function '{fn_name}' for species '{sp_name}' not found in species.py or diff_lib.")
|
|
776
|
+
|
|
777
|
+
# Lumped mixed-phase diffusion scaling (porewater retardation)
|
|
778
|
+
for sp_name, meta in species_defs.items():
|
|
779
|
+
if meta.get("phase") == "mixed" and hasattr(D_mol, sp_name):
|
|
780
|
+
diss_frac = 1.0
|
|
781
|
+
part = meta.get("partition", {})
|
|
782
|
+
if "dissolved" in part:
|
|
783
|
+
try:
|
|
784
|
+
diss_frac = float(eval(part["dissolved"], eval_ctx))
|
|
785
|
+
except Exception:
|
|
786
|
+
diss_frac = getattr(self.mp, f"{sp_name}_dissolved", getattr(self.mp, "Fe2_diss", 1.0))
|
|
787
|
+
else:
|
|
788
|
+
diss_frac = getattr(self.mp, f"{sp_name}_dissolved", getattr(self.mp, "Fe2_diss", 1.0))
|
|
789
|
+
setattr(D_mol, sp_name, getattr(D_mol, sp_name) * diss_frac)
|
|
790
|
+
|
|
791
|
+
# Bioturbation & Bio-irrigation profiles
|
|
792
|
+
D_mol.D_irr = compute_bio_irrigation_alpha(z, self.mp.BI0, self.mp.BI_depth)
|
|
793
|
+
D_mol.D_bio = compute_sigmoidal_db(z, self.mp.BT0, self.mp.BT_depth, self.mp.BT_attenuation)
|
|
794
|
+
|
|
795
|
+
# 9. Isotope boundary conditions companion setup (generalized across all species)
|
|
796
|
+
if self.mp.isotopes:
|
|
797
|
+
for sp_name, meta in species_defs.items():
|
|
798
|
+
iso_target = meta.get("isotope")
|
|
799
|
+
if not iso_target or iso_target not in species_defs:
|
|
800
|
+
continue
|
|
801
|
+
# Determine delta value: from bc dict, or mp.<sp>_d, or 0.0 default
|
|
802
|
+
delta_val = None
|
|
803
|
+
sp_bc = meta.get("bc", {})
|
|
804
|
+
if isinstance(sp_bc, dict):
|
|
805
|
+
if "delta" in sp_bc:
|
|
806
|
+
delta_val = float(sp_bc["delta"])
|
|
807
|
+
if delta_val is None:
|
|
808
|
+
delta_attr = f"{sp_name}_d"
|
|
809
|
+
delta_val = float(getattr(self.mp, delta_attr, 0.0))
|
|
810
|
+
|
|
811
|
+
iso_meta = species_defs[iso_target]
|
|
812
|
+
iso_phase = iso_meta.get("phase", meta.get("phase", "liquid"))
|
|
813
|
+
iso_bc_type = "particulate" if iso_phase == "solid" else "dissolved"
|
|
814
|
+
|
|
815
|
+
if sp_name in bc_map and bc_map[sp_name]["top"] > 0.0:
|
|
816
|
+
top_mass_32 = get_l_mass(bc_map[sp_name]["top"], delta_val, self.mp.VCDT)
|
|
817
|
+
setattr(self.mp, f"bc_{iso_target}", top_mass_32)
|
|
818
|
+
bc_map[iso_target] = {"top": top_mass_32, "type": iso_bc_type}
|
|
819
|
+
else:
|
|
820
|
+
setattr(self.mp, f"bc_{iso_target}", 0.0)
|
|
821
|
+
bc_map[iso_target] = {"top": 0.0, "type": iso_bc_type}
|
|
822
|
+
|
|
823
|
+
# 10. Initial conditions and boundary constraints
|
|
824
|
+
phi_top = phi_val[0] if hasattr(phi_val, "__getitem__") else phi_val
|
|
825
|
+
for sp_name in species_transport:
|
|
826
|
+
var = getattr(c, sp_name)
|
|
827
|
+
props = bc_map.get(sp_name, {"top": 0.0, "type": "dissolved"})
|
|
828
|
+
if props["type"] == "particulate":
|
|
829
|
+
# Convert bulk surface flux J_bulk to solid-phase flux: J_solid = J_bulk / (1 - phi)
|
|
830
|
+
J_solid = props["top"] / (1.0 - phi_top)
|
|
831
|
+
|
|
832
|
+
D_total = getattr(D_mol, sp_name, 0.0) + D_mol.D_bio
|
|
833
|
+
if not isinstance(D_total, CellVariable):
|
|
834
|
+
D_total = CellVariable(mesh=mesh, value=D_total)
|
|
835
|
+
|
|
836
|
+
d_left = D_total.faceValue[mesh.facesLeft.value][0]
|
|
837
|
+
if d_left > 1e-20:
|
|
838
|
+
# Robin BC: flux = w * C - D * dC/dx -> dC/dx = (w * C - J_solid) / D
|
|
839
|
+
var.faceGrad.constrain(
|
|
840
|
+
[(self.mp.w * var.faceValue - J_solid) / D_total.faceValue],
|
|
841
|
+
mesh.facesLeft,
|
|
842
|
+
)
|
|
843
|
+
else:
|
|
844
|
+
val = J_solid / self.mp.w if self.mp.w > 0 else 0.0
|
|
845
|
+
var.setValue(val)
|
|
846
|
+
var.constrain(val, mesh.facesLeft)
|
|
847
|
+
else:
|
|
848
|
+
var.setValue(props["top"])
|
|
849
|
+
var.constrain(props["top"], mesh.facesLeft)
|
|
850
|
+
|
|
851
|
+
var.faceGrad.constrain([0.0], mesh.facesRight)
|
|
852
|
+
|
|
853
|
+
# Restore state if specified
|
|
854
|
+
if self.mp.state_data:
|
|
855
|
+
read_state(c, self.mp.state_data)
|
|
856
|
+
|
|
857
|
+
check_peclet_numbers(mesh, self.mp, D_mol, species_transport, bc_map)
|
|
858
|
+
|
|
859
|
+
self._c = c
|
|
860
|
+
self._k = k
|
|
861
|
+
self._species_transport = species_transport
|
|
862
|
+
self._species_all = species_all
|
|
863
|
+
self._D_mol = D_mol
|
|
864
|
+
self._bc_map = bc_map
|
|
865
|
+
self._mesh = mesh
|
|
866
|
+
|
|
867
|
+
return self.mp, c, k, species_transport, z, D_mol, eq_mod.diagenetic_reactions
|
|
868
|
+
|
|
869
|
+
def run(self, plot_queue=None, **override_kwargs) -> DiageneticModelResult:
|
|
870
|
+
"""Runs the simulation and returns a DiageneticModelResult object."""
|
|
871
|
+
if override_kwargs:
|
|
872
|
+
self.configure(**override_kwargs)
|
|
873
|
+
|
|
874
|
+
self.setup(plot_queue=plot_queue)
|
|
875
|
+
|
|
876
|
+
plotter = None
|
|
877
|
+
video_dt = getattr(self.mp, "video_dt", None)
|
|
878
|
+
if plot_queue is None and video_dt is not None:
|
|
879
|
+
output_path = f"{self.mp.plot_name}.pdf"
|
|
880
|
+
video_path = os.path.abspath(f"{self.mp.plot_name}.mp4")
|
|
881
|
+
plotter = LivePlotter(
|
|
882
|
+
layout_path=self.mp.layout_file,
|
|
883
|
+
display_length=self.mp.display_length,
|
|
884
|
+
output_path=output_path,
|
|
885
|
+
video_path=video_path,
|
|
886
|
+
gui=False,
|
|
887
|
+
max_queue_size=getattr(self.mp, "video_queue_size", 100),
|
|
888
|
+
resume_threshold=getattr(self.mp, "video_resume_threshold", 50),
|
|
889
|
+
video_dpi=getattr(self.mp, "video_dpi", 120),
|
|
890
|
+
video_workers=getattr(self.mp, "video_workers", None),
|
|
891
|
+
)
|
|
892
|
+
plotter.start()
|
|
893
|
+
plot_queue = plotter.queue
|
|
894
|
+
|
|
895
|
+
try:
|
|
896
|
+
diag_rxn_func = self.mp.reactions_module.diagenetic_reactions
|
|
897
|
+
|
|
898
|
+
def default_equilibrium_reactions(mp, c, k, f, RATES, dt):
|
|
899
|
+
inst_rxns = getattr(mp, "instantenous_reactions", [])
|
|
900
|
+
for r in inst_rxns:
|
|
901
|
+
sig = inspect.signature(r[0])
|
|
902
|
+
if "f" in sig.parameters:
|
|
903
|
+
r[0](c, r[1], mp, dt, RATES, f=f)
|
|
904
|
+
else:
|
|
905
|
+
r[0](c, r[1], mp, dt, RATES)
|
|
906
|
+
return f, RATES
|
|
907
|
+
|
|
908
|
+
step, max_change = run_solver(
|
|
909
|
+
self.mp,
|
|
910
|
+
self._c,
|
|
911
|
+
self._species_all,
|
|
912
|
+
self._species_transport,
|
|
913
|
+
self._k,
|
|
914
|
+
diag_rxn_func,
|
|
915
|
+
default_equilibrium_reactions,
|
|
916
|
+
self._mesh,
|
|
917
|
+
self._D_mol,
|
|
918
|
+
self._bc_map,
|
|
919
|
+
self.mp.z,
|
|
920
|
+
plot_queue=plot_queue,
|
|
921
|
+
)
|
|
922
|
+
finally:
|
|
923
|
+
if plotter:
|
|
924
|
+
plotter.stop()
|
|
925
|
+
|
|
926
|
+
max_steps = getattr(self.mp, "max_steps", None)
|
|
927
|
+
converged = "Yes" if (max_steps is None or step < max_steps) else "No"
|
|
928
|
+
total_time = getattr(self.mp, "current_time", getattr(self.mp, "t_end", 0.0))
|
|
929
|
+
|
|
930
|
+
result = DiageneticModelResult(
|
|
931
|
+
mp=self.mp,
|
|
932
|
+
c=self._c,
|
|
933
|
+
k=self._k,
|
|
934
|
+
species_list=self._species_transport,
|
|
935
|
+
z=self.mp.z,
|
|
936
|
+
D_mol=self._D_mol,
|
|
937
|
+
diagenetic_reactions=diag_rxn_func,
|
|
938
|
+
converged=converged,
|
|
939
|
+
step=step,
|
|
940
|
+
total_time=total_time,
|
|
941
|
+
)
|
|
942
|
+
return result
|