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,1363 @@
1
+ #!/usr/bin/env python
2
+ """Plot diagenetic modeling data.
3
+
4
+ This module provides flexible plotting functionality for diagenetic modeling data.
5
+ The main `plot()` function can be used with default settings or with a custom
6
+ plot_description dictionary for complete control over plot structure.
7
+
8
+ It also supports overlaying measured data from a CSV file and loading plot
9
+ layouts dynamically from external Python files.
10
+
11
+ Basic Usage:
12
+ -----------
13
+ import plot_data_new
14
+ import pandas as pd
15
+
16
+ df = pd.read_csv("model_output.csv")
17
+ plot_data_new.plot(df, display_length=4, outfile="output.pdf")
18
+
19
+ Overlaying Measured Data:
20
+ ------------------------
21
+ plot_data_new.plot(df, 4, "output.pdf", measured_data_path="measured.csv")
22
+
23
+ Custom Plot Description:
24
+ -----------------------
25
+ plot_description = {
26
+ "first_subplot": {
27
+ "xaxis": [df.z, "Depth [m]"],
28
+ "left": [
29
+ [df.c_SO4, "SO4 [mmol]", {"color": "blue"}],
30
+ [df.c_h2s, "H2S [mmol]", {"color": "red", "linestyle": "--"}],
31
+ ],
32
+ "left_ylabel": "Concentration [mmol/l]",
33
+ "right": [[df.c_o2, "O2 [μmol]", {"color": "green"}]],
34
+ "right_ylabel": "O2 [μmol/l]",
35
+ "options-left": ["set_ylim(-55, 30)", "exclude_y(-4, 20)"], # Broken y-axis & methods
36
+ },
37
+ }
38
+ plot_data_new.plot(df, 10, "output.pdf", plot_description=plot_description)
39
+ """
40
+
41
+ import argparse
42
+ import importlib.util
43
+ import pathlib as pl
44
+ import warnings
45
+
46
+ import matplotlib.patches as mpatches
47
+ import matplotlib.pyplot as plt
48
+ import matplotlib.scale as mscale
49
+ import matplotlib.ticker as mticker
50
+ import matplotlib.transforms as mtransforms
51
+ import numpy as np
52
+ import pandas as pd
53
+
54
+ # import matplotlib
55
+ # matplotlib.use("TkAgg")
56
+
57
+ # Maximum number of right y-axes supported per subplot
58
+ MAX_RIGHT_AXES = 10
59
+
60
+
61
+ def plot(
62
+ df,
63
+ display_length,
64
+ outfile,
65
+ show=True,
66
+ plot_description=None,
67
+ measured_data_path=None,
68
+ fig_handle=None,
69
+ keep_open=False,
70
+ title=None,
71
+ ):
72
+ """Plot data dynamically based on plot_description.
73
+
74
+ Args
75
+ ----
76
+ df: DataFrame with data to plot
77
+ display_length: Length of x-axis to display
78
+ outfile: Output file path
79
+ show: Whether to show the plot
80
+ plot_description: Dictionary describing plot structure. If None, uses default structure.
81
+ measured_data_path: Path to CSV containing measured data to overlay as scatter plots.
82
+ fig_handle: Optional existing figure handle to reuse.
83
+ """
84
+ # Use default plot structure if none provided
85
+ if plot_description is None:
86
+ plot_description = _get_default_plot_description(df)
87
+
88
+ # Filter for valid subplot configurations (must be dictionaries)
89
+ valid_subplots = {k: v for k, v in plot_description.items() if isinstance(v, dict)}
90
+ n_subplots = len(valid_subplots)
91
+
92
+ if n_subplots == 0:
93
+ raise ValueError("No valid subplots to create")
94
+
95
+ # Load measured data if path provided
96
+ df2 = _load_measured_data(measured_data_path)
97
+
98
+ # Create figure and subplots with constrained layout
99
+ if fig_handle is None:
100
+ try:
101
+ fig, axes = plt.subplots(n_subplots, 1, layout="constrained")
102
+ except TypeError:
103
+ fig, axes = plt.subplots(n_subplots, 1, constrained_layout=True)
104
+ if n_subplots == 1:
105
+ axes = [axes]
106
+ else:
107
+ fig = fig_handle
108
+ for ax in fig.axes:
109
+ try:
110
+ ax.set_xscale("linear")
111
+ ax.set_yscale("linear")
112
+ except Exception:
113
+ pass
114
+ fig.clear()
115
+ if hasattr(fig, "set_layout_engine"):
116
+ fig.set_layout_engine("constrained")
117
+ else:
118
+ try:
119
+ fig.set_constrained_layout(True)
120
+ except Exception:
121
+ pass
122
+ axes = fig.subplots(n_subplots, 1)
123
+ if n_subplots == 1:
124
+ axes = [axes]
125
+
126
+ # Get figure width from top-level or first subplot
127
+ # Support both 'fig_width' and 'plot_width'
128
+ fig_width = plot_description.get("fig_width", plot_description.get("plot_width"))
129
+ if fig_width is None and valid_subplots:
130
+ first_config = list(valid_subplots.values())[0]
131
+ fig_width = first_config.get("fig_width", first_config.get("plot_width", 12))
132
+ elif fig_width is None:
133
+ fig_width = 12
134
+
135
+ # Calculate maximum number of right axes across all subplots
136
+ max_right_axes = 0
137
+ # Potential keys: "right", "right1", "right2", ...
138
+ keys_to_check = ["right"] + [f"right{i}" for i in range(1, MAX_RIGHT_AXES + 1)]
139
+ for subplot_config in valid_subplots.values():
140
+ n_right = sum(
141
+ 1 for key in keys_to_check if key in subplot_config and subplot_config[key]
142
+ )
143
+ max_right_axes = max(max_right_axes, n_right)
144
+
145
+ # Grow figure width only for extra right axes beyond the first one
146
+ # Each extra right axis is offset by 60 points (~0.83 inches)
147
+ extra_right_axes = max(0, max_right_axes - 1)
148
+ fig_width += extra_right_axes * 0.83
149
+
150
+ if fig_handle is None:
151
+ fig.set_size_inches(fig_width, 2 + 2 * n_subplots)
152
+
153
+ if title is not None:
154
+ fig.suptitle(f"{title}", fontsize=16)
155
+ # Track all axes for xlim adjustment
156
+ all_axes = []
157
+ ax_objects = []
158
+
159
+ # Process each subplot
160
+ for idx, (subplot_key, subplot_config) in enumerate(valid_subplots.items()):
161
+ # Setup axes for this subplot
162
+ ax_main, right_axes = _setup_subplot_axes(axes[idx], subplot_config)
163
+ all_axes.append(ax_main)
164
+ ax_objects.append(ax_main)
165
+ for rax, _, _, _ in right_axes:
166
+ all_axes.append(rax)
167
+
168
+ # Get x-axis data
169
+ x_data, x_label = _get_xaxis_data(df, subplot_config)
170
+
171
+ # Plot on left axis
172
+ left_config = subplot_config.get("left", [])
173
+ if left_config:
174
+ left_lines, left_labels = _draw_series(ax_main, x_data, left_config, df2)
175
+
176
+ # Set left axis properties
177
+ if left_lines:
178
+ ylabel = subplot_config.get(
179
+ "left_ylabel", left_labels[0] if left_labels else ""
180
+ )
181
+ ax_main.set_ylabel(ylabel)
182
+ if len(left_lines) == 1:
183
+ ax_main.yaxis.label.set_color(left_lines[0].get_color())
184
+ ax_main.tick_params(axis="y", colors=left_lines[0].get_color())
185
+
186
+ # Plot on right axes
187
+ for twin_ax, config_key, series_idx, series in right_axes:
188
+ lines, labels = _draw_series(twin_ax, x_data, [series], df2)
189
+ if not lines:
190
+ continue
191
+
192
+ # Only set axis properties for the first series in a group
193
+ if series_idx == 0:
194
+ line = lines[0]
195
+ label = labels[0]
196
+
197
+ ylabel_key = f"{config_key}_ylabel"
198
+ ylabel = subplot_config.get(ylabel_key, label)
199
+ twin_ax.set_ylabel(ylabel)
200
+ twin_ax.yaxis.label.set_color(line.get_color())
201
+ twin_ax.tick_params(axis="y", colors=line.get_color())
202
+ twin_ax.spines["right"].set_color(line.get_color())
203
+
204
+ # Set x-label (usually only on bottom plot)
205
+ if idx == n_subplots - 1:
206
+ ax_main.set_xlabel(x_label)
207
+
208
+ # Apply scale and any special axis properties
209
+ if "yscale" in subplot_config:
210
+ ax_main.set_yscale(subplot_config["yscale"])
211
+ if "xscale" in subplot_config:
212
+ ax_main.set_xscale(subplot_config["xscale"])
213
+ if "xlim" in subplot_config:
214
+ ax_main.set_xlim(subplot_config["xlim"])
215
+ if "ylim" in subplot_config:
216
+ ax_main.set_ylim(subplot_config["ylim"])
217
+ if "broken_axis" in subplot_config:
218
+ b_val = subplot_config["broken_axis"]
219
+ if isinstance(b_val, (tuple, list)):
220
+ broken_axis(ax_main, *b_val)
221
+ elif isinstance(b_val, str):
222
+ _apply_matplotlib_options(ax_main, f"broken_axis({b_val})" if "(" not in b_val else b_val)
223
+ elif "broken_y" in subplot_config:
224
+ b_val = subplot_config["broken_y"]
225
+ if isinstance(b_val, (tuple, list)):
226
+ broken_axis(ax_main, *b_val)
227
+ elif isinstance(b_val, str):
228
+ _apply_matplotlib_options(ax_main, f"broken_axis({b_val})" if "(" not in b_val else b_val)
229
+ elif "exclude_y" in subplot_config:
230
+ ex = subplot_config["exclude_y"]
231
+ if isinstance(ex, (tuple, list)) and len(ex) >= 2:
232
+ exclude_y(ax_main, ex[0], ex[1])
233
+ elif isinstance(ex, str):
234
+ _apply_matplotlib_options(ax_main, f"exclude_y({ex})" if "(" not in ex else ex)
235
+ if "grid" in subplot_config:
236
+ grid_config = subplot_config["grid"]
237
+ if isinstance(grid_config, dict):
238
+ ax_main.grid(**grid_config)
239
+ else:
240
+ ax_main.grid(grid_config)
241
+
242
+ # Handle show_grid_options
243
+ if "show_grid_options" in subplot_config:
244
+ opts = subplot_config["show_grid_options"]
245
+ if isinstance(opts, dict):
246
+ g_kwargs = opts.copy()
247
+ grid_data = g_kwargs.pop("grid", df.z if "z" in df.columns else None)
248
+ if grid_data is not None:
249
+ show_grid(ax_main, grid_data, **g_kwargs)
250
+
251
+ # Apply properties to right axes if specified (e.g., "right_yscale", "right2_ylim")
252
+ seen_right_axes = set()
253
+ for twin_ax, key, _, _ in right_axes:
254
+ if twin_ax not in seen_right_axes:
255
+ for prop in ["yscale", "xscale", "xlim", "ylim", "grid"]:
256
+ key_prop = f"{key}_{prop}"
257
+ if key_prop in subplot_config:
258
+ val = subplot_config[key_prop]
259
+ if prop == "grid":
260
+ if isinstance(val, dict):
261
+ twin_ax.grid(**val)
262
+ else:
263
+ twin_ax.grid(val)
264
+ else:
265
+ getattr(twin_ax, f"set_{prop}")(val)
266
+ seen_right_axes.add(twin_ax)
267
+
268
+ # Handle legend display
269
+ _add_unified_legend(ax_main, right_axes, subplot_config)
270
+
271
+ # Apply arbitrary matplotlib options
272
+ _apply_all_options(ax_main, right_axes, subplot_config)
273
+
274
+ # Adjust x-axis length for all plots that don't have an explicit xlim
275
+ for idx, (subplot_key, subplot_config) in enumerate(valid_subplots.items()):
276
+ if "xlim" not in subplot_config:
277
+ ax_main = ax_objects[idx]
278
+ if ax_main.get_xscale() == "log":
279
+ ax_main.set_xlim(1e-4, display_length)
280
+ else:
281
+ ax_main.set_xlim(0, display_length)
282
+
283
+ # Determine if constrained layout is active
284
+ is_constrained = False
285
+ if hasattr(fig, "get_layout_engine"):
286
+ is_constrained = fig.get_layout_engine() is not None
287
+ elif hasattr(fig, "get_constrained_layout"):
288
+ is_constrained = fig.get_constrained_layout()
289
+
290
+ if not is_constrained:
291
+ fig.tight_layout()
292
+ _finalize_broken_axes(fig)
293
+ if outfile:
294
+ # Save current size to restore it later (preserves GUI window state)
295
+ original_size = fig.get_size_inches()
296
+
297
+ # Set figure size strictly for PDF output to ensure independence from GUI/handle state
298
+ fig.set_size_inches(fig_width, 2 + 2 * n_subplots)
299
+ if not is_constrained:
300
+ fig.tight_layout()
301
+ _finalize_broken_axes(fig)
302
+ fig.savefig(outfile, bbox_inches="tight")
303
+
304
+ # Restore original size if the figure is meant to stay open or be shown
305
+ if show or keep_open:
306
+ fig.set_size_inches(*original_size)
307
+ if not is_constrained:
308
+ fig.tight_layout()
309
+ _finalize_broken_axes(fig)
310
+
311
+ if show:
312
+ _finalize_broken_axes(fig)
313
+ plt.show()
314
+ elif not keep_open:
315
+ plt.close(fig)
316
+
317
+ return fig, ax_objects
318
+
319
+
320
+ def _load_measured_data(measured_data_path):
321
+ """
322
+ Load measured data from a CSV file.
323
+
324
+ Args
325
+ ----
326
+ measured_data_path: Path to the CSV file.
327
+
328
+ Returns
329
+ -------
330
+ pd.DataFrame or None: Loaded data or None if path not provided/invalid.
331
+ """
332
+ if not measured_data_path:
333
+ return None
334
+
335
+ mpath = pl.Path(measured_data_path)
336
+ if mpath.exists():
337
+ return pd.read_csv(mpath)
338
+
339
+ warnings.warn(f"Measured data file not found: {measured_data_path}")
340
+ return None
341
+
342
+
343
+ class DataFrameWrapper:
344
+ """Wraps a DataFrame to dynamically delegate m_ attributes to a second DataFrame."""
345
+ def __init__(self, df, df2):
346
+ self._df = df
347
+ self._df2 = df2
348
+
349
+ def __getattr__(self, name):
350
+ if name.startswith("m_") and self._df2 is not None:
351
+ real_name = name[2:]
352
+ if hasattr(self._df2, real_name):
353
+ return getattr(self._df2, real_name)
354
+ for col in self._df2.columns:
355
+ if col.lower() == real_name.lower():
356
+ return self._df2[col]
357
+ return getattr(self._df, name)
358
+
359
+ def __getitem__(self, key):
360
+ if isinstance(key, str) and key.startswith("m_") and self._df2 is not None:
361
+ real_name = key[2:]
362
+ if real_name in self._df2.columns:
363
+ return self._df2[real_name]
364
+ for col in self._df2.columns:
365
+ if col.lower() == real_name.lower():
366
+ return self._df2[col]
367
+ return self._df[key]
368
+
369
+ def __len__(self):
370
+ return len(self._df)
371
+
372
+ @property
373
+ def columns(self):
374
+ return self._df.columns
375
+
376
+
377
+ def _setup_subplot_axes(ax_main, subplot_config):
378
+ """
379
+ Configure twin axes for a subplot based on configuration.
380
+ Series under the same key share a twin axis.
381
+
382
+ Args
383
+ ----
384
+ ax_main: The primary matplotlib axis.
385
+ subplot_config: Configuration dictionary for this subplot.
386
+
387
+ Returns
388
+ -------
389
+ tuple: (ax_main, right_axes_list) where right_axes_list is [(ax, config_key, series_idx, series), ...]
390
+ """
391
+ right_axes = []
392
+ current_axis_idx = 0
393
+
394
+ # Potential keys: "right", "right1", "right2", ...
395
+ keys_to_check = ["right"] + [f"right{i}" for i in range(1, MAX_RIGHT_AXES + 1)]
396
+
397
+ for key in keys_to_check:
398
+ if key in subplot_config and subplot_config[key] is not None:
399
+ series_list = subplot_config[key]
400
+ if not series_list:
401
+ continue
402
+
403
+ # Create ONE twin axis for this key
404
+ twin_ax = ax_main.twinx()
405
+ # Position the spine outward based on the axis index (60 points ~ 0.83 inches)
406
+ # The first axis (idx 0) is at the default position ("outward", 0)
407
+ twin_ax.spines.right.set_position(("outward", 60 * current_axis_idx))
408
+
409
+ for series_idx, series in enumerate(series_list):
410
+ right_axes.append((twin_ax, key, series_idx, series))
411
+
412
+ current_axis_idx += 1
413
+
414
+ for twin_ax, _, _, _ in right_axes:
415
+ twin_ax._is_twin_right = True
416
+ ax_main._has_right_axes = len(right_axes) > 0
417
+
418
+ return ax_main, right_axes
419
+
420
+
421
+ def _get_xaxis_data(df, subplot_config):
422
+ """
423
+ Get x-axis data and label from subplot configuration.
424
+
425
+ Args
426
+ ----
427
+ df: Data source.
428
+ subplot_config: Subplot configuration.
429
+
430
+ Returns
431
+ -------
432
+ tuple: (x_data, x_label)
433
+ """
434
+ xaxis_config = subplot_config.get("xaxis")
435
+ if xaxis_config is None:
436
+ if "z" in df.columns:
437
+ return df.z, "Depth [m]"
438
+ return df.index, "Index"
439
+
440
+ x_data = xaxis_config[0]
441
+ x_label = xaxis_config[1] if len(xaxis_config) > 1 else ""
442
+ return x_data, x_label
443
+
444
+
445
+ def _draw_series(ax, x_data, series_list, df2):
446
+ """
447
+ Draw lines and measured scatter points on an axis.
448
+
449
+ Args
450
+ ----
451
+ ax: Matplotlib axis.
452
+ x_data: default x-axis data.
453
+ series_list: List of series configurations.
454
+ df2: Measured data DataFrame.
455
+
456
+ Returns
457
+ -------
458
+ tuple: (lines, labels)
459
+ """
460
+ lines = []
461
+ labels = []
462
+ for series in series_list:
463
+ if len(series) < 2:
464
+ continue
465
+
466
+ # Determine if custom x-data is provided:
467
+ # Standard: [y_data, label, kwargs] where series[1] is a string.
468
+ # Custom: [x_data, y_data, label, kwargs] where series[1] is an array/Series (not a string).
469
+ has_custom_x = False
470
+ if len(series) >= 3 and not isinstance(series[1], (str, bytes)):
471
+ if hasattr(series[1], "__len__") or hasattr(series[1], "__array__"):
472
+ has_custom_x = True
473
+
474
+ if has_custom_x:
475
+ series_x_data = series[0]
476
+ y_data = series[1]
477
+ label = series[2]
478
+ kwargs = dict(series[3]) if len(series) > 3 else {}
479
+ else:
480
+ y_data = series[0]
481
+ label = series[1]
482
+ kwargs = dict(series[2]) if len(series) > 2 else {}
483
+
484
+ # Legacy check: if y_data is actually from df2 (different length than default x_data),
485
+ # use df2's z as the x-axis to prevent dimension mismatches.
486
+ if df2 is not None and "z" in df2.columns and hasattr(y_data, "__len__") and len(y_data) != len(x_data) and len(y_data) == len(df2):
487
+ series_x_data = df2.z
488
+ else:
489
+ series_x_data = x_data
490
+
491
+ fill_only = kwargs.pop("fill_only", False)
492
+ fill = kwargs.pop("fill", False) or fill_only
493
+ fill_alpha = kwargs.pop("alpha", 0.3) if fill else None
494
+ if fill_only:
495
+ kwargs["lw"] = 0
496
+
497
+ # When fill_only: hide line from legend, label the fill patch instead
498
+ plot_label = "_nolegend_" if fill_only else label
499
+ fill_label = label if fill_only else "_nolegend_"
500
+
501
+ (line,) = ax.plot(series_x_data, y_data, label=plot_label, **kwargs)
502
+
503
+ if fill:
504
+ ax.fill_between(
505
+ series_x_data, y_data,
506
+ alpha=fill_alpha, color=line.get_color(), label=fill_label,
507
+ )
508
+ lines.append(line)
509
+ labels.append(label)
510
+
511
+ return lines, labels
512
+
513
+
514
+ def _add_unified_legend(ax_main, right_axes, subplot_config):
515
+ """
516
+ Collect all labels from axes and add a unified legend.
517
+
518
+ Args
519
+ ----
520
+ ax_main: Primary axis.
521
+ right_axes: List of twin axes.
522
+ subplot_config: Subplot configuration.
523
+ """
524
+ if not subplot_config.get("legend", True):
525
+ return
526
+
527
+ raw_lines = []
528
+ raw_labels = []
529
+
530
+ # Main axis
531
+ l_lines, l_labels = ax_main.get_legend_handles_labels()
532
+ raw_lines.extend(l_lines)
533
+ raw_labels.extend(l_labels)
534
+
535
+ # Right axes - only collect from each unique twin axis once
536
+ seen_axes = {ax_main}
537
+ for twin_ax, _, _, _ in right_axes:
538
+ if twin_ax not in seen_axes:
539
+ r_lines, r_labels = twin_ax.get_legend_handles_labels()
540
+ raw_lines.extend(r_lines)
541
+ raw_labels.extend(r_labels)
542
+ seen_axes.add(twin_ax)
543
+
544
+ # Deduplicate by label while preserving order
545
+ final_lines = []
546
+ final_labels = []
547
+ seen_labels = set()
548
+ for line, label in zip(raw_lines, raw_labels):
549
+ if label not in seen_labels:
550
+ final_lines.append(line)
551
+ final_labels.append(label)
552
+ seen_labels.add(label)
553
+
554
+ if final_lines:
555
+ default_fontsize = plt.rcParams.get("legend.fontsize", 10)
556
+ fontsize = (
557
+ "small" if isinstance(default_fontsize, str) else default_fontsize * 0.8
558
+ )
559
+ target_ax = right_axes[-1][0] if right_axes else ax_main
560
+
561
+ leg = target_ax.legend(
562
+ final_lines,
563
+ final_labels,
564
+ loc="upper left",
565
+ frameon=True,
566
+ framealpha=0.7,
567
+ facecolor="white",
568
+ edgecolor="none",
569
+ prop={"size": fontsize},
570
+ )
571
+ leg.set_zorder(100)
572
+
573
+
574
+ def _apply_all_options(ax_main, right_axes, subplot_config):
575
+ """
576
+ Apply matplotlib options to all axes in a subplot.
577
+
578
+ Args
579
+ ----
580
+ ax_main: Primary axis.
581
+ right_axes: List of twin axes.
582
+ subplot_config: Subplot configuration.
583
+ """
584
+ # Left axis
585
+ if "options-left" in subplot_config:
586
+ _apply_matplotlib_options(ax_main, subplot_config["options-left"])
587
+ elif "options" in subplot_config:
588
+ _apply_matplotlib_options(ax_main, subplot_config["options"])
589
+
590
+ # Right axes
591
+ right_axis_map = {}
592
+ current_idx = 0
593
+ keys_to_check = ["right"] + [f"right{i}" for i in range(1, MAX_RIGHT_AXES + 1)]
594
+
595
+ # Map each active key to the twin axis it was assigned (0, 1, 2...)
596
+ for key in keys_to_check:
597
+ if key in subplot_config and subplot_config[key]:
598
+ right_axis_map[key] = current_idx
599
+ current_idx += 1
600
+
601
+ # Get distinct twin axes from the right_axes list (which is [(ax, key, s_idx, s), ...])
602
+ # The axes were created in order of 'keys_to_check' in _setup_subplot_axes.
603
+ unique_twin_axes = []
604
+ seen_axes = set()
605
+ for ax, key, s_idx, s in right_axes:
606
+ if ax not in seen_axes:
607
+ unique_twin_axes.append(ax)
608
+ seen_axes.add(ax)
609
+
610
+ # Apply options to the correct twin axis
611
+ for key, axis_idx in right_axis_map.items():
612
+ opt_key = f"options-{key}"
613
+ if opt_key in subplot_config and axis_idx < len(unique_twin_axes):
614
+ _apply_matplotlib_options(
615
+ unique_twin_axes[axis_idx], subplot_config[opt_key]
616
+ )
617
+
618
+
619
+ def load_layout_from_file(df, layout_path, measured_data_path=None):
620
+ """
621
+ Load a plot layout from a Python file.
622
+
623
+ Args
624
+ ----
625
+ df: DataFrame containing the data to plot.
626
+ layout_path: Path to the Python file containing the layout.
627
+ measured_data_path: Optional path to the measured data CSV.
628
+
629
+ Returns
630
+ -------
631
+ dict: The plot description dictionary.
632
+ """
633
+ df2 = _load_measured_data(measured_data_path)
634
+ df_wrapped = DataFrameWrapper(df, df2)
635
+
636
+ path = pl.Path(layout_path)
637
+ if not path.exists():
638
+ import sys
639
+ argv0_dir = pl.Path(sys.argv[0]).parent
640
+ fallback_path = argv0_dir / path.name
641
+ if fallback_path.exists():
642
+ path = fallback_path
643
+ else:
644
+ fallback_path2 = pl.Path("experiments") / path.name
645
+ if fallback_path2.exists():
646
+ path = fallback_path2
647
+ else:
648
+ raise FileNotFoundError(
649
+ f"Layout file not found: {layout_path} "
650
+ f"(also searched at: {fallback_path} and {fallback_path2})"
651
+ )
652
+
653
+ # Use importlib to load the module from a file path
654
+ spec = importlib.util.spec_from_file_location("dynamic_layout", path)
655
+ module = importlib.util.module_from_spec(spec)
656
+
657
+ # Inject df2 and df_wrapped into module globals to support both direct access to df2
658
+ # and legacy calls that query df.m_c_O2
659
+ module.df = df_wrapped
660
+ module.df2 = df2
661
+
662
+ spec.loader.exec_module(module)
663
+
664
+ if not hasattr(module, "get_layout"):
665
+ raise AttributeError(
666
+ f"Layout file {layout_path} must contain a 'get_layout(df)' function."
667
+ )
668
+
669
+ import inspect
670
+ sig = inspect.signature(module.get_layout)
671
+ has_df2_param = False
672
+ params = list(sig.parameters.values())
673
+ if len(params) >= 2:
674
+ if params[1].kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
675
+ has_df2_param = True
676
+ if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params):
677
+ has_df2_param = True
678
+
679
+ if has_df2_param:
680
+ return module.get_layout(df_wrapped, df2)
681
+ else:
682
+ return module.get_layout(df_wrapped)
683
+
684
+
685
+ class BrokenYTransform(mtransforms.Transform):
686
+ input_dims = 1
687
+ output_dims = 1
688
+ is_separable = True
689
+ has_inverse = True
690
+
691
+ def __init__(self, axis, y1, y2, gap_ratio=0.03, split_ratio=0.5):
692
+ super().__init__()
693
+ self.axis = axis
694
+ self.y1 = float(min(y1, y2))
695
+ self.y2 = float(max(y1, y2))
696
+ self.gap_ratio = float(gap_ratio)
697
+ self.split_ratio = split_ratio
698
+
699
+ def _get_params(self):
700
+ if self.axis is not None:
701
+ ymin, ymax = self.axis.get_view_interval()
702
+ else:
703
+ ymin, ymax = -55.0, 30.0
704
+
705
+ y_low = self.y1
706
+ y_high = self.y2
707
+
708
+ if ymin > ymax:
709
+ ymin, ymax = ymax, ymin
710
+
711
+ if self.split_ratio == "proportional" or self.split_ratio is None:
712
+ range1 = max(y_low - ymin, 1e-9)
713
+ range2 = max(ymax - y_high, 1e-9)
714
+ total = range1 + range2
715
+ f1 = range1 / total
716
+ f1 = min(max(f1, 0.2), 0.8)
717
+ else:
718
+ f1 = float(self.split_ratio)
719
+
720
+ p1 = (1.0 - self.gap_ratio) * f1
721
+ p2 = p1 + self.gap_ratio
722
+ return ymin, ymax, y_low, y_high, p1, p2
723
+
724
+ def transform_non_affine(self, a):
725
+ ymin, ymax, y_low, y_high, p1, p2 = self._get_params()
726
+ vals = np.asarray(a, dtype=float)
727
+ res = np.zeros_like(vals)
728
+ mask1 = vals <= y_low
729
+ mask2 = vals >= y_high
730
+ mask_mid = ~mask1 & ~mask2
731
+
732
+ if y_low > ymin:
733
+ res[mask1] = (vals[mask1] - ymin) / (y_low - ymin) * p1
734
+ else:
735
+ res[mask1] = 0.0
736
+
737
+ if ymax > y_high:
738
+ res[mask2] = p2 + (vals[mask2] - y_high) / (ymax - y_high) * (1.0 - p2)
739
+ else:
740
+ res[mask2] = 1.0
741
+
742
+ if y_high > y_low:
743
+ res[mask_mid] = p1 + (vals[mask_mid] - y_low) / (y_high - y_low) * (p2 - p1)
744
+ else:
745
+ res[mask_mid] = p1
746
+ return res
747
+
748
+ def inverted(self):
749
+ return InvertedBrokenYTransform(self)
750
+
751
+
752
+ class InvertedBrokenYTransform(mtransforms.Transform):
753
+ input_dims = 1
754
+ output_dims = 1
755
+ is_separable = True
756
+ has_inverse = True
757
+
758
+ def __init__(self, fwd):
759
+ super().__init__()
760
+ self.fwd = fwd
761
+
762
+ def transform_non_affine(self, a):
763
+ ymin, ymax, y_low, y_high, p1, p2 = self.fwd._get_params()
764
+ vals = np.asarray(a, dtype=float)
765
+ res = np.zeros_like(vals)
766
+ mask1 = vals <= p1
767
+ mask2 = vals >= p2
768
+ mask_mid = ~mask1 & ~mask2
769
+
770
+ if p1 > 0:
771
+ res[mask1] = ymin + (vals[mask1] / p1) * (y_low - ymin)
772
+ else:
773
+ res[mask1] = ymin
774
+
775
+ if (1.0 - p2) > 0:
776
+ res[mask2] = y_high + ((vals[mask2] - p2) / (1.0 - p2)) * (ymax - y_high)
777
+ else:
778
+ res[mask2] = ymax
779
+
780
+ if (p2 - p1) > 0:
781
+ res[mask_mid] = y_low + ((vals[mask_mid] - p1) / (p2 - p1)) * (y_high - y_low)
782
+ else:
783
+ res[mask_mid] = y_low
784
+ return res
785
+
786
+ def inverted(self):
787
+ return self.fwd
788
+
789
+
790
+ class BrokenLocator(mticker.Locator):
791
+ def __init__(self, y1, y2, axis=None):
792
+ super().__init__()
793
+ self.y1 = float(min(y1, y2))
794
+ self.y2 = float(max(y1, y2))
795
+ self.axis = axis
796
+ self.loc1 = mticker.MaxNLocator(nbins=4, steps=[1, 2, 2.5, 5, 10])
797
+ self.loc2 = mticker.MaxNLocator(nbins=4, steps=[1, 2, 2.5, 5, 10])
798
+
799
+ def tick_values(self, vmin, vmax):
800
+ y1 = self.y1
801
+ y2 = self.y2
802
+ low_min, low_max = min(vmin, y1), max(vmin, y1)
803
+ high_min, high_max = min(vmax, y2), max(vmax, y2)
804
+
805
+ t1 = self.loc1.tick_values(low_min, low_max)
806
+ t1 = t1[(t1 >= low_min) & (t1 <= low_max)]
807
+
808
+ t2 = self.loc2.tick_values(high_min, high_max)
809
+ t2 = t2[(t2 >= high_min) & (t2 <= high_max)]
810
+
811
+ all_ticks = np.unique(np.concatenate([t1, t2]))
812
+ tol1 = (low_max - low_min) * 0.03 if low_max > low_min else 1e-4
813
+ tol2 = (high_max - high_min) * 0.03 if high_max > high_min else 1e-4
814
+ filtered = [
815
+ t for t in all_ticks
816
+ if (t < y1 - tol1) or (t > y2 + tol2)
817
+ ]
818
+ if not filtered:
819
+ filtered = [t for t in all_ticks if t <= y1 or t >= y2]
820
+ return np.array(filtered)
821
+
822
+ def __call__(self):
823
+ if hasattr(self, "axis") and self.axis is not None:
824
+ vmin, vmax = self.axis.get_view_interval()
825
+ else:
826
+ vmin, vmax = -55.0, 30.0
827
+ return self.tick_values(vmin, vmax)
828
+
829
+
830
+ class BrokenYScale(mscale.ScaleBase):
831
+ name = "broken_y"
832
+
833
+ def __init__(self, axis, *, y1=-4, y2=20, gap_ratio=0.03, split_ratio=0.5, **kwargs):
834
+ super().__init__(axis)
835
+ self.axis = axis
836
+ self.y1 = float(min(y1, y2))
837
+ self.y2 = float(max(y1, y2))
838
+ self.gap_ratio = float(gap_ratio)
839
+ self.split_ratio = split_ratio
840
+ self._transform = BrokenYTransform(axis, self.y1, self.y2, self.gap_ratio, self.split_ratio)
841
+
842
+ def get_transform(self):
843
+ return self._transform
844
+
845
+ def set_default_locators_and_formatters(self, axis):
846
+ axis.set_major_locator(BrokenLocator(self.y1, self.y2, axis=axis))
847
+ axis.set_major_formatter(mticker.ScalarFormatter())
848
+
849
+ def limit_range_for_scale(self, vmin, vmax, minpos):
850
+ return vmin, vmax
851
+
852
+
853
+ mscale.register_scale(BrokenYScale)
854
+
855
+
856
+ def _update_broken_y_marks(ax):
857
+ """Draw or update broken-axis visual marks (gap mask and slash lines) on the axis."""
858
+ if not hasattr(ax, "_broken_y_info"):
859
+ return
860
+
861
+ # Clean up previous artists
862
+ for artist in getattr(ax, "_broken_y_artists", []):
863
+ try:
864
+ artist.remove()
865
+ except Exception:
866
+ pass
867
+ ax._broken_y_artists = []
868
+
869
+ transform = ax.yaxis.get_transform()
870
+ if not hasattr(transform, "_get_params"):
871
+ return
872
+
873
+ ymin, ymax, y_low, y_high, p1, p2 = transform._get_params()
874
+
875
+ bg_color = ax.get_facecolor()
876
+ if isinstance(bg_color, tuple) and len(bg_color) == 4 and bg_color[3] == 0:
877
+ bg_color = ax.figure.get_facecolor() if ax.figure else "white"
878
+ if isinstance(bg_color, tuple) and len(bg_color) == 4 and bg_color[3] == 0:
879
+ bg_color = "white"
880
+
881
+ # Mask patch covering the excluded gap across the plot width
882
+ patch = mpatches.Rectangle(
883
+ (0.0, p1),
884
+ 1.0,
885
+ p2 - p1,
886
+ transform=ax.transAxes,
887
+ facecolor=bg_color,
888
+ edgecolor="none",
889
+ zorder=49,
890
+ clip_on=False,
891
+ )
892
+ ax.add_patch(patch)
893
+ ax._broken_y_artists.append(patch)
894
+
895
+ is_twin_right = getattr(ax, "_is_twin_right", False)
896
+ if is_twin_right:
897
+ sides = [1.0]
898
+ else:
899
+ has_right_axes = getattr(ax, "_has_right_axes", False)
900
+ sides = [0.0] if has_right_axes else [0.0, 1.0]
901
+
902
+ fig = ax.figure
903
+ bbox = ax.get_window_extent()
904
+ dpi = fig.dpi if fig else 100
905
+ if bbox.width > 0 and bbox.height > 0:
906
+ w_pts = bbox.width / dpi * 72
907
+ h_pts = bbox.height / dpi * 72
908
+ else:
909
+ w_pts = fig.get_figwidth() * 72 * 0.7 if fig else 400
910
+ h_pts = fig.get_figheight() * 72 * 0.7 if fig else 200
911
+
912
+ dx = 5.0 / max(w_pts, 1.0)
913
+ dy = 3.5 / max(h_pts, 1.0)
914
+
915
+ for x_pos in sides:
916
+ spine_name = "right" if x_pos == 1.0 else "left"
917
+ spine_color = "k"
918
+ spine_lw = 1.0
919
+ if spine_name in ax.spines:
920
+ spine = ax.spines[spine_name]
921
+ spine_color = spine.get_edgecolor()
922
+ spine_lw = spine.get_linewidth() or 1.0
923
+
924
+ # Spine gap line (colored like background)
925
+ (gap_line,) = ax.plot(
926
+ [x_pos, x_pos],
927
+ [p1, p2],
928
+ color=bg_color,
929
+ lw=spine_lw + 2.5,
930
+ transform=ax.transAxes,
931
+ zorder=50,
932
+ clip_on=False,
933
+ )
934
+ ax._broken_y_artists.append(gap_line)
935
+
936
+ # Diagonal slash marks at p1 and p2
937
+ for y_pos in [p1, p2]:
938
+ (slash,) = ax.plot(
939
+ [x_pos - dx, x_pos + dx],
940
+ [y_pos - dy, y_pos + dy],
941
+ color=spine_color,
942
+ lw=spine_lw,
943
+ transform=ax.transAxes,
944
+ zorder=51,
945
+ clip_on=False,
946
+ )
947
+ ax._broken_y_artists.append(slash)
948
+
949
+
950
+ def _finalize_broken_axes(fig):
951
+ """Update all broken axis marks in the figure to reflect final layout and limits."""
952
+ for ax in fig.axes:
953
+ if hasattr(ax, "_broken_y_info"):
954
+ _update_broken_y_marks(ax)
955
+
956
+
957
+ def exclude_y(ax, y1, y2, gap_ratio=0.03, split_ratio=0.5, **kwargs):
958
+ """Set a broken y-axis on an axis, excluding the interval [y1, y2].
959
+
960
+ Args
961
+ ----
962
+ ax: Matplotlib Axes object
963
+ y1, y2: Boundary values of the interval to exclude.
964
+ gap_ratio: Fraction of the axis height allocated to the visual break gap (default: 0.03).
965
+ split_ratio: Height fraction allocated to the lower section (default: 0.5 for equal split,
966
+ or 'proportional' for proportional to data range).
967
+ """
968
+ y_low = min(float(y1), float(y2))
969
+ y_high = max(float(y1), float(y2))
970
+
971
+ cur_ylim = ax.get_ylim()
972
+
973
+ ax.set_yscale(
974
+ "broken_y",
975
+ y1=y_low,
976
+ y2=y_high,
977
+ gap_ratio=gap_ratio,
978
+ split_ratio=split_ratio,
979
+ **kwargs,
980
+ )
981
+
982
+ if cur_ylim != (0.0, 1.0):
983
+ ax.set_ylim(cur_ylim)
984
+
985
+ ax._broken_y_info = {
986
+ "y1": y_low,
987
+ "y2": y_high,
988
+ "gap_ratio": gap_ratio,
989
+ "split_ratio": split_ratio,
990
+ }
991
+
992
+ if not hasattr(ax, "_broken_y_cid"):
993
+ ax._broken_y_cid = ax.callbacks.connect(
994
+ "ylim_changed", lambda a: _update_broken_y_marks(a)
995
+ )
996
+
997
+ _update_broken_y_marks(ax)
998
+
999
+
1000
+ def broken_axis(ax, *args, **kwargs):
1001
+ """Configure a broken axis by specifying the two intervals to display.
1002
+
1003
+ Usage:
1004
+ broken_axis(ax, -45, -35, 20, 30)
1005
+ broken_axis(ax, (-45, -35), (20, 30))
1006
+ broken_axis(ax, -45, -35, 20, 30, split_ratio=0.5, gap_ratio=0.03)
1007
+
1008
+ Args:
1009
+ ax: Matplotlib Axes object.
1010
+ *args: Either 4 numbers (y1_min, y1_max, y2_min, y2_max)
1011
+ or 2 tuples/lists ((y1_min, y1_max), (y2_min, y2_max)).
1012
+ gap_ratio: Fraction of axis height for the break gap (default: 0.03).
1013
+ split_ratio: Height fraction for the lower section (default: 0.5 for equal split,
1014
+ or 'proportional').
1015
+ """
1016
+ gap_ratio = kwargs.pop("gap_ratio", 0.03)
1017
+ split_ratio = kwargs.pop("split_ratio", 0.5)
1018
+
1019
+ if len(args) == 1 and isinstance(args[0], (tuple, list)) and len(args[0]) == 4:
1020
+ y1_min, y1_max, y2_min, y2_max = args[0]
1021
+ elif len(args) == 2 and isinstance(args[0], (tuple, list)) and isinstance(args[1], (tuple, list)):
1022
+ y1_min, y1_max = args[0]
1023
+ y2_min, y2_max = args[1]
1024
+ elif len(args) >= 4:
1025
+ y1_min, y1_max, y2_min, y2_max = args[:4]
1026
+ else:
1027
+ raise ValueError(
1028
+ "broken_axis requires 4 boundary values or 2 pairs defining intervals: "
1029
+ "e.g. broken_axis(-45, -35, 20, 30) or broken_axis((-45, -35), (20, 30))"
1030
+ )
1031
+
1032
+ interval_a = (min(float(y1_min), float(y1_max)), max(float(y1_min), float(y1_max)))
1033
+ interval_b = (min(float(y2_min), float(y2_max)), max(float(y2_min), float(y2_max)))
1034
+ if interval_a[0] > interval_b[0]:
1035
+ interval_a, interval_b = interval_b, interval_a
1036
+
1037
+ ymin, y_low = interval_a
1038
+ y_high, ymax = interval_b
1039
+
1040
+ ax.set_ylim(ymin, ymax)
1041
+ exclude_y(ax, y_low, y_high, gap_ratio=gap_ratio, split_ratio=split_ratio, **kwargs)
1042
+
1043
+
1044
+ broken_y = broken_axis
1045
+
1046
+ # Attach as methods to matplotlib.axes.Axes
1047
+ plt.Axes.exclude_y = exclude_y
1048
+ plt.Axes.broken_axis = broken_axis
1049
+ plt.Axes.broken_y = broken_axis
1050
+
1051
+
1052
+ def _apply_matplotlib_options(ax, options):
1053
+ """Apply arbitrary matplotlib method calls to an axis.
1054
+
1055
+ Args
1056
+ ----
1057
+ ax: Matplotlib axis object
1058
+ options: String or list/tuple containing matplotlib method calls.
1059
+ Separated by commas if multiple calls are within a single string.
1060
+ Examples:
1061
+ "set_ylim(1e-10, 1e-5), set_title('My Title')"
1062
+ ["broken_axis(-45, -35, 20, 30)"]
1063
+ ["set_ylim(-55, 30)", "exclude_y(-4, 20)"]
1064
+
1065
+ The function safely parses and executes each method call on the provided axis.
1066
+ Each method call should be in the format: method_name(arg1, arg2, ...)
1067
+ Multiple calls can be separated by commas.
1068
+ """
1069
+ if not options:
1070
+ return
1071
+
1072
+ if isinstance(options, str):
1073
+ raw_items = [options]
1074
+ elif isinstance(options, (list, tuple)):
1075
+ raw_items = list(options)
1076
+ else:
1077
+ raw_items = [str(options)]
1078
+
1079
+ method_calls = []
1080
+ for item in raw_items:
1081
+ if not isinstance(item, str):
1082
+ item = str(item)
1083
+ item = item.strip()
1084
+ if not item:
1085
+ continue
1086
+
1087
+ # Split by comma to get individual method calls, respecting parentheses
1088
+ current_call = ""
1089
+ paren_depth = 0
1090
+ for char in item:
1091
+ if char == "(":
1092
+ paren_depth += 1
1093
+ current_call += char
1094
+ elif char == ")":
1095
+ paren_depth -= 1
1096
+ current_call += char
1097
+ elif char == "," and paren_depth == 0:
1098
+ if current_call.strip():
1099
+ method_calls.append(current_call.strip())
1100
+ current_call = ""
1101
+ else:
1102
+ current_call += char
1103
+ if current_call.strip():
1104
+ method_calls.append(current_call.strip())
1105
+
1106
+ # Execute each method call
1107
+ for call in method_calls:
1108
+ call = call.strip()
1109
+ if not call:
1110
+ continue
1111
+
1112
+ # Parse method name and arguments
1113
+ if "(" not in call:
1114
+ method_name = call
1115
+ args_str = ""
1116
+ else:
1117
+ method_name = call[: call.index("(")].strip()
1118
+ args_str = call[call.index("(") + 1 : call.rindex(")")].strip()
1119
+
1120
+ # Intercept broken_axis / broken_y
1121
+ if method_name in ("broken_axis", "broken_y"):
1122
+ try:
1123
+ safe_dict = {
1124
+ "ax": ax,
1125
+ "broken_axis": broken_axis,
1126
+ "broken_y": broken_axis,
1127
+ "__builtins__": {},
1128
+ }
1129
+ eval(f"broken_axis(ax, {args_str})", safe_dict)
1130
+ except Exception as e:
1131
+ warnings.warn(f"Failed to execute {method_name}({args_str}): {e}")
1132
+ continue
1133
+
1134
+ # Intercept exclude_y
1135
+ if method_name == "exclude_y":
1136
+ try:
1137
+ safe_dict = {"ax": ax, "exclude_y": exclude_y, "__builtins__": {}}
1138
+ eval(f"exclude_y(ax, {args_str})", safe_dict)
1139
+ except Exception as e:
1140
+ warnings.warn(f"Failed to execute exclude_y({args_str}): {e}")
1141
+ continue
1142
+
1143
+ # Check if method exists on axis
1144
+ if not hasattr(ax, method_name):
1145
+ warnings.warn(f"Axis does not have method '{method_name}', skipping")
1146
+ continue
1147
+
1148
+ # Execute the method call using eval in a context where 'ax' is local
1149
+ try:
1150
+ safe_dict = {"ax": ax, "__builtins__": {}}
1151
+ eval(f"ax.{method_name}({args_str})", safe_dict)
1152
+ except Exception as e:
1153
+ warnings.warn(f"Failed to execute {method_name}({args_str}): {e}")
1154
+
1155
+
1156
+ def _get_default_plot_description(df):
1157
+ """Generate default plot description for backward compatibility.
1158
+
1159
+ This function creates a plot_description dictionary that replicates
1160
+ the original hard-coded plot structure from the legacy implementation.
1161
+
1162
+ Args
1163
+ ----
1164
+ df: DataFrame containing the data to plot
1165
+
1166
+ Returns
1167
+ -------
1168
+ dict: A plot_description dictionary with the default plot structure:
1169
+ - First subplot: Concentrations (SO4, H2S, FeS2, O2, OM, Fe)
1170
+ - Second subplot (if isotopes=True): Isotope deltas (dSO4, dH2S, dFeS2)
1171
+ - Last subplot: Reaction rates (f_o2, f_SO4, f_FeS2, f_h2s, f_poc)
1172
+ """
1173
+ plot_desc = {
1174
+ "first": {
1175
+ "xaxis": [df.z, "Depth [m]"],
1176
+ "left": [
1177
+ [df.c_SO4, r"SO$_{4}$", {"color": "C0"}],
1178
+ [
1179
+ df.c_h2s,
1180
+ r"H$_{2}$S",
1181
+ {
1182
+ "color": "C0",
1183
+ "linestyle": (0, (0.1, 2)),
1184
+ "dash_capstyle": "round",
1185
+ },
1186
+ ],
1187
+ ],
1188
+ "left_ylabel": r"SO$_{4}$ & H$_{2}$S [mmol/l]",
1189
+ "right": [[df.c_FeS2, r"FeS$_{2}$", {"color": "k"}]],
1190
+ "right_ylabel": r"FeS$_{2}$ [mmol/l]",
1191
+ "right2": [[df.c_o2, r"O$_{2}$", {"color": "C2"}]],
1192
+ "right2_ylabel": r"O$_{2}$ [$\mu$mol/l]",
1193
+ "right3": [[df.c_poc, "OM [mol/l]", {"color": "C1"}]],
1194
+ "right3_ylabel": "OM [mol/l]",
1195
+ "right4": [[df.c_Fe3, "Fe [mol/l]", {"color": "C3"}]],
1196
+ "right4_ylabel": "Fe [mol/l]",
1197
+ },
1198
+ }
1199
+
1200
+ # Reaction rates plot
1201
+ subplot_key = "second"
1202
+ plot_desc[subplot_key] = {
1203
+ "xaxis": [df.z, "Depth [m]"],
1204
+ "left": [
1205
+ [df.f_o2, "f_o2", {"color": "C2"}],
1206
+ [df.f_SO4, "f_SO4", {"color": "C0"}],
1207
+ [df.f_FeS2, "f_FeS2", {"color": "k"}],
1208
+ [df.f_h2s, "f_h2s", {"color": "C0", "linestyle": ":"}],
1209
+ [df.f_poc, "f_poc", {"color": "C1"}],
1210
+ ],
1211
+ "left_ylabel": r"f [mol/m$^{3}$ s$^{-1}$]",
1212
+ "yscale": "log",
1213
+ "legend": True,
1214
+ }
1215
+
1216
+ return plot_desc
1217
+
1218
+
1219
+ def main(argv=None):
1220
+ parser = argparse.ArgumentParser(
1221
+ description="Plot diagenetic modeling results with optional measured data overlay.",
1222
+ formatter_class=argparse.RawTextHelpFormatter,
1223
+ )
1224
+
1225
+ parser.add_argument("input_file", help="Path to the model output CSV file.")
1226
+ parser.add_argument(
1227
+ "-d",
1228
+ "--display-length",
1229
+ dest="display_length",
1230
+ default=0,
1231
+ type=float,
1232
+ help="Depth limit for the x-axis (default: full length of data).",
1233
+ )
1234
+ parser.add_argument(
1235
+ "-o",
1236
+ "--output",
1237
+ dest="output_file",
1238
+ default=None,
1239
+ type=str,
1240
+ help="Path for the output PDF (default: <input_file_stem>.pdf).",
1241
+ )
1242
+ parser.add_argument(
1243
+ "-m",
1244
+ "--measured-data",
1245
+ dest="measured_data",
1246
+ default=None,
1247
+ type=str,
1248
+ help="Path to CSV containing measured data to overlay as scatter points.",
1249
+ )
1250
+ parser.add_argument(
1251
+ "-l",
1252
+ "--layout",
1253
+ dest="layout_file",
1254
+ default=None,
1255
+ type=str,
1256
+ help=(
1257
+ "Path to a Python file defining the plot layout.\n"
1258
+ "The file must contain a 'get_layout(df)' function.\n\n"
1259
+ "Example layout file content:\n"
1260
+ "--------------------------------------------------\n"
1261
+ "def get_layout(df):\n"
1262
+ " return {\n"
1263
+ " 'subplot1': {\n"
1264
+ " 'xaxis': [df.z, 'Depth [m]'],\n"
1265
+ " 'left': [[df.c_SO4, 'SO4', {'color': 'C0'}]]\n"
1266
+ " }\n"
1267
+ " }\n"
1268
+ "--------------------------------------------------"
1269
+ ),
1270
+ )
1271
+ parser.add_argument(
1272
+ "--hide",
1273
+ action="store_false",
1274
+ dest="show",
1275
+ help="If set, do not show the plot window, only save the PDF.",
1276
+ )
1277
+ parser.set_defaults(show=True)
1278
+
1279
+ args = parser.parse_args(argv)
1280
+
1281
+ input_path = pl.Path(args.input_file)
1282
+ if not input_path.exists():
1283
+ parser.error(f"Input file not found: {input_path}")
1284
+
1285
+ df = pd.read_csv(input_path)
1286
+
1287
+ # Determine display length
1288
+ if args.display_length > 0:
1289
+ display_length = args.display_length
1290
+ elif "z" in df.columns:
1291
+ display_length = df.z.iat[-1]
1292
+ else:
1293
+ display_length = len(df)
1294
+
1295
+ # Determine output file
1296
+ if args.output_file:
1297
+ outfile = pl.Path(args.output_file)
1298
+ else:
1299
+ outfile = input_path.with_suffix(".pdf")
1300
+
1301
+ # Load custom layout if provided
1302
+ plt_desc = None
1303
+ if args.layout_file:
1304
+ plt_desc = load_layout_from_file(df, args.layout_file, args.measured_data)
1305
+
1306
+ plot(
1307
+ df,
1308
+ display_length,
1309
+ outfile,
1310
+ show=args.show,
1311
+ plot_description=plt_desc,
1312
+ measured_data_path=args.measured_data,
1313
+ )
1314
+ print(f"Plot generated: {outfile}")
1315
+
1316
+
1317
+ if __name__ == "__main__":
1318
+ main()
1319
+
1320
+
1321
+ def show_grid(
1322
+ ax, grid, step=50, thickness="0.1 pt", color="lightgrey", alpha=0.3, **kwargs
1323
+ ):
1324
+ """Plot a vertical line each multiple of a mesh coordinate.
1325
+
1326
+ Add this to the plot referenced by ax
1327
+
1328
+ Line thickness and color, and transparency can be modified by the
1329
+ above parameters.
1330
+ """
1331
+ import numpy as np
1332
+
1333
+ # Handle thickness -> linewidth conversion
1334
+ if "linewidth" in kwargs:
1335
+ lw = kwargs.pop("linewidth")
1336
+ elif "lw" in kwargs:
1337
+ lw = kwargs.pop("lw")
1338
+ else:
1339
+ lw = thickness
1340
+ if isinstance(thickness, str):
1341
+ if "pt" in thickness:
1342
+ lw = float(thickness.replace("pt", "").strip())
1343
+ else:
1344
+ try:
1345
+ lw = float(thickness)
1346
+ except ValueError:
1347
+ lw = 0.1
1348
+
1349
+ # Get defaults for color and alpha if not in kwargs
1350
+ c = kwargs.pop("color", color)
1351
+ a = kwargs.pop("alpha", alpha)
1352
+
1353
+ # Get z values from grid (could be array or fipy mesh)
1354
+ if hasattr(grid, "cellCenters"):
1355
+ z_vals = np.array(grid.cellCenters[0])
1356
+ elif hasattr(grid, "value"):
1357
+ z_vals = np.array(grid.value)
1358
+ else:
1359
+ z_vals = np.array(grid)
1360
+
1361
+ # Plot vertical lines at every 'step' index
1362
+ for i in range(0, len(z_vals), step):
1363
+ ax.axvline(z_vals[i], color=c, linewidth=lw, alpha=a, zorder=-1, **kwargs)