modusa 0.3.51__tar.gz → 0.3.53__tar.gz

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.
Files changed (94) hide show
  1. {modusa-0.3.51 → modusa-0.3.53}/PKG-INFO +1 -1
  2. {modusa-0.3.51 → modusa-0.3.53}/pyproject.toml +1 -1
  3. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/__init__.py +1 -1
  4. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/__init__.py +0 -2
  5. modusa-0.3.53/src/modusa/tools/plotter.py +617 -0
  6. modusa-0.3.51/src/modusa/tools/plotter.py +0 -1340
  7. {modusa-0.3.51 → modusa-0.3.53}/LICENSE.md +0 -0
  8. {modusa-0.3.51 → modusa-0.3.53}/README.md +0 -0
  9. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/.DS_Store +0 -0
  10. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/config.py +0 -0
  11. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/decorators.py +0 -0
  12. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/generate_docs_source.py +0 -0
  13. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/generate_template.py +0 -0
  14. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/list_authors.py +0 -0
  15. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/list_plugins.py +0 -0
  16. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/main.py +0 -0
  17. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/templates/generator.py +0 -0
  18. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/templates/io.py +0 -0
  19. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/templates/model.py +0 -0
  20. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/templates/plugin.py +0 -0
  21. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/templates/test.py +0 -0
  22. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/devtools/templates/tool.py +0 -0
  23. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/fonts/NotoSansDevanagari-Regular.ttf +0 -0
  24. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/__init__.py +0 -0
  25. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/audio.py +0 -0
  26. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/audio_waveforms.py +0 -0
  27. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/base.py +0 -0
  28. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/ftds.py +0 -0
  29. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/s1d.py +0 -0
  30. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/s2d.py +0 -0
  31. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/s_ax.py +0 -0
  32. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/t_ax.py +0 -0
  33. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/generators/tds.py +0 -0
  34. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/__init__.py +0 -0
  35. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/__pycache__/signal1D.cpython-312.pyc.4443461152 +0 -0
  36. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/audio.py +0 -0
  37. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/base.py +0 -0
  38. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/data.py +0 -0
  39. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/ftds.py +0 -0
  40. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/s1d.py +0 -0
  41. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/s2d.py +0 -0
  42. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/s_ax.py +0 -0
  43. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/t_ax.py +0 -0
  44. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/models/tds.py +0 -0
  45. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/plugins/__init__.py +0 -0
  46. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/plugins/base.py +0 -0
  47. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/_plotter_old.py +0 -0
  48. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/ann_loader.py +0 -0
  49. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/audio_converter.py +0 -0
  50. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/audio_loader.py +0 -0
  51. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/audio_player.py +0 -0
  52. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/audio_recorder.py +0 -0
  53. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/base.py +0 -0
  54. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/math_ops.py +0 -0
  55. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/tools/youtube_downloader.py +0 -0
  56. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/.DS_Store +0 -0
  57. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/__init__.py +0 -0
  58. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/config.py +0 -0
  59. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/excp.py +0 -0
  60. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/logger.py +0 -0
  61. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/np_func_cat.py +0 -0
  62. {modusa-0.3.51 → modusa-0.3.53}/src/modusa/utils/plot.py +0 -0
  63. {modusa-0.3.51 → modusa-0.3.53}/tests/__init__.py +0 -0
  64. {modusa-0.3.51 → modusa-0.3.53}/tests/data/song1.mp3 +0 -0
  65. {modusa-0.3.51 → modusa-0.3.53}/tests/data/song1.wav +0 -0
  66. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/audio_waveform.py +0 -0
  67. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_audio.py +0 -0
  68. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_ftds.py +0 -0
  69. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_s1d.py +0 -0
  70. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_s2d.py +0 -0
  71. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_s_ax.py +0 -0
  72. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_signal.py +0 -0
  73. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_signal_generator.py +0 -0
  74. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_t_ax.py +0 -0
  75. {modusa-0.3.51 → modusa-0.3.53}/tests/test_generators/test_tds.py +0 -0
  76. {modusa-0.3.51 → modusa-0.3.53}/tests/test_io/audio_player.py +0 -0
  77. {modusa-0.3.51 → modusa-0.3.53}/tests/test_io/plotter.py +0 -0
  78. {modusa-0.3.51 → modusa-0.3.53}/tests/test_models/test_data.py +0 -0
  79. {modusa-0.3.51 → modusa-0.3.53}/tests/test_models/test_t_ax.py +0 -0
  80. {modusa-0.3.51 → modusa-0.3.53}/tests/test_plugins/youtube_audio_loader.py +0 -0
  81. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/frequency_domain_signal.py +0 -0
  82. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/spectrogram.py +0 -0
  83. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_axis.py +0 -0
  84. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_feature_time_domain_signal.py +0 -0
  85. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_frequency_time_domain_signal.py +0 -0
  86. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_signal1D.py +0 -0
  87. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_signal2D.py +0 -0
  88. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_time_domain_signal.py +0 -0
  89. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_u_ax.py +0 -0
  90. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/test_window_signal.py +0 -0
  91. {modusa-0.3.51 → modusa-0.3.53}/tests/test_signals/time_domain_signal.py +0 -0
  92. {modusa-0.3.51 → modusa-0.3.53}/tests/test_tools/test_audio_converter.py +0 -0
  93. {modusa-0.3.51 → modusa-0.3.53}/tests/test_tools/test_fourier_tranform.py +0 -0
  94. {modusa-0.3.51 → modusa-0.3.53}/tests/test_tools/test_math_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: modusa
