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/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