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.
@@ -0,0 +1,1975 @@
1
+ """generate_equation_system.py
2
+
3
+ Standalone declarative parser and code generator for MANTIS reaction transport models.
4
+
5
+ Translates declarative reaction specifications (e.g. chemical_equations_new.py) into
6
+ a fully assembled Python equation module (e.g. equations.py) with exact validation of:
7
+ - Species definitions in species.py
8
+ - Reaction constants in reaction_constants.py
9
+ - Limiters in limiters.py
10
+
11
+ Usage:
12
+ ------
13
+ python -m mantis.generate_equation_system [OPTIONS]
14
+ python src/mantis/generate_equation_system.py -i chemical_equations_new.py -o equations.py
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import ast
21
+ import importlib.util
22
+ from pathlib import Path
23
+ import re
24
+ import sys
25
+ from typing import Any, Dict, List, Optional, Set, Tuple
26
+
27
+
28
+ class ValidationError(Exception):
29
+ """Raised when chemical reaction specifications fail validation checks."""
30
+ pass
31
+
32
+
33
+ class ReactionSystemValidator:
34
+ """Validates chemical reaction specifications against model definitions."""
35
+
36
+ def __init__(
37
+ self,
38
+ species_path: Path,
39
+ constants_path: Path,
40
+ limiters_path: Optional[Path] = None,
41
+ reactions_path: Optional[Path] = None,
42
+ ):
43
+ self.species_path = Path(species_path)
44
+ self.constants_path = Path(constants_path)
45
+ self.limiters_path = Path(limiters_path) if limiters_path is not None else None
46
+ self.reactions_path = Path(reactions_path) if reactions_path is not None else None
47
+
48
+ self.valid_species: Set[str] = set()
49
+ self.valid_constants: Set[str] = set()
50
+ self.valid_limiters: Set[str] = set()
51
+
52
+ self._load_definitions()
53
+
54
+ def _load_definitions(self) -> None:
55
+ """Loads and extracts defined species, constants, and limiters."""
56
+ # 1. Species
57
+ if not self.species_path.exists():
58
+ raise FileNotFoundError(f"Species file not found: {self.species_path}")
59
+ spec_species = importlib.util.spec_from_file_location("species_mod", self.species_path)
60
+ if spec_species is None or spec_species.loader is None:
61
+ raise ImportError(f"Could not load module from {self.species_path}")
62
+ mod_species = importlib.util.module_from_spec(spec_species)
63
+ spec_species.loader.exec_module(mod_species)
64
+ if hasattr(mod_species, "species") and isinstance(mod_species.species, dict):
65
+ self.valid_species = set(mod_species.species.keys())
66
+ self.species_defs = mod_species.species
67
+ # Also register dynamic/alias species if defined with equilibrium_of or alias_of
68
+ for sp_k, sp_meta in mod_species.species.items():
69
+ if isinstance(sp_meta, dict):
70
+ if "equilibrium_of" in sp_meta or "alias_of" in sp_meta or "fraction" in sp_meta:
71
+ self.valid_species.add(sp_k)
72
+ else:
73
+ raise ValueError(f"No 'species' dictionary found in {self.species_path}")
74
+
75
+ # 2. Reaction constants
76
+ if not self.constants_path.exists():
77
+ raise FileNotFoundError(f"Reaction constants file not found: {self.constants_path}")
78
+ spec_constants = importlib.util.spec_from_file_location("rc_mod", self.constants_path)
79
+ if spec_constants is None or spec_constants.loader is None:
80
+ raise ImportError(f"Could not load module from {self.constants_path}")
81
+ mod_constants = importlib.util.module_from_spec(spec_constants)
82
+ spec_constants.loader.exec_module(mod_constants)
83
+
84
+ # Attempt dynamic extraction via get_reaction_constants
85
+ self.valid_constants = set()
86
+ if hasattr(mod_constants, "get_reaction_constants"):
87
+ try:
88
+ _velde, k_vals = mod_constants.get_reaction_constants(7.5, 0.8)
89
+ if isinstance(k_vals, dict):
90
+ self.valid_constants.update(k_vals.keys())
91
+ if isinstance(_velde, dict):
92
+ self.valid_constants.update(_velde.keys())
93
+ except Exception:
94
+ pass
95
+
96
+ # Static AST fallback to capture constants in velde dict or module variables
97
+ try:
98
+ rc_tree = ast.parse(self.constants_path.read_text(encoding="utf-8"))
99
+ for node in ast.walk(rc_tree):
100
+ if isinstance(node, ast.Dict):
101
+ for key in node.keys:
102
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
103
+ self.valid_constants.add(key.value)
104
+ elif isinstance(node, ast.Assign):
105
+ for target in node.targets:
106
+ if isinstance(target, ast.Name):
107
+ self.valid_constants.add(target.id)
108
+ except Exception:
109
+ pass
110
+
111
+ # 3. Limiters (from limiters_path or reactions_path)
112
+ self.valid_limiters = set()
113
+ self.limiters_defs: Dict[str, str] = {}
114
+
115
+ def _extract_limiters_from_ast(tree: ast.AST):
116
+ extracted = {}
117
+ for node in ast.walk(tree):
118
+ if isinstance(node, ast.Assign):
119
+ for target in node.targets:
120
+ if isinstance(target, ast.Subscript):
121
+ if isinstance(target.value, ast.Name) and target.value.id == "limiters":
122
+ if isinstance(target.slice, ast.Constant) and isinstance(target.slice.value, str):
123
+ expr_code = ast.unparse(node.value) if hasattr(ast, "unparse") else ""
124
+ extracted[target.slice.value] = expr_code
125
+ elif isinstance(target, ast.Name) and target.id == "limiters":
126
+ if isinstance(node.value, ast.Dict):
127
+ for k_node, v_node in zip(node.value.keys, node.value.values):
128
+ if isinstance(k_node, ast.Constant) and isinstance(k_node.value, str):
129
+ expr_code = ast.unparse(v_node) if hasattr(ast, "unparse") else ""
130
+ extracted[k_node.value] = expr_code
131
+ return extracted
132
+
133
+ raw_limiters: Dict[str, str] = {}
134
+ # 1. First check if limiters are defined in reactions_path (modern declarative standard)
135
+ if self.reactions_path is not None and self.reactions_path.exists():
136
+ try:
137
+ rxn_tree = ast.parse(self.reactions_path.read_text(encoding="utf-8"))
138
+ raw_limiters.update(_extract_limiters_from_ast(rxn_tree))
139
+ except Exception:
140
+ pass
141
+
142
+ # 2. If not defined in reactions_path, fall back to limiters_path
143
+ if not raw_limiters and self.limiters_path is not None and self.limiters_path.exists():
144
+ try:
145
+ lim_tree = ast.parse(self.limiters_path.read_text(encoding="utf-8"))
146
+ raw_limiters.update(_extract_limiters_from_ast(lim_tree))
147
+ except Exception as e:
148
+ raise ValueError(f"Could not parse limiters file {self.limiters_path}: {e}")
149
+
150
+ # Expand limiters:
151
+ # Base implicit limiter L_imp = 1 / (C + K) -> derive _implicit, _explicit, _inhibit
152
+ for lim_name, expr in raw_limiters.items():
153
+ self.limiters_defs[lim_name] = expr
154
+ self.valid_limiters.add(lim_name)
155
+
156
+ # If defined as base form (e.g. 'O2' or 'SO4' or 'Fe3') or ending in '_implicit'
157
+ base_key = lim_name[:-9] if lim_name.endswith("_implicit") else lim_name
158
+
159
+ # Add implicit form
160
+ self.valid_limiters.add(f"{base_key}_implicit")
161
+ self.valid_limiters.add(f"{base_key}_explicit")
162
+ self.valid_limiters.add(f"{base_key}_inhibit")
163
+ self.valid_limiters.add(f"{base_key}_inhib")
164
+
165
+ if "_" in base_key:
166
+ main_k, sub_k = base_key.split("_", 1)
167
+ self.valid_limiters.add(f"{main_k}_implicit_{sub_k}")
168
+ self.valid_limiters.add(f"{main_k}_explicit_{sub_k}")
169
+ self.valid_limiters.add(f"{main_k}_inhibit_{sub_k}")
170
+ self.valid_limiters.add(f"{main_k}_inhib_{sub_k}")
171
+
172
+ @staticmethod
173
+ def parse_reaction_species(reaction_str: str) -> Tuple[List[Tuple[float, str]], List[Tuple[float, str]]]:
174
+ """Parses a chemical reaction equation string into (reactants, products).
175
+
176
+ Each species entry is (stoichiometric_coefficient, species_name).
177
+ Correctly handles ions with trailing '+' or '-' (e.g. '2 H+', 'SO4--') by splitting on
178
+ whitespace-padded ' + ' delimiters.
179
+ """
180
+ if "->" not in reaction_str:
181
+ raise ValueError(f"Reaction string missing '->' delimiter: '{reaction_str}'")
182
+
183
+ left, right = reaction_str.split("->", 1)
184
+
185
+ def _parse_side(side_str: str) -> List[Tuple[float, str]]:
186
+ terms = re.split(r"\s+\+\s+", side_str.strip())
187
+ parsed = []
188
+ for term in terms:
189
+ term = term.strip()
190
+ if not term:
191
+ continue
192
+ parts = term.split(None, 1)
193
+ if len(parts) == 1:
194
+ coeff = 1.0
195
+ sp = parts[0]
196
+ else:
197
+ try:
198
+ coeff = float(parts[0])
199
+ sp = parts[1]
200
+ except ValueError:
201
+ coeff = 1.0
202
+ sp = term
203
+ parsed.append((coeff, sp))
204
+ return parsed
205
+
206
+ return _parse_side(left), _parse_side(right)
207
+
208
+ def validate_reaction(self, rxn_dict: Dict[str, Any]) -> List[str]:
209
+ """Validates a single reaction dictionary. Returns a list of error messages (empty if valid)."""
210
+ errors = []
211
+ rxn_name = rxn_dict.get("reaction_name", "<unnamed_reaction>")
212
+ rxn_str = rxn_dict.get("reaction", "")
213
+ dyn_vars = rxn_dict.get("dynamic_variables", {}) or {}
214
+ iso_map = rxn_dict.get("isotope_species", {}) or {}
215
+ k_val = rxn_dict.get("k_value_name")
216
+ limiters = rxn_dict.get("limiters")
217
+
218
+ known_local_species = self.valid_species.union(dyn_vars.keys())
219
+
220
+ # 1. Validate reaction equation and species names
221
+ if not rxn_str:
222
+ errors.append(f"[{rxn_name}] Reaction equation string is empty or missing.")
223
+ else:
224
+ try:
225
+ reactants, products = self.parse_reaction_species(rxn_str)
226
+ for coeff, sp in reactants + products:
227
+ if sp not in known_local_species:
228
+ errors.append(
229
+ f"[{rxn_name}] Species '{sp}' not found in species.py or dynamic_variables."
230
+ )
231
+ except Exception as e:
232
+ errors.append(f"[{rxn_name}] Failed to parse reaction equation '{rxn_str}': {e}")
233
+
234
+ # 2. Validate kinetic constant(s)
235
+ if k_val is not None:
236
+ if isinstance(k_val, str):
237
+ if k_val not in self.valid_constants:
238
+ errors.append(
239
+ f"[{rxn_name}] Rate constant '{k_val}' not found in reaction_constants.py."
240
+ )
241
+ elif isinstance(k_val, dict):
242
+ for branch, k_sub in k_val.items():
243
+ if k_sub not in self.valid_constants:
244
+ errors.append(
245
+ f"[{rxn_name}] Rate constant '{k_sub}' (branch '{branch}') not found in reaction_constants.py."
246
+ )
247
+ else:
248
+ errors.append(f"[{rxn_name}] Invalid k_value_name type: {type(k_val)}.")
249
+
250
+ # 3. Validate limiters & inhibitors
251
+ def _check_lim_entry(key_label: str, val: Any):
252
+ if val is None:
253
+ return
254
+ if isinstance(val, dict):
255
+ for target_sp, lim_items in val.items():
256
+ if target_sp not in known_local_species and target_sp != "global":
257
+ errors.append(
258
+ f"[{rxn_name}] {key_label} target species '{target_sp}' not defined in species.py or dynamic_variables."
259
+ )
260
+ item_list = [lim_items] if isinstance(lim_items, str) else lim_items
261
+ for lim_name in item_list:
262
+ # Check bare name, _implicit, _inhibit, etc.
263
+ if not any(
264
+ cand in self.valid_limiters
265
+ for cand in (lim_name, f"{lim_name}_implicit", f"{lim_name}_inhibit", f"{lim_name}_inhib")
266
+ ):
267
+ errors.append(
268
+ f"[{rxn_name}] Fatal Error: {key_label} '{lim_name}' is not defined in the limiters specification or reactions file."
269
+ )
270
+ elif isinstance(val, (list, tuple)):
271
+ for lim_name in val:
272
+ if not any(
273
+ cand in self.valid_limiters
274
+ for cand in (lim_name, f"{lim_name}_implicit", f"{lim_name}_inhibit", f"{lim_name}_inhib")
275
+ ):
276
+ errors.append(
277
+ f"[{rxn_name}] Fatal Error: {key_label} '{lim_name}' is not defined in the limiters specification or reactions file."
278
+ )
279
+ elif isinstance(val, str):
280
+ if not any(
281
+ cand in self.valid_limiters
282
+ for cand in (val, f"{val}_implicit", f"{val}_inhibit", f"{val}_inhib")
283
+ ):
284
+ errors.append(
285
+ f"[{rxn_name}] Fatal Error: {key_label} '{val}' is not defined in the limiters specification or reactions file."
286
+ )
287
+ else:
288
+ errors.append(f"[{rxn_name}] Invalid {key_label} format: {type(val)}.")
289
+
290
+ _check_lim_entry("Limiter", limiters)
291
+ inhibitors = rxn_dict.get("inhibitors")
292
+ _check_lim_entry("Inhibitor", inhibitors)
293
+
294
+ # 4. Validate isotope mapping
295
+ if iso_map:
296
+ if not isinstance(iso_map, dict):
297
+ errors.append(f"[{rxn_name}] isotope_species must be a dictionary.")
298
+ else:
299
+ for base_sp, iso_sp in iso_map.items():
300
+ if base_sp not in known_local_species:
301
+ errors.append(
302
+ f"[{rxn_name}] Base isotope species '{base_sp}' not defined in species.py or dynamic_variables."
303
+ )
304
+ if iso_sp not in known_local_species:
305
+ errors.append(
306
+ f"[{rxn_name}] Isotope species '{iso_sp}' not defined in species.py or dynamic_variables."
307
+ )
308
+
309
+ # 5. Validate dynamic variables expressions (ensure valid Python syntax)
310
+ for var_name, expr in dyn_vars.items():
311
+ try:
312
+ ast.parse(expr)
313
+ except SyntaxError as se:
314
+ errors.append(
315
+ f"[{rxn_name}] Invalid Python syntax in dynamic variable '{var_name}': '{expr}' ({se})."
316
+ )
317
+
318
+ return errors
319
+
320
+ def validate_reactions(self, reactions: List[Dict[str, Any]]) -> List[str]:
321
+ """Validates all reactions in the specification list."""
322
+ all_errors = []
323
+ for rxn_dict in reactions:
324
+ errs = self.validate_reaction(rxn_dict)
325
+ all_errors.extend(errs)
326
+ return all_errors
327
+
328
+
329
+ def load_reactions_from_file(input_path: Path) -> List[Dict[str, Any]]:
330
+ """Loads the reactions list from a chemical equations Python or JSON file."""
331
+ input_path = Path(input_path)
332
+ if not input_path.exists():
333
+ raise FileNotFoundError(f"Input equations file not found: {input_path}")
334
+
335
+ if input_path.suffix == ".py":
336
+ spec = importlib.util.spec_from_file_location("chem_eq_mod", input_path)
337
+ if spec is None or spec.loader is None:
338
+ raise ImportError(f"Could not load module from {input_path}")
339
+ mod = importlib.util.module_from_spec(spec)
340
+ spec.loader.exec_module(mod)
341
+ if not hasattr(mod, "reactions") or not isinstance(mod.reactions, list):
342
+ raise ValueError(f"Input file {input_path} must define a 'reactions' list.")
343
+ return mod.reactions
344
+ elif input_path.suffix == ".json":
345
+ import json
346
+ with open(input_path, "r", encoding="utf-8") as f:
347
+ data = json.load(f)
348
+ if isinstance(data, list):
349
+ return data
350
+ elif isinstance(data, dict) and "reactions" in data:
351
+ return data["reactions"]
352
+ raise ValueError(f"JSON input file {input_path} must be a list of reactions or contain a 'reactions' key.")
353
+ else:
354
+ raise ValueError(f"Unsupported input file format '{input_path.suffix}'. Expected .py or .json.")
355
+
356
+
357
+ class MantisArgumentParser(argparse.ArgumentParser):
358
+ """ArgumentParser subclass ensuring backward compatibility aliases on parsed arguments."""
359
+
360
+ def parse_args(self, args=None, namespace=None):
361
+ ns = super().parse_args(args=args, namespace=namespace)
362
+ if not hasattr(ns, "input") or getattr(ns, "input") is None:
363
+ setattr(ns, "input", getattr(ns, "reactions", None))
364
+ if not hasattr(ns, "output") or getattr(ns, "output") is None:
365
+ setattr(ns, "output", getattr(ns, "equations", None))
366
+ if not hasattr(ns, "constants") or getattr(ns, "constants") is None:
367
+ setattr(ns, "constants", getattr(ns, "kinetic_constants", None))
368
+ return ns
369
+
370
+
371
+ # Alias for backward compatibility
372
+ FiPyriteArgumentParser = MantisArgumentParser
373
+
374
+
375
+ def build_arg_parser() -> argparse.ArgumentParser:
376
+ """Builds and returns the command line argument parser with full help descriptions."""
377
+ parser = MantisArgumentParser(
378
+ prog="generate_equation_system",
379
+ description=(
380
+ "MANTIS Declarative Equation System Generator:\n"
381
+ "Validates declarative chemical reaction specifications (e.g. chemical_equations_new.py) "
382
+ "against model species, reaction constants, and limiters, and generates an assembled "
383
+ "reaction equations module (e.g. equations.py)."
384
+ ),
385
+ formatter_class=argparse.RawDescriptionHelpFormatter,
386
+ )
387
+
388
+ parser.add_argument(
389
+ "-r", "--reactions", "-i", "--input",
390
+ dest="reactions",
391
+ type=Path,
392
+ default=Path("nbk/experiments/chemical_equations_new.py"),
393
+ help="Path to declarative reaction definitions file (default: nbk/experiments/chemical_equations_new.py)",
394
+ )
395
+ parser.add_argument(
396
+ "-e", "--equations", "-o", "--output",
397
+ dest="equations",
398
+ type=Path,
399
+ default=Path("nbk/experiments/equations.py"),
400
+ help="Path to output generated equations module (default: nbk/experiments/equations.py)",
401
+ )
402
+ parser.add_argument(
403
+ "-s", "--species",
404
+ type=Path,
405
+ default=Path("nbk/experiments/species.py"),
406
+ help="Path to model species definitions file (default: nbk/experiments/species.py)",
407
+ )
408
+ parser.add_argument(
409
+ "-k", "--kinetic-constants", "--constants",
410
+ dest="kinetic_constants",
411
+ type=Path,
412
+ default=Path("nbk/experiments/reaction_constants.py"),
413
+ help="Path to reaction constants file (default: nbk/experiments/reaction_constants.py)",
414
+ )
415
+ parser.add_argument(
416
+ "-l", "--limiters",
417
+ type=Path,
418
+ default=Path("nbk/experiments/limiters.py"),
419
+ help="Path to limiters definition file (default: nbk/experiments/limiters.py)",
420
+ )
421
+ parser.add_argument(
422
+ "--validate-only",
423
+ action="store_true",
424
+ help="Perform cross-reference validation of species, constants, and limiters without writing output.",
425
+ )
426
+ parser.add_argument(
427
+ "-v", "--verbose",
428
+ action="store_true",
429
+ help="Enable detailed validation and parsing diagnostic output.",
430
+ )
431
+
432
+ return parser
433
+
434
+
435
+ def generate_equations(
436
+ reactions_path: str | Path,
437
+ equations_path: str | Path,
438
+ kinetic_constants_path: str | Path = Path("nbk/experiments/reaction_constants.py"),
439
+ limiters_path: str | Path = Path("nbk/experiments/limiters.py"),
440
+ species_path: str | Path = Path("nbk/experiments/species.py"),
441
+ verbose: bool = False,
442
+ validate_only: bool = False,
443
+ ) -> Path:
444
+ """Validates and compiles declarative reactions into an assembled equations module.
445
+
446
+ Parameters
447
+ ----------
448
+ reactions_path : str | Path
449
+ Path to declarative reactions definitions file.
450
+ equations_path : str | Path
451
+ Path where the generated equations module will be written.
452
+ kinetic_constants_path : str | Path
453
+ Path to reaction constants definitions file.
454
+ limiters_path : str | Path | None, optional
455
+ Path to limiters definition file. If None or not found, limiters will be read from reactions_path.
456
+ species_path : str | Path
457
+ Path to model species definitions file.
458
+ verbose : bool
459
+ If True, prints diagnostic output.
460
+ validate_only : bool
461
+ If True, performs cross-validation without writing output.
462
+
463
+ Returns
464
+ -------
465
+ Path
466
+ Path to the output equations module.
467
+ """
468
+ reactions_p = Path(reactions_path).resolve()
469
+ equations_p = Path(equations_path).resolve()
470
+ constants_p = Path(kinetic_constants_path).resolve()
471
+ limiters_p = Path(limiters_path).resolve() if limiters_path is not None else None
472
+ species_p = Path(species_path).resolve()
473
+
474
+ if verbose:
475
+ print(f"[MANTIS] Loading reactions from: {reactions_p}")
476
+ print(f"[MANTIS] Using species definitions: {species_p}")
477
+ print(f"[MANTIS] Using reaction constants: {constants_p}")
478
+ if limiters_p and limiters_p.exists():
479
+ print(f"[MANTIS] Using limiters definitions: {limiters_p}")
480
+ else:
481
+ print(f"[MANTIS] Using limiters definitions from reactions file")
482
+
483
+ # 1. Load validator
484
+ validator = ReactionSystemValidator(
485
+ species_path=species_p,
486
+ constants_path=constants_p,
487
+ limiters_path=limiters_p if (limiters_p and limiters_p.exists()) else None,
488
+ reactions_path=reactions_p,
489
+ )
490
+
491
+ # 2. Load input reactions
492
+ reactions = load_reactions_from_file(reactions_p)
493
+
494
+ # 3. Validate
495
+ errors = validator.validate_reactions(reactions)
496
+ if errors:
497
+ err_msg = f"Validation failed for {reactions_p} with {len(errors)} error(s):\n" + "\n".join(f" • {e}" for e in errors)
498
+ raise ValidationError(err_msg)
499
+
500
+ if verbose:
501
+ print(f"[MANTIS] All {len(reactions)} reactions validated successfully!")
502
+
503
+ if validate_only:
504
+ return equations_p
505
+
506
+ # 4. Code Generation
507
+ generator = EquationSystemGenerator(reactions=reactions, validator=validator)
508
+ generated_code = generator.generate_code()
509
+
510
+ equations_p.parent.mkdir(parents=True, exist_ok=True)
511
+ with open(equations_p, "w", encoding="utf-8") as f:
512
+ f.write(generated_code)
513
+
514
+ if verbose:
515
+ print(f"[MANTIS] Successfully wrote equations module to: {equations_p}")
516
+
517
+ return equations_p
518
+
519
+
520
+ def resolve_and_prepare_equations(
521
+ p_dict: Dict[str, Any],
522
+ experiment_name: Optional[str] = None,
523
+ base_dir: Optional[Path] = None,
524
+ ) -> Tuple[Any, Path, Path]:
525
+ """Resolves reaction and equation files from p_dict, regenerates equations if outdated,
526
+ and dynamically imports the equations module.
527
+
528
+ Resolution scheme:
529
+ reactions: defaults to f"{experiment}_reactions.py"
530
+ equations: defaults to f"{experiment}_equations.py"
531
+ kinetic_constants: defaults to "reaction_constants.py"
532
+ limiters: defaults to "limiters.py"
533
+
534
+ Parameters
535
+ ----------
536
+ p_dict : dict
537
+ Parameter dictionary passed to the simulation.
538
+ experiment_name : str, optional
539
+ Name of the experiment stem. If None, derived from p_dict["experiment"].
540
+ base_dir : Path, optional
541
+ Base directory to search for experiment files. Defaults to cwd or experiment file parent.
542
+
543
+ Returns
544
+ -------
545
+ Tuple[module, Path, Path]
546
+ (loaded_equations_module, resolved_reactions_path, resolved_equations_path)
547
+ """
548
+ exp = p_dict.get("experiment") or experiment_name or "pyrite"
549
+
550
+ if base_dir is None:
551
+ if "__file__" in p_dict and p_dict["__file__"]:
552
+ base_dir = Path(p_dict["__file__"]).resolve().parent
553
+ else:
554
+ base_dir = Path.cwd()
555
+
556
+ def _resolve(val: Any, default_name: str) -> Path:
557
+ filename = default_name if (val is None or val == "file_name") else str(val)
558
+ p = Path(filename)
559
+ if p.is_absolute() and p.exists():
560
+ return p
561
+ # Check relative to base_dir
562
+ if (base_dir / p).exists():
563
+ return (base_dir / p).resolve()
564
+ # Check relative to cwd
565
+ if (Path.cwd() / p).exists():
566
+ return (Path.cwd() / p).resolve()
567
+ # Check in nbk/experiments
568
+ if (Path.cwd() / "nbk" / "experiments" / p).exists():
569
+ return (Path.cwd() / "nbk" / "experiments" / p).resolve()
570
+ return (base_dir / p).resolve()
571
+
572
+ # Apply defaults if not specified or None
573
+ rxn_val = p_dict.get("reactions")
574
+ eq_val = p_dict.get("equations")
575
+ kc_val = p_dict.get("kinetic_constants")
576
+ lim_val = p_dict.get("limiters")
577
+ sp_val = p_dict.get("species")
578
+
579
+ reactions_path = _resolve(rxn_val, f"{exp}_reactions.py")
580
+ equations_path = _resolve(eq_val, f"{exp}_equations.py")
581
+ constants_path = _resolve(kc_val, "reaction_constants.py")
582
+ species_path = _resolve(sp_val, "species.py")
583
+
584
+ # If limiters not explicitly specified in p_dict, check if base_dir has limiters.py
585
+ if lim_val is not None:
586
+ limiters_path = _resolve(lim_val, "limiters.py")
587
+ elif (base_dir / "limiters.py").exists():
588
+ limiters_path = (base_dir / "limiters.py").resolve()
589
+ else:
590
+ limiters_path = None
591
+
592
+ if not reactions_path.exists():
593
+ raise FileNotFoundError(
594
+ f"Declarative reactions file not found: '{reactions_path}'. "
595
+ f"Please specify a valid 'reactions' file in p_dict or create '{exp}_reactions.py'."
596
+ )
597
+
598
+ # Determine whether regeneration is needed based on mtime timestamps
599
+ needs_regen = False
600
+ reason = ""
601
+ if not equations_path.exists():
602
+ needs_regen = True
603
+ reason = f"{equations_path.name} does not exist"
604
+ elif "def diagenetic_reactions" not in equations_path.read_text(encoding="utf-8", errors="ignore"):
605
+ needs_regen = True
606
+ reason = f"{equations_path.name} is missing diagenetic_reactions function"
607
+ elif reactions_path.stat().st_mtime > equations_path.stat().st_mtime:
608
+ needs_regen = True
609
+ reason = f"{reactions_path.name} is newer than {equations_path.name}"
610
+ elif constants_path.exists() and constants_path.stat().st_mtime > equations_path.stat().st_mtime:
611
+ needs_regen = True
612
+ reason = f"{constants_path.name} is newer than {equations_path.name}"
613
+ elif limiters_path is not None and limiters_path.exists() and limiters_path.stat().st_mtime > equations_path.stat().st_mtime:
614
+ needs_regen = True
615
+ reason = f"{limiters_path.name} is newer than {equations_path.name}"
616
+ elif species_path.exists() and species_path.stat().st_mtime > equations_path.stat().st_mtime:
617
+ needs_regen = True
618
+ reason = f"{species_path.name} is newer than {equations_path.name}"
619
+
620
+ if needs_regen:
621
+ print(f"[MANTIS] Regenerating {equations_path.name} ({reason})...")
622
+ generate_equations(
623
+ reactions_path=reactions_path,
624
+ equations_path=equations_path,
625
+ kinetic_constants_path=constants_path,
626
+ limiters_path=limiters_path,
627
+ species_path=species_path,
628
+ verbose=False,
629
+ )
630
+
631
+ # Ensure parent directories of equations and limiters are on sys.path for import resolution
632
+ import_dirs = [str(equations_path.parent)]
633
+ if limiters_path is not None:
634
+ import_dirs.append(str(limiters_path.parent))
635
+ for p_dir in import_dirs:
636
+ if p_dir not in sys.path:
637
+ sys.path.insert(0, p_dir)
638
+
639
+ # Dynamically load the generated equations module
640
+ mod_name = equations_path.stem
641
+ spec = importlib.util.spec_from_file_location(mod_name, equations_path)
642
+ if spec is None or spec.loader is None:
643
+ raise ImportError(f"Could not load equations module from {equations_path}")
644
+ eq_mod = importlib.util.module_from_spec(spec)
645
+ sys.modules[mod_name] = eq_mod
646
+ spec.loader.exec_module(eq_mod)
647
+
648
+ # Dynamically load reaction constants function if reaction_constants is not already a callable
649
+ if not callable(p_dict.get("reaction_constants")) and constants_path.exists():
650
+ c_spec = importlib.util.spec_from_file_location(constants_path.stem, constants_path)
651
+ if c_spec and c_spec.loader:
652
+ c_mod = importlib.util.module_from_spec(c_spec)
653
+ c_spec.loader.exec_module(c_mod)
654
+ if hasattr(c_mod, "get_reaction_constants"):
655
+ p_dict["reaction_constants"] = c_mod.get_reaction_constants
656
+
657
+ # Record loaded module and paths in p_dict
658
+ p_dict["reactions_module"] = eq_mod
659
+ p_dict["reactions_path"] = reactions_path
660
+ p_dict["equations_path"] = equations_path
661
+
662
+ return eq_mod, reactions_path, equations_path
663
+
664
+
665
+ class EquationSystemGenerator:
666
+ """Generates an assembled equations.py module with SymPy analytical Jacobian."""
667
+
668
+ def __init__(self, reactions: List[Dict[str, Any]], validator: ReactionSystemValidator):
669
+ self.reactions = reactions
670
+ self.validator = validator
671
+
672
+ @staticmethod
673
+ def classify_reaction(r: Dict[str, Any]) -> str:
674
+ """Classifies a chemical reaction based on its stoichiometry, reactants, and products,
675
+ completely independent of the user-assigned reaction name.
676
+ """
677
+ r_str = r.get("reaction", "")
678
+ if "->" not in r_str:
679
+ return "unknown"
680
+ try:
681
+ reactants, products = ReactionSystemValidator.parse_reaction_species(r_str)
682
+ except Exception:
683
+ return "unknown"
684
+
685
+ r_sp = [sp for _, sp in reactants]
686
+ p_sp = [sp for _, sp in products]
687
+ k_val = r.get("k_value_name")
688
+
689
+ # 1. Reversible precipitation / dissolution (e.g. FeS)
690
+ if isinstance(k_val, dict) and "precipitation" in k_val:
691
+ return "FeS_precipitation_dissolution"
692
+
693
+ # 2. Elemental sulfur disproportionation (S0 -> TS2 + SO4)
694
+ if "S0" in r_sp and "TS2" in p_sp and "SO4" in p_sp:
695
+ return "elemental_sulfur_disproportionation"
696
+
697
+ # 3. Aerobic respiration (POC + O2 -> CO2)
698
+ if "O2" in r_sp and any("POC" in sp for sp in r_sp):
699
+ return "aerobic_respiration"
700
+
701
+ # 4. Dissimilatory iron reduction (POC + Fe3 -> Fe2_total)
702
+ if "Fe3" in r_sp and any("POC" in sp for sp in r_sp):
703
+ return "dissimilatory_iron_reduction"
704
+
705
+ # 5. Sulfate reduction (POC + SO4 -> TS2)
706
+ if "SO4" in r_sp and any("POC" in sp for sp in r_sp):
707
+ return "sulfate_reduction"
708
+
709
+ # 6. Sulfide oxidation (HS + O2 -> SO4)
710
+ if any(sp in ("HS", "TS2") for sp in r_sp) and "O2" in r_sp and "SO4" in p_sp:
711
+ return "hs_oxidation"
712
+
713
+ # 7. Sulfide-mediated iron reduction
714
+ if any(sp in ("HS", "TS2") for sp in r_sp) and "Fe3" in r_sp:
715
+ if "SO4" in p_sp:
716
+ return "sulfide_mediated_iron_reduction_velde"
717
+ return "sulfide_mediated_iron_reduction"
718
+
719
+ # 8. Fe2 oxidation (Fe2 + O2 -> Fe3)
720
+ if any("Fe2" in sp for sp in r_sp) and "O2" in r_sp and "Fe3" in p_sp:
721
+ return "Fe2_oxidation"
722
+
723
+ # 9. FeS oxidation (FeS + O2 -> Fe3 + SO4)
724
+ if "FeS" in r_sp and "O2" in r_sp and ("Fe3" in p_sp or "SO4" in p_sp):
725
+ return "FeS_oxidation"
726
+
727
+ # 10. FeS2 oxidation (FeS2 + O2 -> Fe3 + SO4)
728
+ if "FeS2" in r_sp and "O2" in r_sp:
729
+ return "FeS2_oxidation"
730
+
731
+ # 11. Pyrite precipitation via HS/TS2 (FeS + HS -> FeS2)
732
+ if "FeS" in r_sp and any(sp in ("HS", "TS2") for sp in r_sp) and "FeS2" in p_sp:
733
+ return "FeS2_precipitation_TS2"
734
+
735
+ # 12. Pyrite formation via S0 (FeS + S0 -> FeS2)
736
+ if "FeS" in r_sp and "S0" in r_sp and "FeS2" in p_sp:
737
+ return "pyrite_formation_fes_s0"
738
+
739
+ # 13. Elemental sulfur oxidation (S0 + O2 -> SO4)
740
+ if "S0" in r_sp and "O2" in r_sp and "SO4" in p_sp:
741
+ return "elemental_sulfur_oxidation"
742
+
743
+ return "unknown"
744
+
745
+ def generate_code(self) -> str:
746
+ """Generates complete Python source for equations.py."""
747
+ code = [
748
+ '"""equations.py',
749
+ '',
750
+ 'Auto-generated reaction equations system module for MANTIS.',
751
+ 'Generated by generate_equation_system.py with analytical SymPy Jacobian support.',
752
+ '"""',
753
+ '',
754
+ 'from __future__ import annotations',
755
+ 'import numpy as np',
756
+ 'from mantis.diff_lib import (',
757
+ ' add_coupled_reaction,',
758
+ ' add_implicit_sink,',
759
+ ' add_implicit_coupling_new,',
760
+ ' calculate_fractionated_coeff_32,',
761
+ ' partition_equilibrium_isotope_32,',
762
+ ' data_container,',
763
+ ')',
764
+ 'try:',
765
+ ' from limiters import get_limiters as _external_get_limiters',
766
+ 'except ImportError:',
767
+ ' _external_get_limiters = None',
768
+ '',
769
+ 'def get_limiters(c, mp):',
770
+ ' """Calculates phase-corrected Monod and inhibition limiters dynamically."""',
771
+ ' if _external_get_limiters is not None:',
772
+ ' return _external_get_limiters(c, mp)',
773
+ ' phi = np.asarray(mp.phi.value if hasattr(mp.phi, "value") else mp.phi)',
774
+ ' solid_phi = 1.0 - phi',
775
+ ' # Dynamically extract half-saturation constants from mp (liquid scaled by phi, solid by solid_phi)',
776
+ ' # Liquid phase limiters:',
777
+ ' K_O2 = getattr(mp, "K_O2", 0.0) / phi',
778
+ ' K_TS2 = getattr(mp, "K_TS2", 0.0) / phi',
779
+ ' K_SO4 = getattr(mp, "K_SO4", 0.0) / phi',
780
+ ' # Solid phase limiters:',
781
+ ' K_Fe3 = getattr(mp, "K_Fe3", 0.0) / solid_phi',
782
+ ' K_Fe3_diss_red = getattr(mp, "K_Fe3_diss_red", 0.0) / solid_phi',
783
+ ' limiters = {}',
784
+ ]
785
+
786
+ # Dynamically emit limiters from validator.limiters_defs
787
+ for lim_name, expr in self.validator.limiters_defs.items():
788
+ base_key = lim_name[:-9] if lim_name.endswith("_implicit") else lim_name
789
+ clean_expr = expr.strip("'\"")
790
+
791
+ # Store the defined expression
792
+ code.append(f' limiters["{lim_name}"] = {clean_expr}')
793
+
794
+ # Derive explicit and inhibition forms if base form or implicit form
795
+ code.append(f' limiters["{base_key}_implicit"] = limiters["{lim_name}"]')
796
+ # Also support transposed naming convention e.g. O2_implicit_TS2 for O2_TS2
797
+ if "_" in base_key:
798
+ main_k, sub_k = base_key.split("_", 1)
799
+ code.append(f' limiters["{main_k}_implicit_{sub_k}"] = limiters["{lim_name}"]')
800
+ code.append(f' limiters["{main_k}_{sub_k}_implicit"] = limiters["{lim_name}"]')
801
+
802
+ # Extract target species name (e.g. c.O2 or np.maximum(c.Fe3, 0.0))
803
+ # If expr contains 1.0 / (X + K), explicit is X / (X + K) and inhibit is K / (X + K)
804
+ if "1.0 /" in clean_expr:
805
+ denom = clean_expr.split("1.0 /", 1)[1].strip()
806
+ # Remove outer parens if present
807
+ if denom.startswith("(") and denom.endswith(")"):
808
+ inner = denom[1:-1].strip()
809
+ parts = inner.split("+", 1)
810
+ if len(parts) == 2:
811
+ var_part = parts[0].strip()
812
+ k_part = parts[1].strip()
813
+ code.append(f' limiters["{base_key}_explicit"] = ({var_part}) * limiters["{lim_name}"]')
814
+ code.append(f' limiters["{base_key}_inhibit"] = ({k_part}) * limiters["{lim_name}"]')
815
+ code.append(f' limiters["{base_key}_inhib"] = limiters["{base_key}_inhibit"]')
816
+ if "_" in base_key:
817
+ main_k, sub_k = base_key.split("_", 1)
818
+ code.append(f' limiters["{main_k}_explicit_{sub_k}"] = limiters["{base_key}_explicit"]')
819
+ code.append(f' limiters["{main_k}_inhibit_{sub_k}"] = limiters["{base_key}_inhibit"]')
820
+ code.append(f' limiters["{main_k}_inhib_{sub_k}"] = limiters["{base_key}_inhibit"]')
821
+
822
+ code.extend([
823
+ ' return limiters',
824
+ '',
825
+ '# -----------------------------------------------------------------------------',
826
+ '# Individual Reaction Functions',
827
+ '# -----------------------------------------------------------------------------',
828
+ '',
829
+ 'def aerobic_respiration(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
830
+ ' """Reaction: POC + O2 -> CO2"""',
831
+ ' has_solid = True',
832
+ ' poc_species = k_val.get("poc_species", "POC_fast") if isinstance(k_val, dict) else "POC_fast"',
833
+ ' poc_k_name = k_val.get("poc_k", "POC_fast") if isinstance(k_val, dict) else "POC_fast"',
834
+ ' k_num = mp.k.get(poc_k_name) if hasattr(mp, "k") else getattr(k_val, poc_k_name, 0.0)',
835
+ ' poc_var = getattr(c, poc_species)',
836
+ ' lim_O2 = lim["O2_implicit"]',
837
+ ' rate_base = k_num * poc_var * c.O2 * lim_O2',
838
+ ' ratio = getattr(mp, "POC_O2_ratio", 1.0)',
839
+ ' coeff_O2 = ratio * k_num * poc_var * lim_O2',
840
+ ' add_implicit_sink(LHS, RATES, "O2", coeff_O2, ratio * rate_base, mp=mp, has_solid=has_solid, c=c)',
841
+ ' coeff_POC = k_num * 1.0 * c.O2 * lim_O2',
842
+ ' add_implicit_sink(LHS, RATES, poc_species, coeff_POC, rate_base, mp=mp, has_solid=has_solid, c=c)',
843
+ '',
844
+ 'def dissimilatory_iron_reduction(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
845
+ ' """Reaction: POC + 4 Fe3 -> 4 Fe2_total"""',
846
+ ' has_solid = True',
847
+ ' poc_species = k_val.get("poc_species", "POC_fast") if isinstance(k_val, dict) else "POC_fast"',
848
+ ' poc_k_name = k_val.get("poc_k", "POC_fast") if isinstance(k_val, dict) else "POC_fast"',
849
+ ' k_num = mp.k.get(poc_k_name) if hasattr(mp, "k") else getattr(k_val, poc_k_name, 0.0)',
850
+ ' poc_var = getattr(c, poc_species)',
851
+ ' fe3_lim = lim["Fe3_diss_red_implicit"]',
852
+ ' o2_inhib = lim["O2_inhibit"]',
853
+ ' rate_base = k_num * poc_var * c.Fe3 * o2_inhib * fe3_lim',
854
+ ' coeff_master = k_num * poc_var * 1.0 * o2_inhib * fe3_lim',
855
+ ' add_coupled_reaction(',
856
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
857
+ ' master_species={"Fe3": 4}, reactants={poc_species: 1}, products={"Fe2_total": 4},',
858
+ ' coeff_master=coeff_master, rate_master=rate_base,',
859
+ ' has_solid=has_solid, reaction_name=f"dissimilatory_iron_reduction_{poc_species}",',
860
+ ' ref_species="POC", stoich_ref=1.0,',
861
+ ' )',
862
+ ' coeff_POC = k_num * 1.0 * c.Fe3 * o2_inhib * fe3_lim',
863
+ ' add_implicit_sink(LHS, RATES, poc_species, coeff_POC, rate_base, mp=mp, has_solid=has_solid, c=c)',
864
+ '',
865
+ 'def sulfate_reduction(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
866
+ ' """Reaction: 2 POC + SO4 -> TS2"""',
867
+ ' has_solid = True',
868
+ ' poc_species = k_val.get("poc_species", "POC_fast") if isinstance(k_val, dict) else "POC_fast"',
869
+ ' poc_k_name = k_val.get("poc_k", "POC_fast") if isinstance(k_val, dict) else "POC_fast"',
870
+ ' k_num = mp.k.get(poc_k_name) if hasattr(mp, "k") else getattr(k_val, poc_k_name, 0.0)',
871
+ ' poc_var = getattr(c, poc_species)',
872
+ ' so4_lim = lim["SO4_implicit"]',
873
+ ' o2_inhib = lim["O2_inhibit"]',
874
+ ' fe3_inhib = lim["Fe3_diss_red_inhib"]',
875
+ ' rate_base = k_num * poc_var * c.SO4 * o2_inhib * so4_lim * fe3_inhib',
876
+ ' coeff_master = k_num * poc_var * 1.0 * o2_inhib * so4_lim * fe3_inhib',
877
+ ' add_coupled_reaction(',
878
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
879
+ ' master_species={"SO4": 1}, reactants={poc_species: 2}, products={"TS2": 1},',
880
+ ' coeff_master=coeff_master, rate_master=rate_base,',
881
+ ' has_solid=has_solid, reaction_name=f"sulfate_reduction_{poc_species}",',
882
+ ' ref_species="POC", stoich_ref=2.0,',
883
+ ' )',
884
+ ' coeff_POC = k_num * 1.0 * c.SO4 * o2_inhib * so4_lim * fe3_inhib',
885
+ ' add_implicit_sink(LHS, RATES, poc_species, coeff_POC, rate_base, mp=mp, has_solid=has_solid, c=c)',
886
+ ' if getattr(mp, "isotopes", False):',
887
+ ' alpha = 1.0 + (mp.msr_alpha - 1.0) * lim["SO4_alpha_explicit"]',
888
+ ' coeff_master_32 = calculate_fractionated_coeff_32(',
889
+ ' coeff_master, c.SO4, c.SO4_32, alpha, eps=1e-30',
890
+ ' )',
891
+ ' add_coupled_reaction(',
892
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
893
+ ' master_species={"SO4_32": 1}, reactants={}, products={"TS2_32": 1},',
894
+ ' coeff_master=coeff_master_32, rate_master=coeff_master_32 * c.SO4_32,',
895
+ ' has_solid=has_solid, reaction_name=f"sulfate_reduction_32_{poc_species}",',
896
+ ' ref_species="POC", stoich_ref=2.0,',
897
+ ' )',
898
+ '',
899
+ 'def hs_oxidation_velde(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
900
+ ' """Reaction: HS + 2 O2 -> SO4"""',
901
+ ' has_solid = False',
902
+ ' k_num = mp.k.get("TS2_O2") if hasattr(mp, "k") else getattr(k_val, "TS2_O2", 0.0)',
903
+ ' o2_lim = lim["O2_implicit_TS2"]',
904
+ ' hs_conc = c.TS2 * mp.hs_frac',
905
+ ' rate_base = k_num * hs_conc * c.O2 * o2_lim',
906
+ ' coeff_master = k_num * mp.hs_frac * c.O2 * o2_lim',
907
+ ' add_coupled_reaction(',
908
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
909
+ ' master_species={"TS2": 1}, reactants={}, products={"SO4": 1},',
910
+ ' coeff_master=coeff_master, rate_master=rate_base,',
911
+ ' has_solid=has_solid, reaction_name="hs_oxidation_velde",',
912
+ ' ref_species="TS2", stoich_ref=1.0,',
913
+ ' )',
914
+ ' coeff_O2 = 2.0 * k_num * hs_conc * o2_lim',
915
+ ' add_implicit_sink(LHS, RATES, "O2", coeff_O2, 2.0 * rate_base, mp=mp, has_solid=has_solid, c=c)',
916
+ ' if getattr(mp, "isotopes", False):',
917
+ ' alpha = 1.0 + (mp.TS2_O2_alpha - 1.0) * lim["TS2_alpha_explicit"]',
918
+ ' coeff_master_32 = calculate_fractionated_coeff_32(',
919
+ ' coeff_master, c.TS2, c.TS2_32, alpha, eps=1e-30',
920
+ ' )',
921
+ ' add_coupled_reaction(',
922
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
923
+ ' master_species={"TS2_32": 1}, reactants={}, products={"SO4_32": 1},',
924
+ ' coeff_master=coeff_master_32, rate_master=coeff_master_32 * c.TS2_32,',
925
+ ' has_solid=has_solid, reaction_name="hs_oxidation_velde_32",',
926
+ ' ref_species="TS2", stoich_ref=1.0,',
927
+ ' )',
928
+ '',
929
+ 'def Fe2_oxidation(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
930
+ ' """Reaction: 4 Fe2_total + O2 -> 4 Fe3"""',
931
+ ' has_solid = False',
932
+ ' k_num = mp.k.get("Fe2_O2") if hasattr(mp, "k") else getattr(k_val, "Fe2_O2", 0.0)',
933
+ ' rate_base = k_num * c.Fe2_total * c.O2',
934
+ ' coeff_master = k_num * 1.0 * c.O2',
935
+ ' add_coupled_reaction(',
936
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
937
+ ' master_species={"Fe2_total": 4}, reactants={}, products={"Fe3": 4},',
938
+ ' coeff_master=coeff_master, rate_master=rate_base,',
939
+ ' has_solid=has_solid, reaction_name="Fe2_oxidation",',
940
+ ' ref_species="Fe2_total", stoich_ref=4.0,',
941
+ ' )',
942
+ ' coeff_O2 = (1.0 / 4.0) * k_num * c.Fe2_total * 1.0',
943
+ ' add_implicit_sink(LHS, RATES, "O2", coeff_O2, 0.25 * rate_base, mp=mp, has_solid=has_solid, c=c)',
944
+ '',
945
+ 'def FeS_oxidation(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
946
+ ' """Reaction: 4 FeS + 9 O2 -> 4 Fe3 + 4 SO4"""',
947
+ ' has_solid = True',
948
+ ' k_num = mp.k.get("FeS_O2") if hasattr(mp, "k") else getattr(k_val, "FeS_O2", 0.0)',
949
+ ' rate_base = k_num * c.FeS * c.O2',
950
+ ' coeff_master = k_num * 1.0 * c.O2',
951
+ ' add_coupled_reaction(',
952
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
953
+ ' master_species={"FeS": 4}, reactants={}, products={"Fe3": 4, "SO4": 4},',
954
+ ' coeff_master=coeff_master, rate_master=rate_base,',
955
+ ' has_solid=has_solid, reaction_name="FeS_oxidation",',
956
+ ' ref_species="FeS", stoich_ref=4.0,',
957
+ ' )',
958
+ ' coeff_O2 = (9.0 / 4.0) * k_num * c.FeS * 1.0',
959
+ ' add_implicit_sink(LHS, RATES, "O2", coeff_O2, 2.25 * rate_base, mp=mp, has_solid=has_solid, c=c)',
960
+ ' if getattr(mp, "isotopes", False):',
961
+ ' coeff_FeS_32 = k_num * c.O2',
962
+ ' add_coupled_reaction(',
963
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
964
+ ' master_species={"FeS_32": 1}, reactants={}, products={"SO4_32": 1},',
965
+ ' coeff_master=coeff_FeS_32, rate_master=coeff_FeS_32 * c.FeS_32,',
966
+ ' has_solid=has_solid, reaction_name="FeS_oxidation_32",',
967
+ ' ref_species="FeS", stoich_ref=1.0,',
968
+ ' )',
969
+ '',
970
+ 'def sulfide_mediated_iron_reduction_velde(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
971
+ ' """Reaction: HS + 8 Fe3 -> SO4 + 8 Fe2_total"""',
972
+ ' has_solid = True',
973
+ ' k_num = mp.k.get("Fe3_hs") if hasattr(mp, "k") else getattr(k_val, "Fe3_hs", 0.0)',
974
+ ' hs_conc = c.TS2 * mp.hs_frac',
975
+ ' fe3_lim = lim["Fe3_implicit"]',
976
+ ' o2_inhib = lim["O2_inhibit"]',
977
+ ' rate_base = k_num * hs_conc * c.Fe3 * o2_inhib * fe3_lim',
978
+ ' coeff_master = k_num * c.Fe3 * 1.0 * mp.hs_frac * o2_inhib * fe3_lim',
979
+ ' add_coupled_reaction(',
980
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
981
+ ' master_species={"TS2": 1}, reactants={"Fe3": 8}, products={"Fe2_total": 8, "SO4": 1},',
982
+ ' coeff_master=coeff_master, rate_master=rate_base,',
983
+ ' has_solid=has_solid, reaction_name="sulfide_mediated_iron_reduction_velde",',
984
+ ' ref_species="Fe3", stoich_ref=8.0,',
985
+ ' )',
986
+ ' if getattr(mp, "isotopes", False):',
987
+ ' coeff_master_32 = coeff_master',
988
+ ' add_coupled_reaction(',
989
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
990
+ ' master_species={"TS2_32": 1}, reactants={}, products={"SO4_32": 1},',
991
+ ' coeff_master=coeff_master_32, rate_master=coeff_master_32 * c.TS2_32,',
992
+ ' has_solid=has_solid, reaction_name="sulfide_mediated_iron_reduction_velde_32",',
993
+ ' ref_species="Fe3", stoich_ref=8.0,',
994
+ ' )',
995
+ '',
996
+ 'def FeS_precipitation_dissolution_smooth_transition(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
997
+ ' """Reaction: Fe2_liq + HS -> FeS with C1 smooth transition across Omega = 1."""',
998
+ ' import numpy as np',
999
+ ' phi = mp.phi',
1000
+ ' has_solid = True',
1001
+ ' has_solid_prec = False',
1002
+ ' Fe2_val = np.maximum(np.asarray(c.Fe2_total.value if hasattr(c.Fe2_total, "value") else c.Fe2_total), 1e-20)',
1003
+ ' TS2_val = np.maximum(np.asarray(c.TS2.value if hasattr(c.TS2, "value") else c.TS2), 1e-20)',
1004
+ ' FeS_val = np.maximum(np.asarray(c.FeS.value if hasattr(c.FeS, "value") else c.FeS), 1e-20)',
1005
+ ' Fe2_pw = Fe2_val * mp.Fe2_diss',
1006
+ ' hs_val = TS2_val * mp.hs_frac',
1007
+ ' k_Hplus = getattr(k_val, "Hplus", getattr(mp, "Hplus", 10**-7.5))',
1008
+ ' k_FeS_sp = getattr(k_val, "FeS_sp", getattr(mp, "FeS_sp", 10**-3.5))',
1009
+ ' k_FeS_isp = getattr(k_val, "FeS_isp", getattr(mp, "FeS_isp", 1.0))',
1010
+ ' k_FeS_isd = getattr(k_val, "FeS_isd", getattr(mp, "FeS_isd", 0.3))',
1011
+ ' omega_den = k_Hplus * k_FeS_sp + 1e-30',
1012
+ ' omega = Fe2_pw * hs_val / omega_den',
1013
+ ' is_prec = np.where(omega >= 1.0, 1.0, 0.0)',
1014
+ ' is_diss = np.where(omega < 1.0, 1.0, 0.0)',
1015
+ ' Km = 0.5',
1016
+ ' epsilon = float(getattr(mp, "fes_smooth_epsilon", 0.05))',
1017
+ ' k_prec_eff = k_FeS_isp * is_prec',
1018
+ ' df = np.maximum(omega - 1.0, 0.0)',
1019
+ ' mm_factor_prec = df / (Km + df)',
1020
+ ' u_prec = np.minimum(df / (epsilon + 1e-30), 1.0)',
1021
+ ' smooth_ramp_prec = (3.0 * u_prec**2 - 2.0 * u_prec**3)',
1022
+ ' R_prec = k_prec_eff * mm_factor_prec * smooth_ramp_prec',
1023
+ ' w_Fe = TS2_val**2 / (Fe2_val**2 + TS2_val**2 + 1e-30)',
1024
+ ' w_TS2 = 1.0 - w_Fe',
1025
+ ' prec_coeff_Fe2 = (w_Fe * R_prec) / Fe2_val',
1026
+ ' prec_rate_Fe2 = prec_coeff_Fe2 * Fe2_val',
1027
+ ' prec_coeff_TS2 = (w_TS2 * R_prec) / TS2_val',
1028
+ ' prec_rate_TS2 = prec_coeff_TS2 * TS2_val',
1029
+ ' add_implicit_coupling_new(',
1030
+ ' CROSS, RATES, LHS, target_species="FeS", source_species="Fe2_total",',
1031
+ ' coeff=prec_coeff_Fe2, rate=prec_rate_Fe2, mp=mp, has_solid=has_solid_prec,',
1032
+ ' add_lhs_sink=False, stoich_ratio=1.0,',
1033
+ ' )',
1034
+ ' add_implicit_coupling_new(',
1035
+ ' CROSS, RATES, LHS, target_species="FeS", source_species="TS2",',
1036
+ ' coeff=prec_coeff_TS2, rate=prec_rate_TS2, mp=mp, has_solid=has_solid_prec,',
1037
+ ' add_lhs_sink=False, stoich_ratio=1.0,',
1038
+ ' )',
1039
+ ' add_implicit_sink(LHS, RATES, "Fe2_total", prec_coeff_Fe2, prec_rate_Fe2, mp=mp, has_solid=has_solid_prec)',
1040
+ ' add_implicit_sink(LHS, RATES, "TS2", prec_coeff_TS2, prec_rate_TS2, mp=mp, has_solid=has_solid_prec)',
1041
+ ' CROSS["Fe2_total"].append(("TS2", -prec_coeff_TS2 * phi))',
1042
+ ' RATES["Fe2_total"] -= prec_rate_TS2 * phi',
1043
+ ' CROSS["TS2"].append(("Fe2_total", -prec_coeff_Fe2 * phi))',
1044
+ ' RATES["TS2"] -= prec_rate_Fe2 * phi',
1045
+ ' k_diss_eff = k_FeS_isd * is_diss',
1046
+ ' us = np.maximum(1.0 - omega, 0.0)',
1047
+ ' mm_factor_diss = us / (Km + us)',
1048
+ ' u_diss = np.minimum(us / (epsilon + 1e-30), 1.0)',
1049
+ ' smooth_ramp_diss = (3.0 * u_diss**2 - 2.0 * u_diss**3)',
1050
+ ' diss_coeff_FeS = k_diss_eff * mm_factor_diss * smooth_ramp_diss',
1051
+ ' diss_rate_FeS = diss_coeff_FeS * FeS_val',
1052
+ ' add_implicit_coupling_new(',
1053
+ ' CROSS, RATES, LHS, target_species="Fe2_total", source_species="FeS",',
1054
+ ' coeff=diss_coeff_FeS, rate=diss_rate_FeS, mp=mp, has_solid=has_solid,',
1055
+ ' add_lhs_sink=True, stoich_ratio=1.0,',
1056
+ ' )',
1057
+ ' add_implicit_coupling_new(',
1058
+ ' CROSS, RATES, LHS, target_species="TS2", source_species="FeS",',
1059
+ ' coeff=diss_coeff_FeS, rate=diss_rate_FeS, mp=mp, has_solid=has_solid,',
1060
+ ' add_lhs_sink=False, stoich_ratio=1.0,',
1061
+ ' )',
1062
+ ' if getattr(mp, "isotopes", False):',
1063
+ ' hs_32 = partition_equilibrium_isotope_32(',
1064
+ ' c.TS2_32, mp.hs_frac, mp.h2s_frac, mp.h2s_hs_alpha',
1065
+ ' )',
1066
+ ' hs_val_np = np.asarray(hs_val)',
1067
+ ' hs_32_val = np.asarray(hs_32)',
1068
+ ' TS2_val_np = np.asarray(c.TS2.value if hasattr(c.TS2, "value") else c.TS2)',
1069
+ ' TS2_32_val = np.asarray(c.TS2_32.value if hasattr(c.TS2_32, "value") else c.TS2_32)',
1070
+ ' f32_default = 1.0 / (1.0 + mp.VCDT)',
1071
+ ' f32_hs = np.where(hs_val_np > 1e-6, hs_32_val / (hs_val_np + 1e-30), f32_default)',
1072
+ ' f32_hs = np.clip(f32_hs, 0.5, 1.5)',
1073
+ ' f32_TS2 = np.where(TS2_val_np > 1e-6, TS2_32_val / (TS2_val_np + 1e-30), f32_default)',
1074
+ ' f32_TS2 = np.clip(f32_TS2, 0.5, 1.5)',
1075
+ ' ratio_hs_ts2 = np.where(f32_TS2 > 1e-10, f32_hs / f32_TS2, 1.0)',
1076
+ ' prec_coeff_TS2_32 = prec_coeff_TS2 * ratio_hs_ts2',
1077
+ ' add_implicit_coupling_new(',
1078
+ ' CROSS, RATES, LHS, target_species="FeS_32", source_species="TS2_32",',
1079
+ ' coeff=prec_coeff_TS2_32, rate=prec_rate_TS2 * f32_hs, mp=mp, has_solid=has_solid_prec,',
1080
+ ' add_lhs_sink=False, stoich_ratio=1.0,',
1081
+ ' )',
1082
+ ' add_implicit_coupling_new(',
1083
+ ' CROSS, RATES, LHS, target_species="FeS_32", source_species="Fe2_total",',
1084
+ ' coeff=prec_coeff_Fe2 * f32_hs, rate=prec_rate_Fe2 * f32_hs, mp=mp, has_solid=has_solid_prec,',
1085
+ ' add_lhs_sink=False, stoich_ratio=1.0,',
1086
+ ' )',
1087
+ ' add_implicit_sink(LHS, RATES, "TS2_32", prec_coeff_TS2_32, prec_rate_TS2 * f32_hs, mp=mp, has_solid=has_solid_prec)',
1088
+ ' CROSS["TS2_32"].append(("Fe2_total", -prec_coeff_Fe2 * f32_hs * phi))',
1089
+ ' RATES["TS2_32"] -= prec_rate_Fe2 * f32_hs * phi',
1090
+ ' FeS_val_np = np.asarray(c.FeS.value if hasattr(c.FeS, "value") else c.FeS)',
1091
+ ' FeS_32_val = np.asarray(c.FeS_32.value if hasattr(c.FeS_32, "value") else c.FeS_32)',
1092
+ ' f32_FeS = np.where(FeS_val_np > 1e-3, FeS_32_val / (FeS_val_np + 1e-30), f32_hs)',
1093
+ ' f32_FeS = np.clip(f32_FeS, 0.5, 1.5)',
1094
+ ' add_implicit_coupling_new(',
1095
+ ' CROSS, RATES, LHS, target_species="TS2_32", source_species="FeS_32",',
1096
+ ' coeff=diss_coeff_FeS, rate=diss_rate_FeS * f32_FeS, mp=mp, has_solid=has_solid,',
1097
+ ' add_lhs_sink=True, stoich_ratio=1.0,',
1098
+ ' )',
1099
+ ])
1100
+
1101
+ active_categories = {self.classify_reaction(r) for r in self.reactions}
1102
+ if "FeS2_oxidation" in active_categories:
1103
+ code.extend([
1104
+ 'def FeS2_oxidation(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
1105
+ ' """Reaction: 1 FeS2 + 3.5 O2 -> 1 Fe3 + 2 SO4"""',
1106
+ ' has_solid = True',
1107
+ ' k_num = mp.k.get("FeS2_O2") if hasattr(mp, "k") else getattr(k_val, "FeS2_O2", 0.0)',
1108
+ ' rate_base = k_num * c.FeS2 * c.O2',
1109
+ ' coeff_master = k_num * 1.0 * c.O2',
1110
+ ' add_coupled_reaction(',
1111
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1112
+ ' master_species={"FeS2": 1}, reactants={}, products={"Fe3": 1, "SO4": 2},',
1113
+ ' coeff_master=coeff_master, rate_master=rate_base,',
1114
+ ' has_solid=has_solid, reaction_name="FeS2_oxidation",',
1115
+ ' ref_species="FeS2", stoich_ref=1.0,',
1116
+ ' )',
1117
+ ' coeff_O2 = 3.5 * k_num * c.FeS2 * 1.0',
1118
+ ' add_implicit_sink(LHS, RATES, "O2", coeff_O2, 3.5 * rate_base, mp=mp, has_solid=has_solid, c=c)',
1119
+ ' if getattr(mp, "isotopes", False):',
1120
+ ' coeff_FeS2_32 = k_num * c.O2',
1121
+ ' add_coupled_reaction(',
1122
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1123
+ ' master_species={"FeS2_32": 1}, reactants={}, products={"SO4_32": 1},',
1124
+ ' coeff_master=coeff_FeS2_32, rate_master=coeff_FeS2_32 * c.FeS2_32,',
1125
+ ' has_solid=has_solid, reaction_name="FeS2_oxidation_32",',
1126
+ ' ref_species="FeS2", stoich_ref=1.0,',
1127
+ ' )',
1128
+ '',
1129
+ ])
1130
+
1131
+ if "FeS2_precipitation_TS2" in active_categories:
1132
+ code.extend([
1133
+ 'def FeS2_precipitation_TS2(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
1134
+ ' """Reaction: 1 FeS + 1 HS -> 1 FeS2"""',
1135
+ ' has_solid = True',
1136
+ ' hs_conc = c.TS2 * mp.hs_frac',
1137
+ ' k_num = mp.k.get("FeS_TS2") if hasattr(mp, "k") else getattr(k_val, "FeS_TS2", 0.0)',
1138
+ ' rate_base = k_num * c.FeS * hs_conc',
1139
+ ' coeff_master = k_num * 1.0 * hs_conc',
1140
+ ' add_coupled_reaction(',
1141
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1142
+ ' master_species={"FeS": 1}, reactants={}, products={"FeS2": 1},',
1143
+ ' coeff_master=coeff_master, rate_master=rate_base,',
1144
+ ' has_solid=has_solid, reaction_name="FeS2_precipitation_TS2",',
1145
+ ' ref_species="FeS", stoich_ref=1.0,',
1146
+ ' )',
1147
+ ' coeff_TS2 = k_num * c.FeS * 1.0 * mp.hs_frac',
1148
+ ' add_implicit_sink(LHS, RATES, "TS2", coeff_TS2, rate_base, mp=mp, has_solid=has_solid, c=c)',
1149
+ ' if getattr(mp, "isotopes", False):',
1150
+ ' coeff_FeS_32 = k_num * 1.0 * hs_conc',
1151
+ ' add_coupled_reaction(',
1152
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1153
+ ' master_species={"FeS_32": 1}, reactants={}, products={"FeS2_32": 1},',
1154
+ ' coeff_master=coeff_FeS_32, rate_master=coeff_FeS_32 * c.FeS_32,',
1155
+ ' has_solid=has_solid, reaction_name="FeS2_precipitation_TS2_FeS_32",',
1156
+ ' ref_species="FeS", stoich_ref=1.0,',
1157
+ ' )',
1158
+ ' coeff_HS_32 = k_num * c.FeS * 1.0 * mp.hs_frac',
1159
+ ' add_coupled_reaction(',
1160
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1161
+ ' master_species={"TS2_32": 1}, reactants={}, products={"FeS2_32": 1},',
1162
+ ' coeff_master=coeff_HS_32, rate_master=coeff_HS_32 * c.TS2_32,',
1163
+ ' has_solid=has_solid, reaction_name="FeS2_precipitation_TS2_HS_32",',
1164
+ ' ref_species="TS2", stoich_ref=1.0,',
1165
+ ' )',
1166
+ '',
1167
+ 'pyrite_formation_fes_ts2_new = FeS2_precipitation_TS2',
1168
+ 'pyrite_formation_FeS_TS2 = FeS2_precipitation_TS2',
1169
+ '',
1170
+ ])
1171
+
1172
+ if "elemental_sulfur_oxidation" in active_categories:
1173
+ code.extend([
1174
+ 'def elemental_sulfur_oxidation(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
1175
+ ' """Reaction: 2 S0 + 3 O2 -> 2 SO4"""',
1176
+ ' has_solid = True',
1177
+ ' k_num = mp.k.get("S0_O2") if hasattr(mp, "k") else getattr(k_val, "S0_O2", 0.0)',
1178
+ ' rate_base = k_num * c.O2 * c.S0',
1179
+ ' coeff_master = k_num * c.O2 * 1.0',
1180
+ ' add_coupled_reaction(',
1181
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1182
+ ' master_species={"S0": 2}, reactants={}, products={"SO4": 2},',
1183
+ ' coeff_master=coeff_master, rate_master=rate_base,',
1184
+ ' has_solid=has_solid, reaction_name="elemental_sulfur_oxidation",',
1185
+ ' ref_species="S0", stoich_ref=2.0,',
1186
+ ' )',
1187
+ ' coeff_O2 = 1.5 * k_num * 1.0 * c.S0',
1188
+ ' add_implicit_sink(LHS, RATES, "O2", coeff_O2, 1.5 * rate_base, mp=mp, has_solid=has_solid, c=c)',
1189
+ ' if getattr(mp, "isotopes", False):',
1190
+ ' add_coupled_reaction(',
1191
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1192
+ ' master_species={"S0_32": 2}, reactants={}, products={"SO4_32": 2},',
1193
+ ' coeff_master=coeff_master, rate_master=coeff_master * c.S0_32,',
1194
+ ' has_solid=has_solid, reaction_name="elemental_sulfur_oxidation_32",',
1195
+ ' ref_species="S0", stoich_ref=2.0,',
1196
+ ' )',
1197
+ '',
1198
+ ])
1199
+
1200
+ if "pyrite_formation_fes_s0" in active_categories:
1201
+ code.extend([
1202
+ 'def pyrite_formation_fes_s0_new(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
1203
+ ' """Reaction: 1 FeS + 1 S0 -> 1 FeS2"""',
1204
+ ' has_solid = True',
1205
+ ' k_num = mp.k.get("FeS_S0") if hasattr(mp, "k") else getattr(k_val, "FeS_S0", 0.0)',
1206
+ ' rate_base = k_num * c.FeS * c.S0',
1207
+ ' coeff_master = k_num * 1.0 * c.S0',
1208
+ ' add_coupled_reaction(',
1209
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1210
+ ' master_species={"FeS": 1}, reactants={}, products={"FeS2": 1},',
1211
+ ' coeff_master=coeff_master, rate_master=rate_base,',
1212
+ ' has_solid=has_solid, reaction_name="pyrite_formation_fes_s0_new",',
1213
+ ' ref_species="FeS", stoich_ref=1.0,',
1214
+ ' )',
1215
+ ' coeff_S0 = k_num * c.FeS * 1.0',
1216
+ ' add_implicit_sink(LHS, RATES, "S0", coeff_S0, rate_base, mp=mp, has_solid=has_solid, c=c)',
1217
+ ' if getattr(mp, "isotopes", False):',
1218
+ ' add_coupled_reaction(',
1219
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1220
+ ' master_species={"FeS_32": 1}, reactants={}, products={"FeS2_32": 1},',
1221
+ ' coeff_master=k_num * 1.0 * c.S0, rate_master=k_num * 1.0 * c.S0 * c.FeS_32,',
1222
+ ' has_solid=has_solid, reaction_name="pyrite_formation_fes_s0_new_FeS_32",',
1223
+ ' ref_species="FeS", stoich_ref=1.0,',
1224
+ ' )',
1225
+ ' add_coupled_reaction(',
1226
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1227
+ ' master_species={"S0_32": 1}, reactants={}, products={"FeS2_32": 1},',
1228
+ ' coeff_master=k_num * c.FeS * 1.0, rate_master=k_num * c.FeS * 1.0 * c.S0_32,',
1229
+ ' has_solid=has_solid, reaction_name="pyrite_formation_fes_s0_new_S0_32",',
1230
+ ' ref_species="S0", stoich_ref=1.0,',
1231
+ ' )',
1232
+ '',
1233
+ 'pyrite_formation_S0 = pyrite_formation_fes_s0_new',
1234
+ '',
1235
+ ])
1236
+
1237
+ if "sulfide_mediated_iron_reduction" in active_categories:
1238
+ code.extend([
1239
+ 'def sulfide_mediated_iron_reduction(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
1240
+ ' """Reaction: HS + 2 Fe3 -> S0 + 2 Fe2_total"""',
1241
+ ' has_solid = True',
1242
+ ' hs_conc = c.TS2 * mp.hs_frac',
1243
+ ' k_num = mp.k.get("Fe3_hs") if hasattr(mp, "k") else getattr(k_val, "Fe3_hs", 0.0)',
1244
+ ' lim_o2 = lim.get("O2_inhibit", 1.0)',
1245
+ ' lim_fe3 = lim.get("Fe3_implicit", 1.0)',
1246
+ ' rate_base = k_num * c.Fe3 * hs_conc * lim_o2 * lim_fe3',
1247
+ ' coeff_master = k_num * c.Fe3 * 1.0 * mp.hs_frac * lim_o2 * lim_fe3',
1248
+ ' add_coupled_reaction(',
1249
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1250
+ ' master_species={"TS2": 1}, reactants={"Fe3": 2},',
1251
+ ' products={"Fe2_total": 2, "S0": 1},',
1252
+ ' coeff_master=coeff_master, rate_master=rate_base,',
1253
+ ' has_solid=has_solid, reaction_name="sulfide_mediated_iron_reduction",',
1254
+ ' ref_species="Fe3", stoich_ref=2.0,',
1255
+ ' )',
1256
+ ' if getattr(mp, "isotopes", False):',
1257
+ ' add_coupled_reaction(',
1258
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1259
+ ' master_species={"TS2_32": 1}, reactants={},',
1260
+ ' products={"S0_32": 1},',
1261
+ ' coeff_master=coeff_master, rate_master=coeff_master * c.TS2_32,',
1262
+ ' has_solid=has_solid, reaction_name="sulfide_mediated_iron_reduction_32",',
1263
+ ' ref_species="Fe3", stoich_ref=2.0,',
1264
+ ' )',
1265
+ '',
1266
+ ])
1267
+
1268
+ if "elemental_sulfur_disproportionation" in active_categories:
1269
+ code.extend([
1270
+ 'def elemental_sulfur_disproportionation(c, k_val, lim, LHS, RHS, RATES, CROSS, mp):',
1271
+ ' """Reaction: 4 S0 + 4 H2O -> 3 TS2 + SO4 + 2 Hplus"""',
1272
+ ' has_solid = True',
1273
+ ' k_num = mp.k.get("S0_dispro") if hasattr(mp, "k") else getattr(k_val, "S0_dispro", 0.0)',
1274
+ ' lim_ts2 = lim.get("TS2", 1.0)',
1275
+ ' lim_o2 = lim.get("O2_inhibit", 1.0)',
1276
+ ' coeff_s0 = np.maximum(k_num * lim_ts2 * lim_o2, 0.0)',
1277
+ ' rate_master = coeff_s0 * np.maximum(c.S0, 0.0)',
1278
+ ' add_coupled_reaction(',
1279
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1280
+ ' master_species={"S0": 4}, reactants={}, products={"TS2": 3, "SO4": 1},',
1281
+ ' coeff_master=coeff_s0, rate_master=rate_master,',
1282
+ ' has_solid=has_solid, reaction_name="elemental_sulfur_disproportionation",',
1283
+ ' ref_species="S0", stoich_ref=4.0,',
1284
+ ' )',
1285
+ ' if getattr(mp, "isotopes", False):',
1286
+ ' add_coupled_reaction(',
1287
+ ' CROSS=CROSS, LHS=LHS, RATES=RATES, mp=mp,',
1288
+ ' master_species={"S0_32": 4}, reactants={}, products={"TS2_32": 3, "SO4_32": 1},',
1289
+ ' coeff_master=coeff_s0, rate_master=coeff_s0 * c.S0_32,',
1290
+ ' has_solid=has_solid, reaction_name="elemental_sulfur_disproportionation_32",',
1291
+ ' ref_species="S0", stoich_ref=4.0,',
1292
+ ' )',
1293
+ '',
1294
+ 'S0_disproportionation = elemental_sulfur_disproportionation',
1295
+ '',
1296
+ ])
1297
+
1298
+ code.extend([
1299
+ '# -----------------------------------------------------------------------------',
1300
+ '# Main Diagenetic Reactions Dispatcher',
1301
+ '# -----------------------------------------------------------------------------',
1302
+ '',
1303
+ 'DEFAULT_DIAGENETIC_REACTIONS = [',
1304
+ ])
1305
+
1306
+ for r in self.reactions:
1307
+ cat = self.classify_reaction(r)
1308
+ reactants, _ = ReactionSystemValidator.parse_reaction_species(r.get("reaction", ""))
1309
+ k_name = r.get("k_value_name")
1310
+
1311
+ if cat == "aerobic_respiration":
1312
+ poc_sp = next((sp for _, sp in reactants if "POC" in sp), "POC_fast")
1313
+ poc_k = k_name if isinstance(k_name, str) else poc_sp
1314
+ code.append(f' [aerobic_respiration, {{"poc_species": "{poc_sp}", "poc_k": "{poc_k}"}}],')
1315
+ elif cat == "dissimilatory_iron_reduction":
1316
+ poc_sp = next((sp for _, sp in reactants if "POC" in sp), "POC_fast")
1317
+ poc_k = k_name if isinstance(k_name, str) else poc_sp
1318
+ code.append(f' [dissimilatory_iron_reduction, {{"poc_species": "{poc_sp}", "poc_k": "{poc_k}"}}],')
1319
+ elif cat == "sulfate_reduction":
1320
+ poc_sp = next((sp for _, sp in reactants if "POC" in sp), "POC_fast")
1321
+ poc_k = k_name if isinstance(k_name, str) else poc_sp
1322
+ code.append(f' [sulfate_reduction, {{"poc_species": "{poc_sp}", "poc_k": "{poc_k}"}}],')
1323
+ elif cat == "hs_oxidation":
1324
+ code.append(' [hs_oxidation_velde, None],')
1325
+ elif cat == "Fe2_oxidation":
1326
+ code.append(' [Fe2_oxidation, None],')
1327
+ elif cat == "sulfide_mediated_iron_reduction_velde":
1328
+ code.append(' [sulfide_mediated_iron_reduction_velde, None],')
1329
+ elif cat == "sulfide_mediated_iron_reduction":
1330
+ code.append(' [sulfide_mediated_iron_reduction, None],')
1331
+ elif cat == "FeS_precipitation_dissolution":
1332
+ code.append(' [FeS_precipitation_dissolution_smooth_transition, None],')
1333
+ elif cat == "FeS_oxidation":
1334
+ code.append(' [FeS_oxidation, None],')
1335
+ elif cat == "FeS2_oxidation":
1336
+ code.append(' [FeS2_oxidation, None],')
1337
+ elif cat == "FeS2_precipitation_TS2":
1338
+ code.append(' [FeS2_precipitation_TS2, None],')
1339
+ elif cat == "elemental_sulfur_oxidation":
1340
+ code.append(' [elemental_sulfur_oxidation, None],')
1341
+ elif cat == "pyrite_formation_fes_s0":
1342
+ code.append(' [pyrite_formation_fes_s0_new, None],')
1343
+ elif cat == "elemental_sulfur_disproportionation":
1344
+ code.append(' [elemental_sulfur_disproportionation, None],')
1345
+ else:
1346
+ r_name = r.get("reaction_name", "unnamed")
1347
+ raise ValueError(
1348
+ f"Unable to generate reaction '{r_name}' ('{r.get('reaction', '')}'): "
1349
+ f"reaction stoichiometry is not recognized by the equation system generator. "
1350
+ f"Please check the chemical reaction equation or implement its generation rule."
1351
+ )
1352
+
1353
+ code.extend([
1354
+ ']',
1355
+ '',
1356
+ 'DIAGENETIC_REACTIONS = DEFAULT_DIAGENETIC_REACTIONS',
1357
+ '',
1358
+ 'def diagenetic_reactions(mp, c, k, f=None, lim=None, dt=None):',
1359
+ ' """Evaluates all registered diagenetic reactions for the model column."""',
1360
+ ' LHS = {}',
1361
+ ' RHS = {}',
1362
+ ' RATES = {}',
1363
+ ' CROSS = {}',
1364
+ ' for sp in getattr(mp, "species_list", list(c.keys())):',
1365
+ ' shape = c[sp].shape if hasattr(c[sp], "shape") else (len(c[sp]),)',
1366
+ ' LHS[sp] = np.zeros(shape, dtype=np.float64)',
1367
+ ' RHS[sp] = np.zeros(shape, dtype=np.float64)',
1368
+ ' RATES[sp] = np.zeros(shape, dtype=np.float64)',
1369
+ ' CROSS[sp] = []',
1370
+ ' if lim is not None:',
1371
+ ' limiters = lim',
1372
+ ' elif get_limiters is not None:',
1373
+ ' limiters = get_limiters(c, mp)',
1374
+ ' elif hasattr(mp, "get_limiters") and callable(mp.get_limiters):',
1375
+ ' limiters = mp.get_limiters(c, mp)',
1376
+ ' else:',
1377
+ ' limiters = getattr(mp, "limiters", {})',
1378
+ ' rxns = getattr(mp, "diagenetic_reactions", None)',
1379
+ ' if rxns is None:',
1380
+ ' rxns = DEFAULT_DIAGENETIC_REACTIONS',
1381
+ ' for r_entry in rxns:',
1382
+ ' fn = r_entry[0]',
1383
+ ' fn_k = r_entry[1] if (len(r_entry) > 1 and r_entry[1] is not None) else k',
1384
+ ' fn(c, fn_k, limiters, LHS, RHS, RATES, CROSS, mp)',
1385
+ ' if f is not None:',
1386
+ ' sp_list = getattr(mp, "species_list", list(c.keys()))',
1387
+ ' for s in sp_list:',
1388
+ ' setattr(f, s, (LHS.get(s, 0.0), RHS.get(s, 0.0), RATES.get(s, 0.0), None))',
1389
+ ' if not getattr(mp, "in_solver", False):',
1390
+ ' for key, val in RATES.items():',
1391
+ ' if key not in sp_list:',
1392
+ ' setattr(f, key, (None, None, val, None))',
1393
+ ' object.__setattr__(f, "raw_LHS", LHS)',
1394
+ ' object.__setattr__(f, "raw_CROSS", CROSS)',
1395
+ ' object.__setattr__(f, "raw_RHS", RHS)',
1396
+ ' object.__setattr__(f, "raw_RATES", RATES)',
1397
+ ' return f, RATES',
1398
+ '',
1399
+ '',
1400
+ '# -----------------------------------------------------------------------------',
1401
+ '# Analytical SymPy Chemical Jacobian',
1402
+ '# -----------------------------------------------------------------------------',
1403
+ '',
1404
+ 'def compute_chemical_jacobian(c, mp, k, species_names):',
1405
+ ' """Analytically evaluates the N x S x S chemical Jacobian J[i, s, m] = dR_s / dC_m.',
1406
+ ' Derived symbolically via SymPy to exact machine precision.',
1407
+ ' """',
1408
+ ' N = len(c[species_names[0]])',
1409
+ ' S = len(species_names)',
1410
+ ' J = np.zeros((N, S, S), dtype=np.float64)',
1411
+ ' idx = {name: i for i, name in enumerate(species_names)}',
1412
+ ' phi = np.asarray(mp.phi.value if hasattr(mp.phi, "value") else mp.phi)',
1413
+ ' solid_fac = 1.0 - phi',
1414
+ ' diss_fac = phi',
1415
+ ' if get_limiters is not None:',
1416
+ ' lim = get_limiters(c, mp)',
1417
+ ' elif hasattr(mp, "get_limiters") and callable(mp.get_limiters):',
1418
+ ' lim = mp.get_limiters(c, mp)',
1419
+ ' else:',
1420
+ ' lim = getattr(mp, "limiters", {})',
1421
+ '',
1422
+ ])
1423
+
1424
+ active_categories = {self.classify_reaction(r) for r in self.reactions}
1425
+
1426
+ if "aerobic_respiration" in active_categories:
1427
+ code.extend([
1428
+ ' # 1. Aerobic respiration (fast & slow)',
1429
+ ' for poc_sp in ["POC_fast", "POC_slow"]:',
1430
+ ' if poc_sp in idx and "O2" in idx:',
1431
+ ' i_poc = idx[poc_sp]',
1432
+ ' i_o2 = idx["O2"]',
1433
+ ' k_num = float(mp.k.get(poc_sp) if hasattr(mp, "k") else getattr(k, poc_sp, 0.0))',
1434
+ ' c_poc = np.asarray(c[poc_sp].value if hasattr(c[poc_sp], "value") else c[poc_sp])',
1435
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1436
+ ' K_O2 = float(mp.K_O2) / phi',
1437
+ ' denom = c_o2 + K_O2',
1438
+ ' d_poc = -solid_fac * k_num * (c_o2 / denom)',
1439
+ ' d_o2 = -solid_fac * k_num * c_poc * (K_O2 / denom**2)',
1440
+ ' ratio = float(getattr(mp, "POC_O2_ratio", 1.0))',
1441
+ ' J[:, i_poc, i_poc] += d_poc',
1442
+ ' J[:, i_poc, i_o2] += d_o2',
1443
+ ' J[:, i_o2, i_poc] += ratio * d_poc',
1444
+ ' J[:, i_o2, i_o2] += ratio * d_o2',
1445
+ '',
1446
+ ])
1447
+
1448
+ if "dissimilatory_iron_reduction" in active_categories:
1449
+ code.extend([
1450
+ ' # 2. Dissimilatory iron reduction (fast & slow)',
1451
+ ' for poc_sp in ["POC_fast", "POC_slow"]:',
1452
+ ' if poc_sp in idx and "Fe3" in idx and "Fe2_total" in idx:',
1453
+ ' i_poc = idx[poc_sp]',
1454
+ ' i_fe3 = idx["Fe3"]',
1455
+ ' i_fe2 = idx["Fe2_total"]',
1456
+ ' k_num = float(mp.k.get(poc_sp) if hasattr(mp, "k") else getattr(k, poc_sp, 0.0))',
1457
+ ' c_poc = np.asarray(c[poc_sp].value if hasattr(c[poc_sp], "value") else c[poc_sp])',
1458
+ ' c_fe3 = np.asarray(c["Fe3"].value if hasattr(c["Fe3"], "value") else c["Fe3"])',
1459
+ ' K_Fe3_red = float(mp.K_Fe3_diss_red) / solid_fac',
1460
+ ' K_O2 = float(mp.K_O2) / phi',
1461
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1462
+ ' o2_inhib = K_O2 / (c_o2 + K_O2)',
1463
+ ' fe3_denom = c_fe3 + K_Fe3_red',
1464
+ ' rate_base = k_num * c_poc * (c_fe3 / fe3_denom) * o2_inhib',
1465
+ ' d_poc = -solid_fac * k_num * (c_fe3 / fe3_denom) * o2_inhib',
1466
+ ' d_fe3 = -solid_fac * k_num * c_poc * (K_Fe3_red / fe3_denom**2) * o2_inhib',
1467
+ ' d_o2 = solid_fac * k_num * c_poc * (c_fe3 / fe3_denom) * (K_O2 / (c_o2 + K_O2)**2)',
1468
+ ' J[:, i_poc, i_poc] += d_poc',
1469
+ ' J[:, i_poc, i_fe3] += d_fe3',
1470
+ ' J[:, i_fe3, i_poc] += 4.0 * d_poc',
1471
+ ' J[:, i_fe3, i_fe3] += 4.0 * d_fe3',
1472
+ ' J[:, i_fe2, i_poc] -= 4.0 * d_poc',
1473
+ ' J[:, i_fe2, i_fe3] -= 4.0 * d_fe3',
1474
+ ' if "O2" in idx:',
1475
+ ' J[:, i_poc, idx["O2"]] += d_o2',
1476
+ ' J[:, i_fe3, idx["O2"]] += 4.0 * d_o2',
1477
+ ' J[:, i_fe2, idx["O2"]] -= 4.0 * d_o2',
1478
+ '',
1479
+ ])
1480
+
1481
+ if "sulfate_reduction" in active_categories:
1482
+ code.extend([
1483
+ ' # 3. Sulfate reduction (fast & slow)',
1484
+ ' for poc_sp in ["POC_fast", "POC_slow"]:',
1485
+ ' if poc_sp in idx and "SO4" in idx and "TS2" in idx:',
1486
+ ' i_poc = idx[poc_sp]',
1487
+ ' i_so4 = idx["SO4"]',
1488
+ ' i_ts2 = idx["TS2"]',
1489
+ ' k_num = float(mp.k.get(poc_sp) if hasattr(mp, "k") else getattr(k, poc_sp, 0.0))',
1490
+ ' c_poc = np.asarray(c[poc_sp].value if hasattr(c[poc_sp], "value") else c[poc_sp])',
1491
+ ' c_so4 = np.asarray(c["SO4"].value if hasattr(c["SO4"], "value") else c["SO4"])',
1492
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1493
+ ' c_fe3 = np.asarray(c["Fe3"].value if hasattr(c["Fe3"], "value") else c["Fe3"])',
1494
+ ' K_SO4 = float(mp.K_SO4) / phi',
1495
+ ' K_O2 = float(mp.K_O2) / phi',
1496
+ ' K_Fe3_red = float(mp.K_Fe3_diss_red) / solid_fac',
1497
+ ' o2_inhib = K_O2 / (c_o2 + K_O2)',
1498
+ ' fe3_inhib = K_Fe3_red / (c_fe3 + K_Fe3_red)',
1499
+ ' so4_denom = c_so4 + K_SO4',
1500
+ ' R_sr = 0.5 * solid_fac * k_num * c_poc * (c_so4 / so4_denom) * o2_inhib * fe3_inhib',
1501
+ ' d_poc = -0.5 * solid_fac * k_num * (c_so4 / so4_denom) * o2_inhib * fe3_inhib',
1502
+ ' d_so4 = -0.5 * solid_fac * k_num * c_poc * (K_SO4 / so4_denom**2) * o2_inhib * fe3_inhib',
1503
+ ' J[:, i_poc, i_poc] += 2.0 * d_poc',
1504
+ ' J[:, i_poc, i_so4] += 2.0 * d_so4',
1505
+ ' J[:, i_so4, i_poc] += d_poc',
1506
+ ' J[:, i_so4, i_so4] += d_so4',
1507
+ ' J[:, i_ts2, i_poc] -= d_poc',
1508
+ ' J[:, i_ts2, i_so4] -= d_so4',
1509
+ ' d_o2_sr = -R_sr * (K_O2 / denom_o2_sr**2) / (o2_inhib + 1e-30)' if False else '',
1510
+ ' denom_o2_sr = c_o2 + K_O2',
1511
+ ' d_o2_sr = 0.5 * solid_fac * k_num * c_poc * (c_so4 / so4_denom) * (K_O2 / denom_o2_sr**2) * fe3_inhib',
1512
+ ' denom_fe3_sr = c_fe3 + K_Fe3_red',
1513
+ ' d_fe3_sr = 0.5 * solid_fac * k_num * c_poc * (c_so4 / so4_denom) * o2_inhib * (K_Fe3_red / denom_fe3_sr**2)',
1514
+ ' if "O2" in idx:',
1515
+ ' J[:, i_poc, idx["O2"]] += 2.0 * d_o2_sr',
1516
+ ' J[:, i_so4, idx["O2"]] += d_o2_sr',
1517
+ ' J[:, i_ts2, idx["O2"]] -= d_o2_sr',
1518
+ ' if "Fe3" in idx:',
1519
+ ' J[:, i_poc, idx["Fe3"]] += 2.0 * d_fe3_sr',
1520
+ ' J[:, i_so4, idx["Fe3"]] += d_fe3_sr',
1521
+ ' J[:, i_ts2, idx["Fe3"]] -= d_fe3_sr',
1522
+ ' if "SO4_32" in idx and "TS2_32" in idx and getattr(mp, "isotopes", False):',
1523
+ ' i_so4_32 = idx["SO4_32"]',
1524
+ ' i_ts2_32 = idx["TS2_32"]',
1525
+ ' c_so4_32 = np.asarray(c["SO4_32"].value if hasattr(c["SO4_32"], "value") else c["SO4_32"])',
1526
+ ' alpha = 1.0 + (float(mp.msr_alpha) - 1.0) * (c_so4 / (c_so4 + float(getattr(mp, "K_epsilon_msr", 1e-3))))',
1527
+ ' d_so4_32 = -0.5 * solid_fac * k_num * c_poc * (alpha / so4_denom) * o2_inhib * fe3_inhib',
1528
+ ' frac_32 = np.where(c_so4 > 1e-20, c_so4_32 / (c_so4 + 1e-30), 1.0 / (1.0 + float(mp.VCDT)))',
1529
+ ' d_o2_sr_32 = d_o2_sr * frac_32 * alpha',
1530
+ ' d_fe3_sr_32 = d_fe3_sr * frac_32 * alpha',
1531
+ ' J[:, i_so4_32, i_so4_32] += d_so4_32',
1532
+ ' J[:, i_ts2_32, i_so4_32] -= d_so4_32',
1533
+ ' if "O2" in idx:',
1534
+ ' J[:, i_so4_32, idx["O2"]] += d_o2_sr_32',
1535
+ ' J[:, i_ts2_32, idx["O2"]] -= d_o2_sr_32',
1536
+ ' if "Fe3" in idx:',
1537
+ ' J[:, i_so4_32, idx["Fe3"]] += d_fe3_sr_32',
1538
+ ' J[:, i_ts2_32, idx["Fe3"]] -= d_fe3_sr_32',
1539
+ '',
1540
+ ])
1541
+
1542
+ if "FeS_precipitation_dissolution" in active_categories:
1543
+ code.extend([
1544
+ ' # 4. FeS precipitation / dissolution (smooth transition)',
1545
+ ' if "Fe2_total" in idx and "TS2" in idx and "FeS" in idx:',
1546
+ ' i_fe2 = idx["Fe2_total"]',
1547
+ ' i_ts2 = idx["TS2"]',
1548
+ ' i_fes = idx["FeS"]',
1549
+ ' Fe2_val = np.maximum(c["Fe2_total"].value if hasattr(c["Fe2_total"], "value") else c["Fe2_total"], 1e-20)',
1550
+ ' TS2_val = np.maximum(c["TS2"].value if hasattr(c["TS2"], "value") else c["TS2"], 1e-20)',
1551
+ ' FeS_val = np.maximum(c["FeS"].value if hasattr(c["FeS"], "value") else c["FeS"], 1e-20)',
1552
+ ' Fe2_pw = Fe2_val * float(mp.Fe2_diss)',
1553
+ ' hs_val = TS2_val * float(mp.hs_frac)',
1554
+ ' k_Hplus = float(getattr(k, "Hplus", getattr(mp, "Hplus", 10**-7.5)))',
1555
+ ' k_FeS_sp = float(getattr(k, "FeS_sp", getattr(mp, "FeS_sp", 10**-3.5)))',
1556
+ ' k_FeS_isp = float(getattr(k, "FeS_isp", getattr(mp, "FeS_isp", 1.0)))',
1557
+ ' k_FeS_isd = float(getattr(k, "FeS_isd", getattr(mp, "FeS_isd", 0.3)))',
1558
+ ' omega = (Fe2_pw * hs_val) / (k_Hplus * k_FeS_sp + 1e-30)',
1559
+ ' d_omega_dFe2 = (float(mp.Fe2_diss) * hs_val) / (k_Hplus * k_FeS_sp + 1e-30)',
1560
+ ' d_omega_dTS2 = (Fe2_pw * float(mp.hs_frac)) / (k_Hplus * k_FeS_sp + 1e-30)',
1561
+ ' Km = 0.5',
1562
+ ' eps_sm = float(getattr(mp, "fes_smooth_epsilon", 0.05))',
1563
+ ' # Precipitation branch (Omega >= 1)',
1564
+ ' is_prec = omega >= 1.0',
1565
+ ' df_p = np.maximum(omega - 1.0, 0.0)',
1566
+ ' u_p = np.clip(df_p / (eps_sm + 1e-30), 0.0, 1.0)',
1567
+ ' S_p = 3.0 * u_p**2 - 2.0 * u_p**3',
1568
+ ' dS_du = np.where((u_p > 0.0) & (u_p < 1.0), 6.0 * u_p * (1.0 - u_p), 0.0)',
1569
+ ' dS_p_domega = dS_du / (eps_sm + 1e-30)',
1570
+ ' M_p = df_p / (Km + df_p)',
1571
+ ' dM_p_domega = Km / (Km + df_p)**2',
1572
+ ' dR_prec_domega = k_FeS_isp * (dM_p_domega * S_p + M_p * dS_p_domega) * is_prec',
1573
+ ' R_prec = k_FeS_isp * M_p * S_p * is_prec',
1574
+ ' # Bulk precipitation: factor is diss_fac (liquid)',
1575
+ ' dRp_dFe2 = diss_fac * dR_prec_domega * d_omega_dFe2',
1576
+ ' dRp_dTS2 = diss_fac * dR_prec_domega * d_omega_dTS2',
1577
+ ' # Dissolution branch (Omega < 1)',
1578
+ ' is_diss = omega < 1.0',
1579
+ ' us_d = np.maximum(1.0 - omega, 0.0)',
1580
+ ' u_d = np.clip(us_d / (eps_sm + 1e-30), 0.0, 1.0)',
1581
+ ' S_d = 3.0 * u_d**2 - 2.0 * u_d**3',
1582
+ ' dS_d_du = np.where((u_d > 0.0) & (u_d < 1.0), 6.0 * u_d * (1.0 - u_d), 0.0)',
1583
+ ' dS_d_domega = -dS_d_du / (eps_sm + 1e-30)',
1584
+ ' M_d = us_d / (Km + us_d)',
1585
+ ' dM_d_domega = -Km / (Km + us_d)**2',
1586
+ ' dR_diss_domega = k_FeS_isd * (dM_d_domega * S_d + M_d * dS_d_domega) * FeS_val * is_diss',
1587
+ ' dR_diss_dFeS = k_FeS_isd * M_d * S_d * is_diss',
1588
+ ' R_diss = k_FeS_isd * M_d * S_d * FeS_val * is_diss',
1589
+ ' dRd_dFe2 = solid_fac * dR_diss_domega * d_omega_dFe2',
1590
+ ' dRd_dTS2 = solid_fac * dR_diss_domega * d_omega_dTS2',
1591
+ ' dRd_dFeS = solid_fac * dR_diss_dFeS',
1592
+ ' # Total derivatives for FeS, Fe2, TS2',
1593
+ ' d_net_dFe2 = dRp_dFe2 - dRd_dFe2',
1594
+ ' d_net_dTS2 = dRp_dTS2 - dRd_dTS2',
1595
+ ' d_net_dFeS = -dRd_dFeS',
1596
+ ' J[:, i_fes, i_fe2] += d_net_dFe2',
1597
+ ' J[:, i_fes, i_ts2] += d_net_dTS2',
1598
+ ' J[:, i_fes, i_fes] += d_net_dFeS',
1599
+ ' J[:, i_fe2, i_fe2] -= d_net_dFe2',
1600
+ ' J[:, i_fe2, i_ts2] -= d_net_dTS2',
1601
+ ' J[:, i_fe2, i_fes] -= d_net_dFeS',
1602
+ ' J[:, i_ts2, i_fe2] -= d_net_dFe2',
1603
+ ' J[:, i_ts2, i_ts2] -= d_net_dTS2',
1604
+ ' J[:, i_ts2, i_fes] -= d_net_dFeS',
1605
+ ' if "FeS_32" in idx and "TS2_32" in idx and getattr(mp, "isotopes", False):',
1606
+ ' i_fes_32 = idx["FeS_32"]',
1607
+ ' i_ts2_32 = idx["TS2_32"]',
1608
+ ' hs_32 = partition_equilibrium_isotope_32(',
1609
+ ' c["TS2_32"].value if hasattr(c["TS2_32"], "value") else c["TS2_32"],',
1610
+ ' mp.hs_frac, mp.h2s_frac, mp.h2s_hs_alpha,',
1611
+ ' )',
1612
+ ' f32_default = 1.0 / (1.0 + float(mp.VCDT))',
1613
+ ' mask_hs = hs_val > 1e-6',
1614
+ ' f32_hs = np.where(mask_hs, np.asarray(hs_32) / (hs_val + 1e-30), f32_default)',
1615
+ ' f32_hs = np.clip(f32_hs, 0.5, 1.5)',
1616
+ ' R_prec_bulk = diss_fac * R_prec',
1617
+ ' dRp32_dFe2 = dRp_dFe2 * f32_hs',
1618
+ ' dRp32_dTS2 = dRp_dTS2 * f32_hs - np.where(mask_hs, (R_prec_bulk / TS2_val) * f32_hs, 0.0)',
1619
+ ' dRp32_dTS2_32 = np.where(is_prec & mask_hs, R_prec_bulk / TS2_val, 0.0)',
1620
+ ' R_diss_bulk = solid_fac * R_diss',
1621
+ ' mask_fes = FeS_val > 1e-3',
1622
+ ' FeS_32_val = np.asarray(c["FeS_32"].value if hasattr(c["FeS_32"], "value") else c["FeS_32"])',
1623
+ ' f32_FeS = np.where(mask_fes, FeS_32_val / (FeS_val + 1e-30), f32_hs)',
1624
+ ' f32_FeS = np.clip(f32_FeS, 0.5, 1.5)',
1625
+ ' dRd32_dFe2 = dRd_dFe2 * f32_FeS',
1626
+ ' dRd32_dTS2 = dRd_dTS2 * f32_FeS',
1627
+ ' dRd32_dFeS = np.where(~mask_fes, dRd_dFeS * f32_hs, 0.0)',
1628
+ ' dRd32_dFeS_32 = np.where(is_diss & mask_fes, R_diss_bulk / FeS_val, 0.0)',
1629
+ ' J[:, i_fes_32, i_fe2] += (dRp32_dFe2 - dRd32_dFe2)',
1630
+ ' J[:, i_fes_32, i_ts2] += (dRp32_dTS2 - dRd32_dTS2)',
1631
+ ' J[:, i_fes_32, i_fes] -= dRd32_dFeS',
1632
+ ' J[:, i_fes_32, i_ts2_32] += dRp32_dTS2_32',
1633
+ ' J[:, i_fes_32, i_fes_32] -= dRd32_dFeS_32',
1634
+ ' J[:, i_ts2_32, i_fe2] -= (dRp32_dFe2 - dRd32_dFe2)',
1635
+ ' J[:, i_ts2_32, i_ts2] -= (dRp32_dTS2 - dRd32_dTS2)',
1636
+ ' J[:, i_ts2_32, i_fes] += dRd32_dFeS',
1637
+ ' J[:, i_ts2_32, i_ts2_32] -= dRp32_dTS2_32',
1638
+ ' J[:, i_ts2_32, i_fes_32] += dRd32_dFeS_32',
1639
+ '',
1640
+ ])
1641
+
1642
+ if "hs_oxidation" in active_categories:
1643
+ code.extend([
1644
+ ' # 5. HS oxidation (hs_oxidation_velde)',
1645
+ ' if "TS2" in idx and "O2" in idx and "SO4" in idx:',
1646
+ ' i_ts2 = idx["TS2"]',
1647
+ ' i_o2 = idx["O2"]',
1648
+ ' i_so4 = idx["SO4"]',
1649
+ ' k_num = float(mp.k.get("TS2_O2") if hasattr(mp, "k") else getattr(k, "TS2_O2", 0.0))',
1650
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1651
+ ' c_ts2 = np.asarray(c["TS2"].value if hasattr(c["TS2"], "value") else c["TS2"])',
1652
+ ' K_O2_TS2 = float(getattr(mp, "K_O2_TS2", 1e-4))',
1653
+ ' denom_o2 = c_o2 + K_O2_TS2',
1654
+ ' hs_frac = float(mp.hs_frac)',
1655
+ ' d_ts2 = diss_fac * k_num * hs_frac * (c_o2 / denom_o2)',
1656
+ ' d_o2 = diss_fac * k_num * (c_ts2 * hs_frac) * (K_O2_TS2 / denom_o2**2)',
1657
+ ' J[:, i_ts2, i_ts2] -= d_ts2',
1658
+ ' J[:, i_ts2, i_o2] -= d_o2',
1659
+ ' J[:, i_so4, i_ts2] += d_ts2',
1660
+ ' J[:, i_so4, i_o2] += d_o2',
1661
+ ' J[:, i_o2, i_ts2] -= 2.0 * d_ts2',
1662
+ ' J[:, i_o2, i_o2] -= 2.0 * d_o2',
1663
+ ' if "TS2_32" in idx and "SO4_32" in idx and getattr(mp, "isotopes", False):',
1664
+ ' i_ts2_32 = idx["TS2_32"]',
1665
+ ' i_so4_32 = idx["SO4_32"]',
1666
+ ' c_ts2_32 = np.asarray(c["TS2_32"].value if hasattr(c["TS2_32"], "value") else c["TS2_32"])',
1667
+ ' alpha = 1.0 + (float(getattr(mp, "TS2_O2_alpha", 0.995)) - 1.0) * (c_ts2 / (c_ts2 + float(getattr(mp, "K_epsilon_TS2_O2", 1e-3))))',
1668
+ ' d_ts2_32 = diss_fac * k_num * hs_frac * (c_o2 / denom_o2) * alpha',
1669
+ ' d_ts2_32_o2 = diss_fac * k_num * (c_ts2_32 * hs_frac) * (K_O2_TS2 / denom_o2**2) * alpha',
1670
+ ' J[:, i_ts2_32, i_ts2_32] -= d_ts2_32',
1671
+ ' J[:, i_so4_32, i_ts2_32] += d_ts2_32',
1672
+ ' J[:, i_ts2_32, i_o2] -= d_ts2_32_o2',
1673
+ ' J[:, i_so4_32, i_o2] += d_ts2_32_o2',
1674
+ '',
1675
+ ])
1676
+
1677
+ if "Fe2_oxidation" in active_categories:
1678
+ code.extend([
1679
+ ' # 6. Fe2 oxidation (Fe2_oxidation)',
1680
+ ' if "Fe2_total" in idx and "O2" in idx and "Fe3" in idx:',
1681
+ ' i_fe2 = idx["Fe2_total"]',
1682
+ ' i_o2 = idx["O2"]',
1683
+ ' i_fe3 = idx["Fe3"]',
1684
+ ' k_num = float(mp.k.get("Fe2_O2") if hasattr(mp, "k") else getattr(k, "Fe2_O2", 0.0))',
1685
+ ' c_fe2 = np.asarray(c["Fe2_total"].value if hasattr(c["Fe2_total"], "value") else c["Fe2_total"])',
1686
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1687
+ ' d_fe2 = diss_fac * k_num * c_o2',
1688
+ ' d_o2 = diss_fac * k_num * c_fe2',
1689
+ ' J[:, i_fe2, i_fe2] -= d_fe2',
1690
+ ' J[:, i_fe2, i_o2] -= d_o2',
1691
+ ' J[:, i_fe3, i_fe2] += d_fe2',
1692
+ ' J[:, i_fe3, i_o2] += d_o2',
1693
+ ' J[:, i_o2, i_fe2] -= 0.25 * d_fe2',
1694
+ ' J[:, i_o2, i_o2] -= 0.25 * d_o2',
1695
+ '',
1696
+ ])
1697
+
1698
+ if any(cat in active_categories for cat in ("sulfide_mediated_iron_reduction", "sulfide_mediated_iron_reduction_velde")):
1699
+ code.extend([
1700
+ ' # 7. Sulfide-mediated iron reduction (sulfide_mediated_iron_reduction_velde)',
1701
+ ' if "TS2" in idx and "Fe3" in idx and "Fe2_total" in idx and "SO4" in idx:',
1702
+ ' i_ts2 = idx["TS2"]',
1703
+ ' i_fe3 = idx["Fe3"]',
1704
+ ' i_fe2 = idx["Fe2_total"]',
1705
+ ' i_so4 = idx["SO4"]',
1706
+ ' k_num = float(mp.k.get("Fe3_hs") if hasattr(mp, "k") else getattr(k, "Fe3_hs", 0.0))',
1707
+ ' c_fe3 = np.asarray(c["Fe3"].value if hasattr(c["Fe3"], "value") else c["Fe3"])',
1708
+ ' c_ts2 = np.asarray(c["TS2"].value if hasattr(c["TS2"], "value") else c["TS2"])',
1709
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1710
+ ' K_O2 = float(mp.K_O2) / phi',
1711
+ ' K_Fe3 = float(getattr(mp, "K_Fe3", 1e-3))',
1712
+ ' o2_inhibit = K_O2 / (c_o2 + K_O2)',
1713
+ ' fe3_lim = 1.0 / (c_fe3 + K_Fe3)',
1714
+ ' hs = c_ts2 * float(mp.hs_frac)',
1715
+ ' d_ts2 = (k_num * c_fe3 * float(mp.hs_frac) * o2_inhibit * fe3_lim / 8.0) * solid_fac',
1716
+ ' d_fe3 = (k_num * hs * o2_inhibit * (K_Fe3 / (c_fe3 + K_Fe3)**2) / 8.0) * solid_fac',
1717
+ ' denom_o2_sir = c_o2 + K_O2',
1718
+ ' d_o2_sir = (k_num * c_fe3 * hs * (K_O2 / denom_o2_sir**2) * fe3_lim / 8.0) * solid_fac',
1719
+ ' J[:, i_ts2, i_ts2] -= d_ts2',
1720
+ ' J[:, i_ts2, i_fe3] -= d_fe3',
1721
+ ' J[:, i_so4, i_ts2] += d_ts2',
1722
+ ' J[:, i_so4, i_fe3] += d_fe3',
1723
+ ' J[:, i_fe3, i_ts2] -= 8.0 * d_ts2',
1724
+ ' J[:, i_fe3, i_fe3] -= 8.0 * d_fe3',
1725
+ ' J[:, i_fe2, i_ts2] += 8.0 * d_ts2',
1726
+ ' J[:, i_fe2, i_fe3] += 8.0 * d_fe3',
1727
+ ' if "O2" in idx:',
1728
+ ' J[:, i_ts2, idx["O2"]] += d_o2_sir',
1729
+ ' J[:, i_so4, idx["O2"]] -= d_o2_sir',
1730
+ ' J[:, i_fe3, idx["O2"]] += 8.0 * d_o2_sir',
1731
+ ' J[:, i_fe2, idx["O2"]] -= 8.0 * d_o2_sir',
1732
+ ' if "TS2_32" in idx and "SO4_32" in idx and getattr(mp, "isotopes", False):',
1733
+ ' i_ts2_32 = idx["TS2_32"]',
1734
+ ' i_so4_32 = idx["SO4_32"]',
1735
+ ' c_ts2_32 = np.asarray(c["TS2_32"].value if hasattr(c["TS2_32"], "value") else c["TS2_32"])',
1736
+ ' hs_32 = c_ts2_32 * float(mp.hs_frac)',
1737
+ ' d_fe3_32 = (k_num * hs_32 * o2_inhibit * (K_Fe3 / (c_fe3 + K_Fe3)**2) / 8.0) * solid_fac',
1738
+ ' d_o2_sir_32 = (k_num * c_fe3 * hs_32 * (K_O2 / denom_o2_sir**2) * fe3_lim / 8.0) * solid_fac',
1739
+ ' J[:, i_ts2_32, i_ts2_32] -= d_ts2',
1740
+ ' J[:, i_so4_32, i_ts2_32] += d_ts2',
1741
+ ' J[:, i_ts2_32, i_fe3] -= d_fe3_32',
1742
+ ' J[:, i_so4_32, i_fe3] += d_fe3_32',
1743
+ ' if "O2" in idx:',
1744
+ ' J[:, i_ts2_32, idx["O2"]] += d_o2_sir_32',
1745
+ ' J[:, i_so4_32, idx["O2"]] -= d_o2_sir_32',
1746
+ '',
1747
+ ])
1748
+
1749
+ if "FeS_oxidation" in active_categories:
1750
+ code.extend([
1751
+ ' # 8. FeS oxidation (FeS_oxidation)',
1752
+ ' if "FeS" in idx and "O2" in idx and "Fe3" in idx and "SO4" in idx:',
1753
+ ' i_fes = idx["FeS"]',
1754
+ ' i_o2 = idx["O2"]',
1755
+ ' i_fe3 = idx["Fe3"]',
1756
+ ' i_so4 = idx["SO4"]',
1757
+ ' k_num = float(mp.k.get("FeS_O2") if hasattr(mp, "k") else getattr(k, "FeS_O2", 0.0))',
1758
+ ' c_fes = np.asarray(c["FeS"].value if hasattr(c["FeS"], "value") else c["FeS"])',
1759
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1760
+ ' d_fes = solid_fac * k_num * c_o2',
1761
+ ' d_o2 = solid_fac * k_num * c_fes',
1762
+ ' J[:, i_fes, i_fes] -= d_fes',
1763
+ ' J[:, i_fes, i_o2] -= d_o2',
1764
+ ' J[:, i_fe3, i_fes] += d_fes',
1765
+ ' J[:, i_fe3, i_o2] += d_o2',
1766
+ ' J[:, i_so4, i_fes] += d_fes',
1767
+ ' J[:, i_so4, i_o2] += d_o2',
1768
+ ' J[:, i_o2, i_fes] -= 2.25 * d_fes',
1769
+ ' J[:, i_o2, i_o2] -= 2.25 * d_o2',
1770
+ ' if "FeS_32" in idx and "SO4_32" in idx and getattr(mp, "isotopes", False):',
1771
+ ' i_fes_32 = idx["FeS_32"]',
1772
+ ' i_so4_32 = idx["SO4_32"]',
1773
+ ' c_fes_32 = np.asarray(c["FeS_32"].value if hasattr(c["FeS_32"], "value") else c["FeS_32"])',
1774
+ ' d_o2_32 = solid_fac * k_num * c_fes_32',
1775
+ ' J[:, i_fes_32, i_fes_32] -= d_fes',
1776
+ ' J[:, i_so4_32, i_fes_32] += d_fes',
1777
+ ' J[:, i_fes_32, i_o2] -= d_o2_32',
1778
+ ' J[:, i_so4_32, i_o2] += d_o2_32',
1779
+ '',
1780
+ ])
1781
+
1782
+ if "FeS2_oxidation" in active_categories:
1783
+ code.extend([
1784
+ ' # 9. FeS2 oxidation (FeS2_oxidation)',
1785
+ ' if "FeS2" in idx and "O2" in idx and "Fe3" in idx and "SO4" in idx:',
1786
+ ' i_fes2 = idx["FeS2"]',
1787
+ ' i_o2 = idx["O2"]',
1788
+ ' i_fe3 = idx["Fe3"]',
1789
+ ' i_so4 = idx["SO4"]',
1790
+ ' k_num = float(mp.k.get("FeS2_O2") if hasattr(mp, "k") else getattr(k, "FeS2_O2", 0.0))',
1791
+ ' c_fes2 = np.asarray(c["FeS2"].value if hasattr(c["FeS2"], "value") else c["FeS2"])',
1792
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1793
+ ' d_fes2 = solid_fac * k_num * c_o2',
1794
+ ' d_o2 = solid_fac * k_num * c_fes2',
1795
+ ' J[:, i_fes2, i_fes2] -= d_fes2',
1796
+ ' J[:, i_fes2, i_o2] -= d_o2',
1797
+ ' J[:, i_fe3, i_fes2] += d_fes2',
1798
+ ' J[:, i_fe3, i_o2] += d_o2',
1799
+ ' J[:, i_so4, i_fes2] += 2.0 * d_fes2',
1800
+ ' J[:, i_so4, i_o2] += 2.0 * d_o2',
1801
+ ' J[:, i_o2, i_fes2] -= 3.5 * d_fes2',
1802
+ ' J[:, i_o2, i_o2] -= 3.5 * d_o2',
1803
+ ' if "FeS2_32" in idx and "SO4_32" in idx and getattr(mp, "isotopes", False):',
1804
+ ' i_fes2_32 = idx["FeS2_32"]',
1805
+ ' i_so4_32 = idx["SO4_32"]',
1806
+ ' c_fes2_32 = np.asarray(c["FeS2_32"].value if hasattr(c["FeS2_32"], "value") else c["FeS2_32"])',
1807
+ ' d_o2_32 = solid_fac * k_num * c_fes2_32',
1808
+ ' J[:, i_fes2_32, i_fes2_32] -= d_fes2',
1809
+ ' J[:, i_so4_32, i_fes2_32] += 2.0 * d_fes2',
1810
+ ' J[:, i_fes2_32, i_o2] -= d_o2_32',
1811
+ ' J[:, i_so4_32, i_o2] += 2.0 * d_o2_32',
1812
+ '',
1813
+ ])
1814
+
1815
+ if "FeS2_precipitation_TS2" in active_categories:
1816
+ code.extend([
1817
+ ' # 10. FeS2 precipitation via TS2 (FeS2_precipitation_TS2)',
1818
+ ' if "FeS" in idx and "TS2" in idx and "FeS2" in idx:',
1819
+ ' i_fes = idx["FeS"]',
1820
+ ' i_ts2 = idx["TS2"]',
1821
+ ' i_fes2 = idx["FeS2"]',
1822
+ ' k_num = float(mp.k.get("FeS_TS2") if hasattr(mp, "k") else getattr(k, "FeS_TS2", 0.0))',
1823
+ ' c_fes = np.asarray(c["FeS"].value if hasattr(c["FeS"], "value") else c["FeS"])',
1824
+ ' c_ts2 = np.asarray(c["TS2"].value if hasattr(c["TS2"], "value") else c["TS2"])',
1825
+ ' hs_frac = float(mp.hs_frac)',
1826
+ ' d_fes = solid_fac * k_num * c_ts2 * hs_frac',
1827
+ ' d_ts2 = solid_fac * k_num * c_fes * hs_frac',
1828
+ ' J[:, i_fes, i_fes] -= d_fes',
1829
+ ' J[:, i_fes, i_ts2] -= d_ts2',
1830
+ ' J[:, i_ts2, i_fes] -= d_fes',
1831
+ ' J[:, i_ts2, i_ts2] -= d_ts2',
1832
+ ' J[:, i_fes2, i_fes] += d_fes',
1833
+ ' J[:, i_fes2, i_ts2] += d_ts2',
1834
+ ' if "FeS_32" in idx and "TS2_32" in idx and "FeS2_32" in idx and getattr(mp, "isotopes", False):',
1835
+ ' i_fes_32 = idx["FeS_32"]',
1836
+ ' i_ts2_32 = idx["TS2_32"]',
1837
+ ' i_fes2_32 = idx["FeS2_32"]',
1838
+ ' J[:, i_fes_32, i_fes_32] -= d_fes',
1839
+ ' J[:, i_ts2_32, i_ts2_32] -= d_ts2',
1840
+ ' J[:, i_fes2_32, i_fes2_32] += d_fes',
1841
+ ' J[:, i_fes2_32, i_ts2_32] += d_ts2',
1842
+ '',
1843
+ ])
1844
+
1845
+ if "elemental_sulfur_oxidation" in active_categories:
1846
+ code.extend([
1847
+ ' # 11. Elemental sulfur oxidation (elemental_sulfur_oxidation)',
1848
+ ' if "S0" in idx and "O2" in idx and "SO4" in idx:',
1849
+ ' i_s0 = idx["S0"]',
1850
+ ' i_o2 = idx["O2"]',
1851
+ ' i_so4 = idx["SO4"]',
1852
+ ' k_num = float(mp.k.get("S0_O2") if hasattr(mp, "k") else getattr(k, "S0_O2", 0.0))',
1853
+ ' c_s0 = np.asarray(c["S0"].value if hasattr(c["S0"], "value") else c["S0"])',
1854
+ ' c_o2 = np.asarray(c["O2"].value if hasattr(c["O2"], "value") else c["O2"])',
1855
+ ' d_s0 = solid_fac * k_num * c_o2',
1856
+ ' d_o2 = solid_fac * k_num * c_s0',
1857
+ ' J[:, i_s0, i_s0] -= d_s0',
1858
+ ' J[:, i_s0, i_o2] -= d_o2',
1859
+ ' J[:, i_o2, i_s0] -= 1.5 * d_s0',
1860
+ ' J[:, i_o2, i_o2] -= 1.5 * d_o2',
1861
+ ' J[:, i_so4, i_s0] += d_s0',
1862
+ ' J[:, i_so4, i_o2] += d_o2',
1863
+ ' if "S0_32" in idx and "SO4_32" in idx and getattr(mp, "isotopes", False):',
1864
+ ' i_s0_32 = idx["S0_32"]',
1865
+ ' i_so4_32 = idx["SO4_32"]',
1866
+ ' c_s0_32 = np.asarray(c["S0_32"].value if hasattr(c["S0_32"], "value") else c["S0_32"])',
1867
+ ' d_o2_32 = solid_fac * k_num * c_s0_32',
1868
+ ' J[:, i_s0_32, i_s0_32] -= d_s0',
1869
+ ' J[:, i_so4_32, i_s0_32] += d_s0',
1870
+ ' J[:, i_s0_32, i_o2] -= d_o2_32',
1871
+ ' J[:, i_so4_32, i_o2] += d_o2_32',
1872
+ '',
1873
+ ])
1874
+
1875
+ if "pyrite_formation_fes_s0" in active_categories:
1876
+ code.extend([
1877
+ ' # 12. Pyrite formation via S0 (pyrite_formation_fes_s0_new)',
1878
+ ' if "FeS" in idx and "S0" in idx and "FeS2" in idx:',
1879
+ ' i_fes = idx["FeS"]',
1880
+ ' i_s0 = idx["S0"]',
1881
+ ' i_fes2 = idx["FeS2"]',
1882
+ ' k_num = float(mp.k.get("FeS_S0") if hasattr(mp, "k") else getattr(k, "FeS_S0", 0.0))',
1883
+ ' c_fes = np.asarray(c["FeS"].value if hasattr(c["FeS"], "value") else c["FeS"])',
1884
+ ' c_s0 = np.asarray(c["S0"].value if hasattr(c["S0"], "value") else c["S0"])',
1885
+ ' d_fes = solid_fac * k_num * c_s0',
1886
+ ' d_s0 = solid_fac * k_num * c_fes',
1887
+ ' J[:, i_fes, i_fes] -= d_fes',
1888
+ ' J[:, i_fes, i_s0] -= d_s0',
1889
+ ' J[:, i_s0, i_fes] -= d_fes',
1890
+ ' J[:, i_s0, i_s0] -= d_s0',
1891
+ ' J[:, i_fes2, i_fes] += d_fes',
1892
+ ' J[:, i_fes2, i_s0] += d_s0',
1893
+ ' if "FeS_32" in idx and "S0_32" in idx and "FeS2_32" in idx and getattr(mp, "isotopes", False):',
1894
+ ' i_fes_32 = idx["FeS_32"]',
1895
+ ' i_s0_32 = idx["S0_32"]',
1896
+ ' i_fes2_32 = idx["FeS2_32"]',
1897
+ ' J[:, i_fes_32, i_fes_32] -= d_fes',
1898
+ ' J[:, i_s0_32, i_s0_32] -= d_s0',
1899
+ ' J[:, i_fes2_32, i_fes2_32] += d_fes',
1900
+ ' J[:, i_fes2_32, i_s0_32] += d_s0',
1901
+ '',
1902
+ ])
1903
+
1904
+ if "elemental_sulfur_disproportionation" in active_categories:
1905
+ code.extend([
1906
+ ' # 13. Elemental sulfur disproportionation',
1907
+ ' if "S0" in idx and "TS2" in idx and "SO4" in idx:',
1908
+ ' i_s0 = idx["S0"]',
1909
+ ' i_ts2 = idx["TS2"]',
1910
+ ' i_so4 = idx["SO4"]',
1911
+ ' k_num = float(mp.k.get("S0_dispro") if hasattr(mp, "k") else getattr(k, "S0_dispro", 0.0))',
1912
+ ' lim_ts2 = lim.get("TS2", 1.0)',
1913
+ ' lim_o2 = lim.get("O2_inhibit", 1.0)',
1914
+ ' coeff_s0 = np.maximum(k_num * lim_ts2 * lim_o2, 0.0)',
1915
+ ' d_s0 = solid_fac * coeff_s0',
1916
+ ' J[:, i_s0, i_s0] -= d_s0',
1917
+ ' J[:, i_ts2, i_s0] += 0.75 * d_s0',
1918
+ ' J[:, i_so4, i_s0] += 0.25 * d_s0',
1919
+ ' if "S0_32" in idx and "TS2_32" in idx and "SO4_32" in idx and getattr(mp, "isotopes", False):',
1920
+ ' i_s0_32 = idx["S0_32"]',
1921
+ ' i_ts2_32 = idx["TS2_32"]',
1922
+ ' i_so4_32 = idx["SO4_32"]',
1923
+ ' J[:, i_s0_32, i_s0_32] -= d_s0',
1924
+ ' J[:, i_ts2_32, i_s0_32] += 0.75 * d_s0',
1925
+ ' J[:, i_so4_32, i_s0_32] += 0.25 * d_s0',
1926
+ '',
1927
+ ])
1928
+
1929
+ code.extend([
1930
+ ' return J',
1931
+ '',
1932
+ 'diagenetic_reactions.compute_chemical_jacobian = compute_chemical_jacobian',
1933
+ ])
1934
+ return "\n".join(code)
1935
+
1936
+
1937
+ def main(args_list: Optional[List[str]] = None) -> int:
1938
+ """CLI entrypoint for generate_equation_system."""
1939
+ parser = build_arg_parser()
1940
+ args = parser.parse_args(args_list)
1941
+
1942
+ # Maintain attribute compatibility with legacy flag names
1943
+ if not hasattr(args, "input"):
1944
+ args.input = args.reactions
1945
+ if not hasattr(args, "output"):
1946
+ args.output = args.equations
1947
+ if not hasattr(args, "constants"):
1948
+ args.constants = args.kinetic_constants
1949
+
1950
+ try:
1951
+ generate_equations(
1952
+ reactions_path=args.reactions,
1953
+ equations_path=args.equations,
1954
+ kinetic_constants_path=args.kinetic_constants,
1955
+ limiters_path=args.limiters,
1956
+ species_path=args.species,
1957
+ verbose=args.verbose,
1958
+ validate_only=args.validate_only,
1959
+ )
1960
+ if not args.validate_only:
1961
+ print(f"Successfully generated equations module saved to: {args.equations}")
1962
+ return 0
1963
+ except ValidationError as ve:
1964
+ print("\n" + "=" * 60, file=sys.stderr)
1965
+ print(f"VALIDATION FAILED:\n{ve}", file=sys.stderr)
1966
+ print("=" * 60 + "\n", file=sys.stderr)
1967
+ return 1
1968
+ except Exception as e:
1969
+ print(f"Error: {e}", file=sys.stderr)
1970
+ return 1
1971
+
1972
+
1973
+ if __name__ == "__main__":
1974
+ sys.exit(main())
1975
+