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/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: