mantis-rtm 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mantis/__init__.py +5 -0
- mantis/_version.py +24 -0
- mantis/diff_lib.py +1937 -0
- mantis/generate_equation_system.py +1975 -0
- mantis/live_plot_lib.py +623 -0
- mantis/model.py +942 -0
- mantis/monitor_plots.py +171 -0
- mantis/monolithic_solver.py +904 -0
- mantis/parallel_video.py +523 -0
- mantis/plot_data_new.py +1363 -0
- mantis/solvers_numba.py +219 -0
- mantis_rtm-0.1.1.dev0.dist-info/METADATA +175 -0
- mantis_rtm-0.1.1.dev0.dist-info/RECORD +17 -0
- mantis_rtm-0.1.1.dev0.dist-info/WHEEL +5 -0
- mantis_rtm-0.1.1.dev0.dist-info/entry_points.txt +6 -0
- mantis_rtm-0.1.1.dev0.dist-info/licenses/LICENSE +661 -0
- mantis_rtm-0.1.1.dev0.dist-info/top_level.txt +1 -0
mantis/live_plot_lib.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import multiprocessing as mp
|
|
4
|
+
import time
|
|
5
|
+
import queue
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import pathlib as pl
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_time_to_seconds(val: Any) -> Optional[float]:
|
|
19
|
+
"""
|
|
20
|
+
Parse a time expression into seconds (float).
|
|
21
|
+
Supports Pint Quantity, string expressions (e.g. '1 year', '10 days'),
|
|
22
|
+
numeric seconds (int, float), or None.
|
|
23
|
+
"""
|
|
24
|
+
if val is None:
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(val, str) and val.strip().lower() in ("none", "false", ""):
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(val, (int, float)):
|
|
29
|
+
return float(val)
|
|
30
|
+
if hasattr(val, "to") and hasattr(val, "magnitude"):
|
|
31
|
+
return float(val.to("seconds").magnitude)
|
|
32
|
+
if isinstance(val, str):
|
|
33
|
+
import pint
|
|
34
|
+
ureg = pint.UnitRegistry()
|
|
35
|
+
return float(ureg.Quantity(val).to("seconds").magnitude)
|
|
36
|
+
return float(val)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BoundedVideoQueue:
|
|
40
|
+
"""
|
|
41
|
+
A process-safe bounded queue with backpressure for video encoding.
|
|
42
|
+
Capacity defaults to maxsize=20.
|
|
43
|
+
When full (count >= maxsize), put() blocks until count <= resume_threshold
|
|
44
|
+
(defaults to 10, i.e. half empty).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
maxsize: int = 20,
|
|
50
|
+
resume_threshold: int = 10,
|
|
51
|
+
ctx: Optional[mp.context.BaseContext] = None,
|
|
52
|
+
):
|
|
53
|
+
if ctx is None:
|
|
54
|
+
ctx = mp.get_context("spawn")
|
|
55
|
+
self.ctx = ctx
|
|
56
|
+
self.maxsize = maxsize
|
|
57
|
+
self.resume_threshold = resume_threshold
|
|
58
|
+
self._queue = ctx.Queue()
|
|
59
|
+
self._lock = ctx.Lock()
|
|
60
|
+
self._cond = ctx.Condition(self._lock)
|
|
61
|
+
self._count = ctx.Value("i", 0)
|
|
62
|
+
self._stop_event = ctx.Event()
|
|
63
|
+
|
|
64
|
+
def put(self, item: Any, block: bool = True, timeout: Optional[float] = None) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Puts an item into the queue. If queue is full (>= maxsize), blocks
|
|
67
|
+
until occupancy drops to <= resume_threshold.
|
|
68
|
+
Sentinel item None bypasses occupancy check so shutdown never deadlocks.
|
|
69
|
+
"""
|
|
70
|
+
if item is None:
|
|
71
|
+
self._queue.put(None)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
start_time = time.time()
|
|
75
|
+
with self._cond:
|
|
76
|
+
while self._count.value >= self.maxsize:
|
|
77
|
+
if self._stop_event.is_set():
|
|
78
|
+
return False
|
|
79
|
+
if not block:
|
|
80
|
+
raise queue.Full
|
|
81
|
+
if timeout is not None:
|
|
82
|
+
remaining = timeout - (time.time() - start_time)
|
|
83
|
+
if remaining <= 0:
|
|
84
|
+
raise queue.Full
|
|
85
|
+
wait_time = min(0.2, remaining)
|
|
86
|
+
else:
|
|
87
|
+
wait_time = 0.2
|
|
88
|
+
self._cond.wait(timeout=wait_time)
|
|
89
|
+
if self._stop_event.is_set():
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
self._queue.put(item)
|
|
93
|
+
self._count.value += 1
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def put_nowait(self, item: Any) -> bool:
|
|
97
|
+
return self.put(item, block=False)
|
|
98
|
+
|
|
99
|
+
def get(self, block: bool = True, timeout: Optional[float] = None) -> Any:
|
|
100
|
+
"""
|
|
101
|
+
Gets an item from the queue and decrements occupancy.
|
|
102
|
+
If occupancy drops <= resume_threshold, wakes up waiting producers.
|
|
103
|
+
"""
|
|
104
|
+
item = self._queue.get(block=block, timeout=timeout)
|
|
105
|
+
if item is not None:
|
|
106
|
+
with self._cond:
|
|
107
|
+
self._count.value = max(0, self._count.value - 1)
|
|
108
|
+
if self._count.value <= self.resume_threshold:
|
|
109
|
+
self._cond.notify_all()
|
|
110
|
+
return item
|
|
111
|
+
|
|
112
|
+
def get_nowait(self) -> Any:
|
|
113
|
+
return self.get(block=False)
|
|
114
|
+
|
|
115
|
+
def qsize(self) -> int:
|
|
116
|
+
with self._lock:
|
|
117
|
+
return self._count.value
|
|
118
|
+
|
|
119
|
+
def empty(self) -> bool:
|
|
120
|
+
with self._lock:
|
|
121
|
+
return self._count.value == 0
|
|
122
|
+
|
|
123
|
+
def full(self) -> bool:
|
|
124
|
+
with self._lock:
|
|
125
|
+
return self._count.value >= self.maxsize
|
|
126
|
+
|
|
127
|
+
def stop(self) -> None:
|
|
128
|
+
"""Signal queue to unblock any waiting producers."""
|
|
129
|
+
self._stop_event.set()
|
|
130
|
+
with self._cond:
|
|
131
|
+
self._cond.notify_all()
|
|
132
|
+
|
|
133
|
+
def cancel_join_thread(self) -> None:
|
|
134
|
+
try:
|
|
135
|
+
self._queue.cancel_join_thread()
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
self.stop()
|
|
141
|
+
try:
|
|
142
|
+
self._queue.cancel_join_thread()
|
|
143
|
+
self._queue.close()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class LivePlotter:
|
|
149
|
+
"""Manages a background process for real-time plotting via a bounded Queue."""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
layout_path: str,
|
|
154
|
+
display_length: float,
|
|
155
|
+
measured_data_path: Optional[str] = None,
|
|
156
|
+
output_path: Optional[str] = None,
|
|
157
|
+
video_path: Optional[str] = None,
|
|
158
|
+
fps: int = 15,
|
|
159
|
+
title: Optional[str] = None,
|
|
160
|
+
gui: bool = False,
|
|
161
|
+
report_step: int = 1,
|
|
162
|
+
max_queue_size: int = 100,
|
|
163
|
+
resume_threshold: int = 50,
|
|
164
|
+
video_dpi: int = 120,
|
|
165
|
+
video_workers: Optional[int] = None,
|
|
166
|
+
):
|
|
167
|
+
self.layout_path = layout_path
|
|
168
|
+
self.display_length = display_length
|
|
169
|
+
self.measured_data_path = measured_data_path
|
|
170
|
+
self.output_path = output_path
|
|
171
|
+
self.video_path = video_path
|
|
172
|
+
self.fps = fps
|
|
173
|
+
self.codec = "libopenh264"
|
|
174
|
+
self.gui = gui
|
|
175
|
+
self.report_step = report_step
|
|
176
|
+
self.max_queue_size = max_queue_size
|
|
177
|
+
self.resume_threshold = resume_threshold
|
|
178
|
+
self.video_dpi = video_dpi
|
|
179
|
+
self.video_workers = video_workers
|
|
180
|
+
self.title = title
|
|
181
|
+
|
|
182
|
+
if self.video_path and not self.gui:
|
|
183
|
+
from mantis.parallel_video import ParallelVideoManager
|
|
184
|
+
|
|
185
|
+
self._backend: Optional[ParallelVideoManager] = ParallelVideoManager(
|
|
186
|
+
layout_path=self.layout_path,
|
|
187
|
+
display_length=self.display_length,
|
|
188
|
+
video_path=self.video_path,
|
|
189
|
+
measured_data_path=self.measured_data_path,
|
|
190
|
+
fps=self.fps,
|
|
191
|
+
video_dpi=self.video_dpi,
|
|
192
|
+
video_workers=self.video_workers,
|
|
193
|
+
max_queue_size=self.max_queue_size,
|
|
194
|
+
resume_threshold=self.resume_threshold,
|
|
195
|
+
)
|
|
196
|
+
self._queue = None
|
|
197
|
+
self._ctx = None
|
|
198
|
+
else:
|
|
199
|
+
self._backend = None
|
|
200
|
+
# Use 'spawn' to avoid inheriting PETSc/MPI signal handlers and state
|
|
201
|
+
self._ctx = mp.get_context("spawn")
|
|
202
|
+
self._queue = BoundedVideoQueue(
|
|
203
|
+
maxsize=max_queue_size,
|
|
204
|
+
resume_threshold=resume_threshold,
|
|
205
|
+
ctx=self._ctx,
|
|
206
|
+
)
|
|
207
|
+
self._process: Optional[mp.Process] = None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def queue(self):
|
|
211
|
+
if self._backend is not None:
|
|
212
|
+
return self._backend.queue
|
|
213
|
+
return self._queue
|
|
214
|
+
|
|
215
|
+
def start(self) -> None:
|
|
216
|
+
"""Launch the background plotting process / parallel video pipeline."""
|
|
217
|
+
if self._backend is not None:
|
|
218
|
+
self._backend.start()
|
|
219
|
+
else:
|
|
220
|
+
self._process = self._ctx.Process(target=self._run_plot_loop, daemon=False)
|
|
221
|
+
self._process.start()
|
|
222
|
+
|
|
223
|
+
def stop(self) -> None:
|
|
224
|
+
"""Stop the background plotting process / parallel video pipeline."""
|
|
225
|
+
if self._backend is not None:
|
|
226
|
+
self._backend.stop()
|
|
227
|
+
if (
|
|
228
|
+
self.output_path
|
|
229
|
+
and hasattr(self._backend.queue, "last_snapshot")
|
|
230
|
+
and self._backend.queue.last_snapshot
|
|
231
|
+
):
|
|
232
|
+
try:
|
|
233
|
+
import mantis.plot_data_new as plot_data_new
|
|
234
|
+
|
|
235
|
+
data, title = self._backend.queue.last_snapshot
|
|
236
|
+
df = pd.DataFrame(data)
|
|
237
|
+
plt_desc = plot_data_new.load_layout_from_file(
|
|
238
|
+
df, self.layout_path, self.measured_data_path
|
|
239
|
+
)
|
|
240
|
+
print(f"[LivePlotter] Saving final plot to {self.output_path}", flush=True)
|
|
241
|
+
plot_data_new.plot(
|
|
242
|
+
df,
|
|
243
|
+
self.display_length,
|
|
244
|
+
outfile=self.output_path,
|
|
245
|
+
show=False,
|
|
246
|
+
plot_description=plt_desc,
|
|
247
|
+
measured_data_path=self.measured_data_path,
|
|
248
|
+
keep_open=False,
|
|
249
|
+
title=title or self.title,
|
|
250
|
+
)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
print(f"[LivePlotter] Failed to save final plot: {e}", flush=True)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
if self._process and self._process.is_alive():
|
|
256
|
+
try:
|
|
257
|
+
self._queue.put(None, timeout=2) # Sentinel for exit
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
self._process.join(timeout=15)
|
|
261
|
+
if self._process.is_alive():
|
|
262
|
+
self._process.terminate()
|
|
263
|
+
self._process.join(timeout=2)
|
|
264
|
+
# Prevent Python atexit from hanging on queue flush thread
|
|
265
|
+
if self._queue is not None:
|
|
266
|
+
try:
|
|
267
|
+
self._queue.cancel_join_thread()
|
|
268
|
+
self._queue.close()
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
def _run_plot_loop(self) -> None:
|
|
273
|
+
"""Internal loop running in the background process."""
|
|
274
|
+
import os
|
|
275
|
+
|
|
276
|
+
# Cache parent PID so we can detect if parent process dies
|
|
277
|
+
parent_pid = os.getppid()
|
|
278
|
+
|
|
279
|
+
# Establish a new process group for the child process.
|
|
280
|
+
# This prevents Ctrl-C (SIGINT) sent to the parent's terminal process group
|
|
281
|
+
# from propagating to the child process and its spawned ffmpeg subprocess.
|
|
282
|
+
try:
|
|
283
|
+
os.setpgrp()
|
|
284
|
+
except OSError as e:
|
|
285
|
+
print(f"[LivePlotter] Failed to set process group: {e}", flush=True)
|
|
286
|
+
|
|
287
|
+
# Ignore SIGINT (Ctrl-C) in child process to allow clean parent-initiated shutdown
|
|
288
|
+
if hasattr(signal, "SIGINT"):
|
|
289
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
290
|
+
|
|
291
|
+
# Handle SIGTERM cleanly to avoid PETSc signal handler dumping MPI_Abort
|
|
292
|
+
if hasattr(signal, "SIGTERM"):
|
|
293
|
+
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0))
|
|
294
|
+
|
|
295
|
+
# Reset signal handlers to default to avoid PETSc's SIGPIPE handling
|
|
296
|
+
if hasattr(signal, "SIGPIPE"):
|
|
297
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
298
|
+
|
|
299
|
+
import matplotlib
|
|
300
|
+
|
|
301
|
+
if self.gui:
|
|
302
|
+
matplotlib.use("TkAgg")
|
|
303
|
+
else:
|
|
304
|
+
matplotlib.use("Agg")
|
|
305
|
+
import mantis.plot_data_new as plot_data_new
|
|
306
|
+
from matplotlib.animation import FFMpegWriter
|
|
307
|
+
|
|
308
|
+
# Re-assert clean SIGTERM handler after imports in case libraries modified it
|
|
309
|
+
if hasattr(signal, "SIGTERM"):
|
|
310
|
+
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0))
|
|
311
|
+
|
|
312
|
+
print(
|
|
313
|
+
f"[LivePlotter] Child process starting. video_path={self.video_path}",
|
|
314
|
+
flush=True,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
fig = None
|
|
318
|
+
ax_objects = None
|
|
319
|
+
last_df = None
|
|
320
|
+
plt_desc = None
|
|
321
|
+
writer = None
|
|
322
|
+
last_title = None
|
|
323
|
+
|
|
324
|
+
print(
|
|
325
|
+
f"[LivePlotter] Background process started (Animation: {self.video_path is not None})."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
writer = None
|
|
329
|
+
|
|
330
|
+
print(
|
|
331
|
+
f"[LivePlotter] Background process started (Animation: {self.video_path is not None})."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
|
|
336
|
+
def process_data_item(data_item) -> bool:
|
|
337
|
+
"""Processes a single data item. Returns True if we should stop."""
|
|
338
|
+
nonlocal fig, ax_objects, last_df, plt_desc, writer, last_title
|
|
339
|
+
if data_item is None:
|
|
340
|
+
print("[LivePlotter] Termination signal received.")
|
|
341
|
+
if fig and last_df is not None and self.output_path:
|
|
342
|
+
print(f"[LivePlotter] Saving final plot to {self.output_path}")
|
|
343
|
+
plot_data_new.plot(
|
|
344
|
+
last_df,
|
|
345
|
+
self.display_length,
|
|
346
|
+
outfile=self.output_path,
|
|
347
|
+
show=False,
|
|
348
|
+
fig_handle=fig,
|
|
349
|
+
plot_description=plt_desc,
|
|
350
|
+
measured_data_path=self.measured_data_path,
|
|
351
|
+
keep_open=True,
|
|
352
|
+
title=last_title or self.title,
|
|
353
|
+
)
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
data, title = data_item
|
|
357
|
+
last_title = title
|
|
358
|
+
df = pd.DataFrame(data)
|
|
359
|
+
last_df = df
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
plt_desc = plot_data_new.load_layout_from_file(df, self.layout_path, self.measured_data_path)
|
|
363
|
+
outfile_path = None
|
|
364
|
+
if fig is None:
|
|
365
|
+
fig, ax_objects = plot_data_new.plot(
|
|
366
|
+
df,
|
|
367
|
+
self.display_length,
|
|
368
|
+
outfile=outfile_path,
|
|
369
|
+
show=False,
|
|
370
|
+
plot_description=plt_desc,
|
|
371
|
+
measured_data_path=self.measured_data_path,
|
|
372
|
+
keep_open=True,
|
|
373
|
+
title=title or self.title,
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
plot_data_new.plot(
|
|
377
|
+
df,
|
|
378
|
+
self.display_length,
|
|
379
|
+
outfile=outfile_path,
|
|
380
|
+
show=False,
|
|
381
|
+
fig_handle=fig,
|
|
382
|
+
plot_description=plt_desc,
|
|
383
|
+
measured_data_path=self.measured_data_path,
|
|
384
|
+
keep_open=True,
|
|
385
|
+
title=title or self.title,
|
|
386
|
+
)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
if "invalid command name" not in str(e):
|
|
389
|
+
print(f"[LivePlotter] Plot update error: {e}")
|
|
390
|
+
fig = None
|
|
391
|
+
|
|
392
|
+
if fig:
|
|
393
|
+
if writer is None and self.video_path:
|
|
394
|
+
print(
|
|
395
|
+
f"[LivePlotter] Initializing FFMpegWriter for {self.video_path}...",
|
|
396
|
+
flush=True,
|
|
397
|
+
)
|
|
398
|
+
try:
|
|
399
|
+
writer = FFMpegWriter(
|
|
400
|
+
fps=self.fps,
|
|
401
|
+
metadata=dict(artist="LivePlotter"),
|
|
402
|
+
)
|
|
403
|
+
except Exception as e:
|
|
404
|
+
print(
|
|
405
|
+
f"[LivePlotter] Failed to initialize FFMpegWriter: {e}. Falling back to GUI.",
|
|
406
|
+
flush=True,
|
|
407
|
+
)
|
|
408
|
+
writer = None
|
|
409
|
+
|
|
410
|
+
if writer:
|
|
411
|
+
if not hasattr(writer, "_saving"):
|
|
412
|
+
print(
|
|
413
|
+
f"[LivePlotter] Setting up writer for {self.video_path}...",
|
|
414
|
+
flush=True,
|
|
415
|
+
)
|
|
416
|
+
writer.setup(fig, self.video_path, dpi=300)
|
|
417
|
+
writer._saving = True
|
|
418
|
+
try:
|
|
419
|
+
writer.grab_frame()
|
|
420
|
+
except Exception as ge:
|
|
421
|
+
print(f"[LivePlotter] Grab frame error: {ge}", flush=True)
|
|
422
|
+
|
|
423
|
+
if self.gui:
|
|
424
|
+
try:
|
|
425
|
+
# Only setup GUI if not in video mode
|
|
426
|
+
plt.ion()
|
|
427
|
+
fig.canvas.draw()
|
|
428
|
+
fig.canvas.flush_events()
|
|
429
|
+
except Exception as e:
|
|
430
|
+
print(f"[LivePlotter] Draw error: {e}")
|
|
431
|
+
fig = None
|
|
432
|
+
plt.pause(0.01)
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
while True:
|
|
436
|
+
try:
|
|
437
|
+
data_item = self._queue.get(timeout=0.1)
|
|
438
|
+
if process_data_item(data_item):
|
|
439
|
+
break
|
|
440
|
+
except queue.Empty:
|
|
441
|
+
if fig and not writer:
|
|
442
|
+
plt.pause(0.01)
|
|
443
|
+
# Check if parent process has died
|
|
444
|
+
if os.getppid() != parent_pid:
|
|
445
|
+
print("[LivePlotter] Parent process died. Exiting.")
|
|
446
|
+
break
|
|
447
|
+
continue
|
|
448
|
+
except Exception as e:
|
|
449
|
+
print(f"[LivePlotter] Loop error: {e}")
|
|
450
|
+
break
|
|
451
|
+
|
|
452
|
+
except KeyboardInterrupt:
|
|
453
|
+
pass
|
|
454
|
+
finally:
|
|
455
|
+
if writer and hasattr(writer, "_saving"):
|
|
456
|
+
print("[LivePlotter] Finishing writer...", flush=True)
|
|
457
|
+
writer.finish()
|
|
458
|
+
print("[LivePlotter] Writer finished.", flush=True)
|
|
459
|
+
if not self.video_path:
|
|
460
|
+
plt.ioff()
|
|
461
|
+
plt.close("all")
|
|
462
|
+
print("[LivePlotter] Background process exiting.")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def capture_state(
|
|
466
|
+
mp_params: Any,
|
|
467
|
+
c: Any,
|
|
468
|
+
k: Any,
|
|
469
|
+
species_list: list[str],
|
|
470
|
+
z: Any,
|
|
471
|
+
D_mol: Any,
|
|
472
|
+
diagenetic_reactions: Any,
|
|
473
|
+
equilibrium_reactions: Any,
|
|
474
|
+
current_dt: float,
|
|
475
|
+
) -> dict[str, np.ndarray]:
|
|
476
|
+
"""
|
|
477
|
+
Capture the current state of the model as a dictionary of numpy arrays.
|
|
478
|
+
"""
|
|
479
|
+
from . import diff_lib
|
|
480
|
+
|
|
481
|
+
# 1. Capture current rates in the main thread (thread-safe snap)
|
|
482
|
+
c_numpy = diff_lib.data_container({s: diff_lib.ArrayProxy(val.value) for s, val in c.items()})
|
|
483
|
+
mp_numpy = diff_lib.data_container(mp_params)
|
|
484
|
+
mp_numpy.phi = diff_lib.ArrayProxy(mp_params.phi.value)
|
|
485
|
+
f_final, RATES = diagenetic_reactions(mp_numpy, c_numpy, k, diff_lib.data_container())
|
|
486
|
+
f_final, RATES = equilibrium_reactions(mp_params, c, k, f_final, RATES, current_dt)
|
|
487
|
+
|
|
488
|
+
# 2. Snapshot values (numpy arrays)
|
|
489
|
+
def snap(obj):
|
|
490
|
+
if hasattr(obj, "value"):
|
|
491
|
+
return obj.value.copy()
|
|
492
|
+
if hasattr(obj, "copy"):
|
|
493
|
+
return obj.copy()
|
|
494
|
+
return obj
|
|
495
|
+
|
|
496
|
+
data = {"z": z.copy()}
|
|
497
|
+
|
|
498
|
+
# Collect concentrations and rates
|
|
499
|
+
for species_name in species_list:
|
|
500
|
+
data[f"c_{species_name}"] = snap(getattr(c, species_name))
|
|
501
|
+
res_tuple = getattr(f_final, species_name)
|
|
502
|
+
data[f"f_{species_name}"] = snap(res_tuple[2])
|
|
503
|
+
|
|
504
|
+
# Capture process-specific rates dynamically
|
|
505
|
+
for key in f_final.keys():
|
|
506
|
+
if key not in species_list:
|
|
507
|
+
res_tuple = getattr(f_final, key)
|
|
508
|
+
data[f"f_{key}"] = snap(res_tuple[2])
|
|
509
|
+
|
|
510
|
+
# Diffusion coefficients
|
|
511
|
+
for d_name, d_val in D_mol.items():
|
|
512
|
+
key = f"D_{d_name}" if d_name in species_list else d_name
|
|
513
|
+
data[key] = snap(d_val)
|
|
514
|
+
|
|
515
|
+
# Isotopes
|
|
516
|
+
isotope_map = {
|
|
517
|
+
"SO4": "SO4_32",
|
|
518
|
+
"H2S": "H2S_32",
|
|
519
|
+
"HS": "HS_32",
|
|
520
|
+
"TS2": "TS2_32",
|
|
521
|
+
"FeS": "FeS_32",
|
|
522
|
+
"S0": "S0_32",
|
|
523
|
+
"FeS2": "FeS2_32",
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
phi_val = data.get("phi", getattr(mp_params, "phi", None))
|
|
527
|
+
|
|
528
|
+
for base, iso in isotope_map.items():
|
|
529
|
+
if f"c_{base}" in data and f"c_{iso}" in data:
|
|
530
|
+
s_total = data[f"c_{base}"]
|
|
531
|
+
if base == "FeS2":
|
|
532
|
+
s_total = 2.0 * s_total
|
|
533
|
+
s32 = data[f"c_{iso}"]
|
|
534
|
+
thresh = diff_lib.get_species_delta_threshold(base, mp=mp_params, phi=phi_val)
|
|
535
|
+
data[f"d_{base}"] = diff_lib.get_delta(s_total, s32, mp_params.VCDT, threshold=thresh)
|
|
536
|
+
|
|
537
|
+
data["w"] = np.ones(len(z)) * mp_params.w
|
|
538
|
+
data["phi"] = np.ones(len(z)) * (
|
|
539
|
+
mp_params.phi.value if hasattr(mp_params.phi, "value") else mp_params.phi
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
return data
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def write_to_queue_async(
|
|
546
|
+
plot_queue: Any,
|
|
547
|
+
mp_params: Any,
|
|
548
|
+
c: Any,
|
|
549
|
+
k: Any,
|
|
550
|
+
species_list: list[str],
|
|
551
|
+
z: Any,
|
|
552
|
+
D_mol: Any,
|
|
553
|
+
diagenetic_reactions: Any,
|
|
554
|
+
equilibrium_reactions: Any,
|
|
555
|
+
current_dt: float,
|
|
556
|
+
title: str,
|
|
557
|
+
) -> None:
|
|
558
|
+
"""
|
|
559
|
+
Simultaneously snaps model state and sends it to the plot_queue.
|
|
560
|
+
If plot_queue is a BoundedVideoQueue and full, this blocks until the queue
|
|
561
|
+
drains to <= resume_threshold.
|
|
562
|
+
"""
|
|
563
|
+
data = capture_state(
|
|
564
|
+
mp_params, c, k, species_list, z, D_mol, diagenetic_reactions, equilibrium_reactions, current_dt
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
plot_queue.put((data, title))
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def save_final_pdf(
|
|
574
|
+
mp: Any,
|
|
575
|
+
c: Any,
|
|
576
|
+
k: Any,
|
|
577
|
+
species_list_full: list[str],
|
|
578
|
+
z: np.ndarray,
|
|
579
|
+
D_mol: Any,
|
|
580
|
+
diagenetic_reactions: Any,
|
|
581
|
+
equilibrium_reactions: Any,
|
|
582
|
+
outfile: Optional[str] = None,
|
|
583
|
+
) -> Optional[str]:
|
|
584
|
+
"""
|
|
585
|
+
Produce a static final PDF plot of the model state.
|
|
586
|
+
"""
|
|
587
|
+
import mantis.plot_data_new as plot_data_new
|
|
588
|
+
|
|
589
|
+
if outfile is None:
|
|
590
|
+
outfile = f"{mp.plot_name}.pdf"
|
|
591
|
+
|
|
592
|
+
layout_file = getattr(mp, "layout_file", "plot_layout.py")
|
|
593
|
+
display_length = getattr(mp, "display_length", 2)
|
|
594
|
+
measured_data_path = getattr(mp, "measured_data_path", None)
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
final_data = capture_state(
|
|
598
|
+
mp,
|
|
599
|
+
c,
|
|
600
|
+
k,
|
|
601
|
+
species_list_full,
|
|
602
|
+
z,
|
|
603
|
+
D_mol,
|
|
604
|
+
diagenetic_reactions,
|
|
605
|
+
equilibrium_reactions,
|
|
606
|
+
current_dt=0.0,
|
|
607
|
+
)
|
|
608
|
+
final_df = pd.DataFrame(final_data)
|
|
609
|
+
plt_desc = plot_data_new.load_layout_from_file(final_df, layout_file, measured_data_path)
|
|
610
|
+
plot_data_new.plot(
|
|
611
|
+
final_df,
|
|
612
|
+
display_length,
|
|
613
|
+
outfile=outfile,
|
|
614
|
+
show=False,
|
|
615
|
+
plot_description=plt_desc,
|
|
616
|
+
measured_data_path=measured_data_path,
|
|
617
|
+
)
|
|
618
|
+
print(f"[Parent] Saved final PDF plot: {outfile}", flush=True)
|
|
619
|
+
return outfile
|
|
620
|
+
except Exception as e:
|
|
621
|
+
print(f"[Parent] Warning: Could not save final PDF plot ({outfile}): {e}", flush=True)
|
|
622
|
+
return None
|
|
623
|
+
|