flixopt 1.0.12__py3-none-any.whl → 2.0.1__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.

Potentially problematic release.


This version of flixopt might be problematic. Click here for more details.

Files changed (73) hide show
  1. docs/examples/00-Minimal Example.md +5 -0
  2. docs/examples/01-Basic Example.md +5 -0
  3. docs/examples/02-Complex Example.md +10 -0
  4. docs/examples/03-Calculation Modes.md +5 -0
  5. docs/examples/index.md +5 -0
  6. docs/faq/contribute.md +49 -0
  7. docs/faq/index.md +3 -0
  8. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  9. docs/images/architecture_flixOpt.png +0 -0
  10. docs/images/flixopt-icon.svg +1 -0
  11. docs/javascripts/mathjax.js +18 -0
  12. docs/release-notes/_template.txt +32 -0
  13. docs/release-notes/index.md +7 -0
  14. docs/release-notes/v2.0.0.md +93 -0
  15. docs/release-notes/v2.0.1.md +12 -0
  16. docs/user-guide/Mathematical Notation/Bus.md +33 -0
  17. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
  18. docs/user-guide/Mathematical Notation/Flow.md +26 -0
  19. docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
  20. docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
  21. docs/user-guide/Mathematical Notation/Storage.md +44 -0
  22. docs/user-guide/Mathematical Notation/index.md +22 -0
  23. docs/user-guide/Mathematical Notation/others.md +3 -0
  24. docs/user-guide/index.md +124 -0
  25. {flixOpt → flixopt}/__init__.py +5 -2
  26. {flixOpt → flixopt}/aggregation.py +113 -140
  27. flixopt/calculation.py +455 -0
  28. {flixOpt → flixopt}/commons.py +7 -4
  29. flixopt/components.py +630 -0
  30. {flixOpt → flixopt}/config.py +9 -8
  31. {flixOpt → flixopt}/config.yaml +3 -3
  32. flixopt/core.py +970 -0
  33. flixopt/effects.py +386 -0
  34. flixopt/elements.py +534 -0
  35. flixopt/features.py +1042 -0
  36. flixopt/flow_system.py +409 -0
  37. flixopt/interface.py +265 -0
  38. flixopt/io.py +308 -0
  39. flixopt/linear_converters.py +331 -0
  40. flixopt/plotting.py +1340 -0
  41. flixopt/results.py +898 -0
  42. flixopt/solvers.py +77 -0
  43. flixopt/structure.py +630 -0
  44. flixopt/utils.py +62 -0
  45. flixopt-2.0.1.dist-info/METADATA +145 -0
  46. flixopt-2.0.1.dist-info/RECORD +57 -0
  47. {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
  48. flixopt-2.0.1.dist-info/top_level.txt +6 -0
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixopt-icon.svg +1 -0
  52. pics/pics.pptx +0 -0
  53. scripts/gen_ref_pages.py +54 -0
  54. site/release-notes/_template.txt +32 -0
  55. flixOpt/calculation.py +0 -629
  56. flixOpt/components.py +0 -614
  57. flixOpt/core.py +0 -182
  58. flixOpt/effects.py +0 -410
  59. flixOpt/elements.py +0 -489
  60. flixOpt/features.py +0 -942
  61. flixOpt/flow_system.py +0 -351
  62. flixOpt/interface.py +0 -203
  63. flixOpt/linear_converters.py +0 -325
  64. flixOpt/math_modeling.py +0 -1145
  65. flixOpt/plotting.py +0 -712
  66. flixOpt/results.py +0 -563
  67. flixOpt/solvers.py +0 -21
  68. flixOpt/structure.py +0 -733
  69. flixOpt/utils.py +0 -134
  70. flixopt-1.0.12.dist-info/METADATA +0 -174
  71. flixopt-1.0.12.dist-info/RECORD +0 -29
  72. flixopt-1.0.12.dist-info/top_level.txt +0 -3
  73. {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info/licenses}/LICENSE +0 -0
flixopt/plotting.py ADDED
@@ -0,0 +1,1340 @@
1
+ """
2
+ This module contains the plotting functionality of the flixopt framework.
3
+ It provides high level functions to plot data with plotly and matplotlib.
4
+ It's meant to be used in results.py, but is designed to be used by the end user as well.
5
+ """
6
+
7
+ import itertools
8
+ import logging
9
+ import pathlib
10
+ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
11
+
12
+ import matplotlib.colors as mcolors
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import pandas as pd
16
+ import plotly.express as px
17
+ import plotly.graph_objects as go
18
+ import plotly.offline
19
+ from plotly.exceptions import PlotlyError
20
+
21
+ if TYPE_CHECKING:
22
+ import pyvis
23
+
24
+ logger = logging.getLogger('flixopt')
25
+
26
+ # Define the colors for the 'portland' colormap in matplotlib
27
+ _portland_colors = [
28
+ [12 / 255, 51 / 255, 131 / 255], # Dark blue
29
+ [10 / 255, 136 / 255, 186 / 255], # Light blue
30
+ [242 / 255, 211 / 255, 56 / 255], # Yellow
31
+ [242 / 255, 143 / 255, 56 / 255], # Orange
32
+ [217 / 255, 30 / 255, 30 / 255], # Red
33
+ ]
34
+
35
+ # Check if the colormap already exists before registering it
36
+ if 'portland' not in plt.colormaps:
37
+ plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
38
+
39
+
40
+ ColorType = Union[str, List[str], Dict[str, str]]
41
+ """Identifier for the colors to use.
42
+ Use the name of a colorscale, a list of colors or a dictionary of labels to colors.
43
+ The colors must be valid color strings (HEX or names). Depending on the Engine used, other formats are possible.
44
+ See also:
45
+ - https://htmlcolorcodes.com/color-names/
46
+ - https://matplotlib.org/stable/tutorials/colors/colormaps.html
47
+ - https://plotly.com/python/builtin-colorscales/
48
+ """
49
+
50
+ PlottingEngine = Literal['plotly', 'matplotlib']
51
+ """Identifier for the plotting engine to use."""
52
+
53
+
54
+ class ColorProcessor:
55
+ """Class to handle color processing for different visualization engines."""
56
+
57
+ def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'):
58
+ """
59
+ Initialize the color processor.
60
+
61
+ Args:
62
+ engine: The plotting engine to use ('plotly' or 'matplotlib')
63
+ default_colormap: Default colormap to use if none is specified
64
+ """
65
+ if engine not in ['plotly', 'matplotlib']:
66
+ raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}')
67
+ self.engine = engine
68
+ self.default_colormap = default_colormap
69
+
70
+ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> List[Any]:
71
+ """
72
+ Generate colors from a named colormap.
73
+
74
+ Args:
75
+ colormap_name: Name of the colormap
76
+ num_colors: Number of colors to generate
77
+
78
+ Returns:
79
+ List of colors in the format appropriate for the engine
80
+ """
81
+ if self.engine == 'plotly':
82
+ try:
83
+ colorscale = px.colors.get_colorscale(colormap_name)
84
+ except PlotlyError as e:
85
+ logger.warning(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}")
86
+ colorscale = px.colors.get_colorscale(self.default_colormap)
87
+
88
+ # Generate evenly spaced points
89
+ color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0]
90
+ return px.colors.sample_colorscale(colorscale, color_points)
91
+
92
+ else: # matplotlib
93
+ try:
94
+ cmap = plt.get_cmap(colormap_name, num_colors)
95
+ except ValueError as e:
96
+ logger.warning(
97
+ f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}"
98
+ )
99
+ cmap = plt.get_cmap(self.default_colormap, num_colors)
100
+
101
+ return [cmap(i) for i in range(num_colors)]
102
+
103
+ def _handle_color_list(self, colors: List[str], num_labels: int) -> List[str]:
104
+ """
105
+ Handle a list of colors, cycling if necessary.
106
+
107
+ Args:
108
+ colors: List of color strings
109
+ num_labels: Number of labels that need colors
110
+
111
+ Returns:
112
+ List of colors matching the number of labels
113
+ """
114
+ if len(colors) == 0:
115
+ logger.warning(f'Empty color list provided. Using {self.default_colormap} instead.')
116
+ return self._generate_colors_from_colormap(self.default_colormap, num_labels)
117
+
118
+ if len(colors) < num_labels:
119
+ logger.warning(
120
+ f'Not enough colors provided ({len(colors)}) for all labels ({num_labels}). Colors will cycle.'
121
+ )
122
+ # Cycle through the colors
123
+ color_iter = itertools.cycle(colors)
124
+ return [next(color_iter) for _ in range(num_labels)]
125
+ else:
126
+ # Trim if necessary
127
+ if len(colors) > num_labels:
128
+ logger.warning(
129
+ f'More colors provided ({len(colors)}) than labels ({num_labels}). Extra colors will be ignored.'
130
+ )
131
+ return colors[:num_labels]
132
+
133
+ def _handle_color_dict(self, colors: Dict[str, str], labels: List[str]) -> List[str]:
134
+ """
135
+ Handle a dictionary mapping labels to colors.
136
+
137
+ Args:
138
+ colors: Dictionary mapping labels to colors
139
+ labels: List of labels that need colors
140
+
141
+ Returns:
142
+ List of colors in the same order as labels
143
+ """
144
+ if len(colors) == 0:
145
+ logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.')
146
+ return self._generate_colors_from_colormap(self.default_colormap, len(labels))
147
+
148
+ # Find missing labels
149
+ missing_labels = set(labels) - set(colors.keys())
150
+ if missing_labels:
151
+ logger.warning(
152
+ f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.'
153
+ )
154
+
155
+ # Generate colors for missing labels
156
+ missing_colors = self._generate_colors_from_colormap(self.default_colormap, len(missing_labels))
157
+
158
+ # Create a copy to avoid modifying the original
159
+ colors_copy = colors.copy()
160
+ for i, label in enumerate(missing_labels):
161
+ colors_copy[label] = missing_colors[i]
162
+ else:
163
+ colors_copy = colors
164
+
165
+ # Create color list in the same order as labels
166
+ return [colors_copy[label] for label in labels]
167
+
168
+ def process_colors(
169
+ self,
170
+ colors: ColorType,
171
+ labels: List[str],
172
+ return_mapping: bool = False,
173
+ ) -> Union[List[Any], Dict[str, Any]]:
174
+ """
175
+ Process colors for the specified labels.
176
+
177
+ Args:
178
+ colors: Color specification (colormap name, list of colors, or label-to-color mapping)
179
+ labels: List of data labels that need colors assigned
180
+ return_mapping: If True, returns a dictionary mapping labels to colors;
181
+ if False, returns a list of colors in the same order as labels
182
+
183
+ Returns:
184
+ Either a list of colors or a dictionary mapping labels to colors
185
+ """
186
+ if len(labels) == 0:
187
+ logger.warning('No labels provided for color assignment.')
188
+ return {} if return_mapping else []
189
+
190
+ # Process based on type of colors input
191
+ if isinstance(colors, str):
192
+ color_list = self._generate_colors_from_colormap(colors, len(labels))
193
+ elif isinstance(colors, list):
194
+ color_list = self._handle_color_list(colors, len(labels))
195
+ elif isinstance(colors, dict):
196
+ color_list = self._handle_color_dict(colors, labels)
197
+ else:
198
+ logger.warning(
199
+ f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.'
200
+ )
201
+ color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels))
202
+
203
+ # Return either a list or a mapping
204
+ if return_mapping:
205
+ return {label: color_list[i] for i, label in enumerate(labels)}
206
+ else:
207
+ return color_list
208
+
209
+
210
+ def with_plotly(
211
+ data: pd.DataFrame,
212
+ mode: Literal['bar', 'line', 'area'] = 'area',
213
+ colors: ColorType = 'viridis',
214
+ title: str = '',
215
+ ylabel: str = '',
216
+ xlabel: str = 'Time in h',
217
+ fig: Optional[go.Figure] = None,
218
+ ) -> go.Figure:
219
+ """
220
+ Plot a DataFrame with Plotly, using either stacked bars or stepped lines.
221
+
222
+ Args:
223
+ data: A DataFrame containing the data to plot, where the index represents time (e.g., hours),
224
+ and each column represents a separate data series.
225
+ mode: The plotting mode. Use 'bar' for stacked bar charts, 'line' for stepped lines,
226
+ or 'area' for stacked area charts.
227
+ colors: Color specification, can be:
228
+ - A string with a colorscale name (e.g., 'viridis', 'plasma')
229
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
230
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
231
+ title: The title of the plot.
232
+ ylabel: The label for the y-axis.
233
+ fig: A Plotly figure object to plot on. If not provided, a new figure will be created.
234
+
235
+ Returns:
236
+ A Plotly figure object containing the generated plot.
237
+ """
238
+ assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}"
239
+ if data.empty:
240
+ return go.Figure()
241
+
242
+ processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns))
243
+
244
+ fig = fig if fig is not None else go.Figure()
245
+
246
+ if mode == 'bar':
247
+ for i, column in enumerate(data.columns):
248
+ fig.add_trace(
249
+ go.Bar(
250
+ x=data.index,
251
+ y=data[column],
252
+ name=column,
253
+ marker=dict(color=processed_colors[i]),
254
+ )
255
+ )
256
+
257
+ fig.update_layout(
258
+ barmode='relative' if mode == 'bar' else None,
259
+ bargap=0, # No space between bars
260
+ bargroupgap=0, # No space between groups of bars
261
+ )
262
+ elif mode == 'line':
263
+ for i, column in enumerate(data.columns):
264
+ fig.add_trace(
265
+ go.Scatter(
266
+ x=data.index,
267
+ y=data[column],
268
+ mode='lines',
269
+ name=column,
270
+ line=dict(shape='hv', color=processed_colors[i]),
271
+ )
272
+ )
273
+ elif mode == 'area':
274
+ data = data.copy()
275
+ data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting
276
+ # Split columns into positive, negative, and mixed categories
277
+ positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()])
278
+ negative_columns = list(data.columns[(data <= 0).where(~np.isnan(data), True).all()])
279
+ negative_columns = [column for column in negative_columns if column not in positive_columns]
280
+ mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns))
281
+
282
+ if mixed_columns:
283
+ logger.warning(
284
+ f'Data for plotting stacked lines contains columns with both positive and negative values:'
285
+ f' {mixed_columns}. These can not be stacked, and are printed as simple lines'
286
+ )
287
+
288
+ # Get color mapping for all columns
289
+ colors_stacked = {column: processed_colors[i] for i, column in enumerate(data.columns)}
290
+
291
+ for column in positive_columns + negative_columns:
292
+ fig.add_trace(
293
+ go.Scatter(
294
+ x=data.index,
295
+ y=data[column],
296
+ mode='lines',
297
+ name=column,
298
+ line=dict(shape='hv', color=colors_stacked[column]),
299
+ fill='tonexty',
300
+ stackgroup='pos' if column in positive_columns else 'neg',
301
+ )
302
+ )
303
+
304
+ for column in mixed_columns:
305
+ fig.add_trace(
306
+ go.Scatter(
307
+ x=data.index,
308
+ y=data[column],
309
+ mode='lines',
310
+ name=column,
311
+ line=dict(shape='hv', color=colors_stacked[column], dash='dash'),
312
+ )
313
+ )
314
+
315
+ # Update layout for better aesthetics
316
+ fig.update_layout(
317
+ title=title,
318
+ yaxis=dict(
319
+ title=ylabel,
320
+ showgrid=True, # Enable grid lines on the y-axis
321
+ gridcolor='lightgrey', # Customize grid line color
322
+ gridwidth=0.5, # Customize grid line width
323
+ ),
324
+ xaxis=dict(
325
+ title=xlabel,
326
+ showgrid=True, # Enable grid lines on the x-axis
327
+ gridcolor='lightgrey', # Customize grid line color
328
+ gridwidth=0.5, # Customize grid line width
329
+ ),
330
+ plot_bgcolor='rgba(0,0,0,0)', # Transparent background
331
+ paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
332
+ font=dict(size=14), # Increase font size for better readability
333
+ legend=dict(
334
+ orientation='h', # Horizontal legend
335
+ yanchor='bottom',
336
+ y=-0.3, # Adjusts how far below the plot it appears
337
+ xanchor='center',
338
+ x=0.5,
339
+ title_text=None, # Removes legend title for a cleaner look
340
+ ),
341
+ )
342
+
343
+ return fig
344
+
345
+
346
+ def with_matplotlib(
347
+ data: pd.DataFrame,
348
+ mode: Literal['bar', 'line'] = 'bar',
349
+ colors: ColorType = 'viridis',
350
+ title: str = '',
351
+ ylabel: str = '',
352
+ xlabel: str = 'Time in h',
353
+ figsize: Tuple[int, int] = (12, 6),
354
+ fig: Optional[plt.Figure] = None,
355
+ ax: Optional[plt.Axes] = None,
356
+ ) -> Tuple[plt.Figure, plt.Axes]:
357
+ """
358
+ Plot a DataFrame with Matplotlib using stacked bars or stepped lines.
359
+
360
+ Args:
361
+ data: A DataFrame containing the data to plot. The index should represent time (e.g., hours),
362
+ and each column represents a separate data series.
363
+ mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines.
364
+ colors: Color specification, can be:
365
+ - A string with a colormap name (e.g., 'viridis', 'plasma')
366
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
367
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
368
+ title: The title of the plot.
369
+ ylabel: The ylabel of the plot.
370
+ xlabel: The xlabel of the plot.
371
+ figsize: Specify the size of the figure
372
+ fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created.
373
+ ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created.
374
+
375
+ Returns:
376
+ A tuple containing the Matplotlib figure and axes objects used for the plot.
377
+
378
+ Notes:
379
+ - If `mode` is 'bar', bars are stacked for both positive and negative values.
380
+ Negative values are stacked separately without extra labels in the legend.
381
+ - If `mode` is 'line', stepped lines are drawn for each data series.
382
+ - The legend is placed below the plot to accommodate multiple data series.
383
+ """
384
+ assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib"
385
+
386
+ if fig is None or ax is None:
387
+ fig, ax = plt.subplots(figsize=figsize)
388
+
389
+ processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns))
390
+
391
+ if mode == 'bar':
392
+ cumulative_positive = np.zeros(len(data))
393
+ cumulative_negative = np.zeros(len(data))
394
+ width = data.index.to_series().diff().dropna().min() # Minimum time difference
395
+
396
+ for i, column in enumerate(data.columns):
397
+ positive_values = np.clip(data[column], 0, None) # Keep only positive values
398
+ negative_values = np.clip(data[column], None, 0) # Keep only negative values
399
+ # Plot positive bars
400
+ ax.bar(
401
+ data.index,
402
+ positive_values,
403
+ bottom=cumulative_positive,
404
+ color=processed_colors[i],
405
+ label=column,
406
+ width=width,
407
+ align='center',
408
+ )
409
+ cumulative_positive += positive_values.values
410
+ # Plot negative bars
411
+ ax.bar(
412
+ data.index,
413
+ negative_values,
414
+ bottom=cumulative_negative,
415
+ color=processed_colors[i],
416
+ label='', # No label for negative bars
417
+ width=width,
418
+ align='center',
419
+ )
420
+ cumulative_negative += negative_values.values
421
+
422
+ elif mode == 'line':
423
+ for i, column in enumerate(data.columns):
424
+ ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column)
425
+
426
+ # Aesthetics
427
+ ax.set_xlabel(xlabel, ha='center')
428
+ ax.set_ylabel(ylabel, va='center')
429
+ ax.set_title(title)
430
+ ax.grid(color='lightgrey', linestyle='-', linewidth=0.5)
431
+ ax.legend(
432
+ loc='upper center', # Place legend at the bottom center
433
+ bbox_to_anchor=(0.5, -0.15), # Adjust the position to fit below plot
434
+ ncol=5,
435
+ frameon=False, # Remove box around legend
436
+ )
437
+ fig.tight_layout()
438
+
439
+ return fig, ax
440
+
441
+
442
+ def heat_map_matplotlib(
443
+ data: pd.DataFrame,
444
+ color_map: str = 'viridis',
445
+ title: str = '',
446
+ xlabel: str = 'Period',
447
+ ylabel: str = 'Step',
448
+ figsize: Tuple[float, float] = (12, 6),
449
+ ) -> Tuple[plt.Figure, plt.Axes]:
450
+ """
451
+ Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis,
452
+ the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot.
453
+
454
+ Args:
455
+ data: A DataFrame containing the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis.
456
+ The values in the DataFrame will be represented as colors in the heatmap.
457
+ color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc.
458
+ figsize: The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches.
459
+
460
+ Returns:
461
+ A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area
462
+ where the heatmap is drawn. These can be used for further customization or saving the plot to a file.
463
+
464
+ Notes:
465
+ - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot.
466
+ - The color scale is normalized based on the minimum and maximum values in the DataFrame.
467
+ - The x-axis labels (periods) are placed at the top of the plot.
468
+ - The colorbar is added horizontally at the bottom of the plot, with a label.
469
+ """
470
+
471
+ # Get the min and max values for color normalization
472
+ color_bar_min, color_bar_max = data.min().min(), data.max().max()
473
+
474
+ # Create the heatmap plot
475
+ fig, ax = plt.subplots(figsize=figsize)
476
+ ax.pcolormesh(data.values, cmap=color_map)
477
+ ax.invert_yaxis() # Flip the y-axis to start at the top
478
+
479
+ # Adjust ticks and labels for x and y axes
480
+ ax.set_xticks(np.arange(len(data.columns)) + 0.5)
481
+ ax.set_xticklabels(data.columns, ha='center')
482
+ ax.set_yticks(np.arange(len(data.index)) + 0.5)
483
+ ax.set_yticklabels(data.index, va='center')
484
+
485
+ # Add labels to the axes
486
+ ax.set_xlabel(xlabel, ha='center')
487
+ ax.set_ylabel(ylabel, va='center')
488
+ ax.set_title(title)
489
+
490
+ # Position x-axis labels at the top
491
+ ax.xaxis.set_label_position('top')
492
+ ax.xaxis.set_ticks_position('top')
493
+
494
+ # Add the colorbar
495
+ sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max))
496
+ sm1._A = []
497
+ fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal')
498
+
499
+ fig.tight_layout()
500
+
501
+ return fig, ax
502
+
503
+
504
+ def heat_map_plotly(
505
+ data: pd.DataFrame,
506
+ color_map: str = 'viridis',
507
+ title: str = '',
508
+ xlabel: str = 'Period',
509
+ ylabel: str = 'Step',
510
+ categorical_labels: bool = True,
511
+ ) -> go.Figure:
512
+ """
513
+ Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis,
514
+ and the index will be displayed on the y-axis. The values in the DataFrame will represent the 'heat' in the plot.
515
+
516
+ Args:
517
+ data: A DataFrame with the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis.
518
+ The values in the DataFrame will be represented as colors in the heatmap.
519
+ color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc.
520
+ categorical_labels: If True, the x and y axes are treated as categorical data (i.e., the index and columns will not be interpreted as continuous data).
521
+ Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data.
522
+ show: Wether to show the figure after creation. (This includes saving the figure)
523
+ save: Wether to save the figure after creation (without showing)
524
+ path: Path to save the figure.
525
+
526
+ Returns:
527
+ A Plotly figure object containing the heatmap. This can be further customized and saved
528
+ or displayed using `fig.show()`.
529
+
530
+ Notes:
531
+ The color bar is automatically scaled to the minimum and maximum values in the data.
532
+ The y-axis is reversed to display the first row at the top.
533
+ """
534
+
535
+ color_bar_min, color_bar_max = data.min().min(), data.max().max() # Min and max values for color scaling
536
+ # Define the figure
537
+ fig = go.Figure(
538
+ data=go.Heatmap(
539
+ z=data.values,
540
+ x=data.columns,
541
+ y=data.index,
542
+ colorscale=color_map,
543
+ zmin=color_bar_min,
544
+ zmax=color_bar_max,
545
+ colorbar=dict(
546
+ title=dict(text='Color Bar Label', side='right'),
547
+ orientation='h',
548
+ xref='container',
549
+ yref='container',
550
+ len=0.8, # Color bar length relative to plot
551
+ x=0.5,
552
+ y=0.1,
553
+ ),
554
+ )
555
+ )
556
+
557
+ # Set axis labels and style
558
+ fig.update_layout(
559
+ title=title,
560
+ xaxis=dict(title=xlabel, side='top', type='category' if categorical_labels else None),
561
+ yaxis=dict(title=ylabel, autorange='reversed', type='category' if categorical_labels else None),
562
+ )
563
+
564
+ return fig
565
+
566
+
567
+ def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarray:
568
+ """
569
+ Reshapes a 1D numpy array into a 2D array suitable for plotting as a colormap.
570
+
571
+ The reshaped array will have the number of rows corresponding to the steps per column
572
+ (e.g., 24 hours per day) and columns representing time periods (e.g., days or months).
573
+
574
+ Args:
575
+ data_1d: A 1D numpy array with the data to reshape.
576
+ nr_of_steps_per_column: The number of steps (rows) per column in the resulting 2D array. For example,
577
+ this could be 24 (for hours) or 31 (for days in a month).
578
+
579
+ Returns:
580
+ The reshaped 2D array. Each internal array corresponds to one column, with the specified number of steps.
581
+ Each column might represents a time period (e.g., day, month, etc.).
582
+ """
583
+
584
+ # Step 1: Ensure the input is a 1D array.
585
+ if data_1d.ndim != 1:
586
+ raise ValueError('Input must be a 1D array')
587
+
588
+ # Step 2: Convert data to float type to allow NaN padding
589
+ if data_1d.dtype != np.float64:
590
+ data_1d = data_1d.astype(np.float64)
591
+
592
+ # Step 3: Calculate the number of columns required
593
+ total_steps = len(data_1d)
594
+ cols = len(data_1d) // nr_of_steps_per_column # Base number of columns
595
+
596
+ # If there's a remainder, add an extra column to hold the remaining values
597
+ if total_steps % nr_of_steps_per_column != 0:
598
+ cols += 1
599
+
600
+ # Step 4: Pad the 1D data to match the required number of rows and columns
601
+ padded_data = np.pad(
602
+ data_1d, (0, cols * nr_of_steps_per_column - total_steps), mode='constant', constant_values=np.nan
603
+ )
604
+
605
+ # Step 5: Reshape the padded data into a 2D array
606
+ data_2d = padded_data.reshape(cols, nr_of_steps_per_column)
607
+
608
+ return data_2d.T
609
+
610
+
611
+ def heat_map_data_from_df(
612
+ df: pd.DataFrame,
613
+ periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'],
614
+ steps_per_period: Literal['W', 'D', 'h', '15min', 'min'],
615
+ fill: Optional[Literal['ffill', 'bfill']] = None,
616
+ ) -> pd.DataFrame:
617
+ """
618
+ Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting,
619
+ based on a specified sample rate.
620
+ If a non-valid combination of periods and steps per period is used, falls back to numerical indices
621
+
622
+ Args:
623
+ df: A DataFrame with a DateTime index containing the data to reshape.
624
+ periods: The time interval of each period (columns of the heatmap),
625
+ such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc.
626
+ steps_per_period: The time interval within each period (rows in the heatmap),
627
+ such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc.
628
+ fill: Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill.
629
+
630
+ Returns:
631
+ A DataFrame suitable for heatmap plotting, with rows representing steps within each period
632
+ and columns representing each period.
633
+ """
634
+ assert pd.api.types.is_datetime64_any_dtype(df.index), (
635
+ 'The index of the Dataframe must be datetime to transfrom it properly for a heatmap plot'
636
+ )
637
+
638
+ # Define formats for different combinations of `periods` and `steps_per_period`
639
+ formats = {
640
+ ('YS', 'W'): ('%Y', '%W'),
641
+ ('YS', 'D'): ('%Y', '%j'), # day of year
642
+ ('YS', 'h'): ('%Y', '%j %H:00'),
643
+ ('MS', 'D'): ('%Y-%m', '%d'), # day of month
644
+ ('MS', 'h'): ('%Y-%m', '%d %H:00'),
645
+ ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting)
646
+ ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'),
647
+ ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour
648
+ ('D', '15min'): ('%Y-%m-%d', '%H:%MM'), # Day and hour
649
+ ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
650
+ ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
651
+ }
652
+
653
+ minimum_time_diff_in_min = df.index.to_series().diff().min().total_seconds() / 60 # Smallest time_diff in minutes
654
+ time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60}
655
+ if time_intervals[steps_per_period] > minimum_time_diff_in_min:
656
+ time_intervals[steps_per_period]
657
+ logger.warning(
658
+ f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to '
659
+ f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.'
660
+ )
661
+
662
+ # Select the format based on the `periods` and `steps_per_period` combination
663
+ format_pair = (periods, steps_per_period)
664
+ assert format_pair in formats, f'{format_pair} is not a valid format. Choose from {list(formats.keys())}'
665
+ period_format, step_format = formats[format_pair]
666
+
667
+ df = df.sort_index() # Ensure DataFrame is sorted by time index
668
+
669
+ resampled_data = df.resample(steps_per_period).mean() # Resample and fill any gaps with NaN
670
+
671
+ if fill == 'ffill': # Apply fill method if specified
672
+ resampled_data = resampled_data.ffill()
673
+ elif fill == 'bfill':
674
+ resampled_data = resampled_data.bfill()
675
+
676
+ resampled_data['period'] = resampled_data.index.strftime(period_format)
677
+ resampled_data['step'] = resampled_data.index.strftime(step_format)
678
+ if '%w_%A' in step_format: # SHift index of strings to ensure proper sorting
679
+ resampled_data['step'] = resampled_data['step'].apply(
680
+ lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x
681
+ )
682
+
683
+ # Pivot the table so periods are columns and steps are indices
684
+ df_pivoted = resampled_data.pivot(columns='period', index='step', values=df.columns[0])
685
+
686
+ return df_pivoted
687
+
688
+
689
+ def plot_network(
690
+ node_infos: dict,
691
+ edge_infos: dict,
692
+ path: Optional[Union[str, pathlib.Path]] = None,
693
+ controls: Union[
694
+ bool,
695
+ List[Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']],
696
+ ] = True,
697
+ show: bool = False,
698
+ ) -> Optional['pyvis.network.Network']:
699
+ """
700
+ Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries.
701
+
702
+ Args:
703
+ path: Path to save the HTML visualization. `False`: Visualization is created but not saved. `str` or `Path`: Specifies file path (default: 'results/network.html').
704
+ controls: UI controls to add to the visualization. `True`: Enables all available controls. `List`: Specify controls, e.g., ['nodes', 'layout'].
705
+ Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'.
706
+ You can play with these and generate a Dictionary from it that can be applied to the network returned by this function.
707
+ network.set_options()
708
+ https://pyvis.readthedocs.io/en/latest/tutorial.html
709
+ show: Whether to open the visualization in the web browser.
710
+ The calculation must be saved to show it. If no path is given, it defaults to 'network.html'.
711
+ Returns:
712
+ The `Network` instance representing the visualization, or `None` if `pyvis` is not installed.
713
+
714
+ Notes:
715
+ - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`.
716
+ - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information.
717
+ """
718
+ try:
719
+ from pyvis.network import Network
720
+ except ImportError:
721
+ logger.critical("Plotting the flow system network was not possible. Please install pyvis: 'pip install pyvis'")
722
+ return None
723
+
724
+ net = Network(directed=True, height='100%' if controls is False else '800px', font_color='white')
725
+
726
+ for node_id, node in node_infos.items():
727
+ net.add_node(
728
+ node_id,
729
+ label=node['label'],
730
+ shape={'Bus': 'circle', 'Component': 'box'}[node['class']],
731
+ color={'Bus': '#393E46', 'Component': '#00ADB5'}[node['class']],
732
+ title=node['infos'].replace(')', '\n)'),
733
+ font={'size': 14},
734
+ )
735
+
736
+ for edge in edge_infos.values():
737
+ net.add_edge(
738
+ edge['start'],
739
+ edge['end'],
740
+ label=edge['label'],
741
+ title=edge['infos'].replace(')', '\n)'),
742
+ font={'color': '#4D4D4D', 'size': 14},
743
+ color='#222831',
744
+ )
745
+
746
+ # Enhanced physics settings
747
+ net.barnes_hut(central_gravity=0.8, spring_length=50, spring_strength=0.05, gravity=-10000)
748
+
749
+ if controls:
750
+ net.show_buttons(filter_=controls) # Adds UI buttons to control physics settings
751
+ if not show and not path:
752
+ return net
753
+ elif path:
754
+ path = pathlib.Path(path) if isinstance(path, str) else path
755
+ net.write_html(path.as_posix())
756
+ elif show:
757
+ path = pathlib.Path('network.html')
758
+ net.write_html(path.as_posix())
759
+
760
+ if show:
761
+ try:
762
+ import webbrowser
763
+
764
+ worked = webbrowser.open(f'file://{path.resolve()}', 2)
765
+ if not worked:
766
+ logger.warning(
767
+ f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}'
768
+ )
769
+ except Exception as e:
770
+ logger.warning(
771
+ f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}'
772
+ )
773
+
774
+
775
+ def pie_with_plotly(
776
+ data: pd.DataFrame,
777
+ colors: ColorType = 'viridis',
778
+ title: str = '',
779
+ legend_title: str = '',
780
+ hole: float = 0.0,
781
+ fig: Optional[go.Figure] = None,
782
+ ) -> go.Figure:
783
+ """
784
+ Create a pie chart with Plotly to visualize the proportion of values in a DataFrame.
785
+
786
+ Args:
787
+ data: A DataFrame containing the data to plot. If multiple rows exist,
788
+ they will be summed unless a specific index value is passed.
789
+ colors: Color specification, can be:
790
+ - A string with a colorscale name (e.g., 'viridis', 'plasma')
791
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
792
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
793
+ title: The title of the plot.
794
+ legend_title: The title for the legend.
795
+ hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0).
796
+ fig: A Plotly figure object to plot on. If not provided, a new figure will be created.
797
+
798
+ Returns:
799
+ A Plotly figure object containing the generated pie chart.
800
+
801
+ Notes:
802
+ - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning.
803
+ - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category
804
+ for better readability.
805
+ - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing.
806
+
807
+ """
808
+ if data.empty:
809
+ logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
810
+ return go.Figure()
811
+
812
+ # Create a copy to avoid modifying the original DataFrame
813
+ data_copy = data.copy()
814
+
815
+ # Check if any negative values and warn
816
+ if (data_copy < 0).any().any():
817
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
818
+ data_copy = data_copy.abs()
819
+
820
+ # If data has multiple rows, sum them to get total for each column
821
+ if len(data_copy) > 1:
822
+ data_sum = data_copy.sum()
823
+ else:
824
+ data_sum = data_copy.iloc[0]
825
+
826
+ # Get labels (column names) and values
827
+ labels = data_sum.index.tolist()
828
+ values = data_sum.values.tolist()
829
+
830
+ # Apply color mapping using the unified color processor
831
+ processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns))
832
+
833
+ # Create figure if not provided
834
+ fig = fig if fig is not None else go.Figure()
835
+
836
+ # Add pie trace
837
+ fig.add_trace(
838
+ go.Pie(
839
+ labels=labels,
840
+ values=values,
841
+ hole=hole,
842
+ marker=dict(colors=processed_colors),
843
+ textinfo='percent+label+value',
844
+ textposition='inside',
845
+ insidetextorientation='radial',
846
+ )
847
+ )
848
+
849
+ # Update layout for better aesthetics
850
+ fig.update_layout(
851
+ title=title,
852
+ legend_title=legend_title,
853
+ plot_bgcolor='rgba(0,0,0,0)', # Transparent background
854
+ paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
855
+ font=dict(size=14), # Increase font size for better readability
856
+ )
857
+
858
+ return fig
859
+
860
+
861
+ def pie_with_matplotlib(
862
+ data: pd.DataFrame,
863
+ colors: ColorType = 'viridis',
864
+ title: str = '',
865
+ legend_title: str = 'Categories',
866
+ hole: float = 0.0,
867
+ figsize: Tuple[int, int] = (10, 8),
868
+ fig: Optional[plt.Figure] = None,
869
+ ax: Optional[plt.Axes] = None,
870
+ ) -> Tuple[plt.Figure, plt.Axes]:
871
+ """
872
+ Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame.
873
+
874
+ Args:
875
+ data: A DataFrame containing the data to plot. If multiple rows exist,
876
+ they will be summed unless a specific index value is passed.
877
+ colors: Color specification, can be:
878
+ - A string with a colormap name (e.g., 'viridis', 'plasma')
879
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
880
+ - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
881
+ title: The title of the plot.
882
+ legend_title: The title for the legend.
883
+ hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0).
884
+ figsize: The size of the figure (width, height) in inches.
885
+ fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created.
886
+ ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created.
887
+
888
+ Returns:
889
+ A tuple containing the Matplotlib figure and axes objects used for the plot.
890
+
891
+ Notes:
892
+ - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning.
893
+ - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category
894
+ for better readability.
895
+ - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing.
896
+
897
+ """
898
+ if data.empty:
899
+ logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.')
900
+ if fig is None or ax is None:
901
+ fig, ax = plt.subplots(figsize=figsize)
902
+ return fig, ax
903
+
904
+ # Create a copy to avoid modifying the original DataFrame
905
+ data_copy = data.copy()
906
+
907
+ # Check if any negative values and warn
908
+ if (data_copy < 0).any().any():
909
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
910
+ data_copy = data_copy.abs()
911
+
912
+ # If data has multiple rows, sum them to get total for each column
913
+ if len(data_copy) > 1:
914
+ data_sum = data_copy.sum()
915
+ else:
916
+ data_sum = data_copy.iloc[0]
917
+
918
+ # Get labels (column names) and values
919
+ labels = data_sum.index.tolist()
920
+ values = data_sum.values.tolist()
921
+
922
+ # Apply color mapping using the unified color processor
923
+ processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, labels)
924
+
925
+ # Create figure and axis if not provided
926
+ if fig is None or ax is None:
927
+ fig, ax = plt.subplots(figsize=figsize)
928
+
929
+ # Draw the pie chart
930
+ wedges, texts, autotexts = ax.pie(
931
+ values,
932
+ labels=labels,
933
+ colors=processed_colors,
934
+ autopct='%1.1f%%',
935
+ startangle=90,
936
+ shadow=False,
937
+ wedgeprops=dict(width=0.5) if hole > 0 else None, # Set width for donut
938
+ )
939
+
940
+ # Adjust the wedgeprops to make donut hole size consistent with plotly
941
+ # For matplotlib, the hole size is determined by the wedge width
942
+ # Convert hole parameter to wedge width
943
+ if hole > 0:
944
+ # Adjust hole size to match plotly's hole parameter
945
+ # In matplotlib, wedge width is relative to the radius (which is 1)
946
+ # For plotly, hole is a fraction of the radius
947
+ wedge_width = 1 - hole
948
+ for wedge in wedges:
949
+ wedge.set_width(wedge_width)
950
+
951
+ # Customize the appearance
952
+ # Make autopct text more visible
953
+ for autotext in autotexts:
954
+ autotext.set_fontsize(10)
955
+ autotext.set_color('white')
956
+
957
+ # Set aspect ratio to be equal to ensure a circular pie
958
+ ax.set_aspect('equal')
959
+
960
+ # Add title
961
+ if title:
962
+ ax.set_title(title, fontsize=16)
963
+
964
+ # Create a legend if there are many segments
965
+ if len(labels) > 6:
966
+ ax.legend(wedges, labels, title=legend_title, loc='center left', bbox_to_anchor=(1, 0, 0.5, 1))
967
+
968
+ # Apply tight layout
969
+ fig.tight_layout()
970
+
971
+ return fig, ax
972
+
973
+
974
+ def dual_pie_with_plotly(
975
+ data_left: pd.Series,
976
+ data_right: pd.Series,
977
+ colors: ColorType = 'viridis',
978
+ title: str = '',
979
+ subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
980
+ legend_title: str = '',
981
+ hole: float = 0.2,
982
+ lower_percentage_group: float = 5.0,
983
+ hover_template: str = '%{label}: %{value} (%{percent})',
984
+ text_info: str = 'percent+label',
985
+ text_position: str = 'inside',
986
+ ) -> go.Figure:
987
+ """
988
+ Create two pie charts side by side with Plotly, with consistent coloring across both charts.
989
+
990
+ Args:
991
+ data_left: Series for the left pie chart.
992
+ data_right: Series for the right pie chart.
993
+ colors: Color specification, can be:
994
+ - A string with a colorscale name (e.g., 'viridis', 'plasma')
995
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
996
+ - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'})
997
+ title: The main title of the plot.
998
+ subtitles: Tuple containing the subtitles for (left, right) charts.
999
+ legend_title: The title for the legend.
1000
+ hole: Size of the hole in the center for creating donut charts (0.0 to 100).
1001
+ lower_percentage_group: Whether to group small segments (below percentage (0...1)) into an "Other" category.
1002
+ hover_template: Template for hover text. Use %{label}, %{value}, %{percent}.
1003
+ text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent',
1004
+ 'label+value', 'percent+value', 'label+percent+value', or 'none'.
1005
+ text_position: Position of text: 'inside', 'outside', 'auto', or 'none'.
1006
+
1007
+ Returns:
1008
+ A Plotly figure object containing the generated dual pie chart.
1009
+ """
1010
+ from plotly.subplots import make_subplots
1011
+
1012
+ # Check for empty data
1013
+ if data_left.empty and data_right.empty:
1014
+ logger.warning('Both datasets are empty. Returning empty figure.')
1015
+ return go.Figure()
1016
+
1017
+ # Create a subplot figure
1018
+ fig = make_subplots(
1019
+ rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05
1020
+ )
1021
+
1022
+ # Process series to handle negative values and apply minimum percentage threshold
1023
+ def preprocess_series(series: pd.Series):
1024
+ """
1025
+ Preprocess a series for pie chart display by handling negative values
1026
+ and grouping the smallest parts together if they collectively represent
1027
+ less than the specified percentage threshold.
1028
+
1029
+ Args:
1030
+ series: The series to preprocess
1031
+
1032
+ Returns:
1033
+ A preprocessed pandas Series
1034
+ """
1035
+ # Handle negative values
1036
+ if (series < 0).any():
1037
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1038
+ series = series.abs()
1039
+
1040
+ # Remove zeros
1041
+ series = series[series > 0]
1042
+
1043
+ # Apply minimum percentage threshold if needed
1044
+ if lower_percentage_group and not series.empty:
1045
+ total = series.sum()
1046
+ if total > 0:
1047
+ # Sort series by value (ascending)
1048
+ sorted_series = series.sort_values()
1049
+
1050
+ # Calculate cumulative percentage contribution
1051
+ cumulative_percent = (sorted_series.cumsum() / total) * 100
1052
+
1053
+ # Find entries that collectively make up less than lower_percentage_group
1054
+ to_group = cumulative_percent <= lower_percentage_group
1055
+
1056
+ if to_group.sum() > 1:
1057
+ # Create "Other" category for the smallest values that together are < threshold
1058
+ other_sum = sorted_series[to_group].sum()
1059
+
1060
+ # Keep only values that aren't in the "Other" group
1061
+ result_series = series[~series.index.isin(sorted_series[to_group].index)]
1062
+
1063
+ # Add the "Other" category if it has a value
1064
+ if other_sum > 0:
1065
+ result_series['Other'] = other_sum
1066
+
1067
+ return result_series
1068
+
1069
+ return series
1070
+
1071
+ data_left_processed = preprocess_series(data_left)
1072
+ data_right_processed = preprocess_series(data_right)
1073
+
1074
+ # Get unique set of all labels for consistent coloring
1075
+ all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index))
1076
+
1077
+ # Get consistent color mapping for both charts using our unified function
1078
+ color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True)
1079
+
1080
+ # Function to create a pie trace with consistently mapped colors
1081
+ def create_pie_trace(data_series, side):
1082
+ if data_series.empty:
1083
+ return None
1084
+
1085
+ labels = data_series.index.tolist()
1086
+ values = data_series.values.tolist()
1087
+ trace_colors = [color_map[label] for label in labels]
1088
+
1089
+ return go.Pie(
1090
+ labels=labels,
1091
+ values=values,
1092
+ name=side,
1093
+ marker_colors=trace_colors,
1094
+ hole=hole,
1095
+ textinfo=text_info,
1096
+ textposition=text_position,
1097
+ insidetextorientation='radial',
1098
+ hovertemplate=hover_template,
1099
+ sort=True, # Sort values by default (largest first)
1100
+ )
1101
+
1102
+ # Add left pie if data exists
1103
+ left_trace = create_pie_trace(data_left_processed, subtitles[0])
1104
+ if left_trace:
1105
+ left_trace.domain = dict(x=[0, 0.48])
1106
+ fig.add_trace(left_trace, row=1, col=1)
1107
+
1108
+ # Add right pie if data exists
1109
+ right_trace = create_pie_trace(data_right_processed, subtitles[1])
1110
+ if right_trace:
1111
+ right_trace.domain = dict(x=[0.52, 1])
1112
+ fig.add_trace(right_trace, row=1, col=2)
1113
+
1114
+ # Update layout
1115
+ fig.update_layout(
1116
+ title=title,
1117
+ legend_title=legend_title,
1118
+ plot_bgcolor='rgba(0,0,0,0)', # Transparent background
1119
+ paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
1120
+ font=dict(size=14),
1121
+ margin=dict(t=80, b=50, l=30, r=30),
1122
+ legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)),
1123
+ )
1124
+
1125
+ return fig
1126
+
1127
+
1128
+ def dual_pie_with_matplotlib(
1129
+ data_left: pd.Series,
1130
+ data_right: pd.Series,
1131
+ colors: ColorType = 'viridis',
1132
+ title: str = '',
1133
+ subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'),
1134
+ legend_title: str = '',
1135
+ hole: float = 0.2,
1136
+ lower_percentage_group: float = 5.0,
1137
+ figsize: Tuple[int, int] = (14, 7),
1138
+ fig: Optional[plt.Figure] = None,
1139
+ axes: Optional[List[plt.Axes]] = None,
1140
+ ) -> Tuple[plt.Figure, List[plt.Axes]]:
1141
+ """
1142
+ Create two pie charts side by side with Matplotlib, with consistent coloring across both charts.
1143
+ Leverages the existing pie_with_matplotlib function.
1144
+
1145
+ Args:
1146
+ data_left: Series for the left pie chart.
1147
+ data_right: Series for the right pie chart.
1148
+ colors: Color specification, can be:
1149
+ - A string with a colormap name (e.g., 'viridis', 'plasma')
1150
+ - A list of color strings (e.g., ['#ff0000', '#00ff00'])
1151
+ - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'})
1152
+ title: The main title of the plot.
1153
+ subtitles: Tuple containing the subtitles for (left, right) charts.
1154
+ legend_title: The title for the legend.
1155
+ hole: Size of the hole in the center for creating donut charts (0.0 to 1.0).
1156
+ lower_percentage_group: Whether to group small segments (below percentage) into an "Other" category.
1157
+ figsize: The size of the figure (width, height) in inches.
1158
+ fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created.
1159
+ axes: A list of Matplotlib axes objects to plot on. If not provided, new axes will be created.
1160
+
1161
+ Returns:
1162
+ A tuple containing the Matplotlib figure and list of axes objects used for the plot.
1163
+ """
1164
+ # Check for empty data
1165
+ if data_left.empty and data_right.empty:
1166
+ logger.warning('Both datasets are empty. Returning empty figure.')
1167
+ if fig is None:
1168
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
1169
+ return fig, axes
1170
+
1171
+ # Create figure and axes if not provided
1172
+ if fig is None or axes is None:
1173
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
1174
+
1175
+ # Process series to handle negative values and apply minimum percentage threshold
1176
+ def preprocess_series(series: pd.Series):
1177
+ """
1178
+ Preprocess a series for pie chart display by handling negative values
1179
+ and grouping the smallest parts together if they collectively represent
1180
+ less than the specified percentage threshold.
1181
+ """
1182
+ # Handle negative values
1183
+ if (series < 0).any():
1184
+ logger.warning('Negative values detected in data. Using absolute values for pie chart.')
1185
+ series = series.abs()
1186
+
1187
+ # Remove zeros
1188
+ series = series[series > 0]
1189
+
1190
+ # Apply minimum percentage threshold if needed
1191
+ if lower_percentage_group and not series.empty:
1192
+ total = series.sum()
1193
+ if total > 0:
1194
+ # Sort series by value (ascending)
1195
+ sorted_series = series.sort_values()
1196
+
1197
+ # Calculate cumulative percentage contribution
1198
+ cumulative_percent = (sorted_series.cumsum() / total) * 100
1199
+
1200
+ # Find entries that collectively make up less than lower_percentage_group
1201
+ to_group = cumulative_percent <= lower_percentage_group
1202
+
1203
+ if to_group.sum() > 1:
1204
+ # Create "Other" category for the smallest values that together are < threshold
1205
+ other_sum = sorted_series[to_group].sum()
1206
+
1207
+ # Keep only values that aren't in the "Other" group
1208
+ result_series = series[~series.index.isin(sorted_series[to_group].index)]
1209
+
1210
+ # Add the "Other" category if it has a value
1211
+ if other_sum > 0:
1212
+ result_series['Other'] = other_sum
1213
+
1214
+ return result_series
1215
+
1216
+ return series
1217
+
1218
+ # Preprocess data
1219
+ data_left_processed = preprocess_series(data_left)
1220
+ data_right_processed = preprocess_series(data_right)
1221
+
1222
+ # Convert Series to DataFrames for pie_with_matplotlib
1223
+ df_left = pd.DataFrame(data_left_processed).T if not data_left_processed.empty else pd.DataFrame()
1224
+ df_right = pd.DataFrame(data_right_processed).T if not data_right_processed.empty else pd.DataFrame()
1225
+
1226
+ # Get unique set of all labels for consistent coloring
1227
+ all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index))
1228
+
1229
+ # Get consistent color mapping for both charts using our unified function
1230
+ color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True)
1231
+
1232
+ # Configure colors for each DataFrame based on the consistent mapping
1233
+ left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else []
1234
+ right_colors = [color_map[col] for col in df_right.columns] if not df_right.empty else []
1235
+
1236
+ # Create left pie chart
1237
+ if not df_left.empty:
1238
+ pie_with_matplotlib(data=df_left, colors=left_colors, title=subtitles[0], hole=hole, fig=fig, ax=axes[0])
1239
+ else:
1240
+ axes[0].set_title(subtitles[0])
1241
+ axes[0].axis('off')
1242
+
1243
+ # Create right pie chart
1244
+ if not df_right.empty:
1245
+ pie_with_matplotlib(data=df_right, colors=right_colors, title=subtitles[1], hole=hole, fig=fig, ax=axes[1])
1246
+ else:
1247
+ axes[1].set_title(subtitles[1])
1248
+ axes[1].axis('off')
1249
+
1250
+ # Add main title
1251
+ if title:
1252
+ fig.suptitle(title, fontsize=16, y=0.98)
1253
+
1254
+ # Adjust layout
1255
+ fig.tight_layout()
1256
+
1257
+ # Create a unified legend if both charts have data
1258
+ if not df_left.empty and not df_right.empty:
1259
+ # Remove individual legends
1260
+ for ax in axes:
1261
+ if ax.get_legend():
1262
+ ax.get_legend().remove()
1263
+
1264
+ # Create handles for the unified legend
1265
+ handles = []
1266
+ labels_for_legend = []
1267
+
1268
+ for label in all_labels:
1269
+ color = color_map[label]
1270
+ patch = plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=label)
1271
+ handles.append(patch)
1272
+ labels_for_legend.append(label)
1273
+
1274
+ # Add unified legend
1275
+ fig.legend(
1276
+ handles=handles,
1277
+ labels=labels_for_legend,
1278
+ title=legend_title,
1279
+ loc='lower center',
1280
+ bbox_to_anchor=(0.5, 0),
1281
+ ncol=min(len(all_labels), 5), # Limit columns to 5 for readability
1282
+ )
1283
+
1284
+ # Add padding at the bottom for the legend
1285
+ fig.subplots_adjust(bottom=0.2)
1286
+
1287
+ return fig, axes
1288
+
1289
+
1290
+ def export_figure(
1291
+ figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]],
1292
+ default_path: pathlib.Path,
1293
+ default_filetype: Optional[str] = None,
1294
+ user_path: Optional[pathlib.Path] = None,
1295
+ show: bool = True,
1296
+ save: bool = False,
1297
+ ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
1298
+ """
1299
+ Export a figure to a file and or show it.
1300
+
1301
+ Args:
1302
+ figure_like: The figure to export. Can be a Plotly figure or a tuple of Matplotlib figure and axes.
1303
+ default_path: The default file path if no user filename is provided.
1304
+ default_filetype: The default filetype if the path doesnt end with a filetype.
1305
+ user_path: An optional user-specified file path.
1306
+ show: Whether to display the figure (default: True).
1307
+ save: Whether to save the figure (default: False).
1308
+
1309
+ Raises:
1310
+ ValueError: If no default filetype is provided and the path doesn't specify a filetype.
1311
+ TypeError: If the figure type is not supported.
1312
+ """
1313
+ filename = user_path or default_path
1314
+ filename = filename.with_name(filename.name.replace('|', '__'))
1315
+ if filename.suffix == '':
1316
+ if default_filetype is None:
1317
+ raise ValueError('No default filetype provided')
1318
+ filename = filename.with_suffix(default_filetype)
1319
+
1320
+ if isinstance(figure_like, plotly.graph_objs.Figure):
1321
+ fig = figure_like
1322
+ if not filename.suffix == '.html':
1323
+ logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}')
1324
+ if show and not save:
1325
+ fig.show()
1326
+ elif save and show:
1327
+ plotly.offline.plot(fig, filename=str(filename))
1328
+ elif save and not show:
1329
+ fig.write_html(filename)
1330
+ return figure_like
1331
+
1332
+ elif isinstance(figure_like, tuple):
1333
+ fig, ax = figure_like
1334
+ if show:
1335
+ fig.show()
1336
+ if save:
1337
+ fig.savefig(str(filename), dpi=300)
1338
+ return fig, ax
1339
+
1340
+ raise TypeError(f'Figure type not supported: {type(figure_like)}')