Skip to content

Commit 4ddcd4f

Browse files
authored
Use area chart instead of bar chart for better performance (#577)
* _style_area_as_bar() helper: - Classifies traces by analyzing y-values: positive, negative, mixed, zero - Sets stackgroup='positive' or stackgroup='negative' for proper separate stacking - Mixed values shown as dashed lines (no fill) - Opaque fills, no line borders, hv line shape Performance: ┌────────────────────────┬───────┐ │ Method │ Time │ ├────────────────────────┼───────┤ │ .plotly.bar() + update │ 0.14s │ ├────────────────────────┼───────┤ │ .plotly.area() + style │ 0.10s │ ├────────────────────────┼───────┤ │ Speedup │ ~1.4x │ └────────────────────────┴───────┘ * New Helper Functions # Iterate over all traces (main + animation frames) def _iter_all_traces(fig: go.Figure): yield from fig.data for frame in getattr(fig, 'frames', []) or []: yield from frame.data # Apply unified hover styling (works with any plot type) def _apply_unified_hover(fig: go.Figure, unit: str = '', decimals: int = 1): # Sets: <b>name</b>: value unit # + hovermode='x unified' + spike lines Updated Methods ┌───────────────────┬──────────────────────────────────────────────┐ │ Method │ Changes │ ├───────────────────┼──────────────────────────────────────────────┤ │ balance() │ + _apply_unified_hover(fig, unit=unit_label) │ ├───────────────────┼──────────────────────────────────────────────┤ │ carrier_balance() │ + _apply_unified_hover(fig, unit=unit_label) │ ├───────────────────┼──────────────────────────────────────────────┤ │ storage() │ + _apply_unified_hover(fig, unit=unit_label) │ └───────────────────┴──────────────────────────────────────────────┘ Result - Hover format: <b>Solar</b>: 45.3 kW - Hovermode: x unified (single tooltip for all traces) - Spikes: Gray vertical line at cursor * 1. _style_area_as_bar (lines 183-221): The class_map is now built by aggregating sign info across all traces returned by _iter_all_traces(fig), including animation frames. The color_map is still derived from fig.data. The implementation uses a sign_flags dictionary to incrementally update has_pos/has_neg flags for each trace.name, then computes class_map from those aggregated flags. 2. _apply_unified_hover (lines 271-274): Replaced the fig.update_layout(xaxis_showspikes=..., ...) with a single fig.update_xaxes(showspikes=True, spikecolor='gray', spikethickness=1) call so spike settings apply to all x-axes (xaxis, xaxis2, xaxis3, ...) in faceted plots.
1 parent f766121 commit 4ddcd4f

1 file changed

Lines changed: 148 additions & 11 deletions

File tree

flixopt/statistics_accessor.py

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,134 @@ def _reshape_time_for_heatmap(
146146
return result.transpose('timestep', 'timeframe', *other_dims)
147147

148148

149+
def _iter_all_traces(fig: go.Figure):
150+
"""Iterate over all traces in a figure, including animation frames.
151+
152+
Yields traces from fig.data first, then from each frame in fig.frames.
153+
Useful for applying styling to all traces including those in animations.
154+
155+
Args:
156+
fig: Plotly Figure.
157+
158+
Yields:
159+
Each trace object from the figure.
160+
"""
161+
yield from fig.data
162+
for frame in getattr(fig, 'frames', []) or []:
163+
yield from frame.data
164+
165+
166+
def _style_area_as_bar(fig: go.Figure) -> None:
167+
"""Style area chart traces to look like bar charts with proper pos/neg stacking.
168+
169+
Iterates over all traces in fig.data and fig.frames (for animations),
170+
setting stepped line shape, removing line borders, making fills opaque,
171+
and assigning stackgroups based on whether values are positive or negative.
172+
173+
Handles faceting + animation combinations by building color and classification
174+
maps from trace names in the base figure.
175+
176+
Args:
177+
fig: Plotly Figure with area chart traces.
178+
"""
179+
import plotly.express as px
180+
181+
default_colors = px.colors.qualitative.Plotly
182+
183+
# Build color map from base figure traces
184+
# trace.name -> color
185+
color_map: dict[str, str] = {}
186+
for i, trace in enumerate(fig.data):
187+
if hasattr(trace, 'line') and trace.line and trace.line.color:
188+
color_map[trace.name] = trace.line.color
189+
else:
190+
color_map[trace.name] = default_colors[i % len(default_colors)]
191+
192+
# Classify traces by aggregating sign info across ALL traces (including animation frames)
193+
# trace.name -> 'positive'|'negative'|'mixed'|'zero'
194+
class_map: dict[str, str] = {}
195+
sign_flags: dict[str, dict[str, bool]] = {} # trace.name -> {'has_pos': bool, 'has_neg': bool}
196+
197+
for trace in _iter_all_traces(fig):
198+
if trace.name not in sign_flags:
199+
sign_flags[trace.name] = {'has_pos': False, 'has_neg': False}
200+
201+
y_vals = trace.y
202+
if y_vals is not None and len(y_vals) > 0:
203+
y_arr = np.asarray(y_vals)
204+
y_clean = y_arr[np.abs(y_arr) > 1e-9]
205+
if len(y_clean) > 0:
206+
if np.any(y_clean > 0):
207+
sign_flags[trace.name]['has_pos'] = True
208+
if np.any(y_clean < 0):
209+
sign_flags[trace.name]['has_neg'] = True
210+
211+
# Compute class_map from aggregated sign flags
212+
for name, flags in sign_flags.items():
213+
has_pos, has_neg = flags['has_pos'], flags['has_neg']
214+
if has_pos and has_neg:
215+
class_map[name] = 'mixed'
216+
elif has_neg:
217+
class_map[name] = 'negative'
218+
elif has_pos:
219+
class_map[name] = 'positive'
220+
else:
221+
class_map[name] = 'zero'
222+
223+
def style_trace(trace: go.Scatter) -> None:
224+
"""Apply bar-like styling to a single trace."""
225+
# Look up color by trace name
226+
color = color_map.get(trace.name, default_colors[0])
227+
228+
# Look up classification
229+
cls = class_map.get(trace.name, 'positive')
230+
231+
# Set stackgroup based on classification (positive and negative stack separately)
232+
if cls in ('positive', 'negative'):
233+
trace.stackgroup = cls
234+
trace.fillcolor = color
235+
trace.line = dict(width=0, color=color, shape='hv')
236+
elif cls == 'mixed':
237+
# Mixed: show as dashed line, no stacking
238+
trace.stackgroup = None
239+
trace.fill = None
240+
trace.line = dict(width=2, color=color, shape='hv', dash='dash')
241+
else: # zero
242+
trace.stackgroup = None
243+
trace.fill = None
244+
trace.line = dict(width=0, color=color, shape='hv')
245+
246+
# Style all traces (main + animation frames)
247+
for trace in _iter_all_traces(fig):
248+
style_trace(trace)
249+
250+
251+
def _apply_unified_hover(fig: go.Figure, unit: str = '', decimals: int = 1) -> None:
252+
"""Apply unified hover mode with clean formatting to any Plotly figure.
253+
254+
Sets up 'x unified' hovermode with spike lines and formats hover labels
255+
as '<b>name</b>: value unit'.
256+
257+
Works with any plot type (area, bar, line, scatter).
258+
259+
Args:
260+
fig: Plotly Figure to style.
261+
unit: Unit string to append (e.g., 'kW', 'MWh'). Empty for no unit.
262+
decimals: Number of decimal places for values.
263+
"""
264+
unit_suffix = f' {unit}' if unit else ''
265+
hover_template = f'<b>%{{fullData.name}}</b>: %{{y:.{decimals}f}}{unit_suffix}<extra></extra>'
266+
267+
# Apply to all traces (main + animation frames)
268+
for trace in _iter_all_traces(fig):
269+
trace.hovertemplate = hover_template
270+
271+
# Layout settings for unified hover
272+
fig.update_layout(hovermode='x unified')
273+
# Apply spike settings to all x-axes (for faceted plots with xaxis, xaxis2, xaxis3, etc.)
274+
fig.update_xaxes(showspikes=True, spikecolor='gray', spikethickness=1)
275+
276+
149277
# --- Helper functions ---
150278

151279

@@ -1522,13 +1650,14 @@ def balance(
15221650
unit_label = ds[first_var].attrs.get('unit', '')
15231651

15241652
_apply_slot_defaults(plotly_kwargs, 'balance')
1525-
fig = ds.plotly.bar(
1653+
fig = ds.plotly.area(
15261654
title=f'{node} [{unit_label}]' if unit_label else node,
1655+
line_shape='hv',
15271656
**color_kwargs,
15281657
**plotly_kwargs,
15291658
)
1530-
fig.update_layout(barmode='relative', bargap=0, bargroupgap=0)
1531-
fig.update_traces(marker_line_width=0)
1659+
_style_area_as_bar(fig)
1660+
_apply_unified_hover(fig, unit=unit_label)
15321661

15331662
if show is None:
15341663
show = CONFIG.Plotting.default_show
@@ -1646,13 +1775,14 @@ def carrier_balance(
16461775
unit_label = ds[first_var].attrs.get('unit', '')
16471776

16481777
_apply_slot_defaults(plotly_kwargs, 'carrier_balance')
1649-
fig = ds.plotly.bar(
1778+
fig = ds.plotly.area(
16501779
title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance',
1780+
line_shape='hv',
16511781
**color_kwargs,
16521782
**plotly_kwargs,
16531783
)
1654-
fig.update_layout(barmode='relative', bargap=0, bargroupgap=0)
1655-
fig.update_traces(marker_line_width=0)
1784+
_style_area_as_bar(fig)
1785+
_apply_unified_hover(fig, unit=unit_label)
16561786

16571787
if show is None:
16581788
show = CONFIG.Plotting.default_show
@@ -2242,15 +2372,22 @@ def storage(
22422372
else:
22432373
color_kwargs = _build_color_kwargs(colors, flow_labels)
22442374

2245-
# Create stacked bar chart for flows
2375+
# Get unit label from flow data
2376+
unit_label = ''
2377+
if flow_ds.data_vars:
2378+
first_var = next(iter(flow_ds.data_vars))
2379+
unit_label = flow_ds[first_var].attrs.get('unit', '')
2380+
2381+
# Create stacked area chart for flows (styled as bar)
22462382
_apply_slot_defaults(plotly_kwargs, 'storage')
2247-
fig = flow_ds.plotly.bar(
2248-
title=f'{storage} Operation ({unit})',
2383+
fig = flow_ds.plotly.area(
2384+
title=f'{storage} Operation [{unit_label}]' if unit_label else f'{storage} Operation',
2385+
line_shape='hv',
22492386
**color_kwargs,
22502387
**plotly_kwargs,
22512388
)
2252-
fig.update_layout(barmode='relative', bargap=0, bargroupgap=0)
2253-
fig.update_traces(marker_line_width=0)
2389+
_style_area_as_bar(fig)
2390+
_apply_unified_hover(fig, unit=unit_label)
22542391

22552392
# Add charge state as line on secondary y-axis
22562393
# Only pass faceting kwargs that add_line_overlay accepts

0 commit comments

Comments
 (0)