mantis-rtm 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mantis/__init__.py +5 -0
- mantis/_version.py +24 -0
- mantis/diff_lib.py +1937 -0
- mantis/generate_equation_system.py +1975 -0
- mantis/live_plot_lib.py +623 -0
- mantis/model.py +942 -0
- mantis/monitor_plots.py +171 -0
- mantis/monolithic_solver.py +904 -0
- mantis/parallel_video.py +523 -0
- mantis/plot_data_new.py +1363 -0
- mantis/solvers_numba.py +219 -0
- mantis_rtm-0.1.1.dev0.dist-info/METADATA +175 -0
- mantis_rtm-0.1.1.dev0.dist-info/RECORD +17 -0
- mantis_rtm-0.1.1.dev0.dist-info/WHEEL +5 -0
- mantis_rtm-0.1.1.dev0.dist-info/entry_points.txt +6 -0
- mantis_rtm-0.1.1.dev0.dist-info/licenses/LICENSE +661 -0
- mantis_rtm-0.1.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -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
|