3
- Version: 0.3.51
3
+ Version: 0.3.53
4
4
  Summary: A modular signal analysis python library.
5
5
  Author-Email: Ankit Anand <ankit0.anand0@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "modusa"
3
- version = "0.3.51"
3
+ version = "0.3.53"
4
4
  description = "A modular signal analysis python library."
5
5
  authors = [
6
6
  { name = "Ankit Anand", email = "ankit0.anand0@gmail.com" },
@@ -1,7 +1,7 @@
1
1
  from modusa.utils import excp, config
2
2
 
3
3
  #=====Giving access to plot functions to plot multiple signals.=====
4
- from modusa.tools import fig1d, fig2d, plot_dist, fig
4
+ from modusa.tools import plot_dist, fig
5
5
  #=====
6
6
 
7
7
  from modusa.tools import play, convert, record
@@ -7,7 +7,5 @@ from .audio_loader import load
7
7
  from .ann_loader import load_ann
8
8
  from .audio_recorder import record
9
9
 
10
- from .plotter import Figure1D as fig1d
11
- from .plotter import Figure2D as fig2d
12
10
  from .plotter import Fig as fig
13
11
  from .plotter import plot_dist
@@ -0,0 +1,617 @@
1
+ #!/usr/bin/env python3
2
+
3
+ #---------------------------------
4
+ # Author: Ankit Anand
5
+ # Date: 26/08/25
6
+ # Email: ankit0.anand0@gmail.com
7
+ #---------------------------------
8
+
9
+ from pathlib import Path
10
+ import matplotlib as mpl
11
+ import matplotlib.font_manager as fm
12
+ import matplotlib.pyplot as plt
13
+ import matplotlib.gridspec as gridspec
14
+ from matplotlib.patches import Rectangle
15
+
16
+ import numpy as np
17
+
18
+ #===== Loading Devanagari font ========
19
+ def _load_devanagari_font():
20
+ """
21
+ Load devanagari font as it works for both English and Hindi.
22
+ """
23
+
24
+ # Path to your bundled font
25
+ font_path = Path(__file__).resolve().parents[1] / "fonts" / "NotoSansDevanagari-Regular.ttf"
26
+
27
+ # Register the font with matplotlib
28
+ fm.fontManager.addfont(str(font_path))
29
+
30
+ # Get the font family name from the file
31
+ hindi_font = fm.FontProperties(fname=str(font_path))
32
+
33
+ # Set as default rcParam
34
+ mpl.rcParams['font.family'] = hindi_font.get_name()
35
+
36
+ _load_devanagari_font()
37
+ #==============
38
+
39
+ class Fig:
40
+ """
41
+ A utility class that provides easy-to-use API for
42
+ plotting 1D/2D signals along with clean representations
43
+ of annotations, events.
44
+ """
45
+
46
+ def __init__(self, arrangement="asm", xlim=None):
47
+
48
+ self._xlim = xlim
49
+ self._curr_row_idx = 1 # Starting from 1 because row 0 is reserved for reference subplot
50
+ self._curr_color_idx = 0 # So that we have different color across all the subplots to avoid legend confusion
51
+
52
+ # Subplot setup
53
+ self._fig, self._axs = self._generate_subplots(arrangement) # This will fill in the all the above variables
54
+
55
+
56
+ def _get_curr_row(self):
57
+ """
58
+ Get the active row where you can add
59
+ either annotations or events.
60
+ """
61
+ curr_row = self._axs[self._curr_row_idx]
62
+ self._curr_row_idx += 1
63
+
64
+ return curr_row
65
+
66
+ def _get_new_color(self):
67
+ """
68
+ Get a new color for different lines.
69
+ """
70
+ colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
71
+ self._curr_color_idx += 1
72
+
73
+ return colors[self._curr_color_idx]
74
+
75
+ def _calculate_extent(self, x, y):
76
+ # Handle spacing safely
77
+ if len(x) > 1:
78
+ dx = x[1] - x[0]
79
+ else:
80
+ dx = 1 # Default spacing for single value
81
+ if len(y) > 1:
82
+ dy = y[1] - y[0]
83
+ else:
84
+ dy = 1 # Default spacing for single value
85
+
86
+ return [x[0] - dx / 2, x[-1] + dx / 2, y[0] - dy / 2, y[-1] + dy / 2]
87
+
88
+
89
+ def _generate_subplots(self, arrangement):
90
+ """
91
+ Generate subplots based on the configuration.
92
+ """
93
+
94
+ xlim = self._xlim
95
+
96
+ n_aux_sp = arrangement.count("a")
97
+ n_signal_sp = arrangement.count("s")
98
+ n_matrix_sp = arrangement.count("m")
99
+ n_sp = 1 + n_aux_sp + n_signal_sp + n_matrix_sp # +1 is for the first reference subplot
100
+
101
+ # Decide heights of different subplots type
102
+ height = {}
103
+ height["r"] = 0.0 # Reference height
104
+ height["a"] = 0.4 # Aux height
105
+ height["s"] = 2.0 # Signal height
106
+ height["m"] = 4.0 # Matrix height
107
+ cbar_width = 0.01
108
+
109
+ arrangement = "r" + arrangement # "r" is to include the reference
110
+
111
+ # Calculate height ratios list based on the arrangement
112
+ for char in arrangement:
113
+ height_ratios = [height[char] for char in arrangement]
114
+
115
+ # Calculate total fig height
116
+ fig_height = height["r"] + (n_aux_sp * height["a"]) + (n_signal_sp * height["s"]) + (n_matrix_sp * height["m"])
117
+
118
+ # Create figure and axs
119
+ fig, axs = plt.subplots(n_sp, 2, figsize=(16, fig_height), height_ratios=height_ratios, width_ratios=[1, cbar_width])
120
+
121
+ for i, char in enumerate(arrangement): # For each of the subplots, we modify the layout accordingly
122
+ if char == "r":
123
+ axs[i, 0].axis("off")
124
+ axs[i, 1].axis("off")
125
+ elif char == "a": # Remove ticks and labels from all the aux subplots
126
+ axs[i, 0].tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
127
+ axs[i, 0].grid(True, linestyle=':', linewidth=0.7, color='gray', alpha=0.7)
128
+ axs[i, 1].axis("off")
129
+ elif char == "s":
130
+ axs[i, 0].tick_params(bottom=False, labelbottom=False)
131
+ axs[i, 0].grid(True, linestyle=':', linewidth=0.7, color='gray', alpha=0.7)
132
+ axs[i, 1].axis("off")
133
+ elif char == "m":
134
+ axs[i, 0].grid(True, linestyle=':', linewidth=0.7, color='gray', alpha=0.7)
135
+ axs[i, 0].tick_params(bottom=False, labelbottom=False)
136
+
137
+ axs[i, 0].sharex(axs[0, 0])
138
+
139
+ axs[-1, 0].tick_params(bottom=True, labelbottom=True)
140
+
141
+ # xlim should be applied on reference subplot, rest all subplots will automatically adjust
142
+ if xlim is not None:
143
+ axs[0, 0].set_xlim(xlim)
144
+
145
+ fig.subplots_adjust(hspace=0.2, wspace=0.05)
146
+
147
+ return fig, axs
148
+
149
+ def add_signal(self, y, x=None, c=None, ls=None, lw=None, m=None, ms=3, label=None, ylabel=None, ylim=None, ax=None):
150
+ """
151
+ Add signal to the figure.
152
+
153
+ Parameters
154
+ ----------
155
+ y: np.ndarray
156
+ - Signal y values.
157
+ x: np.ndarray | None
158
+ - Signal x values.
159
+ - Default: None (indices will be used)
160
+ c: str
161
+ - Color of the line.
162
+ - Default: None
163
+ ls: str
164
+ - Linestyle
165
+ - Default: None
166
+ lw: Number
167
+ - Linewidth
168
+ - Default: None
169
+ m: str
170
+ - Marker
171
+ - Default: None
172
+ ms: number
173
+ - Markersize
174
+ - Default: 3
175
+ label: str
176
+ - Label for the plot.
177
+ - Legend will use this.
178
+ - Default: None
179
+ ylabel: str
180
+ - y-label for the plot.
181
+ - Default: None
182
+ ylim: tuple
183
+ - y-lim for the plot.
184
+ - Default: None
185
+ ax: int
186
+ - Which specific axis to plot (1, 2, 3, ...)
187
+ - None
188
+
189
+ Returns
190
+ -------
191
+ None
192
+ """
193
+
194
+ curr_row = self._get_curr_row() if ax is None else self._axs[ax]
195
+
196
+ if x is None: x = np.arange(y.size)
197
+
198
+ if c is None: c = self._get_new_color()
199
+
200
+ curr_row[0].plot(x, y, color=c, linestyle=ls, linewidth=lw, marker=m, markersize=ms, label=label)
201
+
202
+ if ylabel is not None: curr_row[0].set_ylabel(ylabel)
203
+
204
+ if ylim is not None: curr_row[0].set_ylim(ylim)
205
+
206
+ def add_matrix(self, M, y=None, x=None, c="gray_r", o="lower", label=None, ylabel=None, ylim=None, cbar=True, ax=None):
207
+ """
208
+ Add matrix to the figure.
209
+
210
+ Parameters
211
+ ----------
212
+ M: np.ndarray
213
+ - Matrix (2D) array
214
+ y: np.ndarray | None
215
+ - y axis values.
216
+ x: np.ndarray | None (indices will be used)
217
+ - x axis values.
218
+ - Default: None (indices will be used)
219
+ c: str
220
+ - cmap for the matrix.
221
+ - Default: None
222
+ o: str
223
+ - origin
224
+ - Default: "lower"
225
+ label: str
226
+ - Label for the plot.
227
+ - Legend will use this.
228
+ - Default: None
229
+ ylabel: str
230
+ - y-label for the plot.
231
+ - Default: None
232
+ ylim: tuple
233
+ - y-lim for the plot.
234
+ - Default: None
235
+ cbar: bool
236
+ - Show colorbar
237
+ - Default: True
238
+ ax: int
239
+ - Which specific axis to plot (1, 2, 3, ...)
240
+ - None
241
+
242
+ Returns
243
+ -------
244
+ None
245
+ """
246
+ if x is None: x = np.arange(M.shape[1])
247
+ if y is None: y = np.arange(M.shape[0])
248
+
249
+ curr_row = self._get_curr_row() if ax is None else self._axs[ax]
250
+
251
+ extent = self._calculate_extent(x, y)
252
+ im = curr_row[0].imshow(M, aspect="auto", origin=o, cmap=c, extent=extent)
253
+
254
+ if ylabel is not None: curr_row[0].set_ylabel(ylabel)
255
+
256
+ if ylim is not None: curr_row[0].set_ylim(ylim)
257
+
258
+ if cbar is True:
259
+ cbar = plt.colorbar(im, cax=curr_row[1])
260
+ if label is not None:
261
+ cbar.set_label(label, labelpad=5)
262
+
263
+
264
+ def add_events(self, events, c=None, ls=None, lw=None, label=None, ax=None):
265
+ """
266
+ Add events to the figure.
267
+
268
+ Parameters
269
+ ----------
270
+ events: np.ndarray
271
+ - All the event marker values.
272
+ c: str
273
+ - Color of the event marker.
274
+ - Default: "k"
275
+ ls: str
276
+ - Line style.
277
+ - Default: "-"
278
+ lw: float
279
+ - Linewidth.
280
+ - Default: 1.5
281
+ label: str
282
+ - Label for the event type.
283
+ - This will appear in the legend.
284
+ - Default: None
285
+ ax: int
286
+ - Which specific axis to plot (1, 2, 3, ...)
287
+ - None
288
+
289
+ Returns
290
+ -------
291
+ None
292
+ """
293
+
294
+ curr_row = self._get_curr_row() if ax is None else self._axs[ax]
295
+
296
+ if c is None: c = self._get_new_color()
297
+
298
+ xlim = self._xlim
299
+
300
+ for i, event in enumerate(events):
301
+ if xlim is not None:
302
+ if xlim[0] <= event <= xlim[1]:
303
+ if i == 0: # Label should be set only once for all the events
304
+ curr_row[0].axvline(x=event, color=c, linestyle=ls, linewidth=lw, label=label)
305
+ else:
306
+ curr_row[0].axvline(x=event, color=c, linestyle=ls, linewidth=lw)
307
+ else:
308
+ if i == 0: # Label should be set only once for all the events
309
+ curr_row[0].axvline(x=event, color=c, linestyle=ls, linewidth=lw, label=label)
310
+ else:
311
+ curr_row[0].axvline(x=event, color=c, linestyle=ls, linewidth=lw)
312
+
313
+ def add_annotation(self, ann, label=None, ax=None):
314
+ """
315
+ Add annotation to the figure.
316
+
317
+ Parameters
318
+ ----------
319
+ ann : list[tuple[Number, Number, str]] | None
320
+ - A list of annotation spans. Each tuple should be (start, end, label).
321
+ - Default: None (no annotations).
322
+ label: str
323
+ - Label for the annotation type.
324
+ - This will appear to the right of the aux plot.
325
+ - Default: None
326
+ ax: int
327
+ - Which specific axis to plot (1, 2, 3, ...)
328
+ - None
329
+ Returns
330
+ -------
331
+ None
332
+ """
333
+ curr_row = self._get_curr_row() if ax is None else self._axs[ax]
334
+
335
+ xlim = self._xlim
336
+
337
+ for i, (start, end, tag) in enumerate(ann):
338
+ # We make sure that we only plot annotation that are within the x range of the current view
339
+ if xlim is not None:
340
+ if start >= xlim[1] or end <= xlim[0]:
341
+ continue
342
+
343
+ # Clip boundaries to xlim
344
+ start = max(start, xlim[0])
345
+ end = min(end, xlim[1])
346
+
347
+ box_colors = ["gray", "lightgray"] # Alternates color between two
348
+ box_color = box_colors[i % 2]
349
+
350
+ width = end - start
351
+ rect = Rectangle((start, 0), width, 1, facecolor=box_color, edgecolor="black", alpha=0.7)
352
+ curr_row[0].add_patch(rect)
353
+
354
+ text_obj = curr_row[0].text(
355
+ (start + end) / 2, 0.5, tag,
356
+ ha='center', va='center',
357
+ fontsize=10, color="black", fontweight='bold', zorder=10, clip_on=True
358
+ )
359
+
360
+ text_obj.set_clip_path(rect)
361
+ else:
362
+ box_colors = ["gray", "lightgray"] # Alternates color between two
363
+ box_color = box_colors[i % 2]
364
+
365
+ width = end - start
366
+ rect = Rectangle((start, 0), width, 1, facecolor=box_color, edgecolor="black", alpha=0.7)
367
+ curr_row[0].add_patch(rect)
368
+
369
+ text_obj = curr_row[0].text(
370
+ (start + end) / 2, 0.5, tag,
371
+ ha='center', va='center',
372
+ fontsize=10, color="black", fontweight='bold', zorder=10, clip_on=True
373
+ )
374
+
375
+ text_obj.set_clip_path(rect)
376
+
377
+ if label is not None:
378
+ curr_row[0].set_ylabel(label, rotation=0, ha="center", va="center")
379
+ curr_row[0].yaxis.set_label_position("right")
380
+ curr_row[0].yaxis.set_label_coords(1.05, 0.75)
381
+
382
+ def add_legend(self, ypos=1.0):
383
+ """
384
+ Add legend to the figure.
385
+
386
+ Parameters
387
+ ----------
388
+ ypos: float
389
+ - y position from the top.
390
+ - > 1 to push it higher, < 1 to push it lower
391
+ - Default: 1.3
392
+
393
+ Returns
394
+ -------
395
+ None
396
+ """
397
+ axs = self._axs
398
+ fig = self._fig
399
+
400
+ all_handles, all_labels = [], []
401
+
402
+ for ax in axs:
403
+ handles, labels = ax[0].get_legend_handles_labels()
404
+ all_handles.extend(handles)
405
+ all_labels.extend(labels)
406
+
407
+ # remove duplicates if needed
408
+ fig.legend(all_handles, all_labels, loc='upper right', bbox_to_anchor=(0.95, ypos), ncol=3, frameon=True, bbox_transform=fig.transFigure)
409
+
410
+ def add_title(self, title=None, s=13):
411
+ """
412
+ Add title to the figure.
413
+
414
+ Parameters
415
+ ----------
416
+ title: str | None
417
+ - Title of the figure.
418
+ - Default: None
419
+ s: Number
420
+ - Font size.
421
+ - Default: None
422
+ """
423
+ axs = self._axs
424
+ ref_ax = axs[0, 0] # Title is added to the top subplot (ref subplot)
425
+
426
+ if title is not None:
427
+ ref_ax.set_title(title, pad=10, size=s)
428
+
429
+
430
+ def add_xlabel(self, xlabel=None, s=None):
431
+ """
432
+ Add shared x-label to the figure.
433
+
434
+ Parameters
435
+ ----------
436
+ xlabel: str | None
437
+ - xlabel for the figure.
438
+ - Default: None
439
+ s: Number
440
+ - Font size.
441
+ - Default: None
442
+ """
443
+ axs = self._axs
444
+ ref_ax = axs[-1, 0] # X-label is added to the last subplot
445
+ if xlabel is not None:
446
+ ref_ax.set_xlabel(xlabel, size=s)
447
+
448
+ def add_xticks():
449
+ raise NotImplementedError("Please raise a github issue `https://github.com/meluron-toolbox/modusa/issues`")
450
+
451
+ def save(self, path="./figure.png"):
452
+ """
453
+ Save the figure.
454
+
455
+ Parameters
456
+ ----------
457
+ path: str
458
+ - Path to the output file.
459
+
460
+ Returns
461
+ -------
462
+ None
463
+ """
464
+ fig = self._fig
465
+ fig.savefig(path, bbox_inches="tight")
466
+
467
+
468
+
469
+ #======== Plot distribution ===========
470
+ def plot_dist(*args, ann=None, xlim=None, ylim=None, ylabel=None, xlabel=None, title=None, legend=None, show_hist=True, npoints=200, bins=30):
471
+ """
472
+ Plot distribution.
473
+
474
+ .. code-block:: python
475
+
476
+ import modusa as ms
477
+ import numpy as np
478
+ np.random.seed(42)
479
+ data = np.random.normal(loc=1, scale=1, size=1000)
480
+ ms.plot_dist(data, data+5, data-10, ann=[(0, 1, "A")], legend=("D1", "D2", "D3"), ylim=(0, 1), xlabel="X", ylabel="Counts", title="Distribution")
481
+
482
+ Parameters
483
+ ----------
484
+ *args: ndarray
485
+ - Data arrays for which distribution needs to be plotted.
486
+ - Arrays will be flattened.
487
+ ann : list[tuple[Number, Number, str] | None
488
+ - A list of annotations to mark specific points. Each tuple should be of the form (start, end, label).
489
+ - Default: None => No annotation.
490
+ events : list[Number] | None
491
+ - A list of x-values where vertical lines (event markers) will be drawn.
492
+ - Default: None
493
+ xlim : tuple[Number, Number] | None
494
+ - Limits for the x-axis as (xmin, xmax).
495
+ - Default: None
496
+ ylim : tuple[Number, Number] | None
497
+ - Limits for the y-axis as (ymin, ymax).
498
+ - Default: None
499
+ xlabel : str | None
500
+ - Label for the x-axis.
501
+ - - Default: None
502
+ ylabel : str | None
503
+ - Label for the y-axis.
504
+ - Default: None
505
+ title : str | None
506
+ - Title of the plot.
507
+ - Default: None
508
+ legend : list[str] | None
509
+ - List of legend labels corresponding to each signal if plotting multiple distributions.
510
+ - Default: None
511
+ show_hist: bool
512
+ - Want to show histogram as well.
513
+ npoints: int
514
+ - Number of points for which gaussian needs to be computed between min and max.
515
+ - Higher value means more points are evaluated with the fitted gaussian, thereby higher resolution.
516
+ bins: int
517
+ - The number of bins for histogram.
518
+ - This is used only to plot the histogram.
519
+
520
+ Returns
521
+ -------
522
+ plt.Figure
523
+ - Matplotlib figure.
524
+ """
525
+ from scipy.stats import gaussian_kde
526
+
527
+ if isinstance(legend, str):
528
+ legend = (legend, )
529
+
530
+ if legend is not None:
531
+ if len(legend) < len(args):
532
+ raise ValueError(f"Legend should be provided for each signal.")
533
+
534
+ # Create figure
535
+ fig = plt.figure(figsize=(16, 4))
536
+ gs = gridspec.GridSpec(2, 1, height_ratios=[0.1, 1])
537
+
538
+ colors = plt.get_cmap('tab10').colors
539
+
540
+ dist_ax = fig.add_subplot(gs[1, 0])
541
+ annotation_ax = fig.add_subplot(gs[0, 0], sharex=dist_ax)
542
+
543
+ # Set limits
544
+ if xlim is not None:
545
+ dist_ax.set_xlim(xlim)
546
+
547
+ if ylim is not None:
548
+ dist_ax.set_ylim(ylim)
549
+
550
+ # Add plot
551
+ for i, data in enumerate(args):
552
+ # Fit gaussian to the data
553
+ kde = gaussian_kde(data)
554
+
555
+ # Create points to evaluate KDE
556
+ x = np.linspace(np.min(data), np.max(data), npoints)
557
+ y = kde(x)
558
+
559
+ if legend is not None:
560
+ dist_ax.plot(x, y, color=colors[i], label=legend[i])
561
+ if show_hist is True:
562
+ dist_ax.hist(data, bins=bins, density=True, alpha=0.3, facecolor=colors[i], edgecolor='black', label=legend[i])
563
+ else:
564
+ dist_ax.plot(x, y, color=colors[i])
565
+ if show_hist is True:
566
+ dist_ax.hist(data, bins=bins, density=True, alpha=0.3, facecolor=colors[i], edgecolor='black')
567
+
568
+ # Add annotations
569
+ if ann is not None:
570
+ annotation_ax.set_ylim(0, 1) # For consistent layout
571
+ # Determine visible x-range
572
+ x_view_min = xlim[0] if xlim is not None else np.min(x)
573
+ x_view_max = xlim[1] if xlim is not None else np.max(x)
574
+ for i, (start, end, tag) in enumerate(ann):
575
+ # We make sure that we only plot annotation that are within the x range of the current view
576
+ if start >= x_view_max or end <= x_view_min:
577
+ continue
578
+
579
+ # Clip boundaries to xlim
580
+ start = max(start, x_view_min)
581
+ end = min(end, x_view_max)
582
+
583
+ color = colors[i % len(colors)]
584
+ width = end - start
585
+ rect = Rectangle((start, 0), width, 1, color=color, alpha=0.7)
586
+ annotation_ax.add_patch(rect)
587
+
588
+ text_obj = annotation_ax.text((start + end) / 2, 0.5, tag, ha='center', va='center', fontsize=10, color='white', fontweight='bold', zorder=10, clip_on=True)
589
+ text_obj.set_clip_path(rect)
590
+
591
+ # Add legend
592
+ if legend is not None:
593
+ handles, labels = dist_ax.get_legend_handles_labels()
594
+ fig.legend(handles, labels, loc='upper right', bbox_to_anchor=(0.9, 1.1), ncol=len(legend), frameon=True)
595
+
596
+ # Set title, labels
597
+ if title is not None:
598
+ annotation_ax.set_title(title, pad=10, size=11)
599
+ if xlabel is not None:
600
+ dist_ax.set_xlabel(xlabel)
601
+ if ylabel is not None:
602
+ dist_ax.set_ylabel(ylabel)
603
+
604
+ # Remove the boundaries and ticks from annotation axis
605
+ if ann is not None:
606
+ annotation_ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
607
+ else:
608
+ annotation_ax.axis("off")
609
+
610
+ fig.subplots_adjust(hspace=0.01, wspace=0.05)
611
+ plt.close()
612
+ return fig
613
+
614
+
615
+
616
+
617
+