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,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
+