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.
- docs/examples/00-Minimal Example.md +5 -0
- docs/examples/01-Basic Example.md +5 -0
- docs/examples/02-Complex Example.md +10 -0
- docs/examples/03-Calculation Modes.md +5 -0
- docs/examples/index.md +5 -0
- docs/faq/contribute.md +49 -0
- docs/faq/index.md +3 -0
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +1 -0
- docs/javascripts/mathjax.js +18 -0
- docs/release-notes/_template.txt +32 -0
- docs/release-notes/index.md +7 -0
- docs/release-notes/v2.0.0.md +93 -0
- docs/release-notes/v2.0.1.md +12 -0
- docs/user-guide/Mathematical Notation/Bus.md +33 -0
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
- docs/user-guide/Mathematical Notation/Flow.md +26 -0
- docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
- docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
- docs/user-guide/Mathematical Notation/Storage.md +44 -0
- docs/user-guide/Mathematical Notation/index.md +22 -0
- docs/user-guide/Mathematical Notation/others.md +3 -0
- docs/user-guide/index.md +124 -0
- {flixOpt → flixopt}/__init__.py +5 -2
- {flixOpt → flixopt}/aggregation.py +113 -140
- flixopt/calculation.py +455 -0
- {flixOpt → flixopt}/commons.py +7 -4
- flixopt/components.py +630 -0
- {flixOpt → flixopt}/config.py +9 -8
- {flixOpt → flixopt}/config.yaml +3 -3
- flixopt/core.py +970 -0
- flixopt/effects.py +386 -0
- flixopt/elements.py +534 -0
- flixopt/features.py +1042 -0
- flixopt/flow_system.py +409 -0
- flixopt/interface.py +265 -0
- flixopt/io.py +308 -0
- flixopt/linear_converters.py +331 -0
- flixopt/plotting.py +1340 -0
- flixopt/results.py +898 -0
- flixopt/solvers.py +77 -0
- flixopt/structure.py +630 -0
- flixopt/utils.py +62 -0
- flixopt-2.0.1.dist-info/METADATA +145 -0
- flixopt-2.0.1.dist-info/RECORD +57 -0
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
- flixopt-2.0.1.dist-info/top_level.txt +6 -0
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixopt-icon.svg +1 -0
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +54 -0
- site/release-notes/_template.txt +32 -0
- flixOpt/calculation.py +0 -629
- flixOpt/components.py +0 -614
- flixOpt/core.py +0 -182
- flixOpt/effects.py +0 -410
- flixOpt/elements.py +0 -489
- flixOpt/features.py +0 -942
- flixOpt/flow_system.py +0 -351
- flixOpt/interface.py +0 -203
- flixOpt/linear_converters.py +0 -325
- flixOpt/math_modeling.py +0 -1145
- flixOpt/plotting.py +0 -712
- flixOpt/results.py +0 -563
- flixOpt/solvers.py +0 -21
- flixOpt/structure.py +0 -733
- flixOpt/utils.py +0 -134
- flixopt-1.0.12.dist-info/METADATA +0 -174
- flixopt-1.0.12.dist-info/RECORD +0 -29
- flixopt-1.0.12.dist-info/top_level.txt +0 -3
- {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)}')
|