flixopt 2.2.0b0__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- flixopt/__init__.py +35 -1
- flixopt/aggregation.py +60 -81
- flixopt/calculation.py +381 -196
- flixopt/components.py +1022 -359
- flixopt/config.py +553 -191
- flixopt/core.py +475 -1315
- flixopt/effects.py +477 -214
- flixopt/elements.py +591 -344
- flixopt/features.py +403 -957
- flixopt/flow_system.py +781 -293
- flixopt/interface.py +1159 -189
- flixopt/io.py +50 -55
- flixopt/linear_converters.py +384 -92
- flixopt/modeling.py +759 -0
- flixopt/network_app.py +789 -0
- flixopt/plotting.py +273 -135
- flixopt/results.py +639 -383
- flixopt/solvers.py +25 -21
- flixopt/structure.py +928 -442
- flixopt/utils.py +34 -5
- flixopt-3.0.0.dist-info/METADATA +209 -0
- flixopt-3.0.0.dist-info/RECORD +26 -0
- {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
- flixopt-3.0.0.dist-info/top_level.txt +1 -0
- docs/examples/00-Minimal Example.md +0 -5
- docs/examples/01-Basic Example.md +0 -5
- docs/examples/02-Complex Example.md +0 -10
- docs/examples/03-Calculation Modes.md +0 -5
- docs/examples/index.md +0 -5
- docs/faq/contribute.md +0 -49
- docs/faq/index.md +0 -3
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +0 -1
- docs/javascripts/mathjax.js +0 -18
- docs/release-notes/_template.txt +0 -32
- docs/release-notes/index.md +0 -7
- docs/release-notes/v2.0.0.md +0 -93
- docs/release-notes/v2.0.1.md +0 -12
- docs/release-notes/v2.1.0.md +0 -31
- docs/release-notes/v2.2.0.md +0 -55
- docs/user-guide/Mathematical Notation/Bus.md +0 -33
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
- docs/user-guide/Mathematical Notation/Flow.md +0 -26
- docs/user-guide/Mathematical Notation/Investment.md +0 -115
- docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
- docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
- docs/user-guide/Mathematical Notation/Storage.md +0 -44
- docs/user-guide/Mathematical Notation/index.md +0 -22
- docs/user-guide/Mathematical Notation/others.md +0 -3
- docs/user-guide/index.md +0 -124
- flixopt/config.yaml +0 -10
- flixopt-2.2.0b0.dist-info/METADATA +0 -146
- flixopt-2.2.0b0.dist-info/RECORD +0 -59
- flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixOpt_plotting.jpg +0 -0
- pics/flixopt-icon.svg +0 -1
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +0 -54
- tests/ressources/Zeitreihen2020.csv +0 -35137
- {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/plotting.py
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
|
+
"""Comprehensive visualization toolkit for flixopt optimization results and data analysis.
|
|
2
|
+
|
|
3
|
+
This module provides a unified plotting interface supporting both Plotly (interactive)
|
|
4
|
+
and Matplotlib (static) backends for visualizing energy system optimization results.
|
|
5
|
+
It offers specialized plotting functions for time series, heatmaps, network diagrams,
|
|
6
|
+
and statistical analyses commonly needed in energy system modeling.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
**Dual Backend Support**: Seamless switching between Plotly and Matplotlib
|
|
10
|
+
**Energy System Focus**: Specialized plots for power flows, storage states, emissions
|
|
11
|
+
**Color Management**: Intelligent color processing and palette management
|
|
12
|
+
**Export Capabilities**: High-quality export for reports and publications
|
|
13
|
+
**Integration Ready**: Designed for use with CalculationResults and standalone analysis
|
|
14
|
+
|
|
15
|
+
Main Plot Types:
|
|
16
|
+
- **Time Series**: Flow rates, power profiles, storage states over time
|
|
17
|
+
- **Heatmaps**: High-resolution temporal data visualization with customizable aggregation
|
|
18
|
+
- **Network Diagrams**: System topology with flow visualization
|
|
19
|
+
- **Statistical Plots**: Distribution analysis, correlation studies, performance metrics
|
|
20
|
+
- **Comparative Analysis**: Multi-scenario and sensitivity study visualizations
|
|
21
|
+
|
|
22
|
+
The module integrates seamlessly with flixopt's result classes while remaining
|
|
23
|
+
accessible for standalone data visualization tasks.
|
|
1
24
|
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
It's meant to be used in results.py, but is designed to be used by the end user as well.
|
|
5
|
-
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
6
27
|
|
|
7
28
|
import itertools
|
|
8
29
|
import logging
|
|
30
|
+
import os
|
|
9
31
|
import pathlib
|
|
10
|
-
from typing import TYPE_CHECKING, Any,
|
|
32
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
33
|
|
|
34
|
+
import matplotlib
|
|
12
35
|
import matplotlib.colors as mcolors
|
|
13
36
|
import matplotlib.pyplot as plt
|
|
14
37
|
import numpy as np
|
|
@@ -33,18 +56,63 @@ _portland_colors = [
|
|
|
33
56
|
]
|
|
34
57
|
|
|
35
58
|
# Check if the colormap already exists before registering it
|
|
36
|
-
if '
|
|
37
|
-
plt.colormaps
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
if hasattr(plt, 'colormaps'): # Matplotlib >= 3.7
|
|
60
|
+
registry = plt.colormaps
|
|
61
|
+
if 'portland' not in registry:
|
|
62
|
+
registry.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
|
|
63
|
+
else: # Matplotlib < 3.7
|
|
64
|
+
if 'portland' not in [c for c in plt.colormaps()]:
|
|
65
|
+
plt.register_cmap(name='portland', cmap=mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ColorType = str | list[str] | dict[str, str]
|
|
69
|
+
"""Flexible color specification type supporting multiple input formats for visualization.
|
|
70
|
+
|
|
71
|
+
Color specifications can take several forms to accommodate different use cases:
|
|
72
|
+
|
|
73
|
+
**Named Colormaps** (str):
|
|
74
|
+
- Standard colormaps: 'viridis', 'plasma', 'cividis', 'tab10', 'Set1'
|
|
75
|
+
- Energy-focused: 'portland' (custom flixopt colormap for energy systems)
|
|
76
|
+
- Backend-specific maps available in Plotly and Matplotlib
|
|
77
|
+
|
|
78
|
+
**Color Lists** (list[str]):
|
|
79
|
+
- Explicit color sequences: ['red', 'blue', 'green', 'orange']
|
|
80
|
+
- HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500']
|
|
81
|
+
- Mixed formats: ['red', '#0000FF', 'green', 'orange']
|
|
82
|
+
|
|
83
|
+
**Label-to-Color Mapping** (dict[str, str]):
|
|
84
|
+
- Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'}
|
|
85
|
+
- Ensures consistent colors across different plots and datasets
|
|
86
|
+
- Ideal for energy system components with semantic meaning
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
```python
|
|
90
|
+
# Named colormap
|
|
91
|
+
colors = 'viridis' # Automatic color generation
|
|
92
|
+
|
|
93
|
+
# Explicit color list
|
|
94
|
+
colors = ['red', 'blue', 'green', '#FFD700']
|
|
95
|
+
|
|
96
|
+
# Component-specific mapping
|
|
97
|
+
colors = {
|
|
98
|
+
'Wind_Turbine': 'skyblue',
|
|
99
|
+
'Solar_Panel': 'gold',
|
|
100
|
+
'Natural_Gas': 'brown',
|
|
101
|
+
'Battery': 'green',
|
|
102
|
+
'Electric_Load': 'darkred'
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Color Format Support:
|
|
107
|
+
- **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange'
|
|
108
|
+
- **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00'
|
|
109
|
+
- **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only]
|
|
110
|
+
- **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only]
|
|
111
|
+
|
|
112
|
+
References:
|
|
113
|
+
- HTML Color Names: https://htmlcolorcodes.com/color-names/
|
|
114
|
+
- Matplotlib Colormaps: https://matplotlib.org/stable/tutorials/colors/colormaps.html
|
|
115
|
+
- Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/
|
|
48
116
|
"""
|
|
49
117
|
|
|
50
118
|
PlottingEngine = Literal['plotly', 'matplotlib']
|
|
@@ -52,22 +120,74 @@ PlottingEngine = Literal['plotly', 'matplotlib']
|
|
|
52
120
|
|
|
53
121
|
|
|
54
122
|
class ColorProcessor:
|
|
55
|
-
"""
|
|
123
|
+
"""Intelligent color management system for consistent multi-backend visualization.
|
|
124
|
+
|
|
125
|
+
This class provides unified color processing across Plotly and Matplotlib backends,
|
|
126
|
+
ensuring consistent visual appearance regardless of the plotting engine used.
|
|
127
|
+
It handles color palette generation, named colormap translation, and intelligent
|
|
128
|
+
color cycling for complex datasets with many categories.
|
|
129
|
+
|
|
130
|
+
Key Features:
|
|
131
|
+
**Backend Agnostic**: Automatic color format conversion between engines
|
|
132
|
+
**Palette Management**: Support for named colormaps, custom palettes, and color lists
|
|
133
|
+
**Intelligent Cycling**: Smart color assignment for datasets with many categories
|
|
134
|
+
**Fallback Handling**: Graceful degradation when requested colormaps are unavailable
|
|
135
|
+
**Energy System Colors**: Built-in palettes optimized for energy system visualization
|
|
136
|
+
|
|
137
|
+
Color Input Types:
|
|
138
|
+
- **Named Colormaps**: 'viridis', 'plasma', 'portland', 'tab10', etc.
|
|
139
|
+
- **Color Lists**: ['red', 'blue', 'green'] or ['#FF0000', '#0000FF', '#00FF00']
|
|
140
|
+
- **Label Dictionaries**: {'Generator': 'red', 'Storage': 'blue', 'Load': 'green'}
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
Basic color processing:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Initialize for Plotly backend
|
|
147
|
+
processor = ColorProcessor(engine='plotly', default_colormap='viridis')
|
|
148
|
+
|
|
149
|
+
# Process different color specifications
|
|
150
|
+
colors = processor.process_colors('plasma', ['Gen1', 'Gen2', 'Storage'])
|
|
151
|
+
colors = processor.process_colors(['red', 'blue', 'green'], ['A', 'B', 'C'])
|
|
152
|
+
colors = processor.process_colors({'Wind': 'skyblue', 'Solar': 'gold'}, ['Wind', 'Solar', 'Gas'])
|
|
153
|
+
|
|
154
|
+
# Switch to Matplotlib
|
|
155
|
+
processor = ColorProcessor(engine='matplotlib')
|
|
156
|
+
mpl_colors = processor.process_colors('tab10', component_labels)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Energy system visualization:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
# Specialized energy system palette
|
|
163
|
+
energy_colors = {
|
|
164
|
+
'Natural_Gas': '#8B4513', # Brown
|
|
165
|
+
'Electricity': '#FFD700', # Gold
|
|
166
|
+
'Heat': '#FF4500', # Red-orange
|
|
167
|
+
'Cooling': '#87CEEB', # Sky blue
|
|
168
|
+
'Hydrogen': '#E6E6FA', # Lavender
|
|
169
|
+
'Battery': '#32CD32', # Lime green
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
processor = ColorProcessor('plotly')
|
|
173
|
+
flow_colors = processor.process_colors(energy_colors, flow_labels)
|
|
174
|
+
```
|
|
56
175
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
176
|
+
Args:
|
|
177
|
+
engine: Plotting backend ('plotly' or 'matplotlib'). Determines output color format.
|
|
178
|
+
default_colormap: Fallback colormap when requested palettes are unavailable.
|
|
179
|
+
Common options: 'viridis', 'plasma', 'tab10', 'portland'.
|
|
60
180
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"""
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'):
|
|
184
|
+
"""Initialize the color processor with specified backend and defaults."""
|
|
65
185
|
if engine not in ['plotly', 'matplotlib']:
|
|
66
186
|
raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}')
|
|
67
187
|
self.engine = engine
|
|
68
188
|
self.default_colormap = default_colormap
|
|
69
189
|
|
|
70
|
-
def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) ->
|
|
190
|
+
def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list[Any]:
|
|
71
191
|
"""
|
|
72
192
|
Generate colors from a named colormap.
|
|
73
193
|
|
|
@@ -76,13 +196,13 @@ class ColorProcessor:
|
|
|
76
196
|
num_colors: Number of colors to generate
|
|
77
197
|
|
|
78
198
|
Returns:
|
|
79
|
-
|
|
199
|
+
list of colors in the format appropriate for the engine
|
|
80
200
|
"""
|
|
81
201
|
if self.engine == 'plotly':
|
|
82
202
|
try:
|
|
83
203
|
colorscale = px.colors.get_colorscale(colormap_name)
|
|
84
204
|
except PlotlyError as e:
|
|
85
|
-
logger.
|
|
205
|
+
logger.error(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}")
|
|
86
206
|
colorscale = px.colors.get_colorscale(self.default_colormap)
|
|
87
207
|
|
|
88
208
|
# Generate evenly spaced points
|
|
@@ -93,26 +213,24 @@ class ColorProcessor:
|
|
|
93
213
|
try:
|
|
94
214
|
cmap = plt.get_cmap(colormap_name, num_colors)
|
|
95
215
|
except ValueError as e:
|
|
96
|
-
logger.
|
|
97
|
-
f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}"
|
|
98
|
-
)
|
|
216
|
+
logger.error(f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}")
|
|
99
217
|
cmap = plt.get_cmap(self.default_colormap, num_colors)
|
|
100
218
|
|
|
101
219
|
return [cmap(i) for i in range(num_colors)]
|
|
102
220
|
|
|
103
|
-
def _handle_color_list(self, colors:
|
|
221
|
+
def _handle_color_list(self, colors: list[str], num_labels: int) -> list[str]:
|
|
104
222
|
"""
|
|
105
223
|
Handle a list of colors, cycling if necessary.
|
|
106
224
|
|
|
107
225
|
Args:
|
|
108
|
-
colors:
|
|
226
|
+
colors: list of color strings
|
|
109
227
|
num_labels: Number of labels that need colors
|
|
110
228
|
|
|
111
229
|
Returns:
|
|
112
|
-
|
|
230
|
+
list of colors matching the number of labels
|
|
113
231
|
"""
|
|
114
232
|
if len(colors) == 0:
|
|
115
|
-
logger.
|
|
233
|
+
logger.error(f'Empty color list provided. Using {self.default_colormap} instead.')
|
|
116
234
|
return self._generate_colors_from_colormap(self.default_colormap, num_labels)
|
|
117
235
|
|
|
118
236
|
if len(colors) < num_labels:
|
|
@@ -130,23 +248,23 @@ class ColorProcessor:
|
|
|
130
248
|
)
|
|
131
249
|
return colors[:num_labels]
|
|
132
250
|
|
|
133
|
-
def _handle_color_dict(self, colors:
|
|
251
|
+
def _handle_color_dict(self, colors: dict[str, str], labels: list[str]) -> list[str]:
|
|
134
252
|
"""
|
|
135
253
|
Handle a dictionary mapping labels to colors.
|
|
136
254
|
|
|
137
255
|
Args:
|
|
138
256
|
colors: Dictionary mapping labels to colors
|
|
139
|
-
labels:
|
|
257
|
+
labels: list of labels that need colors
|
|
140
258
|
|
|
141
259
|
Returns:
|
|
142
|
-
|
|
260
|
+
list of colors in the same order as labels
|
|
143
261
|
"""
|
|
144
262
|
if len(colors) == 0:
|
|
145
263
|
logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.')
|
|
146
264
|
return self._generate_colors_from_colormap(self.default_colormap, len(labels))
|
|
147
265
|
|
|
148
266
|
# Find missing labels
|
|
149
|
-
missing_labels = set(labels) - set(colors.keys())
|
|
267
|
+
missing_labels = sorted(set(labels) - set(colors.keys()))
|
|
150
268
|
if missing_labels:
|
|
151
269
|
logger.warning(
|
|
152
270
|
f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.'
|
|
@@ -168,15 +286,15 @@ class ColorProcessor:
|
|
|
168
286
|
def process_colors(
|
|
169
287
|
self,
|
|
170
288
|
colors: ColorType,
|
|
171
|
-
labels:
|
|
289
|
+
labels: list[str],
|
|
172
290
|
return_mapping: bool = False,
|
|
173
|
-
) ->
|
|
291
|
+
) -> list[Any] | dict[str, Any]:
|
|
174
292
|
"""
|
|
175
293
|
Process colors for the specified labels.
|
|
176
294
|
|
|
177
295
|
Args:
|
|
178
296
|
colors: Color specification (colormap name, list of colors, or label-to-color mapping)
|
|
179
|
-
labels:
|
|
297
|
+
labels: list of data labels that need colors assigned
|
|
180
298
|
return_mapping: If True, returns a dictionary mapping labels to colors;
|
|
181
299
|
if False, returns a list of colors in the same order as labels
|
|
182
300
|
|
|
@@ -184,7 +302,7 @@ class ColorProcessor:
|
|
|
184
302
|
Either a list of colors or a dictionary mapping labels to colors
|
|
185
303
|
"""
|
|
186
304
|
if len(labels) == 0:
|
|
187
|
-
logger.
|
|
305
|
+
logger.error('No labels provided for color assignment.')
|
|
188
306
|
return {} if return_mapping else []
|
|
189
307
|
|
|
190
308
|
# Process based on type of colors input
|
|
@@ -195,7 +313,7 @@ class ColorProcessor:
|
|
|
195
313
|
elif isinstance(colors, dict):
|
|
196
314
|
color_list = self._handle_color_dict(colors, labels)
|
|
197
315
|
else:
|
|
198
|
-
logger.
|
|
316
|
+
logger.error(
|
|
199
317
|
f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.'
|
|
200
318
|
)
|
|
201
319
|
color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels))
|
|
@@ -214,7 +332,7 @@ def with_plotly(
|
|
|
214
332
|
title: str = '',
|
|
215
333
|
ylabel: str = '',
|
|
216
334
|
xlabel: str = 'Time in h',
|
|
217
|
-
fig:
|
|
335
|
+
fig: go.Figure | None = None,
|
|
218
336
|
) -> go.Figure:
|
|
219
337
|
"""
|
|
220
338
|
Plot a DataFrame with Plotly, using either stacked bars or stepped lines.
|
|
@@ -230,13 +348,14 @@ def with_plotly(
|
|
|
230
348
|
- A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'})
|
|
231
349
|
title: The title of the plot.
|
|
232
350
|
ylabel: The label for the y-axis.
|
|
351
|
+
xlabel: The label for the x-axis.
|
|
233
352
|
fig: A Plotly figure object to plot on. If not provided, a new figure will be created.
|
|
234
353
|
|
|
235
354
|
Returns:
|
|
236
355
|
A Plotly figure object containing the generated plot.
|
|
237
356
|
"""
|
|
238
|
-
if style not in
|
|
239
|
-
raise ValueError(f"'style' must be one of {
|
|
357
|
+
if style not in ('stacked_bar', 'line', 'area', 'grouped_bar'):
|
|
358
|
+
raise ValueError(f"'style' must be one of {{'stacked_bar','line','area', 'grouped_bar'}}, got {style!r}")
|
|
240
359
|
if data.empty:
|
|
241
360
|
return go.Figure()
|
|
242
361
|
|
|
@@ -251,8 +370,9 @@ def with_plotly(
|
|
|
251
370
|
x=data.index,
|
|
252
371
|
y=data[column],
|
|
253
372
|
name=column,
|
|
254
|
-
marker=dict(
|
|
255
|
-
|
|
373
|
+
marker=dict(
|
|
374
|
+
color=processed_colors[i], line=dict(width=0, color='rgba(0,0,0,0)')
|
|
375
|
+
), # Transparent line with 0 width
|
|
256
376
|
)
|
|
257
377
|
)
|
|
258
378
|
|
|
@@ -263,14 +383,7 @@ def with_plotly(
|
|
|
263
383
|
)
|
|
264
384
|
if style == 'grouped_bar':
|
|
265
385
|
for i, column in enumerate(data.columns):
|
|
266
|
-
fig.add_trace(
|
|
267
|
-
go.Bar(
|
|
268
|
-
x=data.index,
|
|
269
|
-
y=data[column],
|
|
270
|
-
name=column,
|
|
271
|
-
marker=dict(color=processed_colors[i])
|
|
272
|
-
)
|
|
273
|
-
)
|
|
386
|
+
fig.add_trace(go.Bar(x=data.index, y=data[column], name=column, marker=dict(color=processed_colors[i])))
|
|
274
387
|
|
|
275
388
|
fig.update_layout(
|
|
276
389
|
barmode='group',
|
|
@@ -298,7 +411,7 @@ def with_plotly(
|
|
|
298
411
|
mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns))
|
|
299
412
|
|
|
300
413
|
if mixed_columns:
|
|
301
|
-
logger.
|
|
414
|
+
logger.error(
|
|
302
415
|
f'Data for plotting stacked lines contains columns with both positive and negative values:'
|
|
303
416
|
f' {mixed_columns}. These can not be stacked, and are printed as simple lines'
|
|
304
417
|
)
|
|
@@ -348,14 +461,6 @@ def with_plotly(
|
|
|
348
461
|
plot_bgcolor='rgba(0,0,0,0)', # Transparent background
|
|
349
462
|
paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
|
|
350
463
|
font=dict(size=14), # Increase font size for better readability
|
|
351
|
-
legend=dict(
|
|
352
|
-
orientation='h', # Horizontal legend
|
|
353
|
-
yanchor='bottom',
|
|
354
|
-
y=-0.3, # Adjusts how far below the plot it appears
|
|
355
|
-
xanchor='center',
|
|
356
|
-
x=0.5,
|
|
357
|
-
title_text=None, # Removes legend title for a cleaner look
|
|
358
|
-
),
|
|
359
464
|
)
|
|
360
465
|
|
|
361
466
|
return fig
|
|
@@ -368,10 +473,10 @@ def with_matplotlib(
|
|
|
368
473
|
title: str = '',
|
|
369
474
|
ylabel: str = '',
|
|
370
475
|
xlabel: str = 'Time in h',
|
|
371
|
-
figsize:
|
|
372
|
-
fig:
|
|
373
|
-
ax:
|
|
374
|
-
) ->
|
|
476
|
+
figsize: tuple[int, int] = (12, 6),
|
|
477
|
+
fig: plt.Figure | None = None,
|
|
478
|
+
ax: plt.Axes | None = None,
|
|
479
|
+
) -> tuple[plt.Figure, plt.Axes]:
|
|
375
480
|
"""
|
|
376
481
|
Plot a DataFrame with Matplotlib using stacked bars or stepped lines.
|
|
377
482
|
|
|
@@ -397,9 +502,9 @@ def with_matplotlib(
|
|
|
397
502
|
- If `style` is 'stacked_bar', bars are stacked for both positive and negative values.
|
|
398
503
|
Negative values are stacked separately without extra labels in the legend.
|
|
399
504
|
- If `style` is 'line', stepped lines are drawn for each data series.
|
|
400
|
-
- The legend is placed below the plot to accommodate multiple data series.
|
|
401
505
|
"""
|
|
402
|
-
|
|
506
|
+
if style not in ('stacked_bar', 'line'):
|
|
507
|
+
raise ValueError(f"'style' must be one of {{'stacked_bar','line'}} for matplotlib, got {style!r}")
|
|
403
508
|
|
|
404
509
|
if fig is None or ax is None:
|
|
405
510
|
fig, ax = plt.subplots(figsize=figsize)
|
|
@@ -463,8 +568,8 @@ def heat_map_matplotlib(
|
|
|
463
568
|
title: str = '',
|
|
464
569
|
xlabel: str = 'Period',
|
|
465
570
|
ylabel: str = 'Step',
|
|
466
|
-
figsize:
|
|
467
|
-
) ->
|
|
571
|
+
figsize: tuple[float, float] = (12, 6),
|
|
572
|
+
) -> tuple[plt.Figure, plt.Axes]:
|
|
468
573
|
"""
|
|
469
574
|
Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis,
|
|
470
575
|
the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot.
|
|
@@ -473,6 +578,9 @@ def heat_map_matplotlib(
|
|
|
473
578
|
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.
|
|
474
579
|
The values in the DataFrame will be represented as colors in the heatmap.
|
|
475
580
|
color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc.
|
|
581
|
+
title: The title of the plot.
|
|
582
|
+
xlabel: The label for the x-axis.
|
|
583
|
+
ylabel: The label for the y-axis.
|
|
476
584
|
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.
|
|
477
585
|
|
|
478
586
|
Returns:
|
|
@@ -491,7 +599,7 @@ def heat_map_matplotlib(
|
|
|
491
599
|
|
|
492
600
|
# Create the heatmap plot
|
|
493
601
|
fig, ax = plt.subplots(figsize=figsize)
|
|
494
|
-
ax.pcolormesh(data.values, cmap=color_map)
|
|
602
|
+
ax.pcolormesh(data.values, cmap=color_map, shading='auto')
|
|
495
603
|
ax.invert_yaxis() # Flip the y-axis to start at the top
|
|
496
604
|
|
|
497
605
|
# Adjust ticks and labels for x and y axes
|
|
@@ -511,7 +619,7 @@ def heat_map_matplotlib(
|
|
|
511
619
|
|
|
512
620
|
# Add the colorbar
|
|
513
621
|
sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max))
|
|
514
|
-
sm1.
|
|
622
|
+
sm1.set_array([])
|
|
515
623
|
fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal')
|
|
516
624
|
|
|
517
625
|
fig.tight_layout()
|
|
@@ -535,11 +643,11 @@ def heat_map_plotly(
|
|
|
535
643
|
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.
|
|
536
644
|
The values in the DataFrame will be represented as colors in the heatmap.
|
|
537
645
|
color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc.
|
|
646
|
+
title: The title of the heatmap. Default is an empty string.
|
|
647
|
+
xlabel: The label for the x-axis. Default is 'Period'.
|
|
648
|
+
ylabel: The label for the y-axis. Default is 'Step'.
|
|
538
649
|
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).
|
|
539
650
|
Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data.
|
|
540
|
-
show: Wether to show the figure after creation. (This includes saving the figure)
|
|
541
|
-
save: Wether to save the figure after creation (without showing)
|
|
542
|
-
path: Path to save the figure.
|
|
543
651
|
|
|
544
652
|
Returns:
|
|
545
653
|
A Plotly figure object containing the heatmap. This can be further customized and saved
|
|
@@ -630,12 +738,12 @@ def heat_map_data_from_df(
|
|
|
630
738
|
df: pd.DataFrame,
|
|
631
739
|
periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'],
|
|
632
740
|
steps_per_period: Literal['W', 'D', 'h', '15min', 'min'],
|
|
633
|
-
fill:
|
|
741
|
+
fill: Literal['ffill', 'bfill'] | None = None,
|
|
634
742
|
) -> pd.DataFrame:
|
|
635
743
|
"""
|
|
636
744
|
Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting,
|
|
637
745
|
based on a specified sample rate.
|
|
638
|
-
|
|
746
|
+
Only specific combinations of `periods` and `steps_per_period` are supported; invalid combinations raise an assertion.
|
|
639
747
|
|
|
640
748
|
Args:
|
|
641
749
|
df: A DataFrame with a DateTime index containing the data to reshape.
|
|
@@ -650,7 +758,7 @@ def heat_map_data_from_df(
|
|
|
650
758
|
and columns representing each period.
|
|
651
759
|
"""
|
|
652
760
|
assert pd.api.types.is_datetime64_any_dtype(df.index), (
|
|
653
|
-
'The index of the
|
|
761
|
+
'The index of the DataFrame must be datetime to transform it properly for a heatmap plot'
|
|
654
762
|
)
|
|
655
763
|
|
|
656
764
|
# Define formats for different combinations of `periods` and `steps_per_period`
|
|
@@ -663,23 +771,26 @@ def heat_map_data_from_df(
|
|
|
663
771
|
('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting)
|
|
664
772
|
('W', 'h'): ('%Y-w%W', '%w_%A %H:00'),
|
|
665
773
|
('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour
|
|
666
|
-
('D', '15min'): ('%Y-%m-%d', '%H:%
|
|
774
|
+
('D', '15min'): ('%Y-%m-%d', '%H:%M'), # Day and minute
|
|
667
775
|
('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
|
|
668
776
|
('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour
|
|
669
777
|
}
|
|
670
778
|
|
|
671
|
-
|
|
779
|
+
if df.empty:
|
|
780
|
+
raise ValueError('DataFrame is empty.')
|
|
781
|
+
diffs = df.index.to_series().diff().dropna()
|
|
782
|
+
minimum_time_diff_in_min = diffs.min().total_seconds() / 60
|
|
672
783
|
time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60}
|
|
673
784
|
if time_intervals[steps_per_period] > minimum_time_diff_in_min:
|
|
674
|
-
|
|
675
|
-
logger.warning(
|
|
785
|
+
logger.error(
|
|
676
786
|
f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to '
|
|
677
787
|
f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.'
|
|
678
788
|
)
|
|
679
789
|
|
|
680
790
|
# Select the format based on the `periods` and `steps_per_period` combination
|
|
681
791
|
format_pair = (periods, steps_per_period)
|
|
682
|
-
|
|
792
|
+
if format_pair not in formats:
|
|
793
|
+
raise ValueError(f'{format_pair} is not a valid format. Choose from {list(formats.keys())}')
|
|
683
794
|
period_format, step_format = formats[format_pair]
|
|
684
795
|
|
|
685
796
|
df = df.sort_index() # Ensure DataFrame is sorted by time index
|
|
@@ -693,7 +804,7 @@ def heat_map_data_from_df(
|
|
|
693
804
|
|
|
694
805
|
resampled_data['period'] = resampled_data.index.strftime(period_format)
|
|
695
806
|
resampled_data['step'] = resampled_data.index.strftime(step_format)
|
|
696
|
-
if '%w_%A' in step_format: #
|
|
807
|
+
if '%w_%A' in step_format: # Shift index of strings to ensure proper sorting
|
|
697
808
|
resampled_data['step'] = resampled_data['step'].apply(
|
|
698
809
|
lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x
|
|
699
810
|
)
|
|
@@ -707,19 +818,19 @@ def heat_map_data_from_df(
|
|
|
707
818
|
def plot_network(
|
|
708
819
|
node_infos: dict,
|
|
709
820
|
edge_infos: dict,
|
|
710
|
-
path:
|
|
711
|
-
controls:
|
|
712
|
-
|
|
713
|
-
|
|
821
|
+
path: str | pathlib.Path | None = None,
|
|
822
|
+
controls: bool
|
|
823
|
+
| list[
|
|
824
|
+
Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']
|
|
714
825
|
] = True,
|
|
715
826
|
show: bool = False,
|
|
716
|
-
) ->
|
|
827
|
+
) -> pyvis.network.Network | None:
|
|
717
828
|
"""
|
|
718
829
|
Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries.
|
|
719
830
|
|
|
720
831
|
Args:
|
|
721
832
|
path: Path to save the HTML visualization. `False`: Visualization is created but not saved. `str` or `Path`: Specifies file path (default: 'results/network.html').
|
|
722
|
-
controls: UI controls to add to the visualization. `True`: Enables all available controls. `
|
|
833
|
+
controls: UI controls to add to the visualization. `True`: Enables all available controls. `list`: Specify controls, e.g., ['nodes', 'layout'].
|
|
723
834
|
Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'.
|
|
724
835
|
You can play with these and generate a Dictionary from it that can be applied to the network returned by this function.
|
|
725
836
|
network.set_options()
|
|
@@ -781,11 +892,9 @@ def plot_network(
|
|
|
781
892
|
|
|
782
893
|
worked = webbrowser.open(f'file://{path.resolve()}', 2)
|
|
783
894
|
if not worked:
|
|
784
|
-
logger.
|
|
785
|
-
f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}'
|
|
786
|
-
)
|
|
895
|
+
logger.error(f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}')
|
|
787
896
|
except Exception as e:
|
|
788
|
-
logger.
|
|
897
|
+
logger.error(
|
|
789
898
|
f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}'
|
|
790
899
|
)
|
|
791
900
|
|
|
@@ -796,7 +905,7 @@ def pie_with_plotly(
|
|
|
796
905
|
title: str = '',
|
|
797
906
|
legend_title: str = '',
|
|
798
907
|
hole: float = 0.0,
|
|
799
|
-
fig:
|
|
908
|
+
fig: go.Figure | None = None,
|
|
800
909
|
) -> go.Figure:
|
|
801
910
|
"""
|
|
802
911
|
Create a pie chart with Plotly to visualize the proportion of values in a DataFrame.
|
|
@@ -824,7 +933,7 @@ def pie_with_plotly(
|
|
|
824
933
|
|
|
825
934
|
"""
|
|
826
935
|
if data.empty:
|
|
827
|
-
logger.
|
|
936
|
+
logger.error('Empty DataFrame provided for pie chart. Returning empty figure.')
|
|
828
937
|
return go.Figure()
|
|
829
938
|
|
|
830
939
|
# Create a copy to avoid modifying the original DataFrame
|
|
@@ -832,7 +941,7 @@ def pie_with_plotly(
|
|
|
832
941
|
|
|
833
942
|
# Check if any negative values and warn
|
|
834
943
|
if (data_copy < 0).any().any():
|
|
835
|
-
logger.
|
|
944
|
+
logger.error('Negative values detected in data. Using absolute values for pie chart.')
|
|
836
945
|
data_copy = data_copy.abs()
|
|
837
946
|
|
|
838
947
|
# If data has multiple rows, sum them to get total for each column
|
|
@@ -846,7 +955,7 @@ def pie_with_plotly(
|
|
|
846
955
|
values = data_sum.values.tolist()
|
|
847
956
|
|
|
848
957
|
# Apply color mapping using the unified color processor
|
|
849
|
-
processed_colors = ColorProcessor(engine='plotly').process_colors(colors,
|
|
958
|
+
processed_colors = ColorProcessor(engine='plotly').process_colors(colors, labels)
|
|
850
959
|
|
|
851
960
|
# Create figure if not provided
|
|
852
961
|
fig = fig if fig is not None else go.Figure()
|
|
@@ -882,10 +991,10 @@ def pie_with_matplotlib(
|
|
|
882
991
|
title: str = '',
|
|
883
992
|
legend_title: str = 'Categories',
|
|
884
993
|
hole: float = 0.0,
|
|
885
|
-
figsize:
|
|
886
|
-
fig:
|
|
887
|
-
ax:
|
|
888
|
-
) ->
|
|
994
|
+
figsize: tuple[int, int] = (10, 8),
|
|
995
|
+
fig: plt.Figure | None = None,
|
|
996
|
+
ax: plt.Axes | None = None,
|
|
997
|
+
) -> tuple[plt.Figure, plt.Axes]:
|
|
889
998
|
"""
|
|
890
999
|
Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame.
|
|
891
1000
|
|
|
@@ -914,7 +1023,7 @@ def pie_with_matplotlib(
|
|
|
914
1023
|
|
|
915
1024
|
"""
|
|
916
1025
|
if data.empty:
|
|
917
|
-
logger.
|
|
1026
|
+
logger.error('Empty DataFrame provided for pie chart. Returning empty figure.')
|
|
918
1027
|
if fig is None or ax is None:
|
|
919
1028
|
fig, ax = plt.subplots(figsize=figsize)
|
|
920
1029
|
return fig, ax
|
|
@@ -924,7 +1033,7 @@ def pie_with_matplotlib(
|
|
|
924
1033
|
|
|
925
1034
|
# Check if any negative values and warn
|
|
926
1035
|
if (data_copy < 0).any().any():
|
|
927
|
-
logger.
|
|
1036
|
+
logger.error('Negative values detected in data. Using absolute values for pie chart.')
|
|
928
1037
|
data_copy = data_copy.abs()
|
|
929
1038
|
|
|
930
1039
|
# If data has multiple rows, sum them to get total for each column
|
|
@@ -994,7 +1103,7 @@ def dual_pie_with_plotly(
|
|
|
994
1103
|
data_right: pd.Series,
|
|
995
1104
|
colors: ColorType = 'viridis',
|
|
996
1105
|
title: str = '',
|
|
997
|
-
subtitles:
|
|
1106
|
+
subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'),
|
|
998
1107
|
legend_title: str = '',
|
|
999
1108
|
hole: float = 0.2,
|
|
1000
1109
|
lower_percentage_group: float = 5.0,
|
|
@@ -1015,8 +1124,8 @@ def dual_pie_with_plotly(
|
|
|
1015
1124
|
title: The main title of the plot.
|
|
1016
1125
|
subtitles: Tuple containing the subtitles for (left, right) charts.
|
|
1017
1126
|
legend_title: The title for the legend.
|
|
1018
|
-
hole: Size of the hole in the center for creating donut charts (0.0 to
|
|
1019
|
-
lower_percentage_group:
|
|
1127
|
+
hole: Size of the hole in the center for creating donut charts (0.0 to 1.0).
|
|
1128
|
+
lower_percentage_group: Group segments whose cumulative share is below this percentage (0–100) into "Other".
|
|
1020
1129
|
hover_template: Template for hover text. Use %{label}, %{value}, %{percent}.
|
|
1021
1130
|
text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent',
|
|
1022
1131
|
'label+value', 'percent+value', 'label+percent+value', or 'none'.
|
|
@@ -1029,7 +1138,7 @@ def dual_pie_with_plotly(
|
|
|
1029
1138
|
|
|
1030
1139
|
# Check for empty data
|
|
1031
1140
|
if data_left.empty and data_right.empty:
|
|
1032
|
-
logger.
|
|
1141
|
+
logger.error('Both datasets are empty. Returning empty figure.')
|
|
1033
1142
|
return go.Figure()
|
|
1034
1143
|
|
|
1035
1144
|
# Create a subplot figure
|
|
@@ -1052,7 +1161,7 @@ def dual_pie_with_plotly(
|
|
|
1052
1161
|
"""
|
|
1053
1162
|
# Handle negative values
|
|
1054
1163
|
if (series < 0).any():
|
|
1055
|
-
logger.
|
|
1164
|
+
logger.error('Negative values detected in data. Using absolute values for pie chart.')
|
|
1056
1165
|
series = series.abs()
|
|
1057
1166
|
|
|
1058
1167
|
# Remove zeros
|
|
@@ -1108,7 +1217,7 @@ def dual_pie_with_plotly(
|
|
|
1108
1217
|
labels=labels,
|
|
1109
1218
|
values=values,
|
|
1110
1219
|
name=side,
|
|
1111
|
-
|
|
1220
|
+
marker=dict(colors=trace_colors),
|
|
1112
1221
|
hole=hole,
|
|
1113
1222
|
textinfo=text_info,
|
|
1114
1223
|
textposition=text_position,
|
|
@@ -1137,7 +1246,6 @@ def dual_pie_with_plotly(
|
|
|
1137
1246
|
paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background
|
|
1138
1247
|
font=dict(size=14),
|
|
1139
1248
|
margin=dict(t=80, b=50, l=30, r=30),
|
|
1140
|
-
legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)),
|
|
1141
1249
|
)
|
|
1142
1250
|
|
|
1143
1251
|
return fig
|
|
@@ -1148,14 +1256,14 @@ def dual_pie_with_matplotlib(
|
|
|
1148
1256
|
data_right: pd.Series,
|
|
1149
1257
|
colors: ColorType = 'viridis',
|
|
1150
1258
|
title: str = '',
|
|
1151
|
-
subtitles:
|
|
1259
|
+
subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'),
|
|
1152
1260
|
legend_title: str = '',
|
|
1153
1261
|
hole: float = 0.2,
|
|
1154
1262
|
lower_percentage_group: float = 5.0,
|
|
1155
|
-
figsize:
|
|
1156
|
-
fig:
|
|
1157
|
-
axes:
|
|
1158
|
-
) ->
|
|
1263
|
+
figsize: tuple[int, int] = (14, 7),
|
|
1264
|
+
fig: plt.Figure | None = None,
|
|
1265
|
+
axes: list[plt.Axes] | None = None,
|
|
1266
|
+
) -> tuple[plt.Figure, list[plt.Axes]]:
|
|
1159
1267
|
"""
|
|
1160
1268
|
Create two pie charts side by side with Matplotlib, with consistent coloring across both charts.
|
|
1161
1269
|
Leverages the existing pie_with_matplotlib function.
|
|
@@ -1181,7 +1289,7 @@ def dual_pie_with_matplotlib(
|
|
|
1181
1289
|
"""
|
|
1182
1290
|
# Check for empty data
|
|
1183
1291
|
if data_left.empty and data_right.empty:
|
|
1184
|
-
logger.
|
|
1292
|
+
logger.error('Both datasets are empty. Returning empty figure.')
|
|
1185
1293
|
if fig is None:
|
|
1186
1294
|
fig, axes = plt.subplots(1, 2, figsize=figsize)
|
|
1187
1295
|
return fig, axes
|
|
@@ -1199,7 +1307,7 @@ def dual_pie_with_matplotlib(
|
|
|
1199
1307
|
"""
|
|
1200
1308
|
# Handle negative values
|
|
1201
1309
|
if (series < 0).any():
|
|
1202
|
-
logger.
|
|
1310
|
+
logger.error('Negative values detected in data. Using absolute values for pie chart.')
|
|
1203
1311
|
series = series.abs()
|
|
1204
1312
|
|
|
1205
1313
|
# Remove zeros
|
|
@@ -1306,13 +1414,13 @@ def dual_pie_with_matplotlib(
|
|
|
1306
1414
|
|
|
1307
1415
|
|
|
1308
1416
|
def export_figure(
|
|
1309
|
-
figure_like:
|
|
1417
|
+
figure_like: go.Figure | tuple[plt.Figure, plt.Axes],
|
|
1310
1418
|
default_path: pathlib.Path,
|
|
1311
|
-
default_filetype:
|
|
1312
|
-
user_path:
|
|
1419
|
+
default_filetype: str | None = None,
|
|
1420
|
+
user_path: pathlib.Path | None = None,
|
|
1313
1421
|
show: bool = True,
|
|
1314
1422
|
save: bool = False,
|
|
1315
|
-
) ->
|
|
1423
|
+
) -> go.Figure | tuple[plt.Figure, plt.Axes]:
|
|
1316
1424
|
"""
|
|
1317
1425
|
Export a figure to a file and or show it.
|
|
1318
1426
|
|
|
@@ -1337,22 +1445,52 @@ def export_figure(
|
|
|
1337
1445
|
|
|
1338
1446
|
if isinstance(figure_like, plotly.graph_objs.Figure):
|
|
1339
1447
|
fig = figure_like
|
|
1340
|
-
if
|
|
1341
|
-
logger.
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1448
|
+
if filename.suffix != '.html':
|
|
1449
|
+
logger.warning(f'To save a Plotly figure, using .html. Adjusting suffix for {filename}')
|
|
1450
|
+
filename = filename.with_suffix('.html')
|
|
1451
|
+
|
|
1452
|
+
try:
|
|
1453
|
+
is_test_env = 'PYTEST_CURRENT_TEST' in os.environ
|
|
1454
|
+
|
|
1455
|
+
if is_test_env:
|
|
1456
|
+
# Test environment: never open browser, only save if requested
|
|
1457
|
+
if save:
|
|
1458
|
+
fig.write_html(str(filename))
|
|
1459
|
+
# Ignore show flag in tests
|
|
1460
|
+
else:
|
|
1461
|
+
# Production environment: respect show and save flags
|
|
1462
|
+
if save and show:
|
|
1463
|
+
# Save and auto-open in browser
|
|
1464
|
+
plotly.offline.plot(fig, filename=str(filename))
|
|
1465
|
+
elif save and not show:
|
|
1466
|
+
# Save without opening
|
|
1467
|
+
fig.write_html(str(filename))
|
|
1468
|
+
elif show and not save:
|
|
1469
|
+
# Show interactively without saving
|
|
1470
|
+
fig.show()
|
|
1471
|
+
# If neither save nor show: do nothing
|
|
1472
|
+
finally:
|
|
1473
|
+
# Cleanup to prevent socket warnings
|
|
1474
|
+
if hasattr(fig, '_renderer'):
|
|
1475
|
+
fig._renderer = None
|
|
1476
|
+
|
|
1348
1477
|
return figure_like
|
|
1349
1478
|
|
|
1350
1479
|
elif isinstance(figure_like, tuple):
|
|
1351
1480
|
fig, ax = figure_like
|
|
1352
1481
|
if show:
|
|
1353
|
-
|
|
1482
|
+
# Only show if using interactive backend and not in test environment
|
|
1483
|
+
backend = matplotlib.get_backend().lower()
|
|
1484
|
+
is_interactive = backend not in {'agg', 'pdf', 'ps', 'svg', 'template'}
|
|
1485
|
+
is_test_env = 'PYTEST_CURRENT_TEST' in os.environ
|
|
1486
|
+
|
|
1487
|
+
if is_interactive and not is_test_env:
|
|
1488
|
+
plt.show()
|
|
1489
|
+
|
|
1354
1490
|
if save:
|
|
1355
1491
|
fig.savefig(str(filename), dpi=300)
|
|
1492
|
+
plt.close(fig) # Close figure to free memory
|
|
1493
|
+
|
|
1356
1494
|
return fig, ax
|
|
1357
1495
|
|
|
1358
1496
|
raise TypeError(f'Figure type not supported: {type(figure_like)}')
|