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/diff_lib.py
ADDED
|
@@ -0,0 +1,1937 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility library for the mantis package.
|
|
3
|
+
|
|
4
|
+
This package provides a small collection of functions and a lightweight container class
|
|
5
|
+
used throughout the Pyrite Burial model.
|
|
6
|
+
|
|
7
|
+
The module contains:
|
|
8
|
+
|
|
9
|
+
- :class:=data_container= – a simple container that can be initialised from a
|
|
10
|
+
space‑separated string of attribute names with optional default values, or from a
|
|
11
|
+
dictionary mapping attribute names to values.
|
|
12
|
+
|
|
13
|
+
- :func:=diff_coeff= – computes the diffusion coefficient (m² s⁻¹) for a given
|
|
14
|
+
temperature (°C), porosity (percent) and the linear parameters /m0/ and /m1/ from
|
|
15
|
+
Boudreau (1996).
|
|
16
|
+
|
|
17
|
+
- :func:=get_delta= – calculates the isotopic delta value (‰) from the total
|
|
18
|
+
concentration of an isotope pair and a reference ratio.
|
|
19
|
+
|
|
20
|
+
- :func:=get_l_mass= – derives the concentration of the light isotope from a
|
|
21
|
+
measured total concentration, a delta value and the reference ratio.
|
|
22
|
+
|
|
23
|
+
- :func:=relax_solution= – blends a current solution vector with a previous one,
|
|
24
|
+
limiting the change to a specified fraction and enforcing non‑negative values.
|
|
25
|
+
|
|
26
|
+
These helpers are primarily intended for modelling isotope diffusion and fractionation
|
|
27
|
+
processes in geological simulations.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class data_container(dict):
|
|
34
|
+
"""A dictionary-based container with attribute access.
|
|
35
|
+
|
|
36
|
+
Supports initialization from a space-separated string or a dictionary.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, names=None, defaults=None):
|
|
40
|
+
super().__init__()
|
|
41
|
+
if isinstance(names, str):
|
|
42
|
+
names = names.split(" ")
|
|
43
|
+
if isinstance(defaults, list):
|
|
44
|
+
for i, name in enumerate(names):
|
|
45
|
+
if name != "":
|
|
46
|
+
self[name] = defaults[i]
|
|
47
|
+
else:
|
|
48
|
+
for name in names:
|
|
49
|
+
if name != "":
|
|
50
|
+
self[name] = defaults
|
|
51
|
+
elif isinstance(names, dict):
|
|
52
|
+
self.update(names)
|
|
53
|
+
|
|
54
|
+
def __getattr__(self, key):
|
|
55
|
+
try:
|
|
56
|
+
return self[key]
|
|
57
|
+
except KeyError:
|
|
58
|
+
raise AttributeError(
|
|
59
|
+
f"'{self.__class__.__name__}' object has no attribute '{key}'"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __setattr__(self, key, value):
|
|
63
|
+
self[key] = value
|
|
64
|
+
|
|
65
|
+
def __delattr__(self, key):
|
|
66
|
+
try:
|
|
67
|
+
del self[key]
|
|
68
|
+
except KeyError:
|
|
69
|
+
raise AttributeError(
|
|
70
|
+
f"'{self.__class__.__name__}' object has no attribute '{key}'"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FaceSelector:
|
|
75
|
+
"""Mock for mesh.facesLeft and mesh.facesRight."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, index: int, size: int):
|
|
78
|
+
self.value = np.zeros(size, dtype=bool)
|
|
79
|
+
self.value[index] = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Mesh1D:
|
|
83
|
+
"""Lightweight 1D non-uniform finite-volume mesh."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, dx=None, nx=None, Lx=None):
|
|
86
|
+
if dx is None:
|
|
87
|
+
if nx is not None:
|
|
88
|
+
cell_dx = (Lx / nx) if Lx is not None else 1.0
|
|
89
|
+
dx = np.full(nx, cell_dx, dtype=np.float64)
|
|
90
|
+
else:
|
|
91
|
+
dx = np.array([1.0], dtype=np.float64)
|
|
92
|
+
elif np.ndim(dx) == 0:
|
|
93
|
+
if nx is not None:
|
|
94
|
+
dx = np.full(nx, float(dx), dtype=np.float64)
|
|
95
|
+
else:
|
|
96
|
+
dx = np.array([float(dx)], dtype=np.float64)
|
|
97
|
+
self.dx = np.asarray(dx, dtype=np.float64)
|
|
98
|
+
self.cellVolumes = self.dx
|
|
99
|
+
self.numberOfCells = len(self.dx)
|
|
100
|
+
self.faceCoordinates = np.concatenate(([0.0], np.cumsum(self.dx)))
|
|
101
|
+
self.cellCenters = np.array([(self.faceCoordinates[:-1] + self.faceCoordinates[1:]) / 2.0])
|
|
102
|
+
self.faceCenters = np.array([self.faceCoordinates])
|
|
103
|
+
self._cellDistances = np.concatenate((
|
|
104
|
+
[self.dx[0] / 2.0],
|
|
105
|
+
(self.dx[:-1] + self.dx[1:]) / 2.0,
|
|
106
|
+
[self.dx[-1] / 2.0],
|
|
107
|
+
))
|
|
108
|
+
self.facesLeft = FaceSelector(0, self.numberOfCells + 1)
|
|
109
|
+
self.facesRight = FaceSelector(self.numberOfCells, self.numberOfCells + 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ArrayProxy:
|
|
113
|
+
"""Lightweight NumPy array wrapper mimicking FiPy CellVariable for rate evaluations."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, val):
|
|
116
|
+
self.value = np.asarray(val, dtype=np.float64)
|
|
117
|
+
|
|
118
|
+
def __getattr__(self, name):
|
|
119
|
+
return getattr(self.value, name)
|
|
120
|
+
|
|
121
|
+
def __add__(self, other):
|
|
122
|
+
val = other.value if hasattr(other, "value") else other
|
|
123
|
+
return ArrayProxy(self.value + val)
|
|
124
|
+
|
|
125
|
+
def __radd__(self, other):
|
|
126
|
+
val = other.value if hasattr(other, "value") else other
|
|
127
|
+
return ArrayProxy(val + self.value)
|
|
128
|
+
|
|
129
|
+
def __sub__(self, other):
|
|
130
|
+
val = other.value if hasattr(other, "value") else other
|
|
131
|
+
return ArrayProxy(self.value - val)
|
|
132
|
+
|
|
133
|
+
def __rsub__(self, other):
|
|
134
|
+
val = other.value if hasattr(other, "value") else other
|
|
135
|
+
return ArrayProxy(val - self.value)
|
|
136
|
+
|
|
137
|
+
def __mul__(self, other):
|
|
138
|
+
val = other.value if hasattr(other, "value") else other
|
|
139
|
+
return ArrayProxy(self.value * val)
|
|
140
|
+
|
|
141
|
+
def __rmul__(self, other):
|
|
142
|
+
val = other.value if hasattr(other, "value") else other
|
|
143
|
+
return ArrayProxy(val * self.value)
|
|
144
|
+
|
|
145
|
+
def __truediv__(self, other):
|
|
146
|
+
val = other.value if hasattr(other, "value") else other
|
|
147
|
+
return ArrayProxy(self.value / val)
|
|
148
|
+
|
|
149
|
+
def __rtruediv__(self, other):
|
|
150
|
+
val = other.value if hasattr(other, "value") else other
|
|
151
|
+
return ArrayProxy(val / self.value)
|
|
152
|
+
|
|
153
|
+
def __pow__(self, power):
|
|
154
|
+
return ArrayProxy(self.value**power)
|
|
155
|
+
|
|
156
|
+
def __neg__(self):
|
|
157
|
+
return ArrayProxy(-self.value)
|
|
158
|
+
|
|
159
|
+
def __pos__(self):
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __abs__(self):
|
|
163
|
+
return ArrayProxy(abs(self.value))
|
|
164
|
+
|
|
165
|
+
def __array__(self, dtype=None):
|
|
166
|
+
return np.asarray(self.value, dtype=dtype)
|
|
167
|
+
|
|
168
|
+
def __getitem__(self, idx):
|
|
169
|
+
return self.value[idx]
|
|
170
|
+
|
|
171
|
+
def __setitem__(self, idx, val):
|
|
172
|
+
self.value[idx] = val
|
|
173
|
+
|
|
174
|
+
def __len__(self):
|
|
175
|
+
return len(self.value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class FaceValueProxy(ArrayProxy):
|
|
179
|
+
"""Array proxy for face-centered fields supporting FaceSelector indexing."""
|
|
180
|
+
|
|
181
|
+
def __getitem__(self, idx):
|
|
182
|
+
if hasattr(idx, "value"):
|
|
183
|
+
idx = idx.value
|
|
184
|
+
return super().__getitem__(idx)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class VariableArray(ArrayProxy):
|
|
188
|
+
"""Lightweight NumPy array wrapper mimicking FiPy CellVariable for state & rates."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, value=0.0, name="", mesh=None, hasOld=True, **kwargs):
|
|
191
|
+
val = kwargs.get("val", value)
|
|
192
|
+
val_arr = val.value if hasattr(val, "value") else val
|
|
193
|
+
if np.ndim(val_arr) == 0:
|
|
194
|
+
if mesh is not None:
|
|
195
|
+
n_cells = getattr(mesh, "numberOfCells", len(getattr(mesh, "dx", [])))
|
|
196
|
+
val_arr = np.full(n_cells, float(val_arr), dtype=np.float64)
|
|
197
|
+
else:
|
|
198
|
+
val_arr = np.array([float(val_arr)], dtype=np.float64)
|
|
199
|
+
super().__init__(val_arr)
|
|
200
|
+
self.name = name
|
|
201
|
+
self.mesh = mesh
|
|
202
|
+
self.hasOld = hasOld
|
|
203
|
+
self.old = ArrayProxy(self.value.copy())
|
|
204
|
+
|
|
205
|
+
def setValue(self, val, where=None):
|
|
206
|
+
if where is not None:
|
|
207
|
+
mask = where.value if hasattr(where, "value") else where
|
|
208
|
+
if isinstance(mask, np.ndarray) and mask.dtype == bool and len(mask) == len(self.value) + 1:
|
|
209
|
+
if mask[0]:
|
|
210
|
+
self.value[0] = np.asarray(val)
|
|
211
|
+
if mask[-1]:
|
|
212
|
+
self.value[-1] = np.asarray(val)
|
|
213
|
+
else:
|
|
214
|
+
self.value[mask] = np.asarray(val)
|
|
215
|
+
else:
|
|
216
|
+
self.value[:] = np.asarray(val)
|
|
217
|
+
|
|
218
|
+
def updateOld(self):
|
|
219
|
+
self.old.value[:] = self.value
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def faceValue(self):
|
|
223
|
+
if len(self.value) == 0:
|
|
224
|
+
return FaceValueProxy(self.value)
|
|
225
|
+
faces = np.empty(len(self.value) + 1, dtype=np.float64)
|
|
226
|
+
faces[0] = self.value[0]
|
|
227
|
+
faces[1:-1] = 0.5 * (self.value[:-1] + self.value[1:])
|
|
228
|
+
faces[-1] = self.value[-1]
|
|
229
|
+
return FaceValueProxy(faces)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def faceGrad(self):
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def constrain(self, *args, **kwargs):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def diff_coeff(T, m0, m1, phi):
|
|
240
|
+
"""Calculate the diffusion coeefficien in m^2/s.
|
|
241
|
+
|
|
242
|
+
T: temperature in C
|
|
243
|
+
phi: porosity in percent
|
|
244
|
+
m0, m1: parameter as from table X in Boudreau 1996
|
|
245
|
+
"""
|
|
246
|
+
# If phi is a FiPy CellVariable, use its .value for numpy math
|
|
247
|
+
phi_val = getattr(phi, "value", phi)
|
|
248
|
+
return (m0 + m1 * T) * 1e-10 / (1 - np.log(phi_val**2))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_delta(c, li, r, threshold: Union[float, np.ndarray, None] = 1e-6):
|
|
252
|
+
"""Calculate the delta from the mass of light and heavy isotope.
|
|
253
|
+
|
|
254
|
+
:param c: total mass/concentration
|
|
255
|
+
:param li: light isotope mass/concentration
|
|
256
|
+
:param r: reference ratio
|
|
257
|
+
:param threshold: concentration threshold below which delta is undefined (NaN).
|
|
258
|
+
Can be a float or array matching c/li. Defaults to 1e-6.
|
|
259
|
+
|
|
260
|
+
:return : delta
|
|
261
|
+
|
|
262
|
+
"""
|
|
263
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
264
|
+
# 1. Numerical Safeguards
|
|
265
|
+
# Ensure total mass 'c' is at least 'li' (light isotope) to avoid negative heavy mass
|
|
266
|
+
# and prevent division by very near zero or zero.
|
|
267
|
+
li_safe = np.maximum(li, 1e-30)
|
|
268
|
+
c_safe = np.maximum(c, li_safe)
|
|
269
|
+
|
|
270
|
+
h = c_safe - li_safe
|
|
271
|
+
ratio = h / li_safe
|
|
272
|
+
|
|
273
|
+
# 2. Thresholding for NaN
|
|
274
|
+
thresh = 1e-6 if threshold is None else threshold
|
|
275
|
+
d = np.where(li < thresh, np.nan, 1000 * (ratio - r) / r)
|
|
276
|
+
|
|
277
|
+
# 3. Clipping Extreme Values
|
|
278
|
+
d = np.clip(d, -1000.0, 1000.0)
|
|
279
|
+
|
|
280
|
+
return d
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_delta_from_concentration(c, li, r, threshold: Union[float, np.ndarray, None] = 1e-6):
|
|
284
|
+
"""Calculate the delta from the mass of light and heavy isotope.
|
|
285
|
+
|
|
286
|
+
:param c: total mass/concentration
|
|
287
|
+
:param li: light isotope mass/concentration
|
|
288
|
+
:param r: reference ratio
|
|
289
|
+
:param threshold: concentration threshold below which delta is undefined (NaN).
|
|
290
|
+
Can be a float or array matching c/li. Defaults to 1e-6.
|
|
291
|
+
"""
|
|
292
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
293
|
+
li_safe = np.maximum(li, 1e-30)
|
|
294
|
+
c_safe = np.maximum(c, li_safe)
|
|
295
|
+
h = c_safe - li_safe
|
|
296
|
+
ratio = h / li_safe
|
|
297
|
+
thresh = 1e-6 if threshold is None else threshold
|
|
298
|
+
d = np.where(li < thresh, np.nan, 1000 * (ratio - r) / r)
|
|
299
|
+
d = np.clip(d, -1000.0, 1000.0)
|
|
300
|
+
|
|
301
|
+
return d
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def is_solid_species(species_name: str, mp: Any = None) -> bool:
|
|
305
|
+
"""Determine whether a chemical species is in the solid/particulate phase.
|
|
306
|
+
|
|
307
|
+
Checks (in priority order):
|
|
308
|
+
1. mp.bc_map: if species has 'type' in ('particulate', 'solid')
|
|
309
|
+
2. mp.species or mp.species_defs: if species dict has 'solid': True
|
|
310
|
+
3. Standard diagenetic species conventions (FeS, FeS2, S0, Fe3, POC are solid;
|
|
311
|
+
SO4, TS2, h2s, hs, Fe2_total, O2 are dissolved/liquid).
|
|
312
|
+
"""
|
|
313
|
+
if not species_name:
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
base_name = species_name.replace("_32", "")
|
|
317
|
+
|
|
318
|
+
if mp is not None:
|
|
319
|
+
# 1. Check mp.bc_map
|
|
320
|
+
bc_map = getattr(mp, "bc_map", None)
|
|
321
|
+
if isinstance(bc_map, dict):
|
|
322
|
+
for candidate in (species_name, base_name):
|
|
323
|
+
if candidate in bc_map and isinstance(bc_map[candidate], dict):
|
|
324
|
+
sp_type = str(bc_map[candidate].get("type", "")).lower()
|
|
325
|
+
if sp_type in ("particulate", "solid"):
|
|
326
|
+
return True
|
|
327
|
+
if sp_type in ("dissolved", "liquid"):
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
# 2. Check mp.species or mp.species_defs
|
|
331
|
+
for attr in ("species", "species_defs"):
|
|
332
|
+
sp_dict = getattr(mp, attr, None)
|
|
333
|
+
if isinstance(sp_dict, dict):
|
|
334
|
+
for candidate in (species_name, base_name):
|
|
335
|
+
if candidate in sp_dict and isinstance(sp_dict[candidate], dict):
|
|
336
|
+
is_sol = sp_dict[candidate].get("solid")
|
|
337
|
+
if is_sol is not None:
|
|
338
|
+
return bool(is_sol)
|
|
339
|
+
|
|
340
|
+
# 3. Known convention fallback
|
|
341
|
+
known_solids = {"FeS", "FeS2", "S0", "Fe3", "POC", "POC_fast", "POC_slow"}
|
|
342
|
+
known_liquids = {"SO4", "TS2", "h2s", "hs", "H2S", "HS", "Fe2_total", "O2"}
|
|
343
|
+
|
|
344
|
+
if base_name in known_solids:
|
|
345
|
+
return True
|
|
346
|
+
if base_name in known_liquids:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_species_delta_threshold(
|
|
353
|
+
species_name: str,
|
|
354
|
+
mp: Any = None,
|
|
355
|
+
phi: Any = None,
|
|
356
|
+
) -> Union[float, np.ndarray]:
|
|
357
|
+
"""Computes concentration threshold for isotope delta calculation based on species type.
|
|
358
|
+
|
|
359
|
+
Precedence:
|
|
360
|
+
1. Species-specific parameter: <speciesname>_delta_threshold (e.g. TS2_delta_threshold).
|
|
361
|
+
2. Phase-specific override: mp.delta_threshold_solid or mp.delta_threshold_liquid.
|
|
362
|
+
3. Defaults:
|
|
363
|
+
- Liquids: 0.001 mmol/L (1 umol/L porewater).
|
|
364
|
+
- Solids: bulk-equivalent concentration: thresh_liq * phi / (1 - phi) mmol/L (solid).
|
|
365
|
+
"""
|
|
366
|
+
if not species_name:
|
|
367
|
+
return 0.001
|
|
368
|
+
|
|
369
|
+
base_name = species_name.replace("_32", "")
|
|
370
|
+
|
|
371
|
+
# 1. Species-specific override on mp
|
|
372
|
+
if mp is not None:
|
|
373
|
+
for candidate in (
|
|
374
|
+
f"{species_name}_delta_threshold",
|
|
375
|
+
f"{base_name}_delta_threshold",
|
|
376
|
+
f"{species_name.lower()}_delta_threshold",
|
|
377
|
+
f"{base_name.lower()}_delta_threshold",
|
|
378
|
+
):
|
|
379
|
+
val = getattr(mp, candidate, None) if hasattr(mp, candidate) else (mp.get(candidate) if isinstance(mp, dict) else None)
|
|
380
|
+
if val is not None:
|
|
381
|
+
return float(val)
|
|
382
|
+
|
|
383
|
+
# 2. Phase-specific overrides / defaults
|
|
384
|
+
thresh_liq = float(getattr(mp, "delta_threshold_liquid", 0.001)) if mp is not None else 0.001
|
|
385
|
+
|
|
386
|
+
if is_solid_species(species_name, mp=mp):
|
|
387
|
+
custom_solid = getattr(mp, "delta_threshold_solid", None) if mp is not None else None
|
|
388
|
+
if custom_solid is not None:
|
|
389
|
+
return float(custom_solid)
|
|
390
|
+
|
|
391
|
+
# Bulk equivalent: C_bulk = C_solid * (1 - phi) = C_liquid * phi
|
|
392
|
+
# -> C_solid = C_liquid * phi / (1 - phi)
|
|
393
|
+
if phi is None and mp is not None:
|
|
394
|
+
phi = getattr(mp, "phi", None)
|
|
395
|
+
if hasattr(phi, "value"):
|
|
396
|
+
phi = phi.value
|
|
397
|
+
if phi is not None:
|
|
398
|
+
phi_arr = np.asarray(phi, dtype=float)
|
|
399
|
+
denom = np.maximum(1.0 - phi_arr, 1e-12)
|
|
400
|
+
return thresh_liq * phi_arr / denom
|
|
401
|
+
return thresh_liq * 4.0 # default phi=0.8 fallback -> thresh_liq * (0.8 / 0.2) = 0.004
|
|
402
|
+
else:
|
|
403
|
+
return thresh_liq
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def get_l_mass(m, d, r):
|
|
408
|
+
"""Calculate the light isotope mass from the total mass and delta.
|
|
409
|
+
|
|
410
|
+
:param m: total mass/concentration
|
|
411
|
+
:param d: delta
|
|
412
|
+
:param r: reference ratio
|
|
413
|
+
|
|
414
|
+
:return: light isotope mass
|
|
415
|
+
"""
|
|
416
|
+
return (1000.0 * m) / ((d + 1000.0) * r + 1000.0)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def get_total_s_export(df, VCDT=0.044162589):
|
|
420
|
+
"""Calculate the total sulfur and delta34S at the bottom of the column.
|
|
421
|
+
|
|
422
|
+
Uses the logic as defined in run_fipy.py.
|
|
423
|
+
"""
|
|
424
|
+
phi = df.phi.iloc[-1]
|
|
425
|
+
s = phi * (df.c_SO4.iloc[-1] + df.c_h2s.iloc[-1]) + (1 - phi) * (
|
|
426
|
+
df.c_S0.iloc[-1] + df.c_FeS.iloc[-1] + 2 * df.c_FeS2.iloc[-1]
|
|
427
|
+
)
|
|
428
|
+
s32 = phi * (df.c_SO4_32.iloc[-1] + df.c_h2s_32.iloc[-1]) + (1 - phi) * (
|
|
429
|
+
df.c_S0_32.iloc[-1] + df.c_FeS_32.iloc[-1] + df.c_FeS2_32.iloc[-1]
|
|
430
|
+
)
|
|
431
|
+
h = s - s32
|
|
432
|
+
d34s = np.where(s32 < 0.001, np.nan, 1000 * (h / s32 - VCDT) / VCDT)
|
|
433
|
+
return float(s), float(d34s)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def relax_solution(curr_sol, last_sol, fraction):
|
|
437
|
+
"""Blend two solution vectors.
|
|
438
|
+
|
|
439
|
+
In such away that they only chance by a given fraction
|
|
440
|
+
"""
|
|
441
|
+
sol = last_sol * (1 - fraction) + curr_sol * fraction
|
|
442
|
+
return sol * (sol >= 0) # exclude negative solutions
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def bioturbation_profile(z, D_max, cutoff_depth, threshold=1e-12):
|
|
446
|
+
"""
|
|
447
|
+
Calculate the necessary steepness.
|
|
448
|
+
|
|
449
|
+
To fit a mixing profile into 'cutoff_depth', ensuring D_max at surface and
|
|
450
|
+
'threshold' at cutoff.
|
|
451
|
+
"""
|
|
452
|
+
# 1. Safety Checks
|
|
453
|
+
if cutoff_depth <= 0 or D_max <= threshold:
|
|
454
|
+
return np.zeros_like(z)
|
|
455
|
+
|
|
456
|
+
# 2. Calculate the Magnitude of the Drop
|
|
457
|
+
# We need to drop from D_max down to threshold.
|
|
458
|
+
# ratio represents the magnitude of this drop (e.g., 10,000,000x)
|
|
459
|
+
ratio = (D_max / threshold) - 1
|
|
460
|
+
log_ratio = np.log(ratio)
|
|
461
|
+
|
|
462
|
+
# 3. Dynamic Steepness Calculation
|
|
463
|
+
# We want the "slide" to start at z=0 and finish exactly at z=cutoff_depth.
|
|
464
|
+
# To fit the full drop (log_ratio) into the distance (cutoff_depth):
|
|
465
|
+
# k = total_drop_in_log_units / distance
|
|
466
|
+
# We add a safety factor (e.g., 1.1) to ensure the 'shoulder' is slightly below surface
|
|
467
|
+
calculated_steepness = (log_ratio / cutoff_depth) * 1.05
|
|
468
|
+
|
|
469
|
+
# 4. Calculate Inflection Point
|
|
470
|
+
# The inflection point is where the value is half of D_max.
|
|
471
|
+
# Based on the calculated steepness, we shift the curve so the tail hits
|
|
472
|
+
# 'threshold' exactly at 'cutoff_depth'.
|
|
473
|
+
shift = log_ratio / calculated_steepness
|
|
474
|
+
inflection_point = cutoff_depth - shift
|
|
475
|
+
|
|
476
|
+
# 5. Generate Sigmoid
|
|
477
|
+
sigmoid = D_max / (1 + np.exp(calculated_steepness * (z - inflection_point)))
|
|
478
|
+
|
|
479
|
+
# 6. Hard Clamp
|
|
480
|
+
sigmoid[z > cutoff_depth] = 0.0
|
|
481
|
+
|
|
482
|
+
return sigmoid
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def bioturbation_profile_2(z, D_max, cutoff_depth, threshold=1e-12):
|
|
486
|
+
"""
|
|
487
|
+
Generate a sigmoid mixing profile.
|
|
488
|
+
|
|
489
|
+
That fits EXACTLY within 'cutoff_depth'. Calculates dynamic steepness so shallow
|
|
490
|
+
depths get sharper curves automatically.
|
|
491
|
+
"""
|
|
492
|
+
# Safety: If depth is zero or D_max is negligible, return zeros
|
|
493
|
+
if cutoff_depth <= 0 or D_max <= threshold:
|
|
494
|
+
return np.zeros_like(z)
|
|
495
|
+
|
|
496
|
+
# 1. Calculate how "steep" we need to be to drop from D_max to threshold
|
|
497
|
+
# within the allocated depth.
|
|
498
|
+
# formula: D_max / (1 + exp(k * z)) = threshold
|
|
499
|
+
ratio = (D_max / threshold) - 1
|
|
500
|
+
if ratio <= 0:
|
|
501
|
+
return np.zeros_like(z)
|
|
502
|
+
|
|
503
|
+
total_drop_log = np.log(ratio)
|
|
504
|
+
|
|
505
|
+
# We want the drop to finish slightly before the cutoff (95% of depth)
|
|
506
|
+
# to ensure the clamp is clean.
|
|
507
|
+
effective_depth = cutoff_depth * 0.95
|
|
508
|
+
calculated_steepness = total_drop_log / effective_depth
|
|
509
|
+
|
|
510
|
+
# 2. Calculate the "Shift" (Inflection Point)
|
|
511
|
+
# We shift the curve so it starts flat at surface and drops at the end.
|
|
512
|
+
# Shifting by log(ratio)/k moves the tail to the cutoff.
|
|
513
|
+
# We shift slightly less to keep the 'shoulder' near the surface.
|
|
514
|
+
shift = total_drop_log / calculated_steepness
|
|
515
|
+
inflection_point = cutoff_depth - shift
|
|
516
|
+
|
|
517
|
+
# 3. Generate Profile
|
|
518
|
+
sigmoid = D_max / (1 + np.exp(calculated_steepness * (z - inflection_point)))
|
|
519
|
+
|
|
520
|
+
# 4. Hard Clamp to ensure true zero below the cutoff
|
|
521
|
+
sigmoid[z > cutoff_depth] = 0.0
|
|
522
|
+
|
|
523
|
+
return sigmoid
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def compute_sigmoidal_db(z, Db0, xL, xbm):
|
|
527
|
+
"""
|
|
528
|
+
Compute the bio-diffusivity (Db) at a specific depth (z).
|
|
529
|
+
|
|
530
|
+
Using Equation 4 from van de Velde and Meysman (2016).
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
z : float or np.ndarray
|
|
535
|
+
Depth into the sediment in m
|
|
536
|
+
Db0 : float
|
|
537
|
+
Bio-diffusivity coefficient m^2/s
|
|
538
|
+
xL : float
|
|
539
|
+
Depth of the mixed layer in m
|
|
540
|
+
xbm : float
|
|
541
|
+
Attenuation coefficient determining the width of the transition zone [m]
|
|
542
|
+
|
|
543
|
+
Returns
|
|
544
|
+
-------
|
|
545
|
+
float or np.ndarray: The bio-diffusivity at depth z.
|
|
546
|
+
"""
|
|
547
|
+
z_cm = z * 100
|
|
548
|
+
xL_cm = xL * 100
|
|
549
|
+
xbm_cm = xbm * 100
|
|
550
|
+
|
|
551
|
+
# Define the term used in the exponents
|
|
552
|
+
exponent_term = -(z_cm - xL_cm) / (0.25 * xbm_cm)
|
|
553
|
+
|
|
554
|
+
# Use a robust sigmoid formula to avoid overflow/NaN warnings
|
|
555
|
+
# We clip the exponent to a range that won't overflow double precision floats (~700)
|
|
556
|
+
# This still results in 0.0 or Db0 as expected at the limits.
|
|
557
|
+
Db_z = Db0 / (1.0 + np.exp(np.clip(-exponent_term, -700, 700)))
|
|
558
|
+
|
|
559
|
+
return Db_z
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def compute_bio_irrigation_alpha(z, alpha0, x_irr):
|
|
563
|
+
"""
|
|
564
|
+
Compute the bio-irrigation coefficient (alpha) at a specific depth (z).
|
|
565
|
+
|
|
566
|
+
Using Equation 6 from van de Velde and Meysman (2016).
|
|
567
|
+
|
|
568
|
+
Parameters
|
|
569
|
+
----------
|
|
570
|
+
z : float or np.ndarray
|
|
571
|
+
Depth into the sediment (m).
|
|
572
|
+
alpha0: float
|
|
573
|
+
Bio-irrigation coefficient at the sediment-water interface(SWI).
|
|
574
|
+
x_irr : float
|
|
575
|
+
Attenuation coefficient determining the depth of irrigation (m).
|
|
576
|
+
|
|
577
|
+
Returns
|
|
578
|
+
-------
|
|
579
|
+
float or np.ndarray
|
|
580
|
+
The irrigation intensity at depth z.
|
|
581
|
+
"""
|
|
582
|
+
if alpha0 == 0.0 or x_irr <= 0.0:
|
|
583
|
+
return np.zeros_like(z, dtype=np.float64)
|
|
584
|
+
z = z * 100
|
|
585
|
+
x_irr = x_irr * 100
|
|
586
|
+
# Equation 6: alpha(z) = alpha0 * exp(-z / x_irr)
|
|
587
|
+
alpha_z = alpha0 * np.exp(-z / x_irr)
|
|
588
|
+
|
|
589
|
+
return alpha_z
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def make_grid(L, initial_spacing, max_spacing, r=1.05):
|
|
593
|
+
"""
|
|
594
|
+
Construct a 1D grid driven by spacing constraints.
|
|
595
|
+
|
|
596
|
+
Growth is geometric (at rate r) until max_spacing is reached.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
-----
|
|
600
|
+
L (float): Total length of the domain (max depth).
|
|
601
|
+
initial_spacing (float): The size of the first cell (at z=0).
|
|
602
|
+
max_spacing (float): Maximum cell size allowed.
|
|
603
|
+
r (float): Geometric growth rate (default 1.05).
|
|
604
|
+
|
|
605
|
+
Returns
|
|
606
|
+
-------
|
|
607
|
+
tuple (mesh, z_centers)
|
|
608
|
+
mesh: A Mesh1D object.
|
|
609
|
+
z_centers: A numpy array of cell center coordinates.
|
|
610
|
+
"""
|
|
611
|
+
if initial_spacing >= max_spacing:
|
|
612
|
+
initial_spacing = max_spacing
|
|
613
|
+
|
|
614
|
+
dx_list = []
|
|
615
|
+
current_z = 0
|
|
616
|
+
current_dx = initial_spacing
|
|
617
|
+
|
|
618
|
+
# 1. Geometric Section
|
|
619
|
+
while current_z + current_dx < L and current_dx < max_spacing:
|
|
620
|
+
dx_list.append(current_dx)
|
|
621
|
+
current_z += current_dx
|
|
622
|
+
current_dx *= r
|
|
623
|
+
|
|
624
|
+
# 2. Uniform Section
|
|
625
|
+
if current_z < L:
|
|
626
|
+
remaining_L = L - current_z
|
|
627
|
+
n_uniform = int(np.ceil(remaining_L / max_spacing))
|
|
628
|
+
if n_uniform > 0:
|
|
629
|
+
actual_uniform_dx = remaining_L / n_uniform
|
|
630
|
+
dx_list.extend([actual_uniform_dx] * n_uniform)
|
|
631
|
+
|
|
632
|
+
dx_array = np.array(dx_list)
|
|
633
|
+
N = len(dx_array)
|
|
634
|
+
print(f"Grid generated with {N} points.")
|
|
635
|
+
|
|
636
|
+
mesh = Mesh1D(dx=dx_array)
|
|
637
|
+
z_centers = mesh.cellCenters[0]
|
|
638
|
+
|
|
639
|
+
return mesh, z_centers
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def save_data(mp, c, k, species_list, z, D_mol, diagenetic_reactions, equilibrium_reactions=None):
|
|
643
|
+
"""
|
|
644
|
+
Save the model results to a CSV file (Synchronous).
|
|
645
|
+
"""
|
|
646
|
+
c_numpy = data_container({s: ArrayProxy(val.value) for s, val in c.items()})
|
|
647
|
+
mp_numpy = data_container(mp)
|
|
648
|
+
mp_numpy.phi = ArrayProxy(mp.phi.value)
|
|
649
|
+
f_final, RATES = diagenetic_reactions(mp_numpy, c_numpy, k, data_container())
|
|
650
|
+
|
|
651
|
+
return _save_data_to_disk(mp, c, k, species_list, z, D_mol, f_final)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _save_data_to_disk(mp, c, k, species_list, z, D_mol, f_final):
|
|
655
|
+
"""Internal helper to write data to disk."""
|
|
656
|
+
import pandas as pd
|
|
657
|
+
import pathlib as pl
|
|
658
|
+
|
|
659
|
+
data = {"z": z}
|
|
660
|
+
|
|
661
|
+
def get_v(obj):
|
|
662
|
+
"""Helper to extract value from FiPy variables or return as-is."""
|
|
663
|
+
if hasattr(obj, "value"):
|
|
664
|
+
return obj.value
|
|
665
|
+
return obj
|
|
666
|
+
|
|
667
|
+
# 1. Collect all basic concentrations and rates
|
|
668
|
+
for species_name in species_list:
|
|
669
|
+
data[f"c_{species_name}"] = get_v(getattr(c, species_name))
|
|
670
|
+
res_tuple = getattr(f_final, species_name)
|
|
671
|
+
data[f"f_{species_name}"] = get_v(res_tuple[2])
|
|
672
|
+
|
|
673
|
+
# Export process-specific rates dynamically
|
|
674
|
+
for key in f_final.keys():
|
|
675
|
+
if key not in species_list:
|
|
676
|
+
res_tuple = getattr(f_final, key)
|
|
677
|
+
data[f"f_{key}"] = get_v(res_tuple[2])
|
|
678
|
+
|
|
679
|
+
# 2. Save all items in D_mol (diffusion coefficients)
|
|
680
|
+
for d_name, d_val in D_mol.items():
|
|
681
|
+
key = f"D_{d_name}" if d_name in species_list else d_name
|
|
682
|
+
data[key] = get_v(d_val)
|
|
683
|
+
|
|
684
|
+
# 3. Calculate delta values for specific sulfur species
|
|
685
|
+
# We prioritize common names (SO4, h2s, FeS, S0, FeS2)
|
|
686
|
+
isotope_map = {
|
|
687
|
+
"SO4": "SO4_32",
|
|
688
|
+
"h2s": "h2s_32",
|
|
689
|
+
"hs": "hs_32",
|
|
690
|
+
"TS2": "TS2_32",
|
|
691
|
+
"FeS": "FeS_32",
|
|
692
|
+
"S0": "S0_32",
|
|
693
|
+
"FeS2": "FeS2_32",
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
phi_val = data.get("phi", getattr(mp, "phi", None))
|
|
697
|
+
|
|
698
|
+
for base, iso in isotope_map.items():
|
|
699
|
+
if f"c_{base}" in data and f"c_{iso}" in data:
|
|
700
|
+
s_total = data[f"c_{base}"]
|
|
701
|
+
if base == "FeS2":
|
|
702
|
+
s_total = 2.0 * s_total
|
|
703
|
+
s32 = data[f"c_{iso}"]
|
|
704
|
+
thresh = get_species_delta_threshold(base, mp=mp, phi=phi_val)
|
|
705
|
+
data[f"d_{base}"] = get_delta(s_total, s32, mp.VCDT, threshold=thresh)
|
|
706
|
+
|
|
707
|
+
data["w"] = np.ones(len(z)) * mp.w
|
|
708
|
+
data["phi"] = np.ones(len(z)) * mp.phi
|
|
709
|
+
|
|
710
|
+
df = pd.DataFrame(data)
|
|
711
|
+
# Ensure no NaN from misalignment by strictly using the same structure
|
|
712
|
+
fqfn = pl.Path.cwd() / f"{mp.plot_name}.csv"
|
|
713
|
+
df.to_csv(fqfn, index=False)
|
|
714
|
+
|
|
715
|
+
return df, fqfn
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def save_state(c, filename):
|
|
719
|
+
"""
|
|
720
|
+
Save the current concentration profiles (state) to a compressed numpy file.
|
|
721
|
+
|
|
722
|
+
:param c: data_container containing species as CellVariables
|
|
723
|
+
:param filename: Path to save the NPZ file
|
|
724
|
+
"""
|
|
725
|
+
from pathlib import Path
|
|
726
|
+
|
|
727
|
+
path = Path(filename)
|
|
728
|
+
if path.exists():
|
|
729
|
+
path.rename(f"{filename}-old")
|
|
730
|
+
|
|
731
|
+
state_dict = {}
|
|
732
|
+
for key, var in c.items():
|
|
733
|
+
if hasattr(var, "value"):
|
|
734
|
+
state_dict[key] = var.value
|
|
735
|
+
|
|
736
|
+
np.savez_compressed(filename, **state_dict)
|
|
737
|
+
print(f"State saved to {filename}")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def read_state(c, filename):
|
|
741
|
+
"""
|
|
742
|
+
Read concentration profiles (state) from a compressed numpy file and update c.
|
|
743
|
+
|
|
744
|
+
:param c: data_container containing species as CellVariables
|
|
745
|
+
:param filename: Path to the NPZ file
|
|
746
|
+
"""
|
|
747
|
+
import pathlib as pl
|
|
748
|
+
|
|
749
|
+
if not pl.Path(filename).exists():
|
|
750
|
+
print(f"Warning: State file {filename} not found. Skipping initialization.")
|
|
751
|
+
return False
|
|
752
|
+
|
|
753
|
+
with np.load(filename) as data:
|
|
754
|
+
for key in data.files:
|
|
755
|
+
if hasattr(c, key):
|
|
756
|
+
var = getattr(c, key)
|
|
757
|
+
if hasattr(var, "setValue"):
|
|
758
|
+
var.setValue(data[key])
|
|
759
|
+
else:
|
|
760
|
+
c[key] = data[key]
|
|
761
|
+
else:
|
|
762
|
+
print(
|
|
763
|
+
f"Warning: Species {key} found in state file but not in model container."
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
print(f"State loaded from {filename}")
|
|
767
|
+
return True
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def safe_ratio(
|
|
771
|
+
num: np.ndarray,
|
|
772
|
+
den: np.ndarray,
|
|
773
|
+
fill: Union[float, int],
|
|
774
|
+
) -> np.ndarray:
|
|
775
|
+
"""
|
|
776
|
+
Return ``num / den`` element‑wise while protecting against division‑by‑zero.
|
|
777
|
+
|
|
778
|
+
Parameters
|
|
779
|
+
----------
|
|
780
|
+
num : np.ndarray
|
|
781
|
+
Numerator array (any shape that broadcasts with ``den``).
|
|
782
|
+
den : np.ndarray
|
|
783
|
+
Denominator array. Zeros are handled gracefully.
|
|
784
|
+
fill : float or int, optional
|
|
785
|
+
Value to place where ``den == 0``. The default is ``np.nan``.
|
|
786
|
+
Use ``0`` if you prefer a zero‑filled result.
|
|
787
|
+
|
|
788
|
+
Returns
|
|
789
|
+
-------
|
|
790
|
+
np.ndarray
|
|
791
|
+
Array of the same shape as the broadcasted inputs containing the
|
|
792
|
+
element‑wise ratios. Positions where ``den`` is zero contain ``fill``.
|
|
793
|
+
|
|
794
|
+
Notes
|
|
795
|
+
-----
|
|
796
|
+
* ``np.divide`` is used with the ``where`` argument – this avoids the
|
|
797
|
+
creation of intermediate infinities and suppresses the runtime warning.
|
|
798
|
+
* The output array is allocated with ``np.empty_like(num, dtype=float)`` so
|
|
799
|
+
the result is always a floating‑point array, even if the inputs are integer.
|
|
800
|
+
"""
|
|
801
|
+
# Ensure float output – division of ints would truncate otherwise
|
|
802
|
+
out = np.empty_like(num, dtype=float)
|
|
803
|
+
|
|
804
|
+
# Perform division only where denominator is non‑zero
|
|
805
|
+
np.divide(num, den, out=out, where=den != 0)
|
|
806
|
+
|
|
807
|
+
# Fill the “bad” positions
|
|
808
|
+
out[den == 0] = fill
|
|
809
|
+
return out
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def calculate_k_iron_reduction(Fe3, h2s):
|
|
813
|
+
"""
|
|
814
|
+
Calculates the rate constant k_FeOx-SII for an array of ratios.
|
|
815
|
+
Fe3+/H2S
|
|
816
|
+
|
|
817
|
+
Based on Equation 46 and 47 from Halevy et al. (2023).
|
|
818
|
+
"""
|
|
819
|
+
# 1. Define the piecewise conditions for half-life (tau_1/2) in hours
|
|
820
|
+
# Condition 1: Ratio < 1 -> tau = 1.5h [cite: 1462]
|
|
821
|
+
# Condition 2: 1 <= Ratio <= 2 -> Linear transition [cite: 1463]
|
|
822
|
+
# Condition 3: Ratio > 2 -> tau = 0.5h [cite: 1464]
|
|
823
|
+
|
|
824
|
+
ratios = safe_ratio(Fe3, h2s, 10.0)
|
|
825
|
+
|
|
826
|
+
tau_half = np.piecewise(
|
|
827
|
+
ratios,
|
|
828
|
+
[ratios < 1, (ratios >= 1) & (ratios <= 2), ratios > 2],
|
|
829
|
+
[1.5, lambda r: 0.5 + 1.0 * (2.0 - r), 0.5],
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# 2. Calculate the rate constant k = 0.693 / tau_1/2
|
|
833
|
+
k_values = 0.693 / tau_half
|
|
834
|
+
|
|
835
|
+
return k_values / (60 * 60 * 24 * 1e3)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def wt_percent_to_solid_conc(wp, mw, d, phi):
|
|
839
|
+
"""
|
|
840
|
+
Convert a weight‑percent (dry mass) of a solid component to its
|
|
841
|
+
concentration in bulk solution (mmol L⁻¹, i.e. mol m⁻³).
|
|
842
|
+
|
|
843
|
+
Parameters
|
|
844
|
+
----------
|
|
845
|
+
wp : float or array‑like
|
|
846
|
+
Weight percentage of the component (0–100 %).
|
|
847
|
+
mw : float
|
|
848
|
+
Molecular weight of the component (g mol⁻¹).
|
|
849
|
+
d : float
|
|
850
|
+
Grain density of the pure solid (g cm⁻³).
|
|
851
|
+
phi : float
|
|
852
|
+
Porosity of the bulk material (fraction, 0–1).
|
|
853
|
+
|
|
854
|
+
Returns
|
|
855
|
+
-------
|
|
856
|
+
C_bulk : float or np.ndarray
|
|
857
|
+
Concentration in mol m⁻³ (identical numerically to mmol L⁻¹).
|
|
858
|
+
|
|
859
|
+
Notes
|
|
860
|
+
-----
|
|
861
|
+
The conversion assumes a representative bulk volume of 1 m³.
|
|
862
|
+
1 m³ = 1 000 000 cm³, hence the factor 1e6 in the formula.
|
|
863
|
+
"""
|
|
864
|
+
# Ensure inputs are NumPy arrays for broadcasting (works also for scalars)
|
|
865
|
+
wp = np.asarray(wp, dtype=float)
|
|
866
|
+
phi = np.asarray(phi, dtype=float)
|
|
867
|
+
|
|
868
|
+
# 1. Mass fraction (dimensionless)
|
|
869
|
+
w_frac = wp / 100.0
|
|
870
|
+
|
|
871
|
+
phi_val = getattr(phi, "value", phi)
|
|
872
|
+
|
|
873
|
+
# 2. Bulk density of the pure component (g cm⁻³)
|
|
874
|
+
rho_bulk = d * (1.0 - phi_val)
|
|
875
|
+
|
|
876
|
+
# 3. Mass of the component per 1 m³ bulk volume (g)
|
|
877
|
+
# 1 m³ = 1 000 000 cm³
|
|
878
|
+
mass_per_m3 = rho_bulk * w_frac * 1.0e6 # g m⁻³
|
|
879
|
+
|
|
880
|
+
# 4. Convert mass to moles (mol m⁻³)
|
|
881
|
+
C_bulk = mass_per_m3 / mw # mol m⁻³
|
|
882
|
+
|
|
883
|
+
return C_bulk
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def solid_conc_to_umol_per_g(C_solid, d):
|
|
887
|
+
"""
|
|
888
|
+
Convert a concentration that is expressed per unit solid volume
|
|
889
|
+
(mmol L⁻¹ solid) to mol/dry weight.
|
|
890
|
+
|
|
891
|
+
Parameters
|
|
892
|
+
----------
|
|
893
|
+
C_solid : float or array‑like
|
|
894
|
+
Concentration per unit solid volume (mmol L⁻¹ of solid).
|
|
895
|
+
umol/cm^3 or mol/m^3
|
|
896
|
+
d : float
|
|
897
|
+
Grain (particle) density of the pure solid (g cm⁻³).
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
Returns
|
|
901
|
+
-------
|
|
902
|
+
umol_gram : float or np.ndarray
|
|
903
|
+
umol/g *dry* sediment
|
|
904
|
+
"""
|
|
905
|
+
|
|
906
|
+
return C_solid / d
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def solid_conc_to_wt_percent(C_solid, mw, d, phi):
|
|
910
|
+
"""
|
|
911
|
+
Convert a concentration that is expressed per unit solid volume
|
|
912
|
+
(mmol L⁻¹ solid) to a weight‑percent of the dry sediment matrix.
|
|
913
|
+
|
|
914
|
+
Parameters
|
|
915
|
+
----------
|
|
916
|
+
C_solid : float or array‑like
|
|
917
|
+
Concentration per unit solid volume (mmol L⁻¹ of solid).
|
|
918
|
+
Numerically this is equivalent to mol m⁻³ of solid because
|
|
919
|
+
1 mmol L⁻¹ = 1 mol m⁻³.
|
|
920
|
+
mw : float
|
|
921
|
+
Molecular weight of the component (g mol⁻¹).
|
|
922
|
+
d : float
|
|
923
|
+
Grain (particle) density of the pure solid (g cm⁻³).
|
|
924
|
+
phi : float
|
|
925
|
+
Porosity of the bulk sediment (fraction, 0 ≤ phi ≤ 1).
|
|
926
|
+
|
|
927
|
+
Returns
|
|
928
|
+
-------
|
|
929
|
+
wt_percent : float or np.ndarray
|
|
930
|
+
Weight percentage of the component in the *dry* sediment
|
|
931
|
+
(0 – 100 %). If `phi == 1` (no solid) the function returns
|
|
932
|
+
`np.nan` because a weight‑percent is undefined.
|
|
933
|
+
|
|
934
|
+
Notes
|
|
935
|
+
-----
|
|
936
|
+
The conversion follows the steps:
|
|
937
|
+
|
|
938
|
+
1. Convert solid‑phase concentration to bulk‑phase concentration:
|
|
939
|
+
C_bulk = C_solid * (1‑phi) [mol m⁻³ bulk].
|
|
940
|
+
|
|
941
|
+
2. Mass of solute per bulk volume:
|
|
942
|
+
m_sol = C_bulk * mw [g m⁻³].
|
|
943
|
+
|
|
944
|
+
3. Dry bulk density of the sediment:
|
|
945
|
+
rho_dry = d * (1‑phi) * 1e6 [g m⁻³].
|
|
946
|
+
|
|
947
|
+
4. Weight fraction = m_sol / rho_dry, then *100 for wt %.
|
|
948
|
+
|
|
949
|
+
Because the factor (1‑phi) appears in both numerator and denominator,
|
|
950
|
+
it cancels analytically, leaving a result that is independent of porosity.
|
|
951
|
+
The implementation keeps the full expression for clarity and to
|
|
952
|
+
guard against accidental misuse.
|
|
953
|
+
"""
|
|
954
|
+
# -----------------------------------------------------------------
|
|
955
|
+
# 1. Input handling / validation
|
|
956
|
+
# -----------------------------------------------------------------
|
|
957
|
+
C_solid = np.asarray(C_solid, dtype=float)
|
|
958
|
+
|
|
959
|
+
# -----------------------------------------------------------------
|
|
960
|
+
# 2. Core calculation
|
|
961
|
+
# -----------------------------------------------------------------
|
|
962
|
+
# (a) Bulk concentration (mol m⁻³)
|
|
963
|
+
C_bulk = C_solid * (1.0 - phi)
|
|
964
|
+
|
|
965
|
+
# (b) Mass of solute per bulk volume (g m⁻³)
|
|
966
|
+
m_sol = C_bulk * mw # because 1 mmol L⁻¹ = 1 mol m⁻³
|
|
967
|
+
|
|
968
|
+
# (c) Dry bulk density (g m⁻³)
|
|
969
|
+
rho_dry = d * (1.0 - phi) * 1e6 # 1 g cm⁻³ = 1 × 10⁶ g m⁻³
|
|
970
|
+
|
|
971
|
+
# (d) Weight percent
|
|
972
|
+
wt_percent = (m_sol / rho_dry) * 100.0
|
|
973
|
+
|
|
974
|
+
return wt_percent
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def liquid_conc_to_wt_percent(C_pw, mw, d, phi):
|
|
978
|
+
"""
|
|
979
|
+
Convert a dissolved‑species concentration expressed as
|
|
980
|
+
mmol L⁻¹ of pore‑water to a weight‑percentage of the dry sediment
|
|
981
|
+
(wt % dry‑mass).
|
|
982
|
+
|
|
983
|
+
Parameters
|
|
984
|
+
----------
|
|
985
|
+
C_pw : float or array‑like
|
|
986
|
+
Pore‑water concentration (mmol L⁻¹). Numerically this is the
|
|
987
|
+
same as mol m⁻³ because 1 mmol L⁻¹ = 1 mol m⁻³.
|
|
988
|
+
mw : float
|
|
989
|
+
Molecular weight of the dissolved component (g mol⁻¹).
|
|
990
|
+
d : float
|
|
991
|
+
Grain (particle) density of the pure solid (g cm⁻³). Typical
|
|
992
|
+
values for quartz‑rich sediments are ≈2.65 g cm⁻³.
|
|
993
|
+
phi : float
|
|
994
|
+
Porosity of the bulk material (fraction, 0 ≤ phi ≤ 1).
|
|
995
|
+
|
|
996
|
+
Returns
|
|
997
|
+
-------
|
|
998
|
+
wt_percent : float or np.ndarray
|
|
999
|
+
Weight percentage of the component in the *dry* sediment
|
|
1000
|
+
(0 – 100 %). If phi == 0 the function returns 0 because no
|
|
1001
|
+
pore‑water (and thus no dissolved iron) can exist.
|
|
1002
|
+
|
|
1003
|
+
Notes
|
|
1004
|
+
-----
|
|
1005
|
+
The conversion proceeds as
|
|
1006
|
+
|
|
1007
|
+
1. **Moles of solute per bulk volume**
|
|
1008
|
+
`n_bulk = C_pw * phi` (mol m⁻³)
|
|
1009
|
+
|
|
1010
|
+
(`C_pw` is per pore‑water volume, so we multiply by the
|
|
1011
|
+
fraction of the bulk that is water.)
|
|
1012
|
+
|
|
1013
|
+
2. **Mass of solute per bulk volume**
|
|
1014
|
+
`m_solute = n_bulk * mw` (g m⁻³)
|
|
1015
|
+
|
|
1016
|
+
3. **Mass of dry solids per bulk volume**
|
|
1017
|
+
The dry‑bulk density ρ_bulk = d · (1 – phi) (g cm⁻³)
|
|
1018
|
+
Convert to g m⁻³: `rho_bulk = d * (1 - phi) * 1e6`
|
|
1019
|
+
|
|
1020
|
+
4. **Weight percent**
|
|
1021
|
+
`wt% = (m_solute / rho_bulk) * 100`
|
|
1022
|
+
|
|
1023
|
+
The function is fully vectorised (accepts NumPy arrays) and
|
|
1024
|
+
performs basic input validation.
|
|
1025
|
+
"""
|
|
1026
|
+
# -----------------------------------------------------------------
|
|
1027
|
+
# 1. Input validation
|
|
1028
|
+
# -----------------------------------------------------------------
|
|
1029
|
+
C_pw = np.asarray(C_pw, dtype=float)
|
|
1030
|
+
|
|
1031
|
+
# -----------------------------------------------------------------
|
|
1032
|
+
# 2. Core calculation
|
|
1033
|
+
# -----------------------------------------------------------------
|
|
1034
|
+
# (a) moles of solute per bulk volume (mol m⁻³)
|
|
1035
|
+
n_bulk = C_pw * phi # because 1 mmol L⁻¹ = 1 mol m⁻³
|
|
1036
|
+
|
|
1037
|
+
# (b) grams of solute per bulk volume (g m⁻³)
|
|
1038
|
+
m_solute = n_bulk * mw # g m⁻³
|
|
1039
|
+
|
|
1040
|
+
# (c) dry‑bulk density (g m⁻³)
|
|
1041
|
+
rho_bulk = d * (1.0 - phi) * 1e6 # 1 g cm⁻³ = 1 × 10⁶ g m⁻³
|
|
1042
|
+
|
|
1043
|
+
# (d) weight percent
|
|
1044
|
+
wt_percent = (m_solute / rho_bulk) * 100.0
|
|
1045
|
+
|
|
1046
|
+
return wt_percent
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def solid_conc_to_wt_percent_old(C_bulk, mw, d, phi):
|
|
1050
|
+
"""
|
|
1051
|
+
Convert a bulk concentration (mmol L⁻¹ ≡ mol m⁻³) back to weight
|
|
1052
|
+
percentage of the component in the dry solid matrix.
|
|
1053
|
+
|
|
1054
|
+
Parameters
|
|
1055
|
+
----------
|
|
1056
|
+
C_bulk : float or array‑like
|
|
1057
|
+
Bulk concentration (mol m⁻³; same numerically as mmol L⁻¹).
|
|
1058
|
+
mw : float
|
|
1059
|
+
Molecular weight of the component (g mol⁻¹).
|
|
1060
|
+
d : float
|
|
1061
|
+
Grain density of the pure solid (g cm⁻³).
|
|
1062
|
+
phi : float
|
|
1063
|
+
Porosity of the bulk material (fraction, 0–1).
|
|
1064
|
+
|
|
1065
|
+
Returns
|
|
1066
|
+
-------
|
|
1067
|
+
wp : float or np.ndarray
|
|
1068
|
+
Weight percentage (0–100 %) of the component in the dry mass.
|
|
1069
|
+
"""
|
|
1070
|
+
C_bulk = np.asarray(C_bulk, dtype=float)
|
|
1071
|
+
phi = np.asarray(phi, dtype=float)
|
|
1072
|
+
|
|
1073
|
+
# 1. Bulk density of the pure solid (g cm⁻³)
|
|
1074
|
+
phi_val = getattr(phi, "value", phi)
|
|
1075
|
+
rho_bulk = d * (1.0 - phi_val)
|
|
1076
|
+
|
|
1077
|
+
# 2. Mass of the component per 1 m³ bulk volume (g m⁻³)
|
|
1078
|
+
# mass = concentration × molecular weight
|
|
1079
|
+
mass_per_m3 = C_bulk * mw # g m⁻³
|
|
1080
|
+
|
|
1081
|
+
# 3. Convert mass per m³ to a mass fraction of the dry solid.
|
|
1082
|
+
# mass_per_m3 = rho_bulk * w_frac * 1e6 → w_frac = mass / (rho_bulk·1e6)
|
|
1083
|
+
w_frac = mass_per_m3 / (rho_bulk * 1.0e6) # dimensionless
|
|
1084
|
+
|
|
1085
|
+
# 4. Finally express as weight percent (0–100 %)
|
|
1086
|
+
wp = w_frac * 100.0
|
|
1087
|
+
return wp
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
_executor = None
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _get_executor():
|
|
1094
|
+
"""Create a module‑wide ThreadPoolExecutor on first use."""
|
|
1095
|
+
global _executor
|
|
1096
|
+
if _executor is None:
|
|
1097
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
1098
|
+
_executor = ThreadPoolExecutor(max_workers=1)
|
|
1099
|
+
return _executor
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def check_peclet_numbers(mesh, mp, D_mol, species_list, bc_map):
|
|
1103
|
+
# Get cell sizes (dx)
|
|
1104
|
+
# For a 1D mesh, mesh.dx is an array of cell lengths
|
|
1105
|
+
dx = mesh.dx
|
|
1106
|
+
|
|
1107
|
+
for species in species_list:
|
|
1108
|
+
props = bc_map[species]
|
|
1109
|
+
|
|
1110
|
+
# 1. Determine Velocity
|
|
1111
|
+
vel = mp.w
|
|
1112
|
+
if props["type"] == "dissolved":
|
|
1113
|
+
vel = mp.w - mp.advection
|
|
1114
|
+
|
|
1115
|
+
# 2. Determine Diffusion
|
|
1116
|
+
# For solids, D_mol is usually 0, so D is just D_bio
|
|
1117
|
+
d_val = getattr(D_mol, species) + D_mol.D_bio
|
|
1118
|
+
|
|
1119
|
+
# 3. Calculate Pe for every cell
|
|
1120
|
+
# We use a small epsilon to avoid division by zero
|
|
1121
|
+
pe_cells = (np.abs(vel) * dx) / (d_val + 1e-20)
|
|
1122
|
+
|
|
1123
|
+
max_pe = np.max(pe_cells)
|
|
1124
|
+
|
|
1125
|
+
if max_pe > 2:
|
|
1126
|
+
print(f"Species: {species:10} | Max Pe: {max_pe:.2f}")
|
|
1127
|
+
print(
|
|
1128
|
+
f" --> WARNING: Pe > 2. Numerical dispersion/oscillations likely."
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def check_reactant_coexistence(
|
|
1133
|
+
mesh_or_z,
|
|
1134
|
+
c,
|
|
1135
|
+
reaction_pairs: list[dict] | None = None,
|
|
1136
|
+
k: Any = None,
|
|
1137
|
+
D_mol: Any = None,
|
|
1138
|
+
threshold_rel: float = 0.05,
|
|
1139
|
+
min_cells_resolved: int = 3,
|
|
1140
|
+
verbose: bool = True,
|
|
1141
|
+
) -> list[dict]:
|
|
1142
|
+
"""Analyze post-simulation concentration profiles for numerical reactant co-existence.
|
|
1143
|
+
|
|
1144
|
+
Evaluates whether steep reaction fronts (e.g. oxic-anoxic transition, FeS precipitation)
|
|
1145
|
+
are adequately resolved by the mesh or suffer from artificial numerical mixing within
|
|
1146
|
+
discrete grid cells.
|
|
1147
|
+
|
|
1148
|
+
Parameters
|
|
1149
|
+
----------
|
|
1150
|
+
mesh_or_z : Mesh1D, np.ndarray, or tuple
|
|
1151
|
+
FiPy 1D Mesh object, 1D numpy array of cell center coordinates (z in meters),
|
|
1152
|
+
or tuple (z, dz).
|
|
1153
|
+
c : dict or data_container
|
|
1154
|
+
Mapping of species names (or variable objects) to concentration arrays.
|
|
1155
|
+
reaction_pairs : list of dict, optional
|
|
1156
|
+
Custom list of reaction definitions to check. If None, default diagenetic
|
|
1157
|
+
redox/precipitation pairs are checked (TS2+O2, fe2+O2, fe2+TS2, Fe3+TS2, CH4+SO4).
|
|
1158
|
+
Each dict can specify:
|
|
1159
|
+
- 'name': str, human-readable name of reaction
|
|
1160
|
+
- 'sp_A': str, reactant A species name or alias
|
|
1161
|
+
- 'sp_B': str, reactant B species name or alias
|
|
1162
|
+
- 'k': float (optional), rate constant (phase/model units)
|
|
1163
|
+
- 'k_key': str (optional), attribute name in k object
|
|
1164
|
+
- 'D_eff': float (optional), effective diffusion coefficient (m^2/s)
|
|
1165
|
+
k : object or dict, optional
|
|
1166
|
+
Reaction constants container or dictionary.
|
|
1167
|
+
D_mol : object or dict, optional
|
|
1168
|
+
Diffusion coefficients container or dictionary.
|
|
1169
|
+
threshold_rel : float, optional
|
|
1170
|
+
Fraction of maximum reaction rate proxy used to demarcate the active front (default 0.05).
|
|
1171
|
+
min_cells_resolved : int, optional
|
|
1172
|
+
Minimum number of grid cells required to span the front for adequate spatial resolution (default 3).
|
|
1173
|
+
verbose : bool, optional
|
|
1174
|
+
Whether to print diagnostic messages and warnings (default True).
|
|
1175
|
+
|
|
1176
|
+
Returns
|
|
1177
|
+
-------
|
|
1178
|
+
list of dict
|
|
1179
|
+
Diagnostic report for each active reaction front found.
|
|
1180
|
+
"""
|
|
1181
|
+
# 1. Extract coordinates and cell widths
|
|
1182
|
+
if hasattr(mesh_or_z, "dx") and hasattr(mesh_or_z, "cellCenters"):
|
|
1183
|
+
dz = np.asarray(getattr(mesh_or_z.dx, "value", mesh_or_z.dx), dtype=float)
|
|
1184
|
+
cc = getattr(mesh_or_z.cellCenters, "value", mesh_or_z.cellCenters)
|
|
1185
|
+
if isinstance(cc, np.ndarray) and cc.ndim > 1:
|
|
1186
|
+
z = np.asarray(cc[0], dtype=float)
|
|
1187
|
+
elif hasattr(cc, "__getitem__"):
|
|
1188
|
+
first = cc[0]
|
|
1189
|
+
z = np.asarray(getattr(first, "value", first), dtype=float)
|
|
1190
|
+
else:
|
|
1191
|
+
z = np.asarray(getattr(cc, "value", cc), dtype=float)
|
|
1192
|
+
if dz.ndim == 0 or len(dz) != len(z):
|
|
1193
|
+
dz = np.full_like(z, float(dz) if dz.ndim == 0 else float(dz[0]))
|
|
1194
|
+
elif isinstance(mesh_or_z, tuple) and len(mesh_or_z) == 2:
|
|
1195
|
+
z = np.asarray(mesh_or_z[0], dtype=float)
|
|
1196
|
+
dz = np.asarray(mesh_or_z[1], dtype=float)
|
|
1197
|
+
else:
|
|
1198
|
+
z = np.asarray(mesh_or_z, dtype=float)
|
|
1199
|
+
dz = np.empty_like(z)
|
|
1200
|
+
if len(z) <= 1:
|
|
1201
|
+
dz = np.ones_like(z)
|
|
1202
|
+
else:
|
|
1203
|
+
dz[0] = 2.0 * z[0] if z[0] > 0 else (z[1] - z[0])
|
|
1204
|
+
dz[1:-1] = 0.5 * (z[2:] - z[:-2])
|
|
1205
|
+
dz[-1] = z[-1] - z[-2]
|
|
1206
|
+
|
|
1207
|
+
# 2. Helper to extract species concentration array with aliases
|
|
1208
|
+
def _get_species_array(name: str):
|
|
1209
|
+
candidates = [name, f"c_{name}", name.lower(), name.upper()]
|
|
1210
|
+
if name in ("TS2", "h2s", "c_h2s", "c_TS2"):
|
|
1211
|
+
candidates += ["TS2", "c_TS2", "h2s", "c_h2s", "HS", "H2S"]
|
|
1212
|
+
elif name in ("fe2", "Fe2", "fe2_total", "Fe2_total", "Fe2_liq"):
|
|
1213
|
+
candidates += ["fe2", "c_fe2", "Fe2", "fe2_total", "Fe2_total", "Fe2_liq"]
|
|
1214
|
+
elif name in ("Fe3", "c_Fe3"):
|
|
1215
|
+
candidates += ["Fe3", "c_Fe3", "Fe3_total"]
|
|
1216
|
+
elif name in ("O2", "c_O2"):
|
|
1217
|
+
candidates += ["O2", "c_O2"]
|
|
1218
|
+
elif name in ("SO4", "c_SO4"):
|
|
1219
|
+
candidates += ["SO4", "c_SO4"]
|
|
1220
|
+
elif name in ("CH4", "c_CH4"):
|
|
1221
|
+
candidates += ["CH4", "c_CH4"]
|
|
1222
|
+
|
|
1223
|
+
for cand in candidates:
|
|
1224
|
+
val = None
|
|
1225
|
+
if isinstance(c, dict) and cand in c:
|
|
1226
|
+
val = c[cand]
|
|
1227
|
+
elif hasattr(c, cand):
|
|
1228
|
+
val = getattr(c, cand)
|
|
1229
|
+
if val is not None:
|
|
1230
|
+
arr = getattr(val, "value", val)
|
|
1231
|
+
return np.asarray(arr, dtype=float)
|
|
1232
|
+
return None
|
|
1233
|
+
|
|
1234
|
+
# 3. Default reaction pairs if not explicitly passed
|
|
1235
|
+
if reaction_pairs is None:
|
|
1236
|
+
reaction_pairs = [
|
|
1237
|
+
{
|
|
1238
|
+
"name": "Sulfide Oxidation by O2",
|
|
1239
|
+
"sp_A": "TS2",
|
|
1240
|
+
"sp_B": "O2",
|
|
1241
|
+
"k_key": "TS2_O2",
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
"name": "Fe2 Oxidation by O2",
|
|
1245
|
+
"sp_A": "fe2",
|
|
1246
|
+
"sp_B": "O2",
|
|
1247
|
+
"k_key": "fe2_O2",
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
"name": "FeS Precipitation (Fe2 + TS2)",
|
|
1251
|
+
"sp_A": "fe2",
|
|
1252
|
+
"sp_B": "TS2",
|
|
1253
|
+
"k_key": "fe2_h2s",
|
|
1254
|
+
},
|
|
1255
|
+
{
|
|
1256
|
+
"name": "Iron Reduction by Sulfide (Fe3 + TS2)",
|
|
1257
|
+
"sp_A": "Fe3",
|
|
1258
|
+
"sp_B": "TS2",
|
|
1259
|
+
"k_key": "Fe3_TS2",
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
"name": "Methane Oxidation by SO4 (SMTZ)",
|
|
1263
|
+
"sp_A": "CH4",
|
|
1264
|
+
"sp_B": "SO4",
|
|
1265
|
+
"k_key": "CH4_SO4",
|
|
1266
|
+
},
|
|
1267
|
+
]
|
|
1268
|
+
|
|
1269
|
+
results = []
|
|
1270
|
+
warnings_found = []
|
|
1271
|
+
|
|
1272
|
+
for rxn in reaction_pairs:
|
|
1273
|
+
name = rxn["name"]
|
|
1274
|
+
sp_a = rxn["sp_A"]
|
|
1275
|
+
sp_b = rxn["sp_B"]
|
|
1276
|
+
|
|
1277
|
+
ca = _get_species_array(sp_a)
|
|
1278
|
+
cb = _get_species_array(sp_b)
|
|
1279
|
+
|
|
1280
|
+
if ca is None or cb is None:
|
|
1281
|
+
continue
|
|
1282
|
+
|
|
1283
|
+
r_proxy = ca * cb
|
|
1284
|
+
r_max = float(np.max(r_proxy))
|
|
1285
|
+
|
|
1286
|
+
if r_max <= 1e-20:
|
|
1287
|
+
continue # Inactive or non-intersecting front
|
|
1288
|
+
|
|
1289
|
+
front_mask = r_proxy >= (threshold_rel * r_max)
|
|
1290
|
+
n_cells = int(np.sum(front_mask))
|
|
1291
|
+
idx_peak = int(np.argmax(r_proxy))
|
|
1292
|
+
|
|
1293
|
+
z_peak = float(z[idx_peak])
|
|
1294
|
+
dz_peak = float(dz[idx_peak])
|
|
1295
|
+
|
|
1296
|
+
total_flux = float(np.sum(r_proxy * dz))
|
|
1297
|
+
peak_cell_flux_frac = float((r_proxy[idx_peak] * dz_peak) / (total_flux + 1e-30))
|
|
1298
|
+
|
|
1299
|
+
# Resolve rate constant and diffusion coefficient only if explicitly provided
|
|
1300
|
+
k_val = rxn.get("k")
|
|
1301
|
+
if k_val is None and k is not None:
|
|
1302
|
+
k_key = rxn.get("k_key")
|
|
1303
|
+
k_candidates = [k_key] if k_key else []
|
|
1304
|
+
if k_key == "fe2_O2":
|
|
1305
|
+
k_candidates += ["Fe2_O2", "fe2_O2"]
|
|
1306
|
+
elif k_key == "Fe3_TS2":
|
|
1307
|
+
k_candidates += ["Fe3_hs", "Fe3_TS2", "Fe3_h2s"]
|
|
1308
|
+
elif k_key == "fe2_h2s":
|
|
1309
|
+
k_candidates += ["FeS_isp", "fe2_h2s", "FeS_TS2"]
|
|
1310
|
+
|
|
1311
|
+
for cand in k_candidates:
|
|
1312
|
+
if hasattr(k, cand):
|
|
1313
|
+
k_val = getattr(k, cand)
|
|
1314
|
+
break
|
|
1315
|
+
elif isinstance(k, dict) and cand in k:
|
|
1316
|
+
k_val = k[cand]
|
|
1317
|
+
break
|
|
1318
|
+
|
|
1319
|
+
d_eff = rxn.get("D_eff")
|
|
1320
|
+
if d_eff is None and D_mol is not None:
|
|
1321
|
+
cand_list = [sp_a, sp_b, sp_a.upper(), sp_b.upper(), sp_a.lower(), sp_b.lower()]
|
|
1322
|
+
if sp_a in ("fe2", "Fe2") or sp_b in ("fe2", "Fe2"):
|
|
1323
|
+
cand_list += ["Fe2_total", "fe2_total", "Fe2", "fe2"]
|
|
1324
|
+
if sp_a in ("TS2", "h2s") or sp_b in ("TS2", "h2s"):
|
|
1325
|
+
cand_list += ["TS2", "h2s"]
|
|
1326
|
+
|
|
1327
|
+
for sp_cand in cand_list:
|
|
1328
|
+
if hasattr(D_mol, sp_cand):
|
|
1329
|
+
d_eff = getattr(D_mol, sp_cand)
|
|
1330
|
+
break
|
|
1331
|
+
elif isinstance(D_mol, dict) and sp_cand in D_mol:
|
|
1332
|
+
d_eff = D_mol[sp_cand]
|
|
1333
|
+
break
|
|
1334
|
+
|
|
1335
|
+
# Extract local scalars at idx_peak (supports depth-dependent arrays)
|
|
1336
|
+
k_local = None
|
|
1337
|
+
if k_val is not None:
|
|
1338
|
+
k_arr = getattr(k_val, "value", k_val)
|
|
1339
|
+
if hasattr(k_arr, "__getitem__") and not np.isscalar(k_arr):
|
|
1340
|
+
k_local = float(k_arr[idx_peak])
|
|
1341
|
+
else:
|
|
1342
|
+
k_local = float(k_arr)
|
|
1343
|
+
|
|
1344
|
+
d_local = None
|
|
1345
|
+
if d_eff is not None:
|
|
1346
|
+
d_arr = getattr(d_eff, "value", d_eff)
|
|
1347
|
+
if hasattr(d_arr, "__getitem__") and not np.isscalar(d_arr):
|
|
1348
|
+
d_local = float(d_arr[idx_peak])
|
|
1349
|
+
else:
|
|
1350
|
+
d_local = float(d_arr)
|
|
1351
|
+
# Add bioturbation profile if present in D_mol
|
|
1352
|
+
if D_mol is not None and hasattr(D_mol, "D_bio"):
|
|
1353
|
+
dbio = getattr(D_mol.D_bio, "value", D_mol.D_bio)
|
|
1354
|
+
if hasattr(dbio, "__getitem__") and not np.isscalar(dbio):
|
|
1355
|
+
d_local += float(dbio[idx_peak])
|
|
1356
|
+
elif dbio is not None:
|
|
1357
|
+
d_local += float(dbio)
|
|
1358
|
+
|
|
1359
|
+
c_scale = max(float(np.max(ca)), float(np.max(cb)))
|
|
1360
|
+
delta_phys = None
|
|
1361
|
+
da_ii = None
|
|
1362
|
+
if k_local is not None and d_local is not None and k_local > 0 and c_scale > 0:
|
|
1363
|
+
delta_phys = float(np.sqrt(d_local / (k_local * c_scale + 1e-30)))
|
|
1364
|
+
da_ii = float((k_local * c_scale * (dz_peak**2)) / (d_local + 1e-30))
|
|
1365
|
+
|
|
1366
|
+
# Check resolution criteria
|
|
1367
|
+
is_warning = False
|
|
1368
|
+
reasons = []
|
|
1369
|
+
if n_cells < min_cells_resolved:
|
|
1370
|
+
is_warning = True
|
|
1371
|
+
reasons.append(f"Front span ({n_cells} cells) < recommended minimum ({min_cells_resolved})")
|
|
1372
|
+
if peak_cell_flux_frac > 0.5:
|
|
1373
|
+
is_warning = True
|
|
1374
|
+
reasons.append(f"Peak cell contains {peak_cell_flux_frac * 100:.1f}% (>50%) of reaction flux")
|
|
1375
|
+
if delta_phys is not None and dz_peak > (2.0 * delta_phys):
|
|
1376
|
+
is_warning = True
|
|
1377
|
+
reasons.append(f"Local grid cell dz ({dz_peak * 1e3:.2f} mm) > 2x physical front width ({delta_phys * 1e3:.2f} mm)")
|
|
1378
|
+
|
|
1379
|
+
z_front_min = float(np.min(z[front_mask]))
|
|
1380
|
+
z_front_max = float(np.max(z[front_mask]))
|
|
1381
|
+
target_dz = min(dz_peak * 0.33, 0.001)
|
|
1382
|
+
if delta_phys is not None:
|
|
1383
|
+
target_dz = min(target_dz, delta_phys * 0.5)
|
|
1384
|
+
|
|
1385
|
+
info = {
|
|
1386
|
+
"name": name,
|
|
1387
|
+
"sp_A": sp_a,
|
|
1388
|
+
"sp_B": sp_b,
|
|
1389
|
+
"z_peak": z_peak,
|
|
1390
|
+
"dz_peak": dz_peak,
|
|
1391
|
+
"front_bounds": (z_front_min, z_front_max),
|
|
1392
|
+
"n_cells": n_cells,
|
|
1393
|
+
"peak_flux_fraction": peak_cell_flux_frac,
|
|
1394
|
+
"da_ii": da_ii,
|
|
1395
|
+
"delta_phys": delta_phys,
|
|
1396
|
+
"is_warning": is_warning,
|
|
1397
|
+
"reasons": reasons,
|
|
1398
|
+
"recommended_dz": target_dz,
|
|
1399
|
+
}
|
|
1400
|
+
results.append(info)
|
|
1401
|
+
|
|
1402
|
+
if is_warning:
|
|
1403
|
+
da_ii_str = f"{da_ii:.1e}" if da_ii is not None else "N/A"
|
|
1404
|
+
delta_phys_str = f"{delta_phys * 1e3:.2f} mm" if delta_phys is not None else "N/A"
|
|
1405
|
+
msg = (
|
|
1406
|
+
f"\n[WARNING: Numerical Reactant Co-existence Detected]\n"
|
|
1407
|
+
f" Reaction: '{name}' ({sp_a} + {sp_b})\n"
|
|
1408
|
+
f" Front location (peak): z = {z_peak * 100:.2f} cm (depth cell index: {idx_peak})\n"
|
|
1409
|
+
f" Local grid resolution: dz = {dz_peak * 1e3:.2f} mm\n"
|
|
1410
|
+
f" Estimated physical front width: {delta_phys_str}\n"
|
|
1411
|
+
f" Front cell span (N_cells): {n_cells} cell(s) (minimum recommended: >= {min_cells_resolved})\n"
|
|
1412
|
+
f" Peak cell flux fraction: {peak_cell_flux_frac * 100:.1f}%\n"
|
|
1413
|
+
f" Local spatial Damköhler (Da_II): {da_ii_str}\n"
|
|
1414
|
+
f" Reasons: {'; '.join(reasons)}\n"
|
|
1415
|
+
f" --> Action recommended: In make_grid2(), refine the mesh around z = [{z_front_min * 100:.2f}, {z_front_max * 100:.2f}] cm "
|
|
1416
|
+
f"to reaction_zone_spacing <= {target_dz * 1e3:.2f} mm."
|
|
1417
|
+
)
|
|
1418
|
+
warnings_found.append(msg)
|
|
1419
|
+
if verbose:
|
|
1420
|
+
print(msg)
|
|
1421
|
+
|
|
1422
|
+
if verbose and not warnings_found:
|
|
1423
|
+
print("[CHECK PASSED] All tested reaction fronts are adequately resolved (no numerical co-existence detected).")
|
|
1424
|
+
|
|
1425
|
+
return results
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def save_data_async(
|
|
1429
|
+
mp,
|
|
1430
|
+
c,
|
|
1431
|
+
k,
|
|
1432
|
+
species_list,
|
|
1433
|
+
z,
|
|
1434
|
+
D_mol,
|
|
1435
|
+
diagenetic_reactions,
|
|
1436
|
+
equilibrium_reactions,
|
|
1437
|
+
current_dt,
|
|
1438
|
+
title=None,
|
|
1439
|
+
) -> None:
|
|
1440
|
+
"""
|
|
1441
|
+
Schedule a background write of model results to CSV and return immediately.
|
|
1442
|
+
"""
|
|
1443
|
+
# 1. Capture current rates in the main thread (FiPy objects aren't thread-safe)
|
|
1444
|
+
c_numpy = data_container({s: ArrayProxy(val.value) for s, val in c.items()})
|
|
1445
|
+
mp_numpy = data_container(mp)
|
|
1446
|
+
mp_numpy.phi = ArrayProxy(mp.phi.value)
|
|
1447
|
+
f_final, RATES = diagenetic_reactions(mp_numpy, c_numpy, k, data_container())
|
|
1448
|
+
f_final, RATES = equilibrium_reactions(mp, c, k, f_final, RATES, current_dt)
|
|
1449
|
+
|
|
1450
|
+
# 2. Snapshot values (numpy arrays) to avoid race conditions during solver update
|
|
1451
|
+
def snap(obj):
|
|
1452
|
+
if hasattr(obj, "value"):
|
|
1453
|
+
return obj.value.copy()
|
|
1454
|
+
if hasattr(obj, "copy"):
|
|
1455
|
+
return obj.copy()
|
|
1456
|
+
return obj
|
|
1457
|
+
|
|
1458
|
+
c_snap = data_container({s: snap(getattr(c, s)) for s in species_list})
|
|
1459
|
+
f_snap = data_container(
|
|
1460
|
+
{key: (None, None, snap(getattr(f_final, key)[2])) for key in f_final.keys()}
|
|
1461
|
+
)
|
|
1462
|
+
D_mol_snap = data_container({key: snap(val) for key, val in D_mol.items()})
|
|
1463
|
+
|
|
1464
|
+
# Copy metadata
|
|
1465
|
+
mp_snap = data_container(mp)
|
|
1466
|
+
k_snap = data_container(k)
|
|
1467
|
+
z_snap = z.copy()
|
|
1468
|
+
|
|
1469
|
+
# 3. Submit to background worker
|
|
1470
|
+
_get_executor().submit(
|
|
1471
|
+
_save_data_to_disk,
|
|
1472
|
+
mp_snap,
|
|
1473
|
+
c_snap,
|
|
1474
|
+
k_snap,
|
|
1475
|
+
species_list,
|
|
1476
|
+
z_snap,
|
|
1477
|
+
D_mol_snap,
|
|
1478
|
+
f_snap,
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
return None, None
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
_ureg = None
|
|
1485
|
+
|
|
1486
|
+
def get_time_units(t):
|
|
1487
|
+
"""Adjust time units between second to ky."""
|
|
1488
|
+
global _ureg
|
|
1489
|
+
if _ureg is None:
|
|
1490
|
+
import pint
|
|
1491
|
+
_ureg = pint.UnitRegistry()
|
|
1492
|
+
Q_ = _ureg.Quantity
|
|
1493
|
+
|
|
1494
|
+
seconds = 60
|
|
1495
|
+
minutes = seconds * 60
|
|
1496
|
+
hours = minutes * 60
|
|
1497
|
+
days = hours * 24
|
|
1498
|
+
weeks = days * 7
|
|
1499
|
+
months = weeks * 4.5
|
|
1500
|
+
years = months * 12
|
|
1501
|
+
kyears = years * 1000
|
|
1502
|
+
Myears = kyears * 1000
|
|
1503
|
+
|
|
1504
|
+
if t < seconds:
|
|
1505
|
+
t = Q_(f"{t} seconds")
|
|
1506
|
+
elif t < minutes:
|
|
1507
|
+
t = Q_(f"{t} seconds").to("minutes")
|
|
1508
|
+
elif t < hours:
|
|
1509
|
+
t = Q_(f"{t} seconds").to("hours")
|
|
1510
|
+
elif t < days:
|
|
1511
|
+
t = Q_(f"{t} seconds").to("days")
|
|
1512
|
+
elif t < weeks:
|
|
1513
|
+
t = Q_(f"{t} seconds").to("week")
|
|
1514
|
+
elif t < months:
|
|
1515
|
+
t = Q_(f"{t} seconds").to("month")
|
|
1516
|
+
elif t < years:
|
|
1517
|
+
t = Q_(f"{t} seconds").to("year")
|
|
1518
|
+
elif t < kyears:
|
|
1519
|
+
t = Q_(f"{t} seconds").to("kyear")
|
|
1520
|
+
else:
|
|
1521
|
+
t = Q_(f"{t} seconds").to("kyear")
|
|
1522
|
+
|
|
1523
|
+
return t
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def make_grid2(
|
|
1527
|
+
L: float,
|
|
1528
|
+
initial_spacing: float,
|
|
1529
|
+
reaction_zone_spacing: float,
|
|
1530
|
+
max_spacing: float,
|
|
1531
|
+
reaction_zone: tuple,
|
|
1532
|
+
r=1.05,
|
|
1533
|
+
):
|
|
1534
|
+
"""Build a variable distance mesh.
|
|
1535
|
+
|
|
1536
|
+
The mesh will decrease from initial_spacing to reaction_zone_spacing, and then increases to max_spacing below the reaction zone. The reaction zone depth interval is given by the reaction_zone tuple e.g. (0.5, 1)
|
|
1537
|
+
|
|
1538
|
+
Args:
|
|
1539
|
+
-----
|
|
1540
|
+
L (float): Total length of the domain (max depth).
|
|
1541
|
+
initial_spacing (float): The size of the first cell (at z=0).
|
|
1542
|
+
max_spacing (float): Maximum cell size allowed.
|
|
1543
|
+
r (float): Geometric growth rate (default 1.05).
|
|
1544
|
+
|
|
1545
|
+
Returns:
|
|
1546
|
+
-------
|
|
1547
|
+
tuple (mesh, z_centers)
|
|
1548
|
+
mesh: A Mesh1D object.
|
|
1549
|
+
z_centers: A numpy array of cell center coordinates.
|
|
1550
|
+
"""
|
|
1551
|
+
rz_start, rz_end = reaction_zone
|
|
1552
|
+
dx_list = []
|
|
1553
|
+
current_z = 0
|
|
1554
|
+
|
|
1555
|
+
# 1. Refinement phase: from surface to reaction zone
|
|
1556
|
+
current_dx = initial_spacing
|
|
1557
|
+
while current_z + current_dx < rz_start:
|
|
1558
|
+
dx_list.append(current_dx)
|
|
1559
|
+
current_z += current_dx
|
|
1560
|
+
current_dx = max(current_dx / r, reaction_zone_spacing)
|
|
1561
|
+
|
|
1562
|
+
if current_z < rz_start:
|
|
1563
|
+
dx_list.append(rz_start - current_z)
|
|
1564
|
+
current_z = rz_start
|
|
1565
|
+
|
|
1566
|
+
# 2. Reaction zone phase: uniform fine spacing
|
|
1567
|
+
n_rz = int(np.ceil((rz_end - rz_start) / reaction_zone_spacing))
|
|
1568
|
+
actual_rz_dx = (rz_end - rz_start) / n_rz
|
|
1569
|
+
dx_list.extend([actual_rz_dx] * n_rz)
|
|
1570
|
+
current_z = rz_end
|
|
1571
|
+
|
|
1572
|
+
# 3. Coarsening phase: from reaction zone to L
|
|
1573
|
+
current_dx = actual_rz_dx * r
|
|
1574
|
+
while current_z + current_dx < L and current_dx < max_spacing:
|
|
1575
|
+
dx_list.append(current_dx)
|
|
1576
|
+
current_z += current_dx
|
|
1577
|
+
current_dx *= r
|
|
1578
|
+
|
|
1579
|
+
# 4. Uniform Section
|
|
1580
|
+
if current_z < L:
|
|
1581
|
+
remaining_L = L - current_z
|
|
1582
|
+
n_uniform = int(np.ceil(remaining_L / max_spacing))
|
|
1583
|
+
if n_uniform > 0:
|
|
1584
|
+
actual_uniform_dx = remaining_L / n_uniform
|
|
1585
|
+
dx_list.extend([actual_uniform_dx] * n_uniform)
|
|
1586
|
+
|
|
1587
|
+
dx_array = np.array(dx_list)
|
|
1588
|
+
N = len(dx_array)
|
|
1589
|
+
print(f"Grid generated with {N} points.")
|
|
1590
|
+
|
|
1591
|
+
mesh = Mesh1D(dx=dx_array)
|
|
1592
|
+
z_centers = mesh.cellCenters[0]
|
|
1593
|
+
|
|
1594
|
+
return mesh, z_centers
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def get_total_delta(c, mp, index=-1):
|
|
1598
|
+
"""Get total delta that is being buried.
|
|
1599
|
+
|
|
1600
|
+
Calculate the total amount of S and S32 that leaves the system through the lower
|
|
1601
|
+
boundary. Note that we have to count the mass of FeS2 since it has 2 S, however,
|
|
1602
|
+
FeS2_32 is already corrected, so we do not mutiply it.
|
|
1603
|
+
"""
|
|
1604
|
+
from .diff_lib import get_delta
|
|
1605
|
+
|
|
1606
|
+
phi_val = getattr(mp.phi, "value", mp.phi)
|
|
1607
|
+
phi = phi_val[index] if hasattr(phi_val, "__getitem__") else phi_val
|
|
1608
|
+
f_s = 1.0 - phi
|
|
1609
|
+
|
|
1610
|
+
# Liquid species are scaled by porosity (phi)
|
|
1611
|
+
# Solid species are scaled by solid fraction (1-phi)
|
|
1612
|
+
s = phi * (c.SO4.value[index] + c.TS2.value[index]) + f_s * (
|
|
1613
|
+
c.S0.value[index] + c.FeS.value[index] + 2 * c.FeS2.value[index]
|
|
1614
|
+
)
|
|
1615
|
+
s32 = phi * (c.SO4_32.value[index] + c.TS2_32.value[index]) + f_s * (
|
|
1616
|
+
c.S0_32.value[index] + c.FeS_32.value[index] + c.FeS2_32.value[index]
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
return get_delta(s, s32, mp.VCDT)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
# =============================================================================
|
|
1623
|
+
# HELPER FUNCTIONS (Matrix Math Abstraction)
|
|
1624
|
+
# =============================================================================
|
|
1625
|
+
def add_implicit_sink(
|
|
1626
|
+
LHS,
|
|
1627
|
+
RATES,
|
|
1628
|
+
species,
|
|
1629
|
+
coeff,
|
|
1630
|
+
rate,
|
|
1631
|
+
mp,
|
|
1632
|
+
has_solid: bool,
|
|
1633
|
+
c=None,
|
|
1634
|
+
reaction=None,
|
|
1635
|
+
):
|
|
1636
|
+
"""Add an implicit consumption term to the LHS matrix."""
|
|
1637
|
+
phi = mp.phi
|
|
1638
|
+
fac = (1.0 - phi) if has_solid else phi
|
|
1639
|
+
|
|
1640
|
+
bulk_rate = rate * fac
|
|
1641
|
+
LHS[species] = LHS[species] - coeff * fac
|
|
1642
|
+
RATES[species] -= bulk_rate
|
|
1643
|
+
if reaction is not None and not getattr(mp, "in_solver", False):
|
|
1644
|
+
key = f"r_{reaction}_{species}"
|
|
1645
|
+
if key not in RATES:
|
|
1646
|
+
RATES[key] = np.zeros_like(bulk_rate)
|
|
1647
|
+
RATES[key] -= bulk_rate
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def add_explicit_source(
|
|
1651
|
+
RHS,
|
|
1652
|
+
RATES,
|
|
1653
|
+
species,
|
|
1654
|
+
rate,
|
|
1655
|
+
mp,
|
|
1656
|
+
has_solid: bool,
|
|
1657
|
+
update_rates=True,
|
|
1658
|
+
c=None,
|
|
1659
|
+
reaction=None,
|
|
1660
|
+
):
|
|
1661
|
+
"""Add a production term to the RHS vector."""
|
|
1662
|
+
phi = mp.phi
|
|
1663
|
+
fac = (1.0 - phi) if has_solid else phi
|
|
1664
|
+
|
|
1665
|
+
scaled_rate = rate * fac
|
|
1666
|
+
RHS[species] = RHS[species] + scaled_rate
|
|
1667
|
+
if update_rates:
|
|
1668
|
+
RATES[species] += getattr(scaled_rate, "value", scaled_rate)
|
|
1669
|
+
if reaction is not None and not getattr(mp, "in_solver", False):
|
|
1670
|
+
key = f"r_{reaction}_{species}"
|
|
1671
|
+
if key not in RATES:
|
|
1672
|
+
RATES[key] = np.zeros_like(scaled_rate)
|
|
1673
|
+
RATES[key] += getattr(scaled_rate, "value", scaled_rate)
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def add_monod_sink(
|
|
1677
|
+
LHS,
|
|
1678
|
+
RHS,
|
|
1679
|
+
RATES,
|
|
1680
|
+
species: str,
|
|
1681
|
+
conc: Any,
|
|
1682
|
+
K_m: Any,
|
|
1683
|
+
R_max: Any,
|
|
1684
|
+
mp: Any,
|
|
1685
|
+
has_solid: bool,
|
|
1686
|
+
scheme: str = "hybrid",
|
|
1687
|
+
c: Any = None,
|
|
1688
|
+
reaction: str | None = None,
|
|
1689
|
+
multiplier: float = 1.0,
|
|
1690
|
+
):
|
|
1691
|
+
"""Add a Monod consumption term: R = multiplier * R_max * C / (C + K_m).
|
|
1692
|
+
|
|
1693
|
+
Supports three linearization schemes:
|
|
1694
|
+
- 'picard': Secant Picard linearization (unconditional positivity, matches legacy).
|
|
1695
|
+
- 'newton': Tangent Newton-Raphson linearization (quadratic convergence).
|
|
1696
|
+
- 'hybrid': Safeguarded Newton: Newton for C > K_m (zero-order regime),
|
|
1697
|
+
smoothly reverting to Picard for C <= K_m (near-zero first-order regime).
|
|
1698
|
+
"""
|
|
1699
|
+
phi = mp.phi
|
|
1700
|
+
fac = (1.0 - phi) if has_solid else phi
|
|
1701
|
+
|
|
1702
|
+
# Extract concentration values if CellVariable or ArrayProxy
|
|
1703
|
+
C_val = getattr(conc, "value", conc)
|
|
1704
|
+
C_val = np.maximum(C_val, 1e-30)
|
|
1705
|
+
|
|
1706
|
+
# Scale R_max by stoichiometric multiplier if provided
|
|
1707
|
+
R_max_eff = R_max * multiplier if multiplier != 1.0 else R_max
|
|
1708
|
+
|
|
1709
|
+
denom = C_val + K_m
|
|
1710
|
+
monod_frac = C_val / denom
|
|
1711
|
+
physical_rate = R_max_eff * monod_frac
|
|
1712
|
+
bulk_rate = physical_rate * fac
|
|
1713
|
+
|
|
1714
|
+
scheme_lower = scheme.lower() if isinstance(scheme, str) else "hybrid"
|
|
1715
|
+
|
|
1716
|
+
if scheme_lower == "picard":
|
|
1717
|
+
coeff = R_max_eff / denom
|
|
1718
|
+
LHS[species] = LHS[species] - coeff * fac
|
|
1719
|
+
RATES[species] -= bulk_rate
|
|
1720
|
+
elif scheme_lower == "newton":
|
|
1721
|
+
J = R_max_eff * (K_m / (denom * denom))
|
|
1722
|
+
R_rem = -R_max_eff * (monod_frac * monod_frac)
|
|
1723
|
+
scaled_rem = R_rem * fac
|
|
1724
|
+
|
|
1725
|
+
LHS[species] = LHS[species] - J * fac
|
|
1726
|
+
RHS[species] = RHS[species] + scaled_rem
|
|
1727
|
+
RATES[species] -= bulk_rate
|
|
1728
|
+
elif scheme_lower == "hybrid":
|
|
1729
|
+
# Newton where C > K_m, Picard where C <= K_m
|
|
1730
|
+
is_newton = np.where(C_val > K_m, 1.0, 0.0)
|
|
1731
|
+
is_picard = 1.0 - is_newton
|
|
1732
|
+
|
|
1733
|
+
J = R_max_eff * (K_m / (denom * denom))
|
|
1734
|
+
R_rem = -R_max_eff * (monod_frac * monod_frac)
|
|
1735
|
+
coeff_picard = R_max_eff / denom
|
|
1736
|
+
|
|
1737
|
+
coeff_eff = is_newton * J + is_picard * coeff_picard
|
|
1738
|
+
rem_eff = is_newton * R_rem * fac
|
|
1739
|
+
|
|
1740
|
+
LHS[species] = LHS[species] - coeff_eff * fac
|
|
1741
|
+
RHS[species] = RHS[species] + rem_eff
|
|
1742
|
+
RATES[species] -= bulk_rate
|
|
1743
|
+
else:
|
|
1744
|
+
raise ValueError(f"Unknown Monod scheme '{scheme}'. Choose from 'picard', 'newton', or 'hybrid'.")
|
|
1745
|
+
|
|
1746
|
+
if reaction is not None:
|
|
1747
|
+
key = f"r_{reaction}_{species}"
|
|
1748
|
+
if key not in RATES:
|
|
1749
|
+
RATES[key] = np.zeros_like(bulk_rate)
|
|
1750
|
+
RATES[key] -= bulk_rate
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def smooth_ramp(x, eps=0.02):
|
|
1754
|
+
"""C1 continuous smooth ramp function:
|
|
1755
|
+
Approximates max(x, 0) with continuous value and continuous first derivative:
|
|
1756
|
+
S_eps(x) = 0 for x <= -eps
|
|
1757
|
+
= (x + eps)^2 / (4 * eps) for -eps < x < eps
|
|
1758
|
+
= x for x >= eps
|
|
1759
|
+
Eliminates discontinuous operator jumps across phase change boundaries (e.g. Omega = 1).
|
|
1760
|
+
"""
|
|
1761
|
+
quad = (x + eps) * (x + eps) / (4.0 * eps)
|
|
1762
|
+
return np.where(x <= -eps, 0.0, np.where(x >= eps, x, quad))
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def add_implicit_coupling_new(
|
|
1766
|
+
CROSS,
|
|
1767
|
+
RATES,
|
|
1768
|
+
LHS,
|
|
1769
|
+
target_species,
|
|
1770
|
+
source_species,
|
|
1771
|
+
coeff,
|
|
1772
|
+
rate,
|
|
1773
|
+
mp,
|
|
1774
|
+
has_solid: bool,
|
|
1775
|
+
c=None,
|
|
1776
|
+
add_lhs_sink=True,
|
|
1777
|
+
stoich_ratio=1.0,
|
|
1778
|
+
reaction=None,
|
|
1779
|
+
):
|
|
1780
|
+
"""Add a coupled implicit source term with optional stoichiometry."""
|
|
1781
|
+
phi = mp.phi
|
|
1782
|
+
fac = (1.0 - phi) if has_solid else phi
|
|
1783
|
+
|
|
1784
|
+
cross_coeff = coeff * stoich_ratio * fac
|
|
1785
|
+
CROSS[target_species].append((source_species, cross_coeff))
|
|
1786
|
+
|
|
1787
|
+
bulk_rate = rate * fac
|
|
1788
|
+
if add_lhs_sink:
|
|
1789
|
+
LHS[source_species] = LHS[source_species] - coeff * fac
|
|
1790
|
+
RATES[source_species] -= bulk_rate
|
|
1791
|
+
if reaction is not None and not getattr(mp, "in_solver", False):
|
|
1792
|
+
key_src = f"r_{reaction}_{source_species}"
|
|
1793
|
+
if key_src not in RATES:
|
|
1794
|
+
RATES[key_src] = np.zeros_like(bulk_rate)
|
|
1795
|
+
RATES[key_src] -= bulk_rate
|
|
1796
|
+
|
|
1797
|
+
scaled_target_rate = bulk_rate * stoich_ratio
|
|
1798
|
+
RATES[target_species] += scaled_target_rate
|
|
1799
|
+
if reaction is not None and not getattr(mp, "in_solver", False):
|
|
1800
|
+
key_tgt = f"r_{reaction}_{target_species}"
|
|
1801
|
+
if key_tgt not in RATES:
|
|
1802
|
+
RATES[key_tgt] = np.zeros_like(scaled_target_rate)
|
|
1803
|
+
RATES[key_tgt] += scaled_target_rate
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _get_base_species(name: str) -> str:
|
|
1807
|
+
if not name:
|
|
1808
|
+
return ""
|
|
1809
|
+
return name.split("_")[0].upper()
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def add_coupled_reaction(
|
|
1813
|
+
CROSS,
|
|
1814
|
+
LHS,
|
|
1815
|
+
RATES,
|
|
1816
|
+
mp,
|
|
1817
|
+
master_species,
|
|
1818
|
+
reactants,
|
|
1819
|
+
products,
|
|
1820
|
+
coeff_master,
|
|
1821
|
+
rate_master,
|
|
1822
|
+
has_solid: bool,
|
|
1823
|
+
reaction_name: str,
|
|
1824
|
+
ref_species: str = None,
|
|
1825
|
+
stoich_ref: float = None,
|
|
1826
|
+
):
|
|
1827
|
+
"""Couple multiple reactants and products to a single master reactant."""
|
|
1828
|
+
phi = mp.phi
|
|
1829
|
+
fac = (1.0 - phi) if has_solid else phi
|
|
1830
|
+
|
|
1831
|
+
# 1. Parse master_species and master_stoich
|
|
1832
|
+
if isinstance(master_species, dict):
|
|
1833
|
+
# Expecting a single-key dictionary like {"Fe3": 4}
|
|
1834
|
+
master_species_name = list(master_species.keys())[0]
|
|
1835
|
+
master_stoich = float(list(master_species.values())[0])
|
|
1836
|
+
else:
|
|
1837
|
+
master_species_name = str(master_species)
|
|
1838
|
+
master_stoich = 1.0
|
|
1839
|
+
|
|
1840
|
+
# 2. Automatically compute scaling factor based on ref_species stoichiometry in the unnormalized reaction
|
|
1841
|
+
scaling_factor = 1.0
|
|
1842
|
+
if ref_species:
|
|
1843
|
+
if stoich_ref is not None:
|
|
1844
|
+
scaling_factor = master_stoich / float(stoich_ref)
|
|
1845
|
+
else:
|
|
1846
|
+
ref_base = _get_base_species(ref_species)
|
|
1847
|
+
master_base = _get_base_species(master_species_name)
|
|
1848
|
+
|
|
1849
|
+
if ref_base == master_base:
|
|
1850
|
+
stoich_ref_local = master_stoich
|
|
1851
|
+
else:
|
|
1852
|
+
# Check reactants
|
|
1853
|
+
for spec, stoich in reactants.items():
|
|
1854
|
+
if _get_base_species(spec) == ref_base:
|
|
1855
|
+
stoich_ref_local = float(stoich)
|
|
1856
|
+
break
|
|
1857
|
+
else:
|
|
1858
|
+
# Check products
|
|
1859
|
+
for spec, stoich in products.items():
|
|
1860
|
+
if _get_base_species(spec) == ref_base:
|
|
1861
|
+
stoich_ref_local = float(stoich)
|
|
1862
|
+
break
|
|
1863
|
+
else:
|
|
1864
|
+
stoich_ref_local = 1.0
|
|
1865
|
+
|
|
1866
|
+
scaling_factor = master_stoich / stoich_ref_local
|
|
1867
|
+
|
|
1868
|
+
coeff_master_scaled = coeff_master * scaling_factor
|
|
1869
|
+
rate_master_scaled = rate_master * scaling_factor
|
|
1870
|
+
|
|
1871
|
+
# 3. Normalize reactants and products to 1 unit of master species for matrix coupling
|
|
1872
|
+
reactants_norm = {k: v / master_stoich for k, v in reactants.items()}
|
|
1873
|
+
products_norm = {k: v / master_stoich for k, v in products.items()}
|
|
1874
|
+
|
|
1875
|
+
# 4. Apply self-implicit sink to the master reactant
|
|
1876
|
+
LHS[master_species_name] = LHS[master_species_name] - coeff_master_scaled * fac
|
|
1877
|
+
bulk_rate_master = rate_master_scaled * fac
|
|
1878
|
+
if master_species_name in RATES:
|
|
1879
|
+
RATES[master_species_name] -= bulk_rate_master
|
|
1880
|
+
|
|
1881
|
+
if not getattr(mp, "in_solver", False):
|
|
1882
|
+
key_master = f"r_{reaction_name}_{master_species_name}"
|
|
1883
|
+
if key_master not in RATES:
|
|
1884
|
+
RATES[key_master] = np.zeros_like(bulk_rate_master)
|
|
1885
|
+
RATES[key_master] -= bulk_rate_master
|
|
1886
|
+
|
|
1887
|
+
# Helper for cross-coupling other species
|
|
1888
|
+
def couple_species(species, stoich_norm, sign):
|
|
1889
|
+
if species not in CROSS:
|
|
1890
|
+
return
|
|
1891
|
+
|
|
1892
|
+
cross_coeff = coeff_master_scaled * stoich_norm * fac
|
|
1893
|
+
CROSS[species].append((master_species_name, sign * cross_coeff))
|
|
1894
|
+
|
|
1895
|
+
bulk_rate = bulk_rate_master * stoich_norm
|
|
1896
|
+
if species in RATES:
|
|
1897
|
+
RATES[species] += sign * bulk_rate
|
|
1898
|
+
|
|
1899
|
+
if not getattr(mp, "in_solver", False):
|
|
1900
|
+
key = f"r_{reaction_name}_{species}"
|
|
1901
|
+
if key not in RATES:
|
|
1902
|
+
RATES[key] = np.zeros_like(bulk_rate)
|
|
1903
|
+
RATES[key] += sign * bulk_rate
|
|
1904
|
+
|
|
1905
|
+
# 5. Couple other reactants (consumed -> sign = -1.0)
|
|
1906
|
+
for spec, stoich_norm in reactants_norm.items():
|
|
1907
|
+
if spec != master_species_name:
|
|
1908
|
+
couple_species(spec, stoich_norm, sign=-1.0)
|
|
1909
|
+
|
|
1910
|
+
# 6. Couple products (produced -> sign = 1.0)
|
|
1911
|
+
for spec, stoich_norm in products_norm.items():
|
|
1912
|
+
couple_species(spec, stoich_norm, sign=1.0)
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def calculate_fractionated_coeff_32(coeff_total, c_total, c_32, alpha, eps=1e-20):
|
|
1916
|
+
"""
|
|
1917
|
+
Calculate the fractionated isotope rate coefficient for 32S.
|
|
1918
|
+
"""
|
|
1919
|
+
c_tot_np = np.asarray(c_total)
|
|
1920
|
+
c_32_np = np.asarray(c_32)
|
|
1921
|
+
valid = c_tot_np > 1e-12
|
|
1922
|
+
alpha_eff = np.where(valid, alpha, 1.0)
|
|
1923
|
+
safe_tot = np.where(valid, c_tot_np, 1.0)
|
|
1924
|
+
safe_32 = np.where(valid, c_32_np, 1.0)
|
|
1925
|
+
ratio_32 = np.where(valid, safe_32 / safe_tot, 1.0)
|
|
1926
|
+
ratio_32 = np.clip(ratio_32, 0.5, 1.5)
|
|
1927
|
+
denom_ratio = 1.0 + (alpha_eff - 1.0) * ratio_32
|
|
1928
|
+
return coeff_total * alpha_eff / denom_ratio
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def partition_equilibrium_isotope_32(c_32, frac_target, frac_other, alpha_eq_32, eps=1e-30):
|
|
1932
|
+
"""
|
|
1933
|
+
Calculate the concentration of the 32S isotope in a partitioned species
|
|
1934
|
+
under equilibrium fractionation.
|
|
1935
|
+
"""
|
|
1936
|
+
denom = frac_target + alpha_eq_32 * frac_other + eps
|
|
1937
|
+
return c_32 * frac_target / denom
|