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