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/7] _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/7] 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 From 93e30cc7bd6d418035d63c7e9f782f86eca94970 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:22:57 +0100 Subject: [PATCH 3/7] 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. --- flixopt/statistics_accessor.py | 58 +++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 90448fd51..11f80de4e 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -180,36 +180,45 @@ def _style_area_as_bar(fig: go.Figure) -> None: default_colors = px.colors.qualitative.Plotly - # Build color map and classify traces from base figure - # trace.name -> color, trace.name -> 'positive'|'negative'|'mixed'|'zero' + # Build color map from base figure traces + # trace.name -> color 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 + # Classify traces by aggregating sign info across ALL traces (including animation frames) + # trace.name -> 'positive'|'negative'|'mixed'|'zero' + class_map: dict[str, str] = {} + sign_flags: dict[str, dict[str, bool]] = {} # trace.name -> {'has_pos': bool, 'has_neg': bool} + + for trace in _iter_all_traces(fig): + if trace.name not in sign_flags: + sign_flags[trace.name] = {'has_pos': False, 'has_neg': False} + y_vals = trace.y - if y_vals is None or len(y_vals) == 0: - class_map[trace.name] = 'zero' - else: + if y_vals is not None and len(y_vals) > 0: 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' + if len(y_clean) > 0: + if np.any(y_clean > 0): + sign_flags[trace.name]['has_pos'] = True + if np.any(y_clean < 0): + sign_flags[trace.name]['has_neg'] = True + + # Compute class_map from aggregated sign flags + for name, flags in sign_flags.items(): + has_pos, has_neg = flags['has_pos'], flags['has_neg'] + if has_pos and has_neg: + class_map[name] = 'mixed' + elif has_neg: + class_map[name] = 'negative' + elif has_pos: + class_map[name] = 'positive' + else: + class_map[name] = 'zero' def style_trace(trace: go.Scatter) -> None: """Apply bar-like styling to a single trace.""" @@ -260,12 +269,9 @@ def _apply_unified_hover(fig: go.Figure, unit: str = '', decimals: int = 1) -> N trace.hovertemplate = hover_template # Layout settings for unified hover - fig.update_layout( - hovermode='x unified', - xaxis_showspikes=True, - xaxis_spikecolor='gray', - xaxis_spikethickness=1, - ) + fig.update_layout(hovermode='x unified') + # Apply spike settings to all x-axes (for faceted plots with xaxis, xaxis2, xaxis3, etc.) + fig.update_xaxes(showspikes=True, spikecolor='gray', spikethickness=1) # --- Helper functions --- From 30e4ba7602a8a9906ea9ac5e726d0538bfe8f72a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:17:17 +0100 Subject: [PATCH 4/7] Update plotting deps --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ebb15d99..ac39fd48a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ # Visualization "matplotlib >= 3.5.2, < 4", "plotly >= 5.15.0, < 7", - "xarray_plotly >= 0.0.3, < 1", + "xarray_plotly >= 0.0.10, < 1", ] [project.optional-dependencies] From 0d04aa8f726294c24f5b4abb6cc70c516a5b7045 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:03:41 +0100 Subject: [PATCH 5/7] Update plotting deps and use xarray-plotly for fast_bar --- flixopt/statistics_accessor.py | 120 ++------------------------------- 1 file changed, 6 insertions(+), 114 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 11f80de4e..0fcdd6a6d 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -27,6 +27,7 @@ import pandas as pd import plotly.graph_objects as go import xarray as xr +from xarray_plotly.figures import update_traces from .color_processing import ColorType, hex_to_rgba, process_colors from .config import CONFIG @@ -146,108 +147,6 @@ 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. - - 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 from base figure traces - # trace.name -> color - color_map: dict[str, str] = {} - for i, trace in enumerate(fig.data): - 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 traces by aggregating sign info across ALL traces (including animation frames) - # trace.name -> 'positive'|'negative'|'mixed'|'zero' - class_map: dict[str, str] = {} - sign_flags: dict[str, dict[str, bool]] = {} # trace.name -> {'has_pos': bool, 'has_neg': bool} - - for trace in _iter_all_traces(fig): - if trace.name not in sign_flags: - sign_flags[trace.name] = {'has_pos': False, 'has_neg': False} - - y_vals = trace.y - if y_vals is not None and len(y_vals) > 0: - y_arr = np.asarray(y_vals) - y_clean = y_arr[np.abs(y_arr) > 1e-9] - if len(y_clean) > 0: - if np.any(y_clean > 0): - sign_flags[trace.name]['has_pos'] = True - if np.any(y_clean < 0): - sign_flags[trace.name]['has_neg'] = True - - # Compute class_map from aggregated sign flags - for name, flags in sign_flags.items(): - has_pos, has_neg = flags['has_pos'], flags['has_neg'] - if has_pos and has_neg: - class_map[name] = 'mixed' - elif has_neg: - class_map[name] = 'negative' - elif has_pos: - class_map[name] = 'positive' - else: - class_map[name] = 'zero' - - 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 all traces (main + animation frames) - for trace in _iter_all_traces(fig): - 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. @@ -264,9 +163,8 @@ def _apply_unified_hover(fig: go.Figure, unit: str = '', decimals: int = 1) -> N 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 + # Apply to all traces (main + animation frames) using xarray_plotly helper + update_traces(fig, hovertemplate=hover_template) # Layout settings for unified hover fig.update_layout(hovermode='x unified') @@ -1648,13 +1546,11 @@ def balance( unit_label = ds[first_var].attrs.get('unit', '') _apply_slot_defaults(plotly_kwargs, 'balance') - fig = ds.plotly.area( + fig = ds.plotly.fast_bar( title=f'{node} [{unit_label}]' if unit_label else node, - line_shape='hv', **color_kwargs, **plotly_kwargs, ) - _style_area_as_bar(fig) _apply_unified_hover(fig, unit=unit_label) if show is None: @@ -1773,13 +1669,11 @@ def carrier_balance( unit_label = ds[first_var].attrs.get('unit', '') _apply_slot_defaults(plotly_kwargs, 'carrier_balance') - fig = ds.plotly.area( + fig = ds.plotly.fast_bar( title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance', - line_shape='hv', **color_kwargs, **plotly_kwargs, ) - _style_area_as_bar(fig) _apply_unified_hover(fig, unit=unit_label) if show is None: @@ -2378,13 +2272,11 @@ def storage( # Create stacked area chart for flows (styled as bar) _apply_slot_defaults(plotly_kwargs, 'storage') - fig = flow_ds.plotly.area( + fig = flow_ds.plotly.fast_bar( 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 From 6fbf6b2638d72fa9d599778e135ffa33e446dea2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:30:10 +0100 Subject: [PATCH 6/7] Removed both redundant line_shape='hv' parameters - fast_bar() handles that internally --- flixopt/statistics_accessor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index 536e55b30..55259b0ba 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -1550,7 +1550,6 @@ def balance( _apply_slot_defaults(plotly_kwargs, 'balance') fig = ds.plotly.fast_bar( title=f'{node} [{unit_label}]' if unit_label else node, - line_shape='hv', **color_kwargs, **plotly_kwargs, ) @@ -1674,7 +1673,6 @@ def carrier_balance( _apply_slot_defaults(plotly_kwargs, 'carrier_balance') fig = ds.plotly.fast_bar( title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance', - line_shape='hv', **color_kwargs, **plotly_kwargs, ) From e91d937eaf16d9cd425151157c0e93d0732dd71c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:34:09 +0100 Subject: [PATCH 7/7] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01165c249..4f8f8e128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,6 +301,7 @@ fs.transform.cluster( - `FlowSystem.weights` returns `dict[str, xr.DataArray]` (unit weights instead of `1.0` float fallback) - `FlowSystemDimensions` type now includes `'cluster'` +- `statistics.plot.balance()`, `carrier_balance()`, and `storage()` now use `xarray_plotly.fast_bar()` internally (styled stacked areas for better performance) ### 🗑️ Deprecated