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/plot_data_new.py
ADDED
|
@@ -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)
|