From 1574a57ca6c049983f6036f68fef8cccf3f9c120 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:14:47 +0100 Subject: [PATCH 1/2] _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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance: ┌────────────────────────┬───────┐ │ Method │ Time │ ├────────────────────────┼───────┤ │ .plotly.bar() + update │ 0.14s │ ├────────────────────────┼───────┤ │ .plotly.area() + style │ 0.10s │ ├────────────────────────┼───────┤ │ Speedup │ ~1.4x │ └────────────────────────┴───────┘ --- flixopt/statistics_accessor.py | 101 +++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 90ad875b7..cd9d4710c 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -145,6 +145,87 @@ def _reshape_time_for_heatmap( return result.transpose('timestep', 'timeframe', *other_dims) +def _style_area_as_bar(fig: go.Figure) -> None: + """Style area chart traces to look like bar charts with proper pos/neg stacking. + + Iterates over all traces in fig.data and fig.frames (for animations), + setting stepped line shape, removing line borders, making fills opaque, + and assigning stackgroups based on whether values are positive or negative. + + Handles faceting + animation combinations by building color and classification + maps from trace names in the base figure. + + Args: + fig: Plotly Figure with area chart traces. + """ + import plotly.express as px + + default_colors = px.colors.qualitative.Plotly + + # Build color map and classify traces from base figure + # trace.name -> color, trace.name -> 'positive'|'negative'|'mixed'|'zero' + color_map: dict[str, str] = {} + class_map: dict[str, str] = {} + + for i, trace in enumerate(fig.data): + # Get color + if hasattr(trace, 'line') and trace.line and trace.line.color: + color_map[trace.name] = trace.line.color + else: + color_map[trace.name] = default_colors[i % len(default_colors)] + + # Classify based on y values + y_vals = trace.y + if y_vals is None or len(y_vals) == 0: + class_map[trace.name] = 'zero' + else: + y_arr = np.asarray(y_vals) + y_clean = y_arr[np.abs(y_arr) > 1e-9] + if len(y_clean) == 0: + class_map[trace.name] = 'zero' + else: + has_pos = np.any(y_clean > 0) + has_neg = np.any(y_clean < 0) + if has_pos and has_neg: + class_map[trace.name] = 'mixed' + elif has_neg: + class_map[trace.name] = 'negative' + else: + class_map[trace.name] = 'positive' + + def style_trace(trace: go.Scatter) -> None: + """Apply bar-like styling to a single trace.""" + # Look up color by trace name + color = color_map.get(trace.name, default_colors[0]) + + # Look up classification + cls = class_map.get(trace.name, 'positive') + + # Set stackgroup based on classification (positive and negative stack separately) + if cls in ('positive', 'negative'): + trace.stackgroup = cls + trace.fillcolor = color + trace.line = dict(width=0, color=color, shape='hv') + elif cls == 'mixed': + # Mixed: show as dashed line, no stacking + trace.stackgroup = None + trace.fill = None + trace.line = dict(width=2, color=color, shape='hv', dash='dash') + else: # zero + trace.stackgroup = None + trace.fill = None + trace.line = dict(width=0, color=color, shape='hv') + + # Style main traces + for trace in fig.data: + style_trace(trace) + + # Style animation frame traces + for frame in getattr(fig, 'frames', []) or []: + for trace in frame.data: + style_trace(trace) + + # --- Helper functions --- @@ -1529,13 +1610,13 @@ def balance( unit_label = ds[first_var].attrs.get('unit', '') _apply_slot_defaults(plotly_kwargs, 'balance') - fig = ds.plotly.bar( + fig = ds.plotly.area( title=f'{node} [{unit_label}]' if unit_label else node, + line_shape='hv', **color_kwargs, **plotly_kwargs, ) - fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) - fig.update_traces(marker_line_width=0) + _style_area_as_bar(fig) if show is None: show = CONFIG.Plotting.default_show @@ -1653,13 +1734,13 @@ def carrier_balance( unit_label = ds[first_var].attrs.get('unit', '') _apply_slot_defaults(plotly_kwargs, 'carrier_balance') - fig = ds.plotly.bar( + fig = ds.plotly.area( title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance', + line_shape='hv', **color_kwargs, **plotly_kwargs, ) - fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) - fig.update_traces(marker_line_width=0) + _style_area_as_bar(fig) if show is None: show = CONFIG.Plotting.default_show @@ -2249,15 +2330,15 @@ def storage( else: color_kwargs = _build_color_kwargs(colors, flow_labels) - # Create stacked bar chart for flows + # Create stacked area chart for flows (styled as bar) _apply_slot_defaults(plotly_kwargs, 'storage') - fig = flow_ds.plotly.bar( + fig = flow_ds.plotly.area( title=f'{storage} Operation ({unit})', + line_shape='hv', **color_kwargs, **plotly_kwargs, ) - fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) - fig.update_traces(marker_line_width=0) + _style_area_as_bar(fig) # Add charge state as line on secondary y-axis # Only pass faceting kwargs that add_line_overlay accepts From d1497f127aeeb8b907d9c9187ce3a0cb66d145f5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:30:40 +0100 Subject: [PATCH 2/2] New Helper Functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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: name: 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: Solar: 45.3 kW - Hovermode: x unified (single tooltip for all traces) - Spikes: Gray vertical line at cursor --- flixopt/statistics_accessor.py | 64 ++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index cd9d4710c..cfa0f9f68 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -145,6 +145,23 @@ def _reshape_time_for_heatmap( return result.transpose('timestep', 'timeframe', *other_dims) +def _iter_all_traces(fig: go.Figure): + """Iterate over all traces in a figure, including animation frames. + + Yields traces from fig.data first, then from each frame in fig.frames. + Useful for applying styling to all traces including those in animations. + + Args: + fig: Plotly Figure. + + Yields: + Each trace object from the figure. + """ + yield from fig.data + for frame in getattr(fig, 'frames', []) or []: + yield from frame.data + + def _style_area_as_bar(fig: go.Figure) -> None: """Style area chart traces to look like bar charts with proper pos/neg stacking. @@ -216,14 +233,38 @@ def style_trace(trace: go.Scatter) -> None: trace.fill = None trace.line = dict(width=0, color=color, shape='hv') - # Style main traces - for trace in fig.data: + # Style all traces (main + animation frames) + for trace in _iter_all_traces(fig): style_trace(trace) - # Style animation frame traces - for frame in getattr(fig, 'frames', []) or []: - for trace in frame.data: - style_trace(trace) + +def _apply_unified_hover(fig: go.Figure, unit: str = '', decimals: int = 1) -> None: + """Apply unified hover mode with clean formatting to any Plotly figure. + + Sets up 'x unified' hovermode with spike lines and formats hover labels + as 'name: value unit'. + + Works with any plot type (area, bar, line, scatter). + + Args: + fig: Plotly Figure to style. + unit: Unit string to append (e.g., 'kW', 'MWh'). Empty for no unit. + decimals: Number of decimal places for values. + """ + unit_suffix = f' {unit}' if unit else '' + hover_template = f'%{{fullData.name}}: %{{y:.{decimals}f}}{unit_suffix}' + + # Apply to all traces (main + animation frames) + for trace in _iter_all_traces(fig): + trace.hovertemplate = hover_template + + # Layout settings for unified hover + fig.update_layout( + hovermode='x unified', + xaxis_showspikes=True, + xaxis_spikecolor='gray', + xaxis_spikethickness=1, + ) # --- Helper functions --- @@ -1617,6 +1658,7 @@ def balance( **plotly_kwargs, ) _style_area_as_bar(fig) + _apply_unified_hover(fig, unit=unit_label) if show is None: show = CONFIG.Plotting.default_show @@ -1741,6 +1783,7 @@ def carrier_balance( **plotly_kwargs, ) _style_area_as_bar(fig) + _apply_unified_hover(fig, unit=unit_label) if show is None: show = CONFIG.Plotting.default_show @@ -2330,15 +2373,22 @@ def storage( else: color_kwargs = _build_color_kwargs(colors, flow_labels) + # Get unit label from flow data + unit_label = '' + if flow_ds.data_vars: + first_var = next(iter(flow_ds.data_vars)) + unit_label = flow_ds[first_var].attrs.get('unit', '') + # Create stacked area chart for flows (styled as bar) _apply_slot_defaults(plotly_kwargs, 'storage') fig = flow_ds.plotly.area( - title=f'{storage} Operation ({unit})', + title=f'{storage} Operation [{unit_label}]' if unit_label else f'{storage} Operation', line_shape='hv', **color_kwargs, **plotly_kwargs, ) _style_area_as_bar(fig) + _apply_unified_hover(fig, unit=unit_label) # Add charge state as line on secondary y-axis # Only pass faceting kwargs that add_line_overlay accepts