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,904 @@
1
+ """Monolithic Block Newton-Raphson Solver for MANTIS.
2
+
3
+ Solves the coupled non-steady-state reactive-transport system as a monolithic
4
+ nonlinear system using a Block Tridiagonal Matrix Algorithm (Block TDMA)
5
+ accelerated with Numba.
6
+
7
+ Eliminates decoupled Picard/Gauss-Seidel cross-species lag oscillations by
8
+ solving all chemical species simultaneously with a fully coupled chemical Jacobian.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import gc
14
+ import math
15
+ import os
16
+ import sys
17
+ import time
18
+ import traceback
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
21
+
22
+ import numpy as np
23
+
24
+ from .diff_lib import (
25
+ ArrayProxy,
26
+ VariableArray,
27
+ data_container,
28
+ get_time_units,
29
+ save_data,
30
+ save_data_async,
31
+ save_state,
32
+ )
33
+ from .live_plot_lib import parse_time_to_seconds, save_final_pdf, write_to_queue_async
34
+ from .solvers_numba import (
35
+ compute_species_residual_linf,
36
+ compute_species_residual_wrms,
37
+ solve_block_tridiagonal_thomas,
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class AdaptiveDT:
43
+ """Advanced PID-controlled adaptive time stepping.
44
+
45
+ Uses standard H211B controller logic for smooth step size adaptation.
46
+ """
47
+
48
+ dt_min: float
49
+ dt_max: float
50
+ dt_initial: float
51
+ dt_cfl_factor: float = 0.8
52
+ growth_factor: float = 1.2
53
+ cut_factor: float = 0.5
54
+ pid: bool = True
55
+ kP: float = 0.075
56
+ kI: float = 0.175
57
+ kD: float = 0.01
58
+
59
+ enable_failure_ceiling: bool = False
60
+ failure_ceiling_factor: float = 0.7
61
+ failure_hold_steps: int = 10
62
+ ceiling_growth_factor: float = 1.05
63
+ aggressive_cut_factor: float = 0.25
64
+
65
+ enable_mttf_governor: bool = False
66
+ mttf_x1: float = 0.60
67
+ mttf_x2: float = 0.35
68
+ mttf_x3: float = 0.15
69
+ mttf_y1: float = 0.02
70
+ mttf_y2: float = 0.05
71
+ mttf_y3: float = 0.10
72
+
73
+ steps_since_failure: int = 0
74
+ _base_growth_factor: float = 1.2
75
+
76
+ _dt: float = 0.0
77
+ _err_prev: float | None = None
78
+ _dt_prev: float = 0.0
79
+ _dt_ceiling: float | None = None
80
+ _failed_dt: float | None = None
81
+ _steps_at_ceiling: int = 0
82
+
83
+ def __post_init__(self) -> None:
84
+ self._dt = max(self.dt_min, min(self.dt_initial, self.dt_max))
85
+ self._dt_prev = self._dt
86
+ self._base_growth_factor = self.growth_factor
87
+ if self.enable_failure_ceiling:
88
+ self._dt_ceiling = self.dt_max
89
+ self._failed_dt = self.dt_max
90
+ self._steps_at_ceiling = 0
91
+
92
+ @property
93
+ def dt(self) -> float:
94
+ """Return current time step."""
95
+ return self._dt
96
+
97
+ def register_failure(self, failed_dt: float, cut_factor: float | None = None) -> float:
98
+ """Record step failure and set a temporary ceiling or adapt MTTF growth."""
99
+ factor = self.cut_factor if cut_factor is None else cut_factor
100
+ if self.enable_mttf_governor:
101
+ excess = max(self.growth_factor - 1.0, 0.0)
102
+ if self.steps_since_failure < 5:
103
+ excess *= (1.0 - self.mttf_x1)
104
+ factor = min(factor, 0.70)
105
+ elif self.steps_since_failure < 10:
106
+ excess *= (1.0 - self.mttf_x2)
107
+ factor = min(factor, 0.75)
108
+ elif self.steps_since_failure < 20:
109
+ excess *= (1.0 - self.mttf_x3)
110
+ factor = min(factor, 0.80)
111
+ self.growth_factor = 1.0 + max(excess, 0.02)
112
+ self.steps_since_failure = 0
113
+
114
+ self._dt = max(failed_dt * factor, self.dt_min)
115
+ if self.enable_failure_ceiling:
116
+ self._failed_dt = failed_dt
117
+ ceiling_factor = min(self.failure_ceiling_factor, factor * 1.5) if cut_factor is not None else self.failure_ceiling_factor
118
+ self._dt_ceiling = max(self.dt_min, failed_dt * ceiling_factor)
119
+ self._steps_at_ceiling = 0
120
+ return self._dt
121
+
122
+ def record_success(self) -> None:
123
+ """Record a successful step and adapt dynamic growth factor if MTTF governor is active."""
124
+ self.steps_since_failure += 1
125
+ if self.enable_mttf_governor:
126
+ excess = max(self.growth_factor - 1.0, 0.0)
127
+ base_excess = max(self._base_growth_factor - 1.0, 0.01)
128
+ if self.steps_since_failure >= 40:
129
+ excess = min(base_excess, excess + self.mttf_y3 * base_excess)
130
+ elif self.steps_since_failure >= 30:
131
+ excess = min(base_excess, excess + self.mttf_y2 * base_excess)
132
+ elif self.steps_since_failure >= 20:
133
+ excess = min(base_excess, excess + self.mttf_y1 * base_excess)
134
+ self.growth_factor = 1.0 + excess
135
+
136
+ def cfl_limit(self, dx: float, vel: float, D: float) -> float:
137
+ """Compute global CFL estimate for advection-diffusion."""
138
+ adv_limit = math.inf if vel == 0 else dx / abs(vel)
139
+ dif_limit = math.inf if D == 0 else dx * dx / (2 * D)
140
+ return self.dt_cfl_factor * min(adv_limit, dif_limit)
141
+
142
+ def get_effective_max(self, _log: Optional[Callable[[str], None]] = None) -> float:
143
+ """Return effective maximum dt considering the dynamic failure ceiling and hold period."""
144
+ if self.enable_failure_ceiling and self._dt_ceiling is not None:
145
+ if self._dt >= self._dt_ceiling * 0.98:
146
+ self._steps_at_ceiling += 1
147
+
148
+ if self._steps_at_ceiling >= self.failure_hold_steps:
149
+ if self.ceiling_growth_factor > 1.0 and self._dt_ceiling < self.dt_max:
150
+ old_ceiling = self._dt_ceiling
151
+ self._dt_ceiling = min(
152
+ self._dt_ceiling * self.ceiling_growth_factor,
153
+ self.dt_max,
154
+ )
155
+ self._steps_at_ceiling = 0
156
+ if _log is not None:
157
+ _log(
158
+ f" Ceiling hold of {self.failure_hold_steps} steps completed: raising dt ceiling from {get_time_units(old_ceiling):.2f~P} to {get_time_units(self._dt_ceiling):.2f~P}."
159
+ )
160
+ return min(self._dt_ceiling, self.dt_max)
161
+ else:
162
+ return min(self._dt_ceiling, self.dt_max)
163
+ return self.dt_max
164
+
165
+ def update(
166
+ self,
167
+ error_metric: float,
168
+ dt_cfl: float | None = None,
169
+ step_success: bool = True,
170
+ target_error: float = 1e-4,
171
+ cut_factor: float | None = None,
172
+ ) -> float:
173
+ """Compute the next dt based on solver performance and change magnitude."""
174
+ if not step_success:
175
+ factor = self.cut_factor if cut_factor is None else cut_factor
176
+ self._dt = max(self._dt * factor, self.dt_min)
177
+ return self._dt
178
+
179
+ effective_max = self.get_effective_max()
180
+
181
+ if self.pid:
182
+ if self._err_prev is None:
183
+ self._err_prev = target_error
184
+
185
+ err = max(error_metric, 1e-25)
186
+ err_prev = max(self._err_prev, 1e-25)
187
+ err_ref = target_error
188
+
189
+ factor = (
190
+ (err_prev / err) ** self.kP
191
+ * (err_ref / err) ** self.kI
192
+ * (err_ref / err_prev) ** self.kD
193
+ )
194
+
195
+ factor = max(self.cut_factor, min(self.growth_factor, factor))
196
+ self._dt = max(self.dt_min, min(self._dt * factor, effective_max))
197
+ else:
198
+ if error_metric < target_error:
199
+ self._dt = min(self._dt * self.growth_factor, effective_max)
200
+ else:
201
+ self._dt = max(self._dt * self.cut_factor, self.dt_min)
202
+
203
+ if dt_cfl is not None:
204
+ self._dt = min(self._dt, dt_cfl)
205
+
206
+ self._err_prev = error_metric
207
+ return self._dt
208
+
209
+
210
+ def build_native_1d_transport_stencil(
211
+ z: np.ndarray,
212
+ dx: np.ndarray,
213
+ phi: Any,
214
+ D_cell: np.ndarray,
215
+ w: float,
216
+ bc_props: Dict[str, Any],
217
+ D_irr: Optional[np.ndarray] = None,
218
+ solid_scheme: str = "powerlaw",
219
+ ) -> Tuple[np.ndarray, np.ndarray]:
220
+ """Constructs the exact 1D tridiagonal finite-volume transport stencil in pure NumPy.
221
+
222
+ Returns:
223
+ ab: shape (3, N) banded matrix (row 0: upper, row 1: diag, row 2: lower)
224
+ b_transport: shape (N,) boundary flux vector
225
+ """
226
+ N = len(z)
227
+ is_dissolved = (bc_props.get("type", "dissolved") == "dissolved")
228
+ eff_phi_val = np.asarray(phi.value if hasattr(phi, "value") else phi)
229
+ eff_phi = eff_phi_val if is_dissolved else (1.0 - eff_phi_val)
230
+ if eff_phi.ndim == 0:
231
+ eff_phi_arr = np.full(N, float(eff_phi), dtype=np.float64)
232
+ else:
233
+ eff_phi_arr = np.asarray(eff_phi, dtype=np.float64)
234
+ eff_phi_face = (eff_phi_arr[:-1] + eff_phi_arr[1:]) / 2.0
235
+
236
+ d_centers = z[1:] - z[:-1]
237
+ D_cell_arr = np.asarray(D_cell)
238
+ if D_cell_arr.ndim == 0:
239
+ D_cell_arr = np.full(N, float(D_cell_arr), dtype=np.float64)
240
+ # Face diffusion: distance-weighted harmonic mean
241
+ D_face = 2.0 / (1.0 / np.maximum(D_cell_arr[:-1], 1e-30) + 1.0 / np.maximum(D_cell_arr[1:], 1e-30))
242
+ diff_cond = (eff_phi_face * D_face) / d_centers
243
+
244
+ # Face convection
245
+ F_face = eff_phi_face * w
246
+ if solid_scheme == "upwind" and not is_dissolved:
247
+ a_E = diff_cond + np.maximum(F_face, 0.0)
248
+ a_W = diff_cond + np.maximum(-F_face, 0.0)
249
+ else:
250
+ Pe = F_face / np.maximum(diff_cond, 1e-30)
251
+ A_pe = np.maximum(0.0, (1.0 - 0.1 * np.abs(Pe))**5)
252
+ a_E = diff_cond * A_pe + np.maximum(F_face, 0.0)
253
+ a_W = diff_cond * A_pe + np.maximum(-F_face, 0.0)
254
+
255
+ ab = np.zeros((3, N), dtype=np.float64)
256
+ b_transport = np.zeros(N, dtype=np.float64)
257
+
258
+ # Upper diagonal: ab[0, 1:] -> coeff of C_{i+1} in equation i
259
+ ab[0, 1:] = -a_W
260
+ # Lower diagonal: ab[2, :-1] -> coeff of C_{i-1} in equation i
261
+ ab[2, :-1] = -a_E
262
+
263
+ # Main diagonal interior:
264
+ ab[1, 0] = a_E[0]
265
+ ab[1, 1:-1] = a_W[:-1] + a_E[1:]
266
+ ab[1, -1] = a_W[-1]
267
+
268
+ # Left boundary (z=0):
269
+ top_val = bc_props.get("top", 0.0)
270
+ d_top = z[0]
271
+ phi_top = eff_phi_arr[0]
272
+
273
+ if is_dissolved:
274
+ D_top = (phi_top * D_cell_arr[0]) / d_top
275
+ F_top = phi_top * w
276
+ Pe_top = F_top / np.maximum(D_top, 1e-30)
277
+ A_top = np.maximum(0.0, (1.0 - 0.1 * np.abs(Pe_top))**5)
278
+ a_in_diag = D_top * A_top + np.maximum(-F_top, 0.0)
279
+ a_in_rhs = D_top * A_top + np.maximum(F_top, 0.0)
280
+ ab[1, 0] += a_in_diag
281
+ b_transport[0] += a_in_rhs * top_val
282
+ else:
283
+ b_transport[0] += top_val
284
+
285
+ # Right boundary (z=L): Neumann zero-gradient (advection carries mass out)
286
+ ab[1, -1] += eff_phi_arr[-1] * w
287
+
288
+ # Bio-irrigation (non-local exchange for dissolved species)
289
+ if is_dissolved and D_irr is not None:
290
+ irr_arr = np.asarray(D_irr)
291
+ if np.any(irr_arr > 0):
292
+ irr_coeff = eff_phi_arr * irr_arr * dx
293
+ ab[1, :] += irr_coeff
294
+ b_transport += irr_coeff * top_val
295
+
296
+ return ab, b_transport
297
+
298
+
299
+ class GovernorStats:
300
+ """Accumulates solver and governor events and reports summary breakdown."""
301
+
302
+ def __init__(self):
303
+ self.total_sweeps = 0
304
+ self.total_steps = 0
305
+ self.single_sweep_sweeps = 0
306
+ self.multi_sweep_sweeps = 0
307
+ self.near_converged_sweeps = 0
308
+ self.graceful_sweeps = 0
309
+ self.porewater_capped_sweeps = 0
310
+ self.isotope_capped_sweeps = 0
311
+ self.rejected_sweeps = 0
312
+ self.porewater_details: Dict[str, int] = {}
313
+ self.rejected_reasons: Dict[str, int] = {}
314
+
315
+ def record_step(
316
+ self,
317
+ sweeps: int,
318
+ outcome: str,
319
+ porewater_capped: bool = False,
320
+ limiting_species: Optional[str] = None,
321
+ isotope_capped: bool = False,
322
+ ) -> None:
323
+ self.total_steps += 1
324
+ self.total_sweeps += sweeps
325
+
326
+ if porewater_capped:
327
+ self.porewater_capped_sweeps += sweeps
328
+ if limiting_species:
329
+ self.porewater_details[limiting_species] = (
330
+ self.porewater_details.get(limiting_species, 0) + 1
331
+ )
332
+ elif isotope_capped:
333
+ self.isotope_capped_sweeps += sweeps
334
+ elif outcome == "single_sweep":
335
+ self.single_sweep_sweeps += sweeps
336
+ elif outcome == "graceful":
337
+ self.graceful_sweeps += sweeps
338
+ elif outcome == "near_converged":
339
+ self.near_converged_sweeps += sweeps
340
+ else:
341
+ self.multi_sweep_sweeps += sweeps
342
+
343
+ def record_rejection(self, sweeps: int, reason: str = "failed") -> None:
344
+ self.total_sweeps += sweeps
345
+ self.rejected_sweeps += sweeps
346
+ short_reason = reason.split("\n")[0][:60]
347
+ self.rejected_reasons[short_reason] = (
348
+ self.rejected_reasons.get(short_reason, 0) + 1
349
+ )
350
+
351
+ def format_summary(self) -> str:
352
+ tot = max(self.total_sweeps, 1)
353
+ lines = [
354
+ f"Governor & Sweep Summary (Total Sweeps: {self.total_sweeps}, Steps: {self.total_steps}):"
355
+ ]
356
+ if self.single_sweep_sweeps > 0 or self.total_sweeps == 0:
357
+ lines.append(
358
+ f" {self.single_sweep_sweeps / tot * 100:5.1f}% ({self.single_sweep_sweeps:d} sweeps): accepted with a single sweep"
359
+ )
360
+ if self.multi_sweep_sweeps > 0:
361
+ lines.append(
362
+ f" {self.multi_sweep_sweeps / tot * 100:5.1f}% ({self.multi_sweep_sweeps:d} sweeps): accepted with multi-sweeps"
363
+ )
364
+ if self.graceful_sweeps > 0 or self.near_converged_sweeps > 0:
365
+ grace_tot = self.graceful_sweeps + self.near_converged_sweeps
366
+ lines.append(
367
+ f" {grace_tot / tot * 100:5.1f}% ({grace_tot:d} sweeps): accepted near convergence / graceful tolerance"
368
+ )
369
+ if self.porewater_capped_sweeps > 0:
370
+ lim_str = ""
371
+ if self.porewater_details:
372
+ top_sp = max(self.porewater_details.items(), key=lambda x: x[1])[0]
373
+ lim_str = f" (limiting: {top_sp})"
374
+ lines.append(
375
+ f" {self.porewater_capped_sweeps / tot * 100:5.1f}% ({self.porewater_capped_sweeps:d} sweeps): capped because porewater depleted too fast{lim_str}"
376
+ )
377
+ if self.isotope_capped_sweeps > 0:
378
+ lines.append(
379
+ f" {self.isotope_capped_sweeps / tot * 100:5.1f}% ({self.isotope_capped_sweeps:d} sweeps): capped by isotope limiter"
380
+ )
381
+ if self.rejected_sweeps > 0:
382
+ top_reason = ""
383
+ if self.rejected_reasons:
384
+ top_r = max(self.rejected_reasons.items(), key=lambda x: x[1])[0]
385
+ top_reason = f" ({top_r})"
386
+ lines.append(
387
+ f" {self.rejected_sweeps / tot * 100:5.1f}% ({self.rejected_sweeps:d} sweeps): rejected / Picard convergence failure{top_reason}"
388
+ )
389
+ return "\n".join(lines)
390
+
391
+
392
+ class MonolithicNewtonSystem:
393
+ """Manages the 1D monolithic block-tridiagonal ADR system."""
394
+
395
+ def __init__(
396
+ self,
397
+ species_struct: List[Dict[str, Any]],
398
+ mesh: Any,
399
+ mp: Any,
400
+ bc_map: Optional[Dict[str, Any]] = None,
401
+ D_mol: Optional[Any] = None,
402
+ z: Optional[np.ndarray] = None,
403
+ ):
404
+ self.species_struct = species_struct
405
+ self.species_names = [s["name"] for s in species_struct]
406
+ self.num_species = len(self.species_names)
407
+ self.num_cells = getattr(mesh, "numberOfCells", len(mesh.cellVolumes))
408
+ self.vol = np.asarray(mesh.cellVolumes)
409
+ self.z = z if z is not None else np.asarray(mesh.cellCenters[0])
410
+
411
+ N = self.num_cells
412
+ S = self.num_species
413
+
414
+ # Block tridiagonal static transport stencils:
415
+ # A: subdiagonal (coupling to cell i-1), shape (N, S)
416
+ # B_trans: diagonal transport part, shape (N, S)
417
+ # C: superdiagonal (coupling to cell i+1), shape (N, S)
418
+ # b_transport: transport RHS from BCs/irrigation, shape (N, S)
419
+ # eff_phi_vol: effective porosity * cell volume, shape (N, S)
420
+ self.A = np.zeros((N, S), dtype=np.float64)
421
+ self.B_trans = np.zeros((N, S), dtype=np.float64)
422
+ self.C = np.zeros((N, S), dtype=np.float64)
423
+ self.b_transport = np.zeros((N, S), dtype=np.float64)
424
+ self.eff_phi_vol = np.zeros((N, S), dtype=np.float64)
425
+
426
+ phi_val = np.asarray(mp.phi.value if hasattr(mp.phi, "value") else mp.phi)
427
+ solid_scheme = getattr(mp, "solid_convection_term", "powerlaw")
428
+
429
+ for s_idx, s_obj in enumerate(species_struct):
430
+ name = s_obj["name"]
431
+ props = bc_map.get(name, {}) if bc_map is not None else {}
432
+
433
+ is_diss = props.get("type", "dissolved") == "dissolved"
434
+ eff_phi_val = phi_val if is_diss else (1.0 - phi_val)
435
+ self.eff_phi_vol[:, s_idx] = eff_phi_val * self.vol
436
+
437
+ # Effective diffusion coefficient
438
+ D_eff = s_obj.get("D_total", None)
439
+ if D_eff is None:
440
+ D_mol_val = getattr(D_mol, name, 0.0) if D_mol is not None else 0.0
441
+ D_bio_val = getattr(D_mol, "D_bio", 0.0) if D_mol is not None else 0.0
442
+ D_eff = np.maximum(D_mol_val + D_bio_val, 1e-20)
443
+
444
+ # Advection velocity
445
+ w_val = getattr(mp, "w", 0.0)
446
+ if is_diss:
447
+ w_val -= getattr(mp, "advection", 0.0)
448
+
449
+ # Bio-irrigation (dissolved only)
450
+ D_irr_val = (
451
+ getattr(D_mol, "D_irr", None)
452
+ if (is_diss and D_mol is not None)
453
+ else None
454
+ )
455
+
456
+ ab, b_t = build_native_1d_transport_stencil(
457
+ z=self.z,
458
+ dx=self.vol,
459
+ phi=phi_val,
460
+ D_cell=D_eff,
461
+ w=w_val,
462
+ bc_props=props,
463
+ D_irr=D_irr_val,
464
+ solid_scheme=solid_scheme,
465
+ )
466
+
467
+ # ab has shape (3, N):
468
+ # row 0: superdiagonal (ab[0, i+1] couples to cell i+1)
469
+ # row 1: main diagonal (ab[1, i] couples to cell i)
470
+ # row 2: subdiagonal (ab[2, i-1] couples to cell i-1)
471
+ self.B_trans[:, s_idx] = ab[1, :]
472
+ if N > 1:
473
+ self.C[:-1, s_idx] = ab[0, 1:]
474
+ self.A[1:, s_idx] = ab[2, :-1]
475
+ self.b_transport[:, s_idx] = b_t
476
+
477
+ # Preallocated scratch buffers for Block TDMA
478
+ self.B_work = np.zeros((N, S, S), dtype=np.float64)
479
+ self.C_prime = np.zeros((N, S, S), dtype=np.float64)
480
+ self.D_prime = np.zeros((N, S), dtype=np.float64)
481
+ self.res = np.zeros((N, S), dtype=np.float64)
482
+ self.delta_u = np.zeros((N, S), dtype=np.float64)
483
+ self.u_curr = np.zeros((N, S), dtype=np.float64)
484
+ self.u_old = np.zeros((N, S), dtype=np.float64)
485
+ self.J_chem = np.zeros((N, S, S), dtype=np.float64)
486
+
487
+ def load_state(self) -> None:
488
+ """Loads current concentration state into u_curr and u_old."""
489
+ for s_idx, s_obj in enumerate(self.species_struct):
490
+ var = s_obj["var"]
491
+ self.u_curr[:, s_idx] = np.asarray(var.value)
492
+ self.u_old[:, s_idx] = (
493
+ np.asarray(var.old.value)
494
+ if hasattr(var, "old")
495
+ else np.asarray(var.value)
496
+ )
497
+
498
+ def sync_to_variables(self, u_target: np.ndarray) -> None:
499
+ """Updates CellVariables in-place with u_target."""
500
+ for s_idx, s_obj in enumerate(self.species_struct):
501
+ s_obj["var"].setValue(u_target[:, s_idx])
502
+
503
+ def evaluate_transport_operator(self, u: np.ndarray) -> np.ndarray:
504
+ """Computes T * u for all species: A * u_{i-1} + B_trans * u_i + C * u_{i+1}."""
505
+ N, S = u.shape
506
+ Tu = self.B_trans * u
507
+ if N > 1:
508
+ Tu[1:, :] += self.A[1:, :] * u[:-1, :]
509
+ Tu[:-1, :] += self.C[:-1, :] * u[1:, :]
510
+ return Tu
511
+
512
+
513
+ def run_non_steady_state_solver_monolithic(
514
+ mp: Any,
515
+ c: Any,
516
+ species_list_full: List[str],
517
+ species_list_partial: List[str],
518
+ k: Any,
519
+ diagenetic_reactions: Any,
520
+ equilibrium_reactions: Any,
521
+ mesh: Any,
522
+ D_mol: Any,
523
+ bc_map: Dict[str, Any],
524
+ z: np.ndarray,
525
+ plot_queue: Optional[Any] = None,
526
+ ) -> Tuple[int, float]:
527
+ """
528
+ Solves the non-steady state ADR reactive transport system using a Monolithic
529
+ Block Newton-Raphson backend with Numba Block TDMA.
530
+ """
531
+ log_file = (
532
+ open("solver_debug.log", "w", buffering=1)
533
+ if getattr(mp, "verbose", False)
534
+ else None
535
+ )
536
+
537
+ def _log(msg: str) -> None:
538
+ if log_file:
539
+ log_file.write(f"[{time.strftime('%X')}] {msg}\n")
540
+ log_file.flush()
541
+
542
+ dt_controller = AdaptiveDT(
543
+ dt_min=getattr(mp, "dt_min", 1.0),
544
+ dt_max=getattr(mp, "dt_max", 31536000.0),
545
+ dt_initial=getattr(mp, "dt_init", getattr(mp, "dt_min", 1.0)),
546
+ growth_factor=float(getattr(mp, "dt_growth_factor", 1.25)),
547
+ cut_factor=float(getattr(mp, "dt_cut_factor", 0.5)),
548
+ )
549
+
550
+ msg_info = (
551
+ "Starting Monolithic Block Newton-Raphson Solver. "
552
+ f"dt_init: {get_time_units(dt_controller.dt):.2f~P}"
553
+ )
554
+ _log(msg_info)
555
+ print(msg_info)
556
+
557
+ species_struct = [{"name": s, "var": getattr(c, s)} for s in species_list_partial]
558
+ mono_system = MonolithicNewtonSystem(
559
+ species_struct=species_struct,
560
+ mesh=mesh,
561
+ mp=mp,
562
+ bc_map=bc_map,
563
+ D_mol=D_mol,
564
+ z=z,
565
+ )
566
+
567
+ N = mono_system.num_cells
568
+ S = mono_system.num_species
569
+
570
+ # Newton convergence settings
571
+ max_newton_iters = int(getattr(mp, "max_newton_iters", 8))
572
+ newton_tol = float(getattr(mp, "newton_tol", 1e-2))
573
+ newton_norm = getattr(mp, "newton_norm", "wrms")
574
+ target_optimal_iters = int(getattr(mp, "newton_target_optimal", 4))
575
+ max_acceptable_iters = int(getattr(mp, "newton_max_acceptable", 6))
576
+
577
+ # Fast NumPy proxy containers
578
+ c_numpy = data_container({s: ArrayProxy(val.value) for s, val in c.items()})
579
+ mp_numpy = data_container(mp)
580
+ phi_val = mp.phi.value if hasattr(mp.phi, "value") else mp.phi
581
+ mp_numpy.phi = ArrayProxy(phi_val)
582
+ mp_numpy.in_solver = True
583
+
584
+ f_res_scratch = data_container()
585
+
586
+ # Chemical Jacobian evaluation strategy (Analytical SymPy vs Vectorized Finite Difference)
587
+ use_numerical_jacobian = getattr(mp, "use_numerical_jacobian", False)
588
+ jacobian_fn = None if use_numerical_jacobian else getattr(mp, "jacobian_fn", None)
589
+ if jacobian_fn is None and not use_numerical_jacobian:
590
+ jacobian_fn = getattr(diagenetic_reactions, "compute_chemical_jacobian", None)
591
+ if jacobian_fn is None and not use_numerical_jacobian:
592
+ rxn_mod = getattr(diagenetic_reactions, "__module__", None)
593
+ if rxn_mod and rxn_mod in sys.modules:
594
+ jacobian_fn = getattr(sys.modules[rxn_mod], "compute_chemical_jacobian", None)
595
+
596
+ if jacobian_fn is not None:
597
+ _log("Monolithic solver: Analytical chemical Jacobian enabled.")
598
+ print("Monolithic solver: Analytical chemical Jacobian enabled.")
599
+ else:
600
+ _log("Monolithic solver: Finite-difference chemical Jacobian enabled.")
601
+ print("Monolithic solver: Finite-difference chemical Jacobian enabled.")
602
+
603
+ def _eval_rates(u_eval: np.ndarray, dt_step: float) -> Dict[str, np.ndarray]:
604
+ """Evaluates bulk reaction rates for a given concentration state."""
605
+ for s_idx, s_name in enumerate(mono_system.species_names):
606
+ c_numpy[s_name].value[:] = u_eval[:, s_idx]
607
+ mp_numpy.current_dt = dt_step
608
+ _, rates_out = diagenetic_reactions(mp_numpy, c_numpy, k, f=f_res_scratch)
609
+ return {s: np.array(val, copy=True) for s, val in rates_out.items()}
610
+
611
+ governor_stats = GovernorStats()
612
+ video_dt_sec = parse_time_to_seconds(getattr(mp, "video_dt", None))
613
+ last_video_time = -math.inf
614
+ next_milestone_pct = 10
615
+ t_end = getattr(mp, "t_end", math.inf)
616
+ max_steps = getattr(mp, "max_steps", None)
617
+
618
+ step = 0
619
+ total_time = 0.0
620
+ status = "Maximum steps or end time reached"
621
+ current_dt = dt_controller.dt
622
+ wall_start_time = time.perf_counter()
623
+ total_newton_iters = 0
624
+
625
+ try:
626
+ while (max_steps is None or step < max_steps) and total_time < t_end:
627
+ step += 1
628
+
629
+ for s_obj in species_struct:
630
+ s_obj["var"].updateOld()
631
+
632
+ mono_system.load_state()
633
+ u_old = mono_system.u_old.copy()
634
+
635
+ step_converged = False
636
+ step_first_attempt = True
637
+
638
+ while not step_converged:
639
+ try:
640
+ # Current iterate starting at previous time step solution
641
+ u_curr = u_old.copy() if step_first_attempt else mono_system.u_curr.copy()
642
+ prev_wrms_err = math.inf
643
+
644
+ for iter_idx in range(1, max_newton_iters + 1):
645
+ total_newton_iters += 1
646
+ governor_stats.total_sweeps += 1
647
+
648
+ # 1. Evaluate base reaction rates
649
+ rates_base = _eval_rates(u_curr, current_dt)
650
+
651
+ # 2. Form residual vector R = (eff_phi_vol / dt)*(u - u_old) + T*u - b_trans - vol*rates
652
+ Tu = mono_system.evaluate_transport_operator(u_curr)
653
+ R = (mono_system.eff_phi_vol / current_dt) * (u_curr - u_old) + Tu - mono_system.b_transport
654
+ for s_idx, s_name in enumerate(mono_system.species_names):
655
+ r_bulk = rates_base.get(s_name, 0.0)
656
+ if hasattr(r_bulk, "value"):
657
+ r_bulk = np.asarray(r_bulk.value)
658
+ R[:, s_idx] -= mono_system.vol * r_bulk
659
+
660
+ # 3. Form Chemical Jacobian J_chem[:, s, m] = dR_s / dC_m
661
+ if jacobian_fn is not None:
662
+ mono_system.J_chem[:] = jacobian_fn(
663
+ c_numpy, mp_numpy, k, mono_system.species_names
664
+ )
665
+ else:
666
+ mono_system.J_chem.fill(0.0)
667
+ eps_rel = 1e-7
668
+ for m_idx, m_name in enumerate(mono_system.species_names):
669
+ cm = u_curr[:, m_idx]
670
+ delta_m = eps_rel * np.maximum(np.abs(cm), 1e-6)
671
+
672
+ u_curr[:, m_idx] += delta_m
673
+ rates_pert = _eval_rates(u_curr, current_dt)
674
+ u_curr[:, m_idx] -= delta_m
675
+
676
+ for s_idx, s_name in enumerate(mono_system.species_names):
677
+ r_p = rates_pert.get(s_name, 0.0)
678
+ r_b = rates_base.get(s_name, 0.0)
679
+ if hasattr(r_p, "value"):
680
+ r_p = np.asarray(r_p.value)
681
+ if hasattr(r_b, "value"):
682
+ r_b = np.asarray(r_b.value)
683
+ mono_system.J_chem[:, s_idx, m_idx] = (r_p - r_b) / delta_m
684
+
685
+ # 4. Assemble Block Tridiagonal Matrix B_i
686
+ # B_i[s, m] = delta_{sm} * (eff_phi_vol / dt + B_trans) - vol * J_chem[s, m]
687
+ mono_system.B_work.fill(0.0)
688
+ vol_col = mono_system.vol[:, np.newaxis, np.newaxis]
689
+ mono_system.B_work = -vol_col * mono_system.J_chem
690
+
691
+ inv_dt = mono_system.eff_phi_vol / current_dt + mono_system.B_trans
692
+ for s_idx in range(S):
693
+ mono_system.B_work[:, s_idx, s_idx] += inv_dt[:, s_idx]
694
+
695
+ # RHS: -R
696
+ mono_system.res[:] = -R
697
+
698
+ # 5. Solve block tridiagonal linear system for delta_u
699
+ solve_block_tridiagonal_thomas(
700
+ mono_system.A,
701
+ mono_system.B_work,
702
+ mono_system.C,
703
+ mono_system.res,
704
+ mono_system.delta_u,
705
+ mono_system.C_prime,
706
+ mono_system.D_prime,
707
+ )
708
+
709
+ # 6. Backtracking line search with non-negativity protection
710
+ res_norm_curr = np.sqrt(np.mean(R**2))
711
+ alpha = 1.0
712
+ u_best = u_curr.copy()
713
+ min_res_norm = res_norm_curr
714
+ best_alpha = 1.0
715
+
716
+ for line_step in range(4):
717
+ u_trial = u_curr + alpha * mono_system.delta_u
718
+ neg_mask = u_trial < 0.0
719
+ if np.any(neg_mask):
720
+ u_trial[neg_mask] = np.where(u_curr[neg_mask] < 1e-6, 0.0, 0.1 * u_curr[neg_mask])
721
+
722
+ rates_trial = _eval_rates(u_trial, current_dt)
723
+ Tu_trial = mono_system.evaluate_transport_operator(u_trial)
724
+ R_trial = (mono_system.eff_phi_vol / current_dt) * (u_trial - u_old) + Tu_trial - mono_system.b_transport
725
+ for s_idx, s_name in enumerate(mono_system.species_names):
726
+ r_bulk = rates_trial.get(s_name, 0.0)
727
+ if hasattr(r_bulk, "value"):
728
+ r_bulk = np.asarray(r_bulk.value)
729
+ R_trial[:, s_idx] -= mono_system.vol * r_bulk
730
+
731
+ res_norm_trial = np.sqrt(np.mean(R_trial**2))
732
+ if res_norm_trial < min_res_norm or line_step == 3:
733
+ min_res_norm = res_norm_trial
734
+ u_best = u_trial
735
+ best_alpha = alpha
736
+ break
737
+ alpha *= 0.5
738
+ u_best = u_trial
739
+ best_alpha = alpha
740
+
741
+ # 7. Check convergence on actual projected step
742
+ raw_newton_err = 0.0
743
+ newton_atol = float(getattr(mp, "newton_atol", 1e-6))
744
+ is_wrms = (newton_norm == "wrms")
745
+ for s_idx in range(S):
746
+ if is_wrms:
747
+ err_s = compute_species_residual_wrms(
748
+ u_best[:, s_idx],
749
+ u_curr[:, s_idx],
750
+ inner_tol=newton_tol,
751
+ atol=newton_atol,
752
+ )
753
+ else:
754
+ err_s = compute_species_residual_linf(
755
+ u_best[:, s_idx],
756
+ u_curr[:, s_idx],
757
+ inner_tol=newton_tol,
758
+ atol=newton_atol,
759
+ )
760
+ raw_newton_err = max(raw_newton_err, err_s)
761
+
762
+ u_curr[:] = u_best
763
+
764
+ if getattr(mp, "verbose", False):
765
+ max_du_per_species = {
766
+ mono_system.species_names[s]: float(np.max(np.abs(mono_system.delta_u[:, s])))
767
+ for s in range(S)
768
+ }
769
+ top_sp = sorted(max_du_per_species.items(), key=lambda x: x[1], reverse=True)[:3]
770
+ print(
771
+ f" [Iter {iter_idx}] max_R={np.max(np.abs(R)):.2e}, "
772
+ f"res_norm={min_res_norm:.2e}, "
773
+ f"max_du={np.max(np.abs(mono_system.delta_u)):.2e} ({top_sp}), "
774
+ f"alpha={best_alpha:.3f}, err={raw_newton_err:.2e} ({newton_norm})",
775
+ flush=True,
776
+ )
777
+
778
+ res_tol = float(getattr(mp, "newton_res_tol", 1e-12))
779
+ if raw_newton_err <= 1.0 or min_res_norm <= res_tol:
780
+ step_converged = True
781
+ last_iters = iter_idx
782
+ break
783
+
784
+ if not step_converged:
785
+ raise RuntimeError(
786
+ f"Newton iteration failed to converge in {max_newton_iters} iterations (err={raw_newton_err:.2e})"
787
+ )
788
+
789
+ except Exception as ex:
790
+ # Step failed: cut dt and retry
791
+ step_first_attempt = False
792
+ current_dt = max(current_dt * dt_controller.cut_factor, dt_controller.dt_min)
793
+ dt_controller._dt = current_dt
794
+ if current_dt <= dt_controller.dt_min + 1e-15:
795
+ raise RuntimeError(f"Monolithic solver failed at dt_min: {ex}") from ex
796
+ continue
797
+
798
+ # Step accepted
799
+ governor_stats.total_steps += 1
800
+ mono_system.sync_to_variables(u_curr)
801
+ total_time += current_dt
802
+
803
+ # Optional post-step instantaneous reactions
804
+ if hasattr(mp, "instantenous_reactions") and mp.instantenous_reactions:
805
+ try:
806
+ equilibrium_reactions(mp, c, k, None, rates_base, current_dt)
807
+ except Exception:
808
+ pass
809
+
810
+ # Adapt dt based on Newton iterations
811
+ if last_iters <= target_optimal_iters:
812
+ dt_controller._dt = min(current_dt * dt_controller.growth_factor, dt_controller.dt_max)
813
+ governor_stats.multi_sweep_sweeps += 1
814
+ elif last_iters <= max_acceptable_iters:
815
+ dt_controller._dt = min(current_dt * 1.05, dt_controller.dt_max)
816
+ else:
817
+ dt_controller._dt = max(current_dt * 0.85, dt_controller.dt_min)
818
+ governor_stats.graceful_sweeps += 1
819
+ current_dt = dt_controller._dt
820
+
821
+ # Milestone reporting
822
+ pct = int((total_time / t_end) * 100) if t_end < math.inf else 0
823
+ if step % 25 == 0 or pct >= next_milestone_pct:
824
+ t_str = f"{get_time_units(total_time):.1f~P}"
825
+ dt_str = f"{get_time_units(current_dt):.2f~P}"
826
+ print(f"Step {step}, time={t_str} ({pct}%), dt={dt_str}, iters={last_iters}", flush=True)
827
+ if pct >= next_milestone_pct:
828
+ next_milestone_pct += 10
829
+
830
+ # Live plotting / Video frame dispatch
831
+ if plot_queue is not None and video_dt_sec is not None:
832
+ if (total_time - last_video_time) >= video_dt_sec or step == 1 or total_time >= t_end:
833
+ last_video_time = total_time
834
+ title_str = f"Time: {get_time_units(total_time):.2f~P}"
835
+ write_to_queue_async(
836
+ plot_queue,
837
+ mp,
838
+ c,
839
+ getattr(mp, "k", k),
840
+ species_list_full,
841
+ z,
842
+ D_mol,
843
+ diagenetic_reactions,
844
+ equilibrium_reactions,
845
+ current_dt,
846
+ title_str,
847
+ )
848
+
849
+ except KeyboardInterrupt:
850
+ status = "Interrupted by user"
851
+ print(f"\nSimulation interrupted by user at step {step}.")
852
+ except Exception as e:
853
+ status = f"Solver crashed: {e}"
854
+ print(f"\nSimulation failed: {e}")
855
+ traceback.print_exc()
856
+
857
+ wall_total = time.perf_counter() - wall_start_time
858
+ swp_per_sec = (total_newton_iters / wall_total) if wall_total > 0 else 0.0
859
+
860
+ print(
861
+ f"\nFinal Report: {status} in {step} steps ({total_newton_iters} total Newton iterations, "
862
+ f"{swp_per_sec:.2f} it/s). Total Wall Time: {wall_total:.1f}s",
863
+ flush=True,
864
+ )
865
+
866
+ if plot_queue is not None:
867
+ title_str = f"Final Time: {get_time_units(total_time):.2f~P}"
868
+ write_to_queue_async(
869
+ plot_queue,
870
+ mp,
871
+ c,
872
+ getattr(mp, "k", k),
873
+ species_list_full,
874
+ z,
875
+ D_mol,
876
+ diagenetic_reactions,
877
+ equilibrium_reactions,
878
+ current_dt,
879
+ title_str,
880
+ )
881
+
882
+ # Save final results
883
+ experiment = getattr(mp, "experiment", "model_output")
884
+ state_out = f"{experiment}_state.npz"
885
+ try:
886
+ save_state(c, state_out)
887
+ print(f"State saved to {state_out}")
888
+ except Exception as e:
889
+ print(f"Could not save state: {e}")
890
+
891
+ # Save final PDF if plot_queue is None and layout_file is specified
892
+ if hasattr(mp, "layout_file") and mp.layout_file and plot_queue is None:
893
+ save_final_pdf(
894
+ mp,
895
+ c,
896
+ k,
897
+ species_list_full,
898
+ z,
899
+ D_mol,
900
+ diagenetic_reactions,
901
+ equilibrium_reactions,
902
+ )
903
+
904
+ return step, 0.0