pyplotutil 2.0.0__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.
- pyplotutil/__init__.py +70 -0
- pyplotutil/_typing.py +38 -0
- pyplotutil/datautil.py +1295 -0
- pyplotutil/loggingutil.py +743 -0
- pyplotutil/plotutil.py +846 -0
- pyplotutil/py.typed +0 -0
- pyplotutil-2.0.0.dist-info/METADATA +123 -0
- pyplotutil-2.0.0.dist-info/RECORD +10 -0
- pyplotutil-2.0.0.dist-info/WHEEL +4 -0
- pyplotutil-2.0.0.dist-info/licenses/LICENSE +21 -0
pyplotutil/plotutil.py
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""Plotting Utilities for Scientific Data Visualization.
|
|
2
|
+
|
|
3
|
+
This module provides a collection of utilities for creating and saving scientific plots,
|
|
4
|
+
particularly focused on time series data visualization with error representations.
|
|
5
|
+
|
|
6
|
+
Key Features
|
|
7
|
+
-----------
|
|
8
|
+
- Save figures with multiple file formats
|
|
9
|
+
- Plot multiple time series with customizable styles
|
|
10
|
+
- Error visualization (standard deviation, variance, range, standard error)
|
|
11
|
+
- Path handling utilities for figure organization
|
|
12
|
+
|
|
13
|
+
Examples
|
|
14
|
+
--------
|
|
15
|
+
Basic figure saving:
|
|
16
|
+
>>> fig, ax = plt.subplots()
|
|
17
|
+
>>> ax.plot([1, 2, 3], [1, 2, 3])
|
|
18
|
+
>>> save_figure(fig, "output_directory", "my_plot", ".png")
|
|
19
|
+
|
|
20
|
+
Time series with error bars:
|
|
21
|
+
>>> t = np.linspace(0, 10, 100)
|
|
22
|
+
>>> data = np.random.randn(5, 100) # 5 trials, 100 timepoints
|
|
23
|
+
>>> plot_mean_err(ax, t, data, err_type="std", tlim=(0, 5))
|
|
24
|
+
|
|
25
|
+
Multiple time series:
|
|
26
|
+
>>> y_arr = np.array([np.sin(t), np.cos(t)])
|
|
27
|
+
>>> plot_multi_timeseries(ax, t, y_arr, labels=["sin", "cos"])
|
|
28
|
+
|
|
29
|
+
Notes
|
|
30
|
+
-----
|
|
31
|
+
- All plotting functions return matplotlib objects for further customization
|
|
32
|
+
- Error calculations support various statistical measures
|
|
33
|
+
- File paths are sanitized for cross-platform compatibility
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import TYPE_CHECKING, Literal, TypeVar, overload
|
|
41
|
+
|
|
42
|
+
import matplotlib.pyplot as plt
|
|
43
|
+
import numpy as np
|
|
44
|
+
import scienceplots # noqa: F401
|
|
45
|
+
|
|
46
|
+
from pyplotutil._typing import NoDefault, no_default
|
|
47
|
+
from pyplotutil.loggingutil import evlog
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Iterable, Sequence
|
|
51
|
+
|
|
52
|
+
from matplotlib.axes import Axes
|
|
53
|
+
from matplotlib.figure import Figure
|
|
54
|
+
from matplotlib.lines import Line2D
|
|
55
|
+
from matplotlib.typing import ColorType
|
|
56
|
+
|
|
57
|
+
from pyplotutil._typing import FilePath, Unknown
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
FilePathT = TypeVar("FilePathT", str, Path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _style_options(
|
|
64
|
+
*,
|
|
65
|
+
grid: bool = False,
|
|
66
|
+
scatter: bool = False,
|
|
67
|
+
no_latex: bool = False,
|
|
68
|
+
cjk_jp_font: bool = False,
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Generate a list of style options based on input parameters.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
grid : bool, optional
|
|
75
|
+
Enable grid style, by default False
|
|
76
|
+
scatter : bool, optional
|
|
77
|
+
Enable scatter style, by default False
|
|
78
|
+
no_latex : bool, optional
|
|
79
|
+
Disable LaTeX rendering, by default False
|
|
80
|
+
cjk_jp_font : bool, optional
|
|
81
|
+
Enable CJK Japanese font support, by default False
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
list[str]
|
|
86
|
+
List of style options based on enabled parameters
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
styles: list[str] = []
|
|
90
|
+
if grid:
|
|
91
|
+
styles.append("grid")
|
|
92
|
+
if scatter:
|
|
93
|
+
styles.append("scatter")
|
|
94
|
+
if no_latex:
|
|
95
|
+
styles.append("no-latex")
|
|
96
|
+
if cjk_jp_font:
|
|
97
|
+
styles.append("cjk-jp-font")
|
|
98
|
+
return styles
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def apply_science_style(
|
|
102
|
+
*,
|
|
103
|
+
grid: bool = False,
|
|
104
|
+
scatter: bool = False,
|
|
105
|
+
no_latex: bool = False,
|
|
106
|
+
cjk_jp_font: bool = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Apply science style to matplotlib plots.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
grid : bool, optional
|
|
113
|
+
Enable grid style, by default False
|
|
114
|
+
scatter : bool, optional
|
|
115
|
+
Enable scatter style, by default False
|
|
116
|
+
no_latex : bool, optional
|
|
117
|
+
Disable LaTeX rendering, by default False
|
|
118
|
+
cjk_jp_font : bool, optional
|
|
119
|
+
Enable CJK Japanese font support, by default False
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
styles = ["science"]
|
|
123
|
+
styles.extend(_style_options(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font))
|
|
124
|
+
plt.style.use(styles)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def apply_ieee_style(
|
|
128
|
+
*,
|
|
129
|
+
grid: bool = False,
|
|
130
|
+
scatter: bool = False,
|
|
131
|
+
no_latex: bool = False,
|
|
132
|
+
cjk_jp_font: bool = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Apply IEEE style to matplotlib plots.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
grid : bool, optional
|
|
139
|
+
Enable grid style, by default False
|
|
140
|
+
scatter : bool, optional
|
|
141
|
+
Enable scatter style, by default False
|
|
142
|
+
no_latex : bool, optional
|
|
143
|
+
Disable LaTeX rendering, by default False
|
|
144
|
+
cjk_jp_font : bool, optional
|
|
145
|
+
Enable CJK Japanese font support, by default False
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
styles = ["science", "ieee"]
|
|
149
|
+
styles.extend(_style_options(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font))
|
|
150
|
+
plt.style.use(styles)
|
|
151
|
+
plt.rcParams.update({"figure.dpi": "100"})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def apply_nature_style(
|
|
155
|
+
*,
|
|
156
|
+
grid: bool = False,
|
|
157
|
+
scatter: bool = False,
|
|
158
|
+
no_latex: bool = False,
|
|
159
|
+
cjk_jp_font: bool = False,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Apply Nature journal style to matplotlib plots.
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
grid : bool, optional
|
|
166
|
+
Enable grid style, by default False
|
|
167
|
+
scatter : bool, optional
|
|
168
|
+
Enable scatter style, by default False
|
|
169
|
+
no_latex : bool, optional
|
|
170
|
+
Disable LaTeX rendering, by default False
|
|
171
|
+
cjk_jp_font : bool, optional
|
|
172
|
+
Enable CJK Japanese font support, by default False
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
styles = ["science", "nature"]
|
|
176
|
+
styles.extend(_style_options(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font))
|
|
177
|
+
plt.style.use(styles)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def apply_notebook_style(
|
|
181
|
+
*,
|
|
182
|
+
grid: bool = False,
|
|
183
|
+
scatter: bool = False,
|
|
184
|
+
no_latex: bool = False,
|
|
185
|
+
cjk_jp_font: bool = False,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Apply Jupyter notebook style to matplotlib plots.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
grid : bool, optional
|
|
192
|
+
Enable grid style, by default False
|
|
193
|
+
scatter : bool, optional
|
|
194
|
+
Enable scatter style, by default False
|
|
195
|
+
no_latex : bool, optional
|
|
196
|
+
Disable LaTeX rendering, by default False
|
|
197
|
+
cjk_jp_font : bool, optional
|
|
198
|
+
Enable CJK Japanese font support, by default False
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
styles = ["science", "notebook"]
|
|
202
|
+
styles.extend(_style_options(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font))
|
|
203
|
+
plt.style.use(styles)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def apply_style(
|
|
207
|
+
style: Literal["science", "ieee", "nature", "notebook"],
|
|
208
|
+
*,
|
|
209
|
+
grid: bool = False,
|
|
210
|
+
scatter: bool = False,
|
|
211
|
+
no_latex: bool = False,
|
|
212
|
+
cjk_jp_font: bool = False,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Apply specific style to matplotlib plots.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
style: Literal["science", "ieee", "nature", "notebook"]
|
|
219
|
+
Style name to apply. Choose from "science", "ieee", "nature", "notebook".
|
|
220
|
+
grid : bool, optional
|
|
221
|
+
Enable grid style, by default False
|
|
222
|
+
scatter : bool, optional
|
|
223
|
+
Enable scatter style, by default False
|
|
224
|
+
no_latex : bool, optional
|
|
225
|
+
Disable LaTeX rendering, by default False
|
|
226
|
+
cjk_jp_font : bool, optional
|
|
227
|
+
Enable CJK Japanese font support, by default False
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
if style == "science":
|
|
231
|
+
apply_science_style(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font)
|
|
232
|
+
elif style == "ieee":
|
|
233
|
+
apply_ieee_style(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font)
|
|
234
|
+
elif style == "nature":
|
|
235
|
+
apply_nature_style(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font)
|
|
236
|
+
elif style == "notebook":
|
|
237
|
+
apply_notebook_style(grid=grid, scatter=scatter, no_latex=no_latex, cjk_jp_font=cjk_jp_font)
|
|
238
|
+
else:
|
|
239
|
+
msg = f"Unsupported style: {style}"
|
|
240
|
+
evlog().error(msg)
|
|
241
|
+
raise ValueError(msg)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def compatible_filename(filename: FilePathT) -> FilePathT:
|
|
245
|
+
"""Convert filename to a compatible format by replacing special characters.
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
filename : str or Path
|
|
250
|
+
The input filename to be converted.
|
|
251
|
+
|
|
252
|
+
Returns
|
|
253
|
+
-------
|
|
254
|
+
str or Path
|
|
255
|
+
The converted filename with special characters replaced.
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
table = {":": "", " ": "_", "(": "", ")": "", "+": "x", "=": "-"}
|
|
259
|
+
compat_filename = str(filename).translate(str.maketrans(table)) # type: ignore[arg-type]
|
|
260
|
+
if compat_filename != str(filename):
|
|
261
|
+
evlog().debug("Filename has been converted to compatible one.")
|
|
262
|
+
evlog().debug(" given filename: %s", filename)
|
|
263
|
+
evlog().debug(" converted filename: %s", compat_filename)
|
|
264
|
+
return type(filename)(compat_filename)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def make_figure_paths(
|
|
268
|
+
output_directory: FilePath,
|
|
269
|
+
basename: str,
|
|
270
|
+
extensions: str | Iterable[str],
|
|
271
|
+
*,
|
|
272
|
+
separate_dir_by_main_module: bool | str,
|
|
273
|
+
separate_dir_by_ext: bool,
|
|
274
|
+
) -> list[Path]:
|
|
275
|
+
"""Generate figure file paths based on given parameters.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
output_directory : str or Path
|
|
280
|
+
Directory where figures will be saved.
|
|
281
|
+
basename : str
|
|
282
|
+
Base name for the figure files.
|
|
283
|
+
extensions : str or Iterable[str]
|
|
284
|
+
File extensions to use.
|
|
285
|
+
separate_dir_by_main_module : bool or str
|
|
286
|
+
Whether to create separate directory by main module name.
|
|
287
|
+
separate_dir_by_ext : bool
|
|
288
|
+
Whether to separate files by extension in different directories.
|
|
289
|
+
|
|
290
|
+
Returns
|
|
291
|
+
-------
|
|
292
|
+
list[Path]
|
|
293
|
+
List of generated figure paths.
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
if isinstance(extensions, str):
|
|
297
|
+
extensions = [extensions]
|
|
298
|
+
# Make a generator that ensures each extension starts with '.', and remove duplicates.
|
|
299
|
+
extensions = {x if x.startswith(".") else f".{x}" for x in extensions}
|
|
300
|
+
|
|
301
|
+
main_module_name = None
|
|
302
|
+
if isinstance(separate_dir_by_main_module, str):
|
|
303
|
+
main_module_name = separate_dir_by_main_module
|
|
304
|
+
elif separate_dir_by_main_module:
|
|
305
|
+
try:
|
|
306
|
+
import __main__ # noqa: PLC0415 # must resolve the running script at call time
|
|
307
|
+
|
|
308
|
+
main_module_name = Path(__main__.__file__).stem
|
|
309
|
+
except ImportError:
|
|
310
|
+
main_module_name = None
|
|
311
|
+
|
|
312
|
+
built_path = Path(output_directory)
|
|
313
|
+
if main_module_name:
|
|
314
|
+
built_path /= main_module_name
|
|
315
|
+
built_path /= basename
|
|
316
|
+
if built_path.suffix.startswith(".") and built_path.suffix[1].isdigit():
|
|
317
|
+
# When data_file_path.suffix starts with a digit it's not a suffix.
|
|
318
|
+
# >>> Path('awesome_ratio-2.5').with_suffix('.svg')
|
|
319
|
+
# 'awesome_ratio-2.svg' # this is wrong filename
|
|
320
|
+
built_path = built_path.with_name(built_path.name + ".x")
|
|
321
|
+
|
|
322
|
+
figure_paths: list[Path] = []
|
|
323
|
+
for ext in extensions:
|
|
324
|
+
figure_path = built_path
|
|
325
|
+
if separate_dir_by_ext:
|
|
326
|
+
figure_path = built_path.parent / ext[1:] / built_path.name
|
|
327
|
+
figure_paths.append(compatible_filename(figure_path.with_suffix(ext)))
|
|
328
|
+
return figure_paths
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def save_figure(
|
|
332
|
+
fig: Figure,
|
|
333
|
+
output_directory: FilePath,
|
|
334
|
+
basename: str,
|
|
335
|
+
extensions: str | Iterable[str] | None,
|
|
336
|
+
*,
|
|
337
|
+
separate_dir_by_main_module: bool | str = False,
|
|
338
|
+
separate_dir_by_ext: bool = False,
|
|
339
|
+
make_directories: bool = True,
|
|
340
|
+
dpi: float | Literal["figure"] = "figure",
|
|
341
|
+
bbox_inches: Literal["tight"] | None = "tight",
|
|
342
|
+
pad_inches: float | Literal["layout"] = 0.1,
|
|
343
|
+
) -> list[Path]:
|
|
344
|
+
"""Save figures to specified paths with given parameters.
|
|
345
|
+
|
|
346
|
+
Parameters
|
|
347
|
+
----------
|
|
348
|
+
fig : Figure
|
|
349
|
+
Matplotlib figure object to save.
|
|
350
|
+
output_directory : str or Path
|
|
351
|
+
Directory where figures will be saved.
|
|
352
|
+
basename : str
|
|
353
|
+
Base name for the figure files.
|
|
354
|
+
extensions : str or Iterable[str] or None
|
|
355
|
+
File extensions to use.
|
|
356
|
+
separate_dir_by_main_module : bool or str, optional
|
|
357
|
+
Whether to create separate directory by main module name, by default False.
|
|
358
|
+
separate_dir_by_ext : bool, optional
|
|
359
|
+
Whether to separate files by extension, by default False.
|
|
360
|
+
make_directories : bool, optional
|
|
361
|
+
Whether to create directories if they don't exist, by default True.
|
|
362
|
+
dpi : float or "figure", optional
|
|
363
|
+
Resolution of the output figure, by default "figure".
|
|
364
|
+
bbox_inches : "tight" or None, optional
|
|
365
|
+
Bounding box in inches, by default "tight".
|
|
366
|
+
pad_inches : float or "layout", optional
|
|
367
|
+
Padding in inches, by default 0.1.
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
list[Path]
|
|
372
|
+
List of paths where figures were saved.
|
|
373
|
+
|
|
374
|
+
Raises
|
|
375
|
+
------
|
|
376
|
+
ValueError
|
|
377
|
+
If output_directory is None.
|
|
378
|
+
|
|
379
|
+
"""
|
|
380
|
+
if output_directory is None:
|
|
381
|
+
msg = f"'None' is not allowed for directory path: {output_directory}, {type(output_directory)}"
|
|
382
|
+
evlog().critical(msg)
|
|
383
|
+
raise ValueError(msg)
|
|
384
|
+
|
|
385
|
+
if extensions is None:
|
|
386
|
+
evlog().warning("Nothing saved.")
|
|
387
|
+
evlog().debug("Figures have not been saved since no extension is provided.")
|
|
388
|
+
return []
|
|
389
|
+
|
|
390
|
+
figure_paths = make_figure_paths(
|
|
391
|
+
output_directory,
|
|
392
|
+
basename,
|
|
393
|
+
extensions,
|
|
394
|
+
separate_dir_by_main_module=separate_dir_by_main_module,
|
|
395
|
+
separate_dir_by_ext=separate_dir_by_ext,
|
|
396
|
+
)
|
|
397
|
+
if make_directories:
|
|
398
|
+
for directory_path in {p.parent for p in figure_paths}:
|
|
399
|
+
directory_path.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
evlog().debug("Directory created: %s", str(directory_path))
|
|
401
|
+
|
|
402
|
+
for figure_path in figure_paths:
|
|
403
|
+
fig.savefig(figure_path, dpi=dpi, bbox_inches=bbox_inches, pad_inches=pad_inches)
|
|
404
|
+
evlog().info("Figure saved: %s", str(figure_path))
|
|
405
|
+
|
|
406
|
+
return figure_paths
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def extract_common_path(*paths: str | Path) -> Path:
|
|
410
|
+
"""Extract the common path from multiple file paths.
|
|
411
|
+
|
|
412
|
+
Parameters
|
|
413
|
+
----------
|
|
414
|
+
*paths : str or Path
|
|
415
|
+
Variable number of path arguments.
|
|
416
|
+
|
|
417
|
+
Returns
|
|
418
|
+
-------
|
|
419
|
+
Path
|
|
420
|
+
Common path shared between all input paths.
|
|
421
|
+
|
|
422
|
+
"""
|
|
423
|
+
are_absolute = [x.is_absolute() for x in map(Path, paths)]
|
|
424
|
+
if not all(are_absolute) and any(are_absolute):
|
|
425
|
+
# When absolute and relative paths are mixed, convert them to absolute ones.
|
|
426
|
+
path_objects = [Path(path).resolve() for path in paths]
|
|
427
|
+
else:
|
|
428
|
+
# Convert paths to Path objects
|
|
429
|
+
path_objects = [Path(path) for path in paths]
|
|
430
|
+
|
|
431
|
+
# Find the shortest path
|
|
432
|
+
shortest_path = min(path_objects, key=lambda p: len(p.parts))
|
|
433
|
+
|
|
434
|
+
# Iterate over the shortest path's parts
|
|
435
|
+
common_parts = []
|
|
436
|
+
for i, part in enumerate(shortest_path.parts):
|
|
437
|
+
if all(part in path.parts[: i + 1] for path in path_objects):
|
|
438
|
+
common_parts.append(part)
|
|
439
|
+
else:
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
# Join common parts back into a path object
|
|
443
|
+
common_path = Path(*common_parts)
|
|
444
|
+
if common_path.is_file():
|
|
445
|
+
# When common path exists and it is a file, its parent directory is returned.
|
|
446
|
+
common_path = common_path.parent
|
|
447
|
+
return common_path
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _get_limits(
|
|
451
|
+
xlim: Sequence[float] | None,
|
|
452
|
+
fallback: tuple[float, float] | None,
|
|
453
|
+
fallback_xlim: tuple[float, float] | None,
|
|
454
|
+
) -> tuple[float, float] | None:
|
|
455
|
+
"""Calculate axis limits based on input sequence and fallback values.
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
xlim : Sequence[float] | None
|
|
460
|
+
Input sequence of values to determine limits from
|
|
461
|
+
fallback : tuple[float, float] | None
|
|
462
|
+
Default fallback limits to use if xlim is empty
|
|
463
|
+
fallback_xlim : tuple[float, float] | None
|
|
464
|
+
Secondary fallback limits that override primary fallback
|
|
465
|
+
|
|
466
|
+
Returns
|
|
467
|
+
-------
|
|
468
|
+
tuple[float, float] | None
|
|
469
|
+
Calculated axis limits as (min, max) tuple, or None if xlim is None
|
|
470
|
+
|
|
471
|
+
"""
|
|
472
|
+
if xlim is None:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
fixed_xlim: tuple[float, float] | None = None
|
|
476
|
+
if len(xlim) == 0:
|
|
477
|
+
if fallback is not None:
|
|
478
|
+
fixed_xlim = fallback
|
|
479
|
+
if fallback_xlim is not None:
|
|
480
|
+
fixed_xlim = fallback_xlim
|
|
481
|
+
elif len(xlim) == 1:
|
|
482
|
+
fixed_xlim = (-abs(xlim[0]), abs(xlim[0]))
|
|
483
|
+
else:
|
|
484
|
+
fixed_xlim = (min(xlim), max(xlim))
|
|
485
|
+
return fixed_xlim
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@overload
|
|
489
|
+
def get_limits(
|
|
490
|
+
xlim: Sequence[float] | None,
|
|
491
|
+
ylim: Sequence[float] | None,
|
|
492
|
+
*,
|
|
493
|
+
fallback: tuple[float, float] | None = None,
|
|
494
|
+
fallback_xlim: tuple[float, float] | None = None,
|
|
495
|
+
fallback_ylim: tuple[float, float] | None = None,
|
|
496
|
+
) -> tuple[tuple[float, float] | None, tuple[float, float] | None]: ...
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@overload
|
|
500
|
+
def get_limits(
|
|
501
|
+
xlim: Sequence[float] | None,
|
|
502
|
+
ylim: NoDefault = no_default,
|
|
503
|
+
*,
|
|
504
|
+
fallback: tuple[float, float] | None = None,
|
|
505
|
+
fallback_xlim: tuple[float, float] | None = None,
|
|
506
|
+
fallback_ylim: tuple[float, float] | None = None,
|
|
507
|
+
) -> tuple[float, float] | None: ...
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def get_limits(
|
|
511
|
+
xlim: Sequence[float] | None,
|
|
512
|
+
ylim: Sequence[float] | None | NoDefault = no_default,
|
|
513
|
+
*,
|
|
514
|
+
fallback: tuple[float, float] | None = None,
|
|
515
|
+
fallback_xlim: tuple[float, float] | None = None,
|
|
516
|
+
fallback_ylim: tuple[float, float] | None = None,
|
|
517
|
+
) -> tuple[float, float] | None | tuple[tuple[float, float] | None, tuple[float, float] | None]:
|
|
518
|
+
"""Calculate axis limits for one or two dimensions.
|
|
519
|
+
|
|
520
|
+
Parameters
|
|
521
|
+
----------
|
|
522
|
+
xlim : Sequence[float] | None
|
|
523
|
+
Input sequence for x-axis limits
|
|
524
|
+
ylim : Sequence[float] | None | NoDefault, optional
|
|
525
|
+
Input sequence for y-axis limits
|
|
526
|
+
fallback : tuple[float, float] | None, optional
|
|
527
|
+
Default fallback limits for both axes
|
|
528
|
+
fallback_xlim : tuple[float, float] | None, optional
|
|
529
|
+
Specific fallback limits for x-axis
|
|
530
|
+
fallback_ylim : tuple[float, float] | None, optional
|
|
531
|
+
Specific fallback limits for y-axis
|
|
532
|
+
|
|
533
|
+
Returns
|
|
534
|
+
-------
|
|
535
|
+
tuple[float, float] | None | tuple[tuple[float, float] | None, tuple[float, float] | None]
|
|
536
|
+
Single axis limits or tuple of (x_limits, y_limits)
|
|
537
|
+
|
|
538
|
+
"""
|
|
539
|
+
fixed_xlim = _get_limits(xlim, fallback, fallback_xlim)
|
|
540
|
+
if ylim is no_default:
|
|
541
|
+
return fixed_xlim
|
|
542
|
+
fixed_ylim = _get_limits(ylim, fallback, fallback_ylim)
|
|
543
|
+
return fixed_xlim, fixed_ylim
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def get_tlim_mask(t: np.ndarray, tlim: tuple[float, float] | None) -> np.ndarray:
|
|
547
|
+
"""Create a boolean mask for time limits.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
t : np.ndarray
|
|
552
|
+
Time array.
|
|
553
|
+
tlim : tuple[float, float] or None
|
|
554
|
+
Time limits (min, max).
|
|
555
|
+
|
|
556
|
+
Returns
|
|
557
|
+
-------
|
|
558
|
+
np.ndarray
|
|
559
|
+
Boolean mask array.
|
|
560
|
+
|
|
561
|
+
"""
|
|
562
|
+
return np.full(t.shape, fill_value=True) if tlim is None else (t >= tlim[0]) & (t <= tlim[1])
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def calculate_mean_err(
|
|
566
|
+
data_array: np.ndarray,
|
|
567
|
+
err_type: str = "std",
|
|
568
|
+
ddof: int = 0,
|
|
569
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
|
570
|
+
"""Calculate mean and error metrics for data array.
|
|
571
|
+
|
|
572
|
+
Parameters
|
|
573
|
+
----------
|
|
574
|
+
data_array : np.ndarray
|
|
575
|
+
Input data array.
|
|
576
|
+
err_type : str, optional
|
|
577
|
+
Type of error to calculate ("std", "var", "range", "se", "ci"), by default "std".
|
|
578
|
+
ddof : int, optional
|
|
579
|
+
Delta degrees of freedom, by default 0.
|
|
580
|
+
|
|
581
|
+
Returns
|
|
582
|
+
-------
|
|
583
|
+
tuple[np.ndarray, np.ndarray, np.ndarray | None]
|
|
584
|
+
Mean, error1, and error2 (if applicable) arrays.
|
|
585
|
+
|
|
586
|
+
Raises
|
|
587
|
+
------
|
|
588
|
+
TypeError
|
|
589
|
+
If err_type is not a string.
|
|
590
|
+
ValueError
|
|
591
|
+
If err_type is not recognized.
|
|
592
|
+
NotImplementedError
|
|
593
|
+
If err_type is "ci".
|
|
594
|
+
|
|
595
|
+
"""
|
|
596
|
+
if not isinstance(err_type, str):
|
|
597
|
+
msg = f"`err_type` must be string: {err_type}, (type: {type(err_type)})"
|
|
598
|
+
raise TypeError(msg)
|
|
599
|
+
|
|
600
|
+
mean = np.mean(data_array, axis=0)
|
|
601
|
+
if err_type.lower() in ("std", "sd"):
|
|
602
|
+
# standard deviation
|
|
603
|
+
std = np.std(data_array, axis=0, ddof=ddof)
|
|
604
|
+
return mean, std, None
|
|
605
|
+
|
|
606
|
+
if err_type.lower() == "var":
|
|
607
|
+
# variance
|
|
608
|
+
var = np.var(data_array, axis=0, ddof=ddof)
|
|
609
|
+
return mean, var, None
|
|
610
|
+
|
|
611
|
+
if err_type.lower() == "range":
|
|
612
|
+
# range
|
|
613
|
+
lower = mean - np.min(data_array, axis=0)
|
|
614
|
+
upper = np.max(data_array, axis=0) - mean
|
|
615
|
+
return mean, lower, upper
|
|
616
|
+
|
|
617
|
+
if err_type.lower() == "se":
|
|
618
|
+
# standard error of the mean: deviation over the number of trials
|
|
619
|
+
std = np.std(data_array, axis=0, ddof=ddof)
|
|
620
|
+
se = std / np.sqrt(data_array.shape[0])
|
|
621
|
+
return mean, se, None
|
|
622
|
+
|
|
623
|
+
if err_type.lower() == "ci":
|
|
624
|
+
# confidence interval
|
|
625
|
+
raise NotImplementedError
|
|
626
|
+
|
|
627
|
+
msg = f"unrecognized error type: {err_type}"
|
|
628
|
+
raise ValueError(msg)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def plot_multi_timeseries(
|
|
632
|
+
ax: Axes,
|
|
633
|
+
t: np.ndarray,
|
|
634
|
+
y_arr: np.ndarray,
|
|
635
|
+
*,
|
|
636
|
+
tlim: tuple[float, float] | None,
|
|
637
|
+
lw: int | None,
|
|
638
|
+
color: ColorType | None = None,
|
|
639
|
+
fmt: str | None = None,
|
|
640
|
+
labels: str | Iterable[str] | None = None,
|
|
641
|
+
cmap_name: str | None = None,
|
|
642
|
+
) -> list[Line2D]:
|
|
643
|
+
"""Plot multiple time series on the same axes.
|
|
644
|
+
|
|
645
|
+
Parameters
|
|
646
|
+
----------
|
|
647
|
+
ax : Axes
|
|
648
|
+
Matplotlib axes object.
|
|
649
|
+
t : np.ndarray
|
|
650
|
+
Array of time values.
|
|
651
|
+
y_arr : np.ndarray
|
|
652
|
+
Array of y values.
|
|
653
|
+
tlim : tuple[float, float] or None
|
|
654
|
+
Time limits.
|
|
655
|
+
lw : int or None
|
|
656
|
+
Line width.
|
|
657
|
+
color : ColorType or None, optional
|
|
658
|
+
Line color, by default None.
|
|
659
|
+
fmt : str or None, optional
|
|
660
|
+
Format string, by default None.
|
|
661
|
+
labels : str or Iterable[str] or None, optional
|
|
662
|
+
Labels for lines, by default None.
|
|
663
|
+
cmap_name : str or None, optional
|
|
664
|
+
Colormap name, by default None.
|
|
665
|
+
|
|
666
|
+
Returns
|
|
667
|
+
-------
|
|
668
|
+
list[Line2D]
|
|
669
|
+
List of plotted lines.
|
|
670
|
+
|
|
671
|
+
"""
|
|
672
|
+
mask = get_tlim_mask(t, tlim)
|
|
673
|
+
y_arr = np.atleast_2d(y_arr)
|
|
674
|
+
cmap = plt.get_cmap(cmap_name) if cmap_name is not None else None
|
|
675
|
+
if labels is None:
|
|
676
|
+
labels = [f"{i}" for i in range(len(y_arr))]
|
|
677
|
+
elif isinstance(labels, str):
|
|
678
|
+
labels = [labels] if len(y_arr) == 1 else [f"{labels}_{i}" for i in range(len(y_arr))]
|
|
679
|
+
|
|
680
|
+
kwargs: dict[str, Unknown] = {}
|
|
681
|
+
if lw is not None:
|
|
682
|
+
kwargs["lw"] = lw
|
|
683
|
+
if color is not None:
|
|
684
|
+
kwargs["c"] = color
|
|
685
|
+
|
|
686
|
+
t_mask = t[mask]
|
|
687
|
+
lines: list[Line2D] = []
|
|
688
|
+
for i, (y, label) in enumerate(zip(y_arr, labels, strict=True)):
|
|
689
|
+
kwargs["label"] = str(label)
|
|
690
|
+
if color is None and cmap is not None:
|
|
691
|
+
kwargs["c"] = cmap(i)
|
|
692
|
+
|
|
693
|
+
if fmt is None: # noqa: SIM108
|
|
694
|
+
_lines = ax.plot(t_mask, y[mask], **kwargs)
|
|
695
|
+
else:
|
|
696
|
+
_lines = ax.plot(t_mask, y[mask], fmt, **kwargs)
|
|
697
|
+
lines.extend(_lines)
|
|
698
|
+
return lines
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def plot_mean_err(
|
|
702
|
+
ax: Axes,
|
|
703
|
+
t: np.ndarray,
|
|
704
|
+
y_arr: np.ndarray,
|
|
705
|
+
err_type: str | None,
|
|
706
|
+
*,
|
|
707
|
+
tlim: tuple[float, float] | None,
|
|
708
|
+
lw: int | None,
|
|
709
|
+
capsize: int | None,
|
|
710
|
+
color: ColorType | None = None,
|
|
711
|
+
fmt: str | None = None,
|
|
712
|
+
label: str | None = None,
|
|
713
|
+
) -> Line2D:
|
|
714
|
+
"""Plot mean with error bars.
|
|
715
|
+
|
|
716
|
+
Parameters
|
|
717
|
+
----------
|
|
718
|
+
ax : Axes
|
|
719
|
+
Matplotlib axes object.
|
|
720
|
+
t : np.ndarray
|
|
721
|
+
Array of time values.
|
|
722
|
+
y_arr : np.ndarray
|
|
723
|
+
Array of y values.
|
|
724
|
+
err_type : str or None
|
|
725
|
+
Type of error to plot.
|
|
726
|
+
tlim : tuple[float, float] or None
|
|
727
|
+
Time limits.
|
|
728
|
+
lw : int or None
|
|
729
|
+
Line width.
|
|
730
|
+
capsize : int or None
|
|
731
|
+
Size of error bar caps.
|
|
732
|
+
color : ColorType or None, optional
|
|
733
|
+
Line color, by default None.
|
|
734
|
+
fmt : str or None, optional
|
|
735
|
+
Format string, by default None.
|
|
736
|
+
label : str or None, optional
|
|
737
|
+
Label for the plot, by default None.
|
|
738
|
+
|
|
739
|
+
Returns
|
|
740
|
+
-------
|
|
741
|
+
Line2D
|
|
742
|
+
The plotted line.
|
|
743
|
+
|
|
744
|
+
"""
|
|
745
|
+
mask = get_tlim_mask(t, tlim)
|
|
746
|
+
y_arr = np.atleast_2d(y_arr)
|
|
747
|
+
|
|
748
|
+
kwargs: dict[str, Unknown] = {}
|
|
749
|
+
if lw is not None:
|
|
750
|
+
kwargs["lw"] = lw
|
|
751
|
+
if capsize is not None:
|
|
752
|
+
kwargs["capsize"] = capsize
|
|
753
|
+
if color is not None:
|
|
754
|
+
kwargs["c"] = color
|
|
755
|
+
if fmt is not None:
|
|
756
|
+
kwargs["fmt"] = fmt
|
|
757
|
+
kwargs["label"] = label
|
|
758
|
+
|
|
759
|
+
if err_type is None or err_type == "none":
|
|
760
|
+
mean, _, _ = calculate_mean_err(y_arr)
|
|
761
|
+
lines = plot_multi_timeseries(ax, t, mean, tlim=tlim, lw=lw, color=color, fmt=fmt, labels=label)
|
|
762
|
+
else:
|
|
763
|
+
mean, err1, err2 = calculate_mean_err(y_arr, err_type=err_type)
|
|
764
|
+
if err2 is None:
|
|
765
|
+
eb = ax.errorbar(t[mask], mean[mask], yerr=err1[mask], **kwargs)
|
|
766
|
+
else:
|
|
767
|
+
eb = ax.errorbar(t[mask], mean[mask], yerr=(err1[mask], err2[mask]), **kwargs)
|
|
768
|
+
lines = [eb.lines[0]]
|
|
769
|
+
return lines[0]
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def fill_between_err(
|
|
773
|
+
ax: Axes,
|
|
774
|
+
t: np.ndarray,
|
|
775
|
+
y_arr: np.ndarray,
|
|
776
|
+
err_type: str | None,
|
|
777
|
+
*,
|
|
778
|
+
tlim: tuple[float, float] | None,
|
|
779
|
+
color: ColorType | None,
|
|
780
|
+
alpha: float | None,
|
|
781
|
+
interpolate: bool = False,
|
|
782
|
+
suppress_exception: bool = False,
|
|
783
|
+
) -> Axes:
|
|
784
|
+
"""Fill between error bounds.
|
|
785
|
+
|
|
786
|
+
Parameters
|
|
787
|
+
----------
|
|
788
|
+
ax : Axes
|
|
789
|
+
Matplotlib axes object.
|
|
790
|
+
t : np.ndarray
|
|
791
|
+
Array of time values.
|
|
792
|
+
y_arr : np.ndarray
|
|
793
|
+
Array of y values.
|
|
794
|
+
err_type : str or None
|
|
795
|
+
Type of error to fill.
|
|
796
|
+
tlim : tuple[float, float] or None
|
|
797
|
+
Time limits.
|
|
798
|
+
color : ColorType or None
|
|
799
|
+
Fill color.
|
|
800
|
+
alpha : float or None
|
|
801
|
+
Fill transparency.
|
|
802
|
+
interpolate : bool, optional
|
|
803
|
+
Whether to use interpolate, by default False.
|
|
804
|
+
suppress_exception : bool, optional
|
|
805
|
+
Whether to suppress exceptions, by default False.
|
|
806
|
+
|
|
807
|
+
Returns
|
|
808
|
+
-------
|
|
809
|
+
Axes
|
|
810
|
+
The modified axes object.
|
|
811
|
+
|
|
812
|
+
Raises
|
|
813
|
+
------
|
|
814
|
+
ValueError
|
|
815
|
+
If err_type is None and suppress_exception is False.
|
|
816
|
+
|
|
817
|
+
"""
|
|
818
|
+
if err_type is None or err_type == "none":
|
|
819
|
+
if suppress_exception:
|
|
820
|
+
return ax
|
|
821
|
+
msg = "`err_type` for `fill_between_err` must not be None."
|
|
822
|
+
raise ValueError(msg)
|
|
823
|
+
|
|
824
|
+
mask = get_tlim_mask(t, tlim)
|
|
825
|
+
y_arr = np.atleast_2d(y_arr)
|
|
826
|
+
|
|
827
|
+
kwargs: dict[str, Unknown] = {}
|
|
828
|
+
if color is not None:
|
|
829
|
+
kwargs["facecolor"] = color
|
|
830
|
+
if alpha is not None:
|
|
831
|
+
kwargs["alpha"] = alpha
|
|
832
|
+
kwargs["interpolate"] = interpolate
|
|
833
|
+
|
|
834
|
+
mean, err1, err2 = calculate_mean_err(y_arr, err_type=err_type)
|
|
835
|
+
# Note that fill_between always goes behind lines.
|
|
836
|
+
if err2 is None:
|
|
837
|
+
ax.fill_between(t[mask], mean[mask] + err1[mask], mean[mask] - err1[mask], **kwargs)
|
|
838
|
+
else:
|
|
839
|
+
# err1 is the distance below the mean and err2 the distance above, as in `plot_mean_err`.
|
|
840
|
+
ax.fill_between(t[mask], mean[mask] - err1[mask], mean[mask] + err2[mask], **kwargs)
|
|
841
|
+
return ax
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# Local Variables:
|
|
845
|
+
# jinx-local-words: "Colormap FilePathT Iterable Jupyter LaTeX arg basename bbox ci cjk cmap csv customizable dataset ddof dir facecolor fmt ieee jp linspace lw matplotlib ndarray noqa np plt png randn sd se str timepoints timeseries tlim xlim ylim" # noqa: E501
|
|
846
|
+
# End:
|