From cc7de3885b49926713c0f51ff843cd51ceb33483 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:13:13 +0200 Subject: [PATCH 01/36] Feature/398 feature facet plots in results (#419) * Add animation and faceting options to plots * Adjust size of the frame * Utilize plotly express directly * Rmeocve old class * Use plotly express and modify stackgroup afterwards * Add modifications also to animations * Mkae more compact * Remove height stuff * Remove line and make set opacity =0 for area * Integrate faceting and animating into existing with_plotly method * Improve results.py * Improve results.py * Move check if dims are found to plotting.py * Fix usage of indexer * Change selection string with indexer * Change behaviout of parameter "indexing" * Update CHANGELOG.md * Add new selection parameter to plotting methods * deprectae old indexer parameter * deprectae old indexer parameter * Add test * Add test * Add test * Add test * Fix not supportet check for matplotlib * Typo in CHANGELOG.md --- CHANGELOG.md | 2 + flixopt/plotting.py | 323 +++++++++++++++++-------- flixopt/results.py | 283 +++++++++++++++++----- tests/ressources/Sim1--flow_system.nc4 | Bin 0 -> 218834 bytes tests/ressources/Sim1--solution.nc4 | Bin 0 -> 210822 bytes tests/ressources/Sim1--summary.yaml | 92 +++++++ tests/test_select_features.py | 222 +++++++++++++++++ 7 files changed, 758 insertions(+), 164 deletions(-) create mode 100644 tests/ressources/Sim1--flow_system.nc4 create mode 100644 tests/ressources/Sim1--solution.nc4 create mode 100644 tests/ressources/Sim1--summary.yaml create mode 100644 tests/test_select_features.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc3be435..b580e6b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,10 +54,12 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### ✨ Added +- Added faceting and animation options to plotting methods ### 💥 Breaking Changes ### ♻️ Changed +- Changed indexer behaviour. Defaults to not indexing instead of the first value except for time. Also changed naming when indexing. ### 🗑️ Deprecated diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 218a8ab0e..a5b88ffc2 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -39,6 +39,7 @@ import plotly.express as px import plotly.graph_objects as go import plotly.offline +import xarray as xr from plotly.exceptions import PlotlyError if TYPE_CHECKING: @@ -326,143 +327,253 @@ def process_colors( def with_plotly( - data: pd.DataFrame, + data: pd.DataFrame | xr.DataArray | xr.Dataset, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', colors: ColorType = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', fig: go.Figure | None = None, + facet_by: str | list[str] | None = None, + animate_by: str | None = None, + facet_cols: int = 3, + shared_yaxes: bool = True, + shared_xaxes: bool = True, ) -> go.Figure: """ - Plot a DataFrame with Plotly, using either stacked bars or stepped lines. + Plot data with Plotly using facets (subplots) and/or animation for multidimensional data. + + Uses Plotly Express for convenient faceting and animation with automatic styling. + For simple plots without faceting, can optionally add to an existing figure. Args: - data: A DataFrame containing the data to plot, where the index represents time (e.g., hours), - and each column represents a separate data series. - mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, - or 'area' for stacked area charts. - colors: Color specification, can be: - - A string with a colorscale name (e.g., 'viridis', 'plasma') - - A list of color strings (e.g., ['#ff0000', '#00ff00']) - - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) - title: The title of the plot. + data: A DataFrame or xarray DataArray/Dataset to plot. + mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for lines, + 'area' for stacked area charts, or 'grouped_bar' for grouped bar charts. + colors: Color specification (colormap, list, or dict mapping labels to colors). + title: The main title of the plot. ylabel: The label for the y-axis. xlabel: The label for the x-axis. - fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + fig: A Plotly figure object to plot on (only for simple plots without faceting). + If not provided, a new figure will be created. + facet_by: Dimension(s) to create facets for. Creates a subplot grid. + Can be a single dimension name or list of dimensions (max 2 for facet_row and facet_col). + If the dimension doesn't exist in the data, it will be silently ignored. + animate_by: Dimension to animate over. Creates animation frames. + If the dimension doesn't exist in the data, it will be silently ignored. + facet_cols: Number of columns in the facet grid (used when facet_by is single dimension). + shared_yaxes: Whether subplots share y-axes. + shared_xaxes: Whether subplots share x-axes. Returns: - A Plotly figure object containing the generated plot. + A Plotly figure object containing the faceted/animated plot. + + Examples: + Simple plot: + + ```python + fig = with_plotly(df, mode='area', title='Energy Mix') + ``` + + Facet by scenario: + + ```python + fig = with_plotly(ds, facet_by='scenario', facet_cols=2) + ``` + + Animate by period: + + ```python + fig = with_plotly(ds, animate_by='period') + ``` + + Facet and animate: + + ```python + fig = with_plotly(ds, facet_by='scenario', animate_by='period') + ``` """ if mode not in ('stacked_bar', 'line', 'area', 'grouped_bar'): raise ValueError(f"'mode' must be one of {{'stacked_bar','line','area', 'grouped_bar'}}, got {mode!r}") - if data.empty: - return go.Figure() - processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns)) - - fig = fig if fig is not None else go.Figure() + # Handle empty data + if isinstance(data, pd.DataFrame) and data.empty: + return go.Figure() + elif isinstance(data, xr.DataArray) and data.size == 0: + return go.Figure() + elif isinstance(data, xr.Dataset) and len(data.data_vars) == 0: + return go.Figure() - if mode == 'stacked_bar': - for i, column in enumerate(data.columns): - fig.add_trace( - go.Bar( - x=data.index, - y=data[column], - name=column, - marker=dict( - color=processed_colors[i], line=dict(width=0, color='rgba(0,0,0,0)') - ), # Transparent line with 0 width + # Warn if fig parameter is used with faceting + if fig is not None and (facet_by is not None or animate_by is not None): + logger.warning('The fig parameter is ignored when using faceting or animation. Creating a new figure.') + fig = None + + # Convert xarray to long-form DataFrame for Plotly Express + if isinstance(data, (xr.DataArray, xr.Dataset)): + # Convert to long-form (tidy) DataFrame + # Structure: time, variable, value, scenario, period, ... (all dims as columns) + if isinstance(data, xr.Dataset): + # Stack all data variables into long format + df_long = data.to_dataframe().reset_index() + # Melt to get: time, scenario, period, ..., variable, value + id_vars = [dim for dim in data.dims] + value_vars = list(data.data_vars) + df_long = df_long.melt(id_vars=id_vars, value_vars=value_vars, var_name='variable', value_name='value') + else: + # DataArray + df_long = data.to_dataframe().reset_index() + if data.name: + df_long = df_long.rename(columns={data.name: 'value'}) + else: + # Unnamed DataArray, find the value column + value_col = [col for col in df_long.columns if col not in data.dims][0] + df_long = df_long.rename(columns={value_col: 'value'}) + df_long['variable'] = data.name or 'data' + else: + # Already a DataFrame - convert to long format for Plotly Express + df_long = data.reset_index() + if 'time' not in df_long.columns: + # First column is probably time + df_long = df_long.rename(columns={df_long.columns[0]: 'time'}) + # Melt to long format + id_vars = [ + col + for col in df_long.columns + if col in ['time', 'scenario', 'period'] + or col in (facet_by if isinstance(facet_by, list) else [facet_by] if facet_by else []) + ] + value_vars = [col for col in df_long.columns if col not in id_vars] + df_long = df_long.melt(id_vars=id_vars, value_vars=value_vars, var_name='variable', value_name='value') + + # Validate facet_by and animate_by dimensions exist in the data + available_dims = [col for col in df_long.columns if col not in ['variable', 'value']] + + # Check facet_by dimensions + if facet_by is not None: + if isinstance(facet_by, str): + if facet_by not in available_dims: + logger.debug( + f"Dimension '{facet_by}' not found in data. Available dimensions: {available_dims}. " + f'Ignoring facet_by parameter.' ) - ) - - fig.update_layout( - barmode='relative', - bargap=0, # No space between bars - bargroupgap=0, # No space between grouped bars + facet_by = None + elif isinstance(facet_by, list): + # Filter out dimensions that don't exist + missing_dims = [dim for dim in facet_by if dim not in available_dims] + facet_by = [dim for dim in facet_by if dim in available_dims] + if missing_dims: + logger.debug( + f'Dimensions {missing_dims} not found in data. Available dimensions: {available_dims}. ' + f'Using only existing dimensions: {facet_by if facet_by else "none"}.' + ) + if len(facet_by) == 0: + facet_by = None + + # Check animate_by dimension + if animate_by is not None and animate_by not in available_dims: + logger.debug( + f"Dimension '{animate_by}' not found in data. Available dimensions: {available_dims}. " + f'Ignoring animate_by parameter.' ) - if mode == 'grouped_bar': - for i, column in enumerate(data.columns): - fig.add_trace(go.Bar(x=data.index, y=data[column], name=column, marker=dict(color=processed_colors[i]))) + animate_by = None + + # Setup faceting parameters for Plotly Express + facet_row = None + facet_col = None + if facet_by: + if isinstance(facet_by, str): + # Single facet dimension - use facet_col with facet_col_wrap + facet_col = facet_by + elif len(facet_by) == 1: + facet_col = facet_by[0] + elif len(facet_by) == 2: + # Two facet dimensions - use facet_row and facet_col + facet_row = facet_by[0] + facet_col = facet_by[1] + else: + raise ValueError(f'facet_by can have at most 2 dimensions, got {len(facet_by)}') + + # Process colors + all_vars = df_long['variable'].unique().tolist() + processed_colors = ColorProcessor(engine='plotly').process_colors(colors, all_vars) + color_discrete_map = {var: color for var, color in zip(all_vars, processed_colors, strict=False)} + + # Create plot using Plotly Express based on mode + common_args = { + 'data_frame': df_long, + 'x': 'time', + 'y': 'value', + 'color': 'variable', + 'facet_row': facet_row, + 'facet_col': facet_col, + 'animation_frame': animate_by, + 'color_discrete_map': color_discrete_map, + 'title': title, + 'labels': {'value': ylabel, 'time': xlabel, 'variable': ''}, + } - fig.update_layout( - barmode='group', - bargap=0.2, # No space between bars - bargroupgap=0, # space between grouped bars - ) + # Add facet_col_wrap for single facet dimension + if facet_col and not facet_row: + common_args['facet_col_wrap'] = facet_cols + + if mode == 'stacked_bar': + fig = px.bar(**common_args) + fig.update_traces(marker_line_width=0) + fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) + elif mode == 'grouped_bar': + fig = px.bar(**common_args) + fig.update_layout(barmode='group', bargap=0.2, bargroupgap=0) elif mode == 'line': - for i, column in enumerate(data.columns): - fig.add_trace( - go.Scatter( - x=data.index, - y=data[column], - mode='lines', - name=column, - line=dict(shape='hv', color=processed_colors[i]), - ) - ) + fig = px.line(**common_args, line_shape='hv') # Stepped lines elif mode == 'area': - data = data.copy() - data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting - # Split columns into positive, negative, and mixed categories - positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()]) - negative_columns = list(data.columns[(data <= 0).where(~np.isnan(data), True).all()]) - negative_columns = [column for column in negative_columns if column not in positive_columns] - mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns)) - - if mixed_columns: - logger.error( - f'Data for plotting stacked lines contains columns with both positive and negative values:' - f' {mixed_columns}. These can not be stacked, and are printed as simple lines' - ) + # Use Plotly Express to create the area plot (preserves animation, legends, faceting) + fig = px.area(**common_args, line_shape='hv') - # Get color mapping for all columns - colors_stacked = {column: processed_colors[i] for i, column in enumerate(data.columns)} - - for column in positive_columns + negative_columns: - fig.add_trace( - go.Scatter( - x=data.index, - y=data[column], - mode='lines', - name=column, - line=dict(shape='hv', color=colors_stacked[column]), - fill='tonexty', - stackgroup='pos' if column in positive_columns else 'neg', - ) - ) + # Classify each variable based on its values + variable_classification = {} + for var in all_vars: + var_data = df_long[df_long['variable'] == var]['value'] + var_data_clean = var_data[(var_data < -1e-5) | (var_data > 1e-5)] - for column in mixed_columns: - fig.add_trace( - go.Scatter( - x=data.index, - y=data[column], - mode='lines', - name=column, - line=dict(shape='hv', color=colors_stacked[column], dash='dash'), + if len(var_data_clean) == 0: + variable_classification[var] = 'zero' + else: + has_pos, has_neg = (var_data_clean > 0).any(), (var_data_clean < 0).any() + variable_classification[var] = ( + 'mixed' if has_pos and has_neg else ('negative' if has_neg else 'positive') ) - ) - # Update layout for better aesthetics + # Log warning for mixed variables + mixed_vars = [v for v, c in variable_classification.items() if c == 'mixed'] + if mixed_vars: + logger.warning(f'Variables with both positive and negative values: {mixed_vars}. Plotted as dashed lines.') + + all_traces = list(fig.data) + for frame in fig.frames: + all_traces.extend(frame.data) + + for trace in all_traces: + trace.stackgroup = variable_classification.get(trace.name, None) + # No opacity and no line for stacked areas + if trace.stackgroup is not None: + if hasattr(trace, 'line') and trace.line.color: + trace.fillcolor = trace.line.color # Will be solid by default + trace.line.width = 0 + + # Update layout with basic styling (Plotly Express handles sizing automatically) fig.update_layout( - title=title, - yaxis=dict( - title=ylabel, - showgrid=True, # Enable grid lines on the y-axis - gridcolor='lightgrey', # Customize grid line color - gridwidth=0.5, # Customize grid line width - ), - xaxis=dict( - title=xlabel, - showgrid=True, # Enable grid lines on the x-axis - gridcolor='lightgrey', # Customize grid line color - gridwidth=0.5, # Customize grid line width - ), - plot_bgcolor='rgba(0,0,0,0)', # Transparent background - paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background - font=dict(size=14), # Increase font size for better readability + plot_bgcolor='rgba(0,0,0,0)', + paper_bgcolor='rgba(0,0,0,0)', + font=dict(size=12), ) + # Update axes to share if requested (Plotly Express already handles this, but we can customize) + if not shared_yaxes: + fig.update_yaxes(matches=None) + if not shared_xaxes: + fig.update_xaxes(matches=None) + return fig diff --git a/flixopt/results.py b/flixopt/results.py index b55d48744..0393f5661 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -694,7 +694,8 @@ def plot_heatmap( save: bool | pathlib.Path = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', - indexer: dict[FlowSystemDimensions, Any] | None = None, + select: dict[FlowSystemDimensions, Any] | None = None, + **kwargs, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ Plots a heatmap of the solution of a variable. @@ -707,7 +708,7 @@ def plot_heatmap( save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. + select: Optional data selection dict. Supports single values, lists, slices, and index arrays. If None, uses first value for each dimension. If empty dict {}, uses all values. @@ -718,13 +719,13 @@ def plot_heatmap( Select specific scenario and period: - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', indexer={'scenario': 'base', 'period': 2024}) + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', select={'scenario': 'base', 'period': 2024}) Time filtering (summer months only): >>> results.plot_heatmap( ... 'Boiler(Qth)|flow_rate', - ... indexer={ + ... select={ ... 'scenario': 'base', ... 'time': results.solution.time[results.solution.time.dt.month.isin([6, 7, 8])], ... }, @@ -733,9 +734,24 @@ def plot_heatmap( Save to specific location: >>> results.plot_heatmap( - ... 'Boiler(Qth)|flow_rate', indexer={'scenario': 'base'}, save='path/to/my_heatmap.html' + ... 'Boiler(Qth)|flow_rate', select={'scenario': 'base'}, save='path/to/my_heatmap.html' ... ) """ + # Handle deprecated indexer parameter + if 'indexer' in kwargs: + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + dataarray = self.solution[variable_name] return plot_heatmap( @@ -748,7 +764,8 @@ def plot_heatmap( save=save, show=show, engine=engine, - indexer=indexer, + select=select, + **kwargs, ) def plot_network( @@ -920,30 +937,110 @@ def plot_node_balance( show: bool = True, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', - indexer: dict[FlowSystemDimensions, Any] | None = None, + select: dict[FlowSystemDimensions, Any] | None = None, unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', mode: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', drop_suffix: bool = True, + facet_by: str | list[str] | None = 'scenario', + animate_by: str | None = 'period', + facet_cols: int = 3, + **kwargs, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ - Plots the node balance of the Component or Bus. + Plots the node balance of the Component or Bus with optional faceting and animation. + Args: save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. - If None, uses first value for each dimension (except time). - If empty dict {}, uses all values. + select: Optional data selection dict. Supports: + - Single values: {'scenario': 'base', 'period': 2024} + - Multiple values: {'scenario': ['base', 'high', 'renewable']} + - Slices: {'time': slice('2024-01', '2024-06')} + - Index arrays: {'time': time_array} + Note: Applied BEFORE faceting/animation. unit_type: The unit type to use for the dataset. Can be 'flow_rate' or 'flow_hours'. - 'flow_rate': Returns the flow_rates of the Node. - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, or 'area' for stacked area charts. drop_suffix: Whether to drop the suffix from the variable names. + facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) + or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. + Example: 'scenario' creates one subplot per scenario. + Example: ['scenario', 'period'] creates a grid of subplots for each scenario-period combination. + animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through + dimension values. Only one dimension can be animated. Ignored if not found. + facet_cols: Number of columns in the facet grid layout (default: 3). + + Examples: + Basic plot (current behavior): + + >>> results['Boiler'].plot_node_balance() + + Facet by scenario: + + >>> results['Boiler'].plot_node_balance(facet_by='scenario', facet_cols=2) + + Animate by period: + + >>> results['Boiler'].plot_node_balance(animate_by='period') + + Facet by scenario AND animate by period: + + >>> results['Boiler'].plot_node_balance(facet_by='scenario', animate_by='period') + + Select single scenario, then facet by period: + + >>> results['Boiler'].plot_node_balance(select={'scenario': 'base'}, facet_by='period') + + Select multiple scenarios and facet by them: + + >>> results['Boiler'].plot_node_balance( + ... select={'scenario': ['base', 'high', 'renewable']}, facet_by='scenario' + ... ) + + Time range selection (summer months only): + + >>> results['Boiler'].plot_node_balance(select={'time': slice('2024-06', '2024-08')}, facet_by='scenario') """ - ds = self.node_balance(with_last_timestep=True, unit_type=unit_type, drop_suffix=drop_suffix, indexer=indexer) + # Handle deprecated indexer parameter + if 'indexer' in kwargs: + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) - ds, suffix_parts = _apply_indexer_to_data(ds, indexer, drop=True) + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError(f'plot_node_balance() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + + if engine not in {'plotly', 'matplotlib'}: + raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]') + + # Don't pass select/indexer to node_balance - we'll apply it afterwards + ds = self.node_balance(with_last_timestep=True, unit_type=unit_type, drop_suffix=drop_suffix) + + ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) + + # Check if faceting/animating would actually happen based on available dimensions + if engine == 'matplotlib': + dims_to_facet = [] + if facet_by is not None: + dims_to_facet.extend([facet_by] if isinstance(facet_by, str) else facet_by) + if animate_by is not None: + dims_to_facet.append(animate_by) + + # Only raise error if any of the specified dimensions actually exist in the data + existing_dims = [dim for dim in dims_to_facet if dim in ds.dims] + if existing_dims: + raise ValueError( + f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' + ) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = ( @@ -952,13 +1049,16 @@ def plot_node_balance( if engine == 'plotly': figure_like = plotting.with_plotly( - ds.to_dataframe(), + ds, + facet_by=facet_by, + animate_by=animate_by, colors=colors, mode=mode, title=title, + facet_cols=facet_cols, ) default_filetype = '.html' - elif engine == 'matplotlib': + else: figure_like = plotting.with_matplotlib( ds.to_dataframe(), colors=colors, @@ -966,8 +1066,6 @@ def plot_node_balance( title=title, ) default_filetype = '.png' - else: - raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') return plotting.export_figure( figure_like=figure_like, @@ -986,7 +1084,8 @@ def plot_node_balance_pie( save: bool | pathlib.Path = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', - indexer: dict[FlowSystemDimensions, Any] | None = None, + select: dict[FlowSystemDimensions, Any] | None = None, + **kwargs, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]: """Plot pie chart of flow hours distribution. Args: @@ -996,10 +1095,25 @@ def plot_node_balance_pie( save: Whether to save plot. show: Whether to display plot. engine: Plotting engine ('plotly' or 'matplotlib'). - indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. - If None, uses first value for each dimension. - If empty dict {}, uses all values. + select: Optional data selection dict. Supports single values, lists, slices, and index arrays. """ + # Handle deprecated indexer parameter + if 'indexer' in kwargs: + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError( + f'plot_node_balance_pie() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}' + ) + inputs = sanitize_dataset( ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, threshold=1e-5, @@ -1015,8 +1129,8 @@ def plot_node_balance_pie( drop_suffix='|', ) - inputs, suffix_parts = _apply_indexer_to_data(inputs, indexer, drop=True) - outputs, suffix_parts = _apply_indexer_to_data(outputs, indexer, drop=True) + inputs, suffix_parts = _apply_indexer_to_data(inputs, select=select, drop=True, **kwargs) + outputs, suffix_parts = _apply_indexer_to_data(outputs, select=select, drop=True, **kwargs) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'{self.label} (total flow hours){suffix}' @@ -1068,7 +1182,8 @@ def node_balance( with_last_timestep: bool = False, unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', drop_suffix: bool = False, - indexer: dict[FlowSystemDimensions, Any] | None = None, + select: dict[FlowSystemDimensions, Any] | None = None, + **kwargs, ) -> xr.Dataset: """ Returns a dataset with the node balance of the Component or Bus. @@ -1081,10 +1196,23 @@ def node_balance( - 'flow_rate': Returns the flow_rates of the Node. - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. drop_suffix: Whether to drop the suffix from the variable names. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. - If None, uses first value for each dimension. - If empty dict {}, uses all values. + select: Optional data selection dict. Supports single values, lists, slices, and index arrays. """ + # Handle deprecated indexer parameter + if 'indexer' in kwargs: + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError(f'node_balance() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + ds = self.solution[self.inputs + self.outputs] ds = sanitize_dataset( @@ -1103,7 +1231,7 @@ def node_balance( drop_suffix='|' if drop_suffix else None, ) - ds, _ = _apply_indexer_to_data(ds, indexer, drop=True) + ds, _ = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) if unit_type == 'flow_hours': ds = ds * self._calculation_results.hours_per_timestep @@ -1141,7 +1269,8 @@ def plot_charge_state( colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', mode: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', - indexer: dict[FlowSystemDimensions, Any] | None = None, + select: dict[FlowSystemDimensions, Any] | None = None, + **kwargs, ) -> plotly.graph_objs.Figure: """Plot storage charge state over time, combined with the node balance. @@ -1151,21 +1280,35 @@ def plot_charge_state( colors: Color scheme. Also see plotly. engine: Plotting engine to use. Only 'plotly' is implemented atm. mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, or 'area' for stacked area charts. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. - If None, uses first value for each dimension. - If empty dict {}, uses all values. + select: Optional data selection dict. Supports single values, lists, slices, and index arrays. Raises: ValueError: If component is not a storage. """ + # Handle deprecated indexer parameter + if 'indexer' in kwargs: + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError(f'plot_charge_state() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - ds = self.node_balance(with_last_timestep=True, indexer=indexer) + # Don't pass select/indexer to node_balance - we'll apply it afterwards + ds = self.node_balance(with_last_timestep=True) charge_state = self.charge_state - ds, suffix_parts = _apply_indexer_to_data(ds, indexer, drop=True) - charge_state, suffix_parts = _apply_indexer_to_data(charge_state, indexer, drop=True) + ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) + charge_state, suffix_parts = _apply_indexer_to_data(charge_state, select=select, drop=True, **kwargs) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'Operation Balance of {self.label}{suffix}' @@ -1545,7 +1688,8 @@ def plot_heatmap( save: bool | pathlib.Path = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', - indexer: dict[str, Any] | None = None, + select: dict[str, Any] | None = None, + **kwargs, ): """Plot heatmap of time series data. @@ -1559,11 +1703,24 @@ def plot_heatmap( save: Whether to save plot. show: Whether to display plot. engine: Plotting engine. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. - If None, uses first value for each dimension. - If empty dict {}, uses all values. + select: Optional data selection dict. Supports single values, lists, slices, and index arrays. """ - dataarray, suffix_parts = _apply_indexer_to_data(dataarray, indexer, drop=True) + # Handle deprecated indexer parameter + if 'indexer' in kwargs: + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + + dataarray, suffix_parts = _apply_indexer_to_data(dataarray, select=select, drop=True, **kwargs) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' name = name if not suffix_parts else name + suffix @@ -1821,35 +1978,45 @@ def apply_filter(array, coord_name: str, coord_values: Any | list[Any]): def _apply_indexer_to_data( - data: xr.DataArray | xr.Dataset, indexer: dict[str, Any] | None = None, drop=False + data: xr.DataArray | xr.Dataset, + select: dict[str, Any] | None = None, + drop=False, + **kwargs, ) -> tuple[xr.DataArray | xr.Dataset, list[str]]: """ - Apply indexer selection or auto-select first values for non-time dimensions. + Apply selection to data. Args: data: xarray Dataset or DataArray - indexer: Optional selection dict - If None, uses first value for each dimension (except time). - If empty dict {}, uses all values. + select: Optional selection dict (takes precedence over indexer) + drop: Whether to drop dimensions after selection Returns: Tuple of (selected_data, selection_string) """ selection_string = [] + # Handle deprecated indexer parameter + indexer = kwargs.get('indexer') if indexer is not None: - # User provided indexer - data = data.sel(indexer, drop=drop) - selection_string.extend(f'{v}[{k}]' for k, v in indexer.items()) - else: - # Auto-select first value for each dimension except 'time' - selection = {} - for dim in data.dims: - if dim != 'time' and dim in data.coords: - first_value = data.coords[dim].values[0] - selection[dim] = first_value - selection_string.append(f'{first_value}[{dim}]') - if selection: - data = data.sel(selection, drop=drop) + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", + DeprecationWarning, + stacklevel=3, + ) + + # Check for unexpected kwargs + unexpected_kwargs = set(kwargs.keys()) - {'indexer'} + if unexpected_kwargs: + raise TypeError(f'_apply_indexer_to_data() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + + # Merge both dicts, select takes precedence + selection = {**(indexer or {}), **(select or {})} + + if selection: + data = data.sel(selection, drop=drop) + selection_string.extend(f'{dim}={val}' for dim, val in selection.items()) return data, selection_string diff --git a/tests/ressources/Sim1--flow_system.nc4 b/tests/ressources/Sim1--flow_system.nc4 new file mode 100644 index 0000000000000000000000000000000000000000..b56abf52da478f9fff54cad88c180808f50f5011 GIT binary patch literal 218834 zcmeI531C#k`M~ET8$byVAVDr6Ajly{IOPxugv1~akl=w>mSwXcTa#?u-9W$qQk8nw zqP0~NFSOpZ*0!Ry)zW&`t7_F+Yw@T@QLGpJYxVzqZ{~aZb~bN!v)RZdexu~=_q}iC z&HU!=?EB_==atPb?RIGYLj{H|U4%>InIGxT*D5@BVz#Lwna;1K4-UsAW%HM1jrpWt zU$y<;>sdoh`|M*>G{Y-DY?oINmn!+tEMef8f`O1TA+oasWk5gX&OtjvbQ1ZZi|~ZQ z!4Sy(48trC7RpQr&S8+Bg?Kt21gSE0d1mPs9mq1Rs?5LeXAFHv_SiXi$hBtCyA3vs zz8K4ZoM4bCM~>O~c;V*qnbQT&WGMoevuUETR00}@G$0U{8O~gau(Zgf3;b8SXl_LX z#On?JB20vszhH4mdBuVy#qd!JOC{7TL@Alx+#b?gWr2!q5?)ysMNgxDa(+Z zi&w_fWR1-cgec3*5h6NTOr6&}qUp|vAorN7mgOEVnVS+^d6Vr{#hM}uXa4=);|qiH zmY2;3?r@)Ug*;V`{NyBg%NSz1vfMoGhgo7taoNJ$5l*$#8BD2`>U~f}mEzOyyl{r( z+*{*}Rf>U*$#53p^c)$Bz9e)jj!rXYEPZO6bB8x_8$rStZ=e!TQ3D#FK{n{#ai$ zYNP0jXUS$;)m&+1iQXJ1FLj@~qRafk+BtSDM7pXegpb(7!Aw~^^{y;;>G6`8W>wW&U)nSx}&X_TSB#2DEEpC5Z zrLW!xf9k^Skbk2OZNtF^pOZ}$WYHaj+X)S;JT)P-^ag6{19gxDD$f`?FW|571ZD2oW?-&LQ|b%Wtv|J)sK_@tSnG2tSh#vrQ?dm^+f)h`9ZwYuG75DTsH$?; zdxD-?Ul?jJwvbEemQ+*vw56u6EZ1JeA3^R+oVrRz*xmyqOp+;2t-2o6!fbmHtpm zIX2X#Oj1T|8B21hQivn#NX(vG+S)2 z$5vC_6-~NvQ&q$g>QJ?5NyUt^#kdkia&hgpX56|;4}5tyKz7ydi?6E6=M9^`aDKCd zweSmKmfjl(SwC8ifP~@qSLH`TV>_YDs2$KDqGjZqJ+n0dc*Fru$&Gio zmcEt0Xd{S@4>t0-q_&|Z?5XqNLyqF^sjIYI%etr5cxpU#USB0#L;h+&GR0*}LDYUP zPzmR(-wGvT5zHTB{7Qi??o$XF%g31b#uM;L>3U>|WGcbWdc@EoOb;}Worq4}qpDJx zi=+(j=Xa?ruf{UwC)6|pbM>TB5e^2-CtBN+7hVX>61MLV!$_9Zb6Nai$kZc|R52h@ znx9J`#Wr|?lQogV<6FcqQr4sZVW#a5d{oa^(PchQxNyF&7QSY&-<=A7-Dy!@9=kO} z_Se-nnCdo;Sn1IB zM*h+|q!VKul|t0FEMW>%72kz4nr#Km$Au_23~LP>oG|nlRy9*)(C&IoC&6m*EMtfm zXb2}?Wy+0mbgC#}s37tj7+nSkt18G1_c;UYQ? zXNi@#3w3BP3V%k&wTiS$254< zkG@Cf(H)X(iff2@&>a4RS#f5h`)o% zaRFsu&G*1}fli``HUwP^K6e#Vr5if%>Y<+kd$`W9wHeq(jHGj;#?Ce~6j2N{y%yQT zBrXlw*~)U-+GffDHg4b8l3ITVJJ6ad5%u6{MItqMe0bN|9^J7ZF{hw|#vJ3IzE$i| z?h5}%#=cML4>db#L8kstw8Kya-IeOG!<4COoW@S(c|yLzV)$d8rExDBu@YjNiePnh zE4!8ILb3~A2<+C!U^?7|AwRebg%gV=7L6^M0RI;j70odJ7pjxn9GXLWc%Z29!ENGo zuMPUv27-Q%9A2ROQ)m1Ks&79RBS9cN<*%DmTYAbLb)xI`*sqO&^klEPl`TD8w?LX( zdb(~OO;73c*YuRm08LNn4Ak_L&LB-s_QYGy{}Gx!U3ZwKr*wvEdb;i?O;72J*7TIl zQJS988LR2ZUX8q1Jjs5dW>43hr0FRg7+Nrc&S9FSr*vpclI$s+8Ja!ihX(w0`&kb5 zb2U9(cb=xF>lSNzy6&->p3*7P^pws5O;718)bwP(NYj&jxuz%kC7Pb>kJt2Mzf9AU z{c=rD_9tk1vR|R;iNh(Hp3+&V=_wtzrl)jPYkEq@qv->vECx-|8GzIT+) z{hB?c^LtHC={%_EDV>KjJ*D#}O;71Otm!G8M>IX9^O&Y5`^Pms**~S}>AFvAdP?UR zO;71OujwhB7c@Pk^P;AwbY9W)l+NEYJ*D%irl)jX*YuRmKQ%q2vscqoI&W%vvVTj{ zll{Azp04|zrl)k?*YuRmhnk+!*{|s-osTp(xIt4^!=iA)C?l)ca74?He^C%Pw8~l^ps8)O;70@qUkA}ZknFb>8|N1ogSK= z(&??~DV=;xPw5nBdP+xO6A!BMB=fO-5O}k*%;#I`__jKlv%8jJe}Dl~0CQn>7tL}l z4g`XgFvJNHj?Abrvt1n~{?ZMS`fRS7$A7w_v;t;}3i@{ED9Cgq<}R^RG>SqH)m334 zHHO*d-^lU%r|dHugvcbt|CUG-dLGXOha+_c+3I?9#9XwF1E#R@y;^$AQRzn?urv(%$l3T9M*m*~ z?hT(@a;>rbu467}Ty@${r=DfF`k%YY=<&p%1!&PXr}z+fxW7vnAXT0z9zK3!*)yW3 zuQl#SmePZCtfuN^jS?SDx;F;}2ZjMBP1~9TgkljE9~SoyJ4zSDu)2 zR-)8fWPGXjOfem@())GstHxDd{Pd%Hz3=~U#Ja)f?J}nC8xo2wS#Qsoq9;V?wp47; zbAlyV^~%#jx0f%;-nw@FU(SAVz>N!TFm5~dzunNZ5C>I@5Qt^iNj&g}foC+Xnt4Vi z*K03bbGXUi*zI%Ajb(6KpB4Z5>aOoyu<6s0vxZfRmB&4`7!$>6w$}tET}L!~U;O3! zu|)QuLS2 z&A+UC$o6YG-}pq?n}6-~*kS$lP8N^24?PA881F`EQB^B|=P7{a{{8WNa4SyjwDpP4 z9yZH2IIe;n zDaL&&f940NU-{fJoReh0JSf~I!Rx7km6aYE#BO#Q0^~`3biLs-&6bjn1^srd{I0c_ z*qkI{s4jo_^)~}OUeM!)lU{Cw;JmoFkDB5_BZw(4E*^l{%Fc3qw^U{jGlZGM3}VMv zvI~rVUJS+%XvkgM_P3>9VPB)QeED9N1ZPh@_3qxe|2q2vvm3e|uiDZ@##9Y-#GFx8 zb~V$B$hn$~&n*{~T-3)b9O3(i7qk#`e5n->;ZGfU?_bOUuEum*T0pwL=x8q>&ib;; zINK|?P5OTS{iyGUnEU8}rIM@C$3kyC1_e)8Yu+J4ZPv9u{FL!4jg`9Sb8d_et+k z=Cn;9?CFN-bS*?*kR~!F+=Fb*i5RK*E`scsnK?95M|yh)DrDR`c@R=Na;y}8sVL4KO4HB#KfP!?XoJeO?!yCsgWz1 ztJdlsqV3xyGrfx=*&d=dJTiVS^S*=6zkTV(N2SXC7we)6b*ogV&N%&=ZPz=HdWTK@ zCF_1^FG#bWojL*V-iQ}UH0Pf>uBd;9o&I?A_#6{p0!)AjFaajO1lk_~=`+kSjMn)K zX>kUn-&R#3DV{|zmA?TZc?8Z5VQ$(-ZS&!}c4rG0h1lgjAYa}6sq0KvFYQ-9Lc7{9 zO&$(%Ci*OM|2Aq>}1IL>voN!~|1yQavT+AUjxuJ>104X^yyP<@Fk4%6G zFaajO1egF5U;<2_!xOOPj~>j4z}>e=&NvYf$LnwcV&ZXMIdpuQwT@}MRHXREDM z<|%E$iNKeYKXk*q<%>#1&L#a`fmZ;7?>k)l=kwt?VhQACgn057r*si_f_A*P|Ib^x zh&v#k)5J@GafgU<&`uD4dgGy<;xX9SWr)~Pab3Pxe5epp#Ly?+C=d(5cB&YE)yRJ0 zR2Zh5CN>q{&`-<*+p*%bU6YO!0rTUorV-=C0}ySJ=zICr$zlQIy}!u0E_aGJ8yw#v zQ9HWdbg>SyGfC_`&|{vt()BRme`;&7C;;sY@mlpCO2lPwiSgo*n^!CpyI@dpiWqgq z6^q3!X0-A7|6M8$12$vDyS05!5JiygF!A>94nINM10P3=>!3&4OtapYeEWlg_+~a$g^%i*X42VSnD63%BE-n=}gRB%91{cfC zv)$;dycMibW?5}GJKPKf=B;Kq|MJf3NoTjh*)hUeBP{P>+cHG|c}D47pTfC(@GCcp%k025#W?VCX6O%up_ zKoZT6X#(hN*!>ioFK(Cd`PudHpdZUjYk$r4!KM?Not6FSo$@R`V&mgAK3ccUR%WTK z3&v7i`0+V=;E-By@`p8P#O^QyPJs%ZwO%i2GS z_R*rF^li)KM%?0$DnqIHw&*j@*m03He^(_IIXHP|);#2hY1F4(npegnhf#P~U;<2l z2`~XBzyz2;ha(U*g4F$AvV8ipZtBmhUG6z#rhM=}thEpRY1!P^T88xHC(Rd?9_te= zL)!DAj8DsuQb+XINzYLt=DUooH|id*)R{c{@yJK^ei0eR%ik@bc;(@ndEx8-ZTrK! zbiI}xqwf7xkIBd}_x^GmBQszXW-hFzbeH?W-pZ=U#eqPu(qHEZ`$A^4JeZmaizXoy zntu)T$|Z8A=!kxt^e15^D-5r}x@OTGQto1B3MUuvEtg2Z4iI?B904k>V80!)AjFaajO1egF5U;^!%fRq1_?EJuo*neo> zHvs3J2`~XBzyz286JP>NfC;pKfE*?~n0q~5f1k{52JH2C4CDmIiD<9KS@8Mh3h^Qi zS*8E&s<_Rk{c2^Jya433TSa*Dmpk6Ta^>VaE^neqm(OahE)S8VvsO4QCcp%k z025#WOn?bw9)Zl;nBt`SC8e_n7Q8`2g~T%=)9;YXg=^a#l6jh346^%C{|X86M=gCn zK!P=7KfFd3{bYF%DX$@;eJp>MC;bt8&c_*m=-&`l=k;Uhe}h0dT-T0u3CL};pJv`A z!CN8m=pbt=(9+sU%fw%~7;h=wI1t?OKz<_Ch4ZA}^w-5g$b!8t7J=M0b@Ao@K3_)V zwH=+D*JS5=zOFg3Jm1p>mp?KACcp%k025#WOn?b6feuFC;CplZv!z>|0p8s4P`P$* z4jrzx@#cPZ^Rz#ta3?u=d&$nu{eZo_6sU6)Ccp%k025#WOn?b60VdEs3AD|`(-i>2 z?lzsG$tyu_yNRc%Z@_Z%u06-K4H5KZ?L<~$d?S}n?qH6dAkl%jxKhMI!AvpQ&iAHL_ z(tLKDet-ch6+>j2wUqfU6JP>NfC(@GCcp%kK>H>T^;G}P=w_bkm*S&ns(V|~Vo{lqYP#`_e z+`r0Ol$)lZg#^2YeAZuf@*hxlL(02{48R1kvs{0yi$D0_yn~1wd{|X1<8P+h^VAbQ z*iWD3G0xr4WA}Ty1>lPo}c&@xToL#%lop5TJYD&CrmV7 zY&`pfEuhS%On?b60Vco%m;e)K{{+wruvUS*bAo&@w(nWkHai-mK40wTXHF`UF2u~1 zZYr3-iOU@!OaF0HC8C?RvC>e>DDtg|y2XKz2{q_B%Kv4o===;)PE#?S<@3 zCWg!wOn?b60Vco%m;e)C0!*NT5Rm@d$8GG-%~w+^ajG4LR{L}RzAAJ4xlK?YyZ?ss zKkf1skTY>}#YeI?jEAhXCdj+z+1B1YX?gQp7xMM5-4g%ai1YTjwEOy}16}*Q5Y*%l z$eDO=ocG@&sq4b|{&4Dh$aRilo~hRQ9Oz24!42=1m;fN3W&%ur2`~XBzyz2;J0l>y zhAUd@H6$2A+*Tvq02)VYwa+>7&iH=CH#vrQ7F4<2uQ*$i&jC5L`M&rNK4jxK9E6ks z$D8E>#}5Wb06W#0wsTw_&1-mzuJ7b!^dtj^W0<`R2QsW?0!)AjFaajO1eid_ByceH z+DOkHDw7A~<#fzCNxoEZYHv)iTlagzcn9$oW1Oy$*%SUYY0w#8@P})nebEY+eiiz+b{tpzyz286JP>NfC(^x z4naVAZMU>^E7-L3Wmx;>e)fQzjEalL&}G9#V@dR5tOhgipjx(@k8}y;b!7a=?ma&+ z(~HRGf}Cml_x723%yt}5x0Xnbdql$1`!7=W=8rz<9hzv3gjx}tywidt)4Z9z(Dw-{8uiB) zNyJ`?8=240cEjB-4)|HLo9pDQ_M%)RIR2Ks)dYn2G!tL~On?b60Vco%m;e*#I0WV` zUr;VO4Wt4Y;KU3UcW;$)zf<>0A7}8!7tcR}Z1~uL2%tZk{I zKIuvXG9xG74}4L6DfGeXBu4+!zQ6Bw4eX_wd>P1X*Bg=gaQ(ISzWrX*jDYJ0%SjG8 z)YLDr_Pmg{{6drO200VG)4#7MzM{qVY1{Oq+^QkFO_Og2`LfGj1CmbOY@$`wdo?c> z7c^j?MF;#g9LaV1} z5*1yDW2{f+2dDcva<4|)v&00L025#WOn?b60Vco%m_Yj>FmL&yQqjqJ-^G!zUUH-u zMENfVae^33;ua9ci$W6bG{qq#z6S2e5HXa*f=)t=6-SUb&lHD|_;-jq1eRv15HQak zN#a$eID*8-KpX=3QsF)G>?jfs>x>r~P2zkI$BCmz41zdEj3M!E5NCk73I*WT%m8y0 zmVh_{im1Xm5GRQu5^piZ2_(J>;#4t_#9rV!O$KunN3b~bvu zLO@Z>4i}UFb~5`Br+r1@b#Ps{u|=Nxj$E(FH-MapeHkZ~?)(MzLNwpql%AdreCco0 zs>f&EY`)@BU-fw6VTRQz0BJ3xI@X`3^?KMlS@05gvvFVoOn?b60Vco%m;e*#I0U5E zbCBP-QqxO5EaSsx3p||FSPkiXCygX69MR=Wh?apQuPilYpf=qY~B?rA0RyZf;q6aMjKR9_oy`rp~g+ZD( zgl;zm_KGA2{>ucI025#WOn?b60Vco%Iue15@P;4Qlo{UeV^Fj9@xI4F&NOfM^E=AB z;kcW07WdDRK{6=XoEze0x__jvvS zeN`i0h<~G}Z2SY}3v`7KZ@SMt$;mrTw2ur#H$xG7$Ap59On?b60Vco%m;e)C0!*O8 z6UYefxO{eIc*plZb=$q;UxA!y-tp6So_~_8w6Pg6R@`L(en+yiT=y)LmDuuW&C<~y za~q-;B6_TNXO>KLjd>2L1LmA(K_zvEWyf8tWykaVwf;)5h*@r|VqqF3d4>H`^_J&o zUNVmzE3kZE0!)AjFaajO1eibvB_JQTpUtcX?ujc?ec(?2Ds;P9@ONAKyJH{ zyVRY6slPb-JXyJ!G4s~)rXKuWs+B9fsUr1ak$V9=2C2PR4W^PeCG0bAcfR!Lt*O^hyXWvy&3ob*xEf#akvZP<^Kc8;z3CS;`9+X3(VKp^_X9a? zB8j1QmqDap5jtj0=7=bpK5n(0OM z%zX>wOj`%J=B)e;rDY4|3B!xUm*M;)5p2*P&c?$M~O`ycgv0FFm{j4he#jmo+p&JUZQ^d0RXqn8@&v2`~XBzyz28 z6JP>NfC+Rw0`rzHC>J@NId?S*`PS9Dx5z>c46c=)*}$D&f3d9N6`V_C5=hgM+kA9q zdlr62z0q=xnA}xN{u*3khh{P@Kkq5z^DE5aMs-yq%smuQ;!eE9u$h+YtF zvS>PT&7A^AQH#XxE1$Yc6dZ~MEAHQSm)HunW5lw#cm75cz{jcLkE2f5EnWlnYlt|# z&x7}gt2zlWR($#Kp5KZK%+C6eqv`GuluIBDB{a zz3=I*-OVh4_dQ0v@+fWI_xFAW_Y8zoz3+UrK{0vXsZTe)Ztn|`L2@T=JlXZUx3f3i zUcX43ZzjM5m;e)C0!)AjFaajeVgjXQ^KFxu!DoH!M>q5{TeO(FkA z-)eJWIe5&j@ye`FH;bg~ufvr09z`jm8;v1O6q%W#nG|J$j3JKDui_1a!l9;mU(j7u z6Ik!A4m1QmSwXQTs#rtxSj9^wM#l;IDb6Gnr%#E$E>!RHhZ^eEj$G>Y)yRTxDaku3k=ZrWCp5vCw;bK-%3s%Oi&P6*;$5t9z&~VHavMiW~t?UE9u`2?^8g8 z*zyjSus+k|&q2<__4~PVu!0TIRpi4&5_hS;rshOXO@mLCqPb}*ct+kJ(ng2_Tc%#- zq6UhTkz-`-{PeeF#7cFt?SNFeP8ku&LG&l5zONq9W--zOTA!Exy@=e~F;Q8;1egF5 zU;<2l2`~XB&<+W-Q!gnuM?R=#f|v9KRJ+|v`cjks2Xfmv1*wmnr^icra9ic_h%NfC(@GCcp%~0RruGto^6MF5`Ed z0bVKu!*xVFW4VU?@!QCFxZPMTr`K1>*ZqZzkBnz5Q$F=gGNx6f-DRG`)pDW!vX97M zq&+p<`XIJYoj)eyQSprBrK|5FNfC(@GCcp$b5P|5v zH+Uh$`Pq3>4cAmv#Nn~~Nj9^|?} z?2P#Xiezpp2oXn=`hs=q_v{MR`r^hMct6FQt}ch0cgBsf`ezhnhKe#6_pclo_SM!0 zf}WaDV`rP?iYRWpMaEF(eE2pHz;!Xr??#!PX-9ka?QUufM<;JT(Yl$uSN;9E)nXWex%7p!F) z@2e@DWr{^Eyu^7FEOzW6!lJKOR#IBBtfaiS#Jy-i#d0C~zyvnzgQ)a`Jt1E>A-}`G z0_VC_`fGi4A%CDw#Yi;&4X@!cZ41r62Cl$_Knuy~2yeZQ3RkX<62;*9Xt@f?U=RDx zwo5|nzb0BPy(C&bZ*R1`es{EdBW}5E3!Cq-3HWhj^!fKHqUBeMqvhL=kCqEpN6WMx zpRPhn?n$Oq^(526d6H@AJIS=Von%^nPO`qLeCr^Z(I_qU9u+MY&5V{mSrIJ<{n7H` zrs(pG-W)B{#AnLSCu&YJ$uv`$WSWLdvOdZ9`kzE!|E-F`Bh9_qPsRnmhUgA=FT94k zym)CaQ11(d{k{++gNfC(@GCcp$bGyy07 zp?8v{u44ZoNm6Xa1egF5U;<2l2`~XBzy#Vqf#@A-U=*%?b@yz;g+qB5CRc=E1DCbF zrUiBDe4+4CPta5A3&RLVliaArS5@WnhC}W^mD_Jxz{p>C1MZLt!Ep66k1&!)$yi$+ z*UAl+-QH?XaIMcB3VXsn8b32!{Z+hy6c4sJ4*NYdQRdr6(PaiG^P|W-<}y`&*dr$@ z_6t)e_COW87sd94J<60u@C?`C%Ba9@ROt`N{L{#u;Toju29ceWm70LLS5MGg9cT#B z(4XNNtRf7I?8EI107`R^$zA2~LIo7j$e`gWOu~Eu4ICP-VanXxFjO z^L+SL5@>Dj!3OR<_*ULGTxcf11egF5U;<2l2{3_gCxPhR1DIWz_o#2Z;p(Ai9HE?G zc)wJ*+FcXyRK~uKi6-G1E}RC8A$}~`Z$MLZ4OdSYN%V`lRzsb<8alf>Uyvq-i(cD7 z-4QZhWNUfqweqD(H2v3b(O_z#crtG^P1tbdtGrQ(VtY~Ki=Z%_S!~}4%^EjcG!MOJR1Ac1^)i{5;iA2_4v(_YiO|e*!v%Xfo59Im z5~rEzhO4WxkK}>gVs?{yWwhbSjY+e)7n#xQcEi;zfxXNF&3-prv@h9VQD2$J7v}D> zWP*lkh)P+#a1CD)hqzw9pxN%D*7hb`$h`@QHy@Y)6JP>NfC(@GCcp%k025#WOn?b| zTM4wbci|fDUHG=%I$Ulhzyz286JP>NfC(^xZy^EuK4^JkB>)=w&h6q7(_n|E%-jc! zUKdP98XBicv_`m_6xL|8l4<0b?5&YyrLabhl}zKqWN%I3Pzq~uhLULl z2H9H^E0n^TFrj3c3PJWX#erm+xNM804eUcZXecnh*`@E67 zxkvOqXteJeou_@@NTz+?NM3Ed*;ZbU_JO1Gv=1D~v=1D~v=1D~+sdNzL;J$fdD<6_ zWZD;wWZD;wWZD;wWZD;wWZD;wWZD;w*2wHCyj zS_?W)tp%N@)`DbeEy$i)3p!7&1uZlBu;IdulD{Jhc`iQ)@x?)LM{C ztp&-{T98bw1*J!=1Ffzo@?yzF`2x~JXa+A6E$G0 z)LG_o7qaM7UJZ@4V9}tSJJ#tbC@e}H;hN?u1Z7?&-e!y?=%1Kb7g%Y>Oa3AN0T~M* zfu0vbgz&cfy#5*zL5XsSrduZgjgz?%)IfVsPzzqQn$?2WCORm@080rUo_ln+5@j!r z%NX@1Uey6?*+&8q_KE23c5QbWm_z*K1shG+Cbb67{g6r&fwbN zVc{pFL4HZ|LU2>qryRC@N)%=+zh6OkOdJ@C!7rxc&Q3c&Gl{kT%9}SjkK57Uge_T| z=?0rUdi(^qk3ky00rNWY`rw}hgp5bt7qMaaUDPb|Udivh{6<}~yB1_*&62PGiXTN7 znK3S#+s;W4GXKs)_XPdGZb-^-Pb+noI7>X`NkbBDNJ@9Rik)Tp*W&!EoW<}B<9a0| zO@~`c{#C_Jt~*J_CtsLbRMEY%sMvj7ZW%Om66gslGR5QeN@Bq)A$q#AtlR}TQY`1;^$B<$qr#OH zS6Omfs#IC*n;6ivpDMUYN-Ojx!qWR-=M{BVczo3?<MWqjIdh zfk!~RwJSiRPk}_{DmsD4WG$YNiAVbb#WUsPI*XNVC z<3#u=#kF8}$_sOAP%GK3ycO|wIMJuPDR$=*ocvVL)SJAeHtg$t#N3sHj^Bn-n}YX3 zZ}&2cd-;GB6!g5Jmu0$2%1fQD@`{ov*4}1L0Mpi9XG-+fCw!BAK>52nR5P22p>Gab zmR3GK83m1z`Q;wC(-v7#7_mMaFa^3Fd{wRtHL+R@s+Gx0fY-|0Vbtta9Nzw+=!x2o za^`yyMmVSCmlRq7m=k;$m=XblXKHRXKvYoJ=v0eHP4ardB-v!M0x_Yp_7DHL1*Y*H zcUk@vrxg)K`rwfXl?7At%ceMUp?`_L)EHml^myF4g|2d;z%NmmBxn}9^9vQ4e$vIg z*H3~EV*mD)Ta~I*Q4**asNts|m=v%msAyZ5%5vBBPQMQpNVBxeIUOFUa?4%QDvCY% zB~Eumd2U6y{%nLg_QY))$=rDI&jVVsB#FNdnB)Ry!v#-ZW|ctgYqjpT3&)XY0RRm zL-J`SX##EmH$th>p-+kD_AF;v$#qv$q@*}g%cdze1tksgS^tb0PwPH^;VlL7Ej?JW zc{H$O^mEbrzp+`J2y3w`oihrY<>k40LvSqqlf|vn@5QNPxGKLmzobB1$EMb;0~pGQ zEfrD!a@HL<6B%!hGuPI(ubG?KN6F}>{+rZzpt1g)fWyPXpH8V3gFy*)$$9 ztr@_YPoFWxnQ*8N{9MbXcT`zKlXcNc)yAz&9Zco*HlI=)j?{b(E*o_4siDk0&Ds{@ zJv_>%HF!q#=^m7P9$Br_bz?r(^ycTiPz5cEQ`cwaf1?WA>?QF|ZJ6f68GPK-=-ZYQ(6p(Z z&!d((U)_#->JP;xUrs((TMEE$efnr_sKzf1(&98G*w&{baaqVAX$89XE^Hz0jrPH9 zBw4$XH@Yqc3)R1xlCSrqDqL@hPq&5|c~kwkB?eZ|GBMTvCiI zeunvt9KIv9{;tOW>PB6x7bD~fP!pePP@ITI+tkG8>N9fGq$b>oPmA%28#R$-m$#Jw zJ)JA_=JENfV!grmCi_&OL; z>aQqRYp*6&tM1jxWYiqBc{OF_An;w+{gMGs$l!9%4q zB#SARws`eX509vi=j97md#&_@3v zB_7WUOT6@ecF_LCN<8Xcro>}86-ADf#B_7LJti+?8X-Yhv_gW<$?UX6;XooMRM~O%Mxk^0h&sXB{ybF|gw6jo&M?1GF z@uim3Y*@Pl-qUOZZ-qyDo>Jf3&G z5|4JCQ{vIi1|=Tt;NoGtUeL}VsJ}&tNBuXHcs%c$ zN<7;6w-S$b{-eaBo$X3I+S#GRqn&q^c(n7L5|4J?SK`smhe|x!`ACUJJ0C0YXy-E} z9_{Q=;?d4tB_8d3uEe9AgGxNwIi$p+9bEE^{R-`Tt>~kjZ1LHm5eV#oFPK zpEmfK^4y7uarmgjMZba9rb$bFqOkBV&A7wD3qR3yQ=2sLs#G)mRnOckS8?$r`Nb7- z=*Qm+O1#*dgEt(Vy`bIdWfLyS%;bGbeVMeSSJCgcL7CQQ{9}OJy$q~+BoP53Km>>Y z5g-CYfCw~90y8ETrB1gXYWO;OU+MDP85E(vhTw2`Qu@MLWA5ZZS5+5lYQUNC} zF+-$`$45<^keo5O*@ua{e;bmsDKYATd5?FjEd4Dy?z>H{&o>mE(f{6=9Vf2z+;n77 z?^O9>nc&6fkky~9iE|~Gc zlI!|74T44+~tvzEqg9sbNXcmPprMQ$=Hmy^XI=aZ_IgHIuHBemWQ>w z=0EVkjLALUIe6cY$CI`sR^GMz$KG$IEco#4vUk=Wf3?N6<6i9i^q8oMsh1tw|HN67 z)@I!L@%GQR9!PA#4(7IfUvGtZ5xfzGrG#rUCcm`%(w0B%x$x!9i(h;F(}P#X4*9st zrbxDGaJ!wN!MZ?$9X^@2GUh=0yd4t{wEuWzWz5`T7krs>E6$ia^o9t>j1K>4J$kz%eC&eD22b_O zj7Z5TOX(M(Ip-d0Gy2(#>@}{zt9GyGzj)(AV{U(7MMT);Ips&9en|W|CFZhrdzW_p zY0EBFk~n_?FLOfV#HTyev?cMkm!eZ$FPk(x6y>aQ_Nmfn8d;OSGY`O}i6nT@OI^)3lzMt&*=*UZd`RK9% z9ro`y+H`6q>p$f59pV%p!zo_*Iq%V(2Znw1>yMosH+M`pHhXI2%^Nqb8#-yy)x{k? z`QqD44t_g5@r}r9+@&|)w{LWZ*>B9#%lW7$<8y9gWw)bw<2sz2cl?!;3EzFPqE%$) ztcSkra$Bb_KGX4CI;ZPwy$Ql2qh8Y@ymro;F+XqFb?CPTS85B6 zCMPsK_E@*OBiQ%JEpGvZ0yZb+=i(8&*owW8!)8pL^juTN>JFC-Km6X*N>4lXKv}!3 z+{(({d0~IKzQy9a54X%|vSewW?MF6_+}Hc*J2N`H)2qednJ2%B-*GhM*UEd2Ep+HT zIQO}ZMS8x-qj@I}4vX6O!jz1CoA=L7|8;TB`2+4ON(14&oc$x)9ldZ|ujcHA3%j@G z>MT4mB6-{5tqXR}&1uIH?$Pdou2cBbN#z%}*687jK>$Hfhb5i{d*R+i?^+$73t!j5z-td~<8s^8^?Jr?$pkj-~JsE0O; zUsM<8eRg1NyT~PdV&0#g;40l2-6v!D3BAItcV{mG*#B%ARrK6BtGh0XU@za=VSv{( zWXzXsDkU%YGo4n4W+i=*I%&5M4PUbTLM!^Xf}vw4%SokI;*)1@Y}(@6OXGT+fB&ixx5w;!G-}|; zKaV;w?*AUVAmiBg`U#f5{*qgiv$nTKjx=z)_<;V1|G6rC{gvZGSFV43bB{YJzF8cZ zxBixXQ7M~U!w#P@son8e-^SduYv_Z&<*iSMOX<{d?ykIPTmQxXd@cN!D7FCK*1r5e>Gf+LUvPAveiOQX*Eap864xXnMs)H` z=}~X-s#{~24E8CkZZ^=FevUXZ&dJq!1D$a&{Kf-+mf+77{8^#88|W;R!>}cgM-MUh z#dO^GP0ALNSo^Pavw;qOR^rcBxB!iF=x}n*GgS;+riEgNaqrg~D(j|3eEF5RM)4r8 ze{V_>9Bjj>sIA?ADlR{=D_j;0sM?{Scp^XqhyW2F0z`laG)e;e*_3bG*_oxR5hI|t z$$`-5QT$~Njh&`X_7~(A!<#AjxXRb>w!j<^j;8Y09q_5YsZMUd$`|i8+_%BHZn)4E z>B3v$@v;EMs~b2Xi}7M(Vt>mG#1^Crb?b~hqn{UQ5$t}b?YV*QFvqiUkvA`BFfQGs zD?~|f?J;|D@jdxSp0V5ILJwmpN@}oMjDG!8JnV0(dp@Q7lv)ipLq*bfGBLoQz4hvOJw!n;>QxUH6ulO)^fFP(>V5Lsozf*YLHC8|v`~1`JJ;S_ z#WEysTaS{%6-Hab2uSdY$M8zWBX1kt*MeI(#&a)h6@9V3a4dq~UShynVRGsV;{M}Z zICf_F?VoaO|H013#`}!_T;AB7imz>V-@}?DLn9zY zKZa^rGB0_ke*NXO$%4Zgi6iLI4*tt1{MTPrOINODBOTFsJbO(C9MHkTqj^A~wQkQl z;J}BJUVQrcp2i?jNHJb^11~>gN5mocK_kStZn3abGmK&cD__2xBwND&kLdjC@PRAE zV2{m!L|P~Y0Qgk8o#QK)O-N$Chk04nI2=~O)+R^S%7-K(Km>>Y5g-CYfCw~n0(Il? zyZ}$VJcR5?eM+S;ShFYf&^tV_PzlUN4vj5mfuA4m^N+AM7fCvx)B0vO)KqDlO zb?8SsdwaIhRU19A&bim@MO2^;521w2kjKY2KPLUtBwa%7(t z&eC8PhpueI8~=-7Pr=vD?D8-AoyNL@^{y;sM_D|Z3C`gl#^Rn%WSL;GJA2{VLtWUH zFoV&9-4>I4COZpY4PYO{qbRi^XQVd?_2J zm!Z`oxf9t!Q0T`-{c`=~ELJyGar0|euwh_q5WA_N8iicdpoKVT_C zEcCG+%su!3M*LpKx|vAUYU!3a2Bf9*1( z=}gheSG$pf4vL;CzLjN-!Rw7zg$LuJ5h8sO0U|&IhyW2F0z`laG;jjqDLYrw^tW=g zI(p~#zw)UY-^C01sT<$%R9{& zI+2ZF9PFIo5r#(9Uk)lxF^&!4)sNkkoa3rFQYsN30z`la5CI}U1c(3;Xb1#^r+!Wy z4~m>Fw{Dstk<@xnx=ZyuLn(R5!Frj}T z@5{=ABqBfrhyW2F0z`laG&Tap0ziG3olgz%V86Yv+-D`s`diDsPt{TSyg|4&Qa)ee zC_erKjPLIrnBt}J1rE#IGqS2Ua9t4-jg z)ZmraXn2h}cdXM>P*{|j;dYl5x=Qjr&T?J7C5+}c^lu6Jr>2R4Bb+?MBQD|Osr`zl zZ_|})^l@s2WbS=l6e?sxGVAsUgY{uZW*`*B-0|QQev5oI$mO@*&wt;`&U4O$wCaTW z$#bp_Cek4SM1Tko0U|&IhyW2F0*!)z@YX6q{OdkV_6RsHN=Ckm4Voozk48G7@HGnl z!LOS-i;BTzHTJKH(zkAcU-!8zjQc};;Pa&XImAtT#% z`d82LqY+Mx?>tk^kcVu6^*?_QHK-51^H3;?*>^ryk?D!y!<`d|wWK_zZR~4(HzR$cgYr+0d+#UC?gz zvMNfR7W!4C-+#+%z*ybSRCY;8ms|5xxg}6g>y&wGgr)`dJvr-r-U4Es@+*6EILuh5 z98~GJjbZtH(9sqqO`mo?PIc!8Pbj_)t*kY{rLRPQ2oM1xKm>>Y5g-CYppg-%!eb1| zU#Tl^>eJ?8g3$1$>TFf)zV~t#+cmP7zq=#Ev#G=7($tCaw6&g19oIs@`n_jU)1gAV zBn$aF#t<^#U){WV`s(0S(YEt_-Sm?O{>T_*)gfQl%7Y{#Km>>Y5g-CYfCvx)BG8x! z2=DIRI^O34!z$W~`Q*j;#g^JuM$tI5PX5&*0!coSL<@ zUG|EzhphGTJJz4p<8#60*5m%LSso4Y%w|dWPOczPy-&M&>ed0WU#1h zB=bC)X=5KQ^JzRD)Oj5HpEnYeD%uH3M?`j3>ov0H4HgD#wnkPIX3s#8w?r0(#o!my zF*|ClPQVleU+p|%zV~o-!hMQoOlwrDLydHZ01+SpM1Tko0U|&Ih(N<6VCO$1RL3%bN34AyKeDD)T$6pVaI@U3Sm4#B}+@XjIL5iKfE_dBt^wDsmU{FQ~r z+4-g|?J~&tLyAwT&J9Kd$WO^cfCvx)B0vO)01+Sp4U0hCcnPld;+c4jgi-4iWB(Xg zKfDCk`O@NST@3PdZ|q+Fhg?QEYQ#lh%|o}?(ErPPweC7^-xN&O5sMAKyHMmEAr8#% zV#7VrgZPiin}WEgHq`NEHhtx`tmt?KO{zUFdU%-QkW*B%K5S-_3q|6k!BLGoMa~B~ z8m`9Re-Q>fT|DCISbo+Y(w3CMp?bm2^^OgY{V2KK0SHkt5g-CYfCvx)B0vNh7lFE& z4lDvl<_G5~AlJ>l|G{mu!rL%3ukt;5L;P5sq5h~|(_f;;$9_DTnO&>Y5g-CYfCvzQMn>R|I|CW&C9W-( zb6*gC@j&*Fpy$=j;0D6O!k^h7402bK&oc2fm9ZU|gQq&!Nt+P*C9ZrQB(s}46Xad@ z^mq&oY7jG+=hX2GW`~;un2>GIG>Y5g-CYpz#o>;yJg@Ub_p; zb8zpmm#pTc7gKt8~Vj!Hv&8J6u>M+o(=NHGq&W5nUy<2 zyk+X5-D0eNqHMuhuP43jLgCW<-ZLu?D;OUExo%!H9XxT$6_Q>}4t8G1kZKxvSn)>a zoyuy0BQ+vG1c(3;AOb{y2oM1x&;SX@x8TAweG?WfT=;QzUVvS^{m-k1c*j%R$8*L( zZ$WG5BZmAvt@+UGX4iXxA}<8FK2FJnW-9VP>Qk2m-S4ZI^UL8-HP#kZ8patb4Kv*C zvO-r$zQ@V;C-Sr6P!rt(SMcGH>++P~<}Z2f(7@Wc_U`-S6<5dao!&M}ka(FF9zxI~ zGI+&ByeSL@``)Qzk;u;nrg#XhDR_gn`ypY4Zo+Qt<2*ez@W-b#_7NBoX%GP-Km>>Y z5g-CYfCvzQMnXV%Vy(ioWL_aHOgJd&TVOtNTkcA>N*JbZa>Q5~OKrwdPl8&A$2xFt zrTubpy@ul)d3vSb=UiHX13hB&n9Q-`M~@qun>~6wEN+g6#Fp@npM$^Vrr9{o90~3} zGl%P$>Y z5g-CYfCw~n0_hV*k7W@Zuo}Z)hmApO-Yr6oU$~V&k~5ZYXL-*%MI3!LDgt%0Ui=kc zWOn8@De|izFJ71XTCiSAFY;P|AO#{o1c(3;AOb{y2>fvfG?2NK=|2ni>Gz&nxfObb z`Eh&^$c=Mu<=~^?8@Wp(wyt^aC&Ay?x+driCNDkrPiQ3YRhB*>JBvkhM8yj5sRy%8 zC_V*ZH`W=&cR}pI5>WgM`buY(h~f!I?a7i*><$x0U0D|t$AQ?Jorz+lo_ZFFf7esH zqPSg8?S^7(1V52JH_4+?LF~!8V`?sly`fw(ECz7^q|5LMh(lN}6n_P=7pTi{RwP&M zgW^P8G=7_55s3X*3Z`xZu@6MZ5DW8716h9*FVMvSD9#4)EC!7z!YUBEg1QX<1~Cyv zFJ$-<#2)Ny6k|{071Y<(7HZ!=Zwr438-krr-CZOH{5)6jt((dmG&2|w9Vrk2B0vO) z01+SpM1Tkofd)sQfxfd_8P4C{V~o2iH5_j#<@vN4u4P4c2#l!%$oAwl&wNA~+SV7Mo2J zPApTdB0D7!0U|&IhyW2F0z`laG%x}UWXAa8e&T_&h8|2FZ;hIIF!<<;N3#dBT({@- zVEzVjy9P{~^;WTAoj=rdwPU#ky3`d!c%<{p2yBL5ZS z#_OYh5?}g!$a-UKXPmE7d~@DCcpx8LaG*~jKm>>Y5g-CYfCvx)BG8x!G?1~GN6xGt zV>91I+-I!eO$7bxj-#|0cD;2MoyxqohPougJ0U|&IhyW2F0z|-< zfbe>Ltm9wm&{_+p1(#pe`juFQ{uZ?L4D+Lo^3iO>m?}1c(3;AOb{y2oM1xKm-~f zfd;ywbA01+SpM1Tko0U|&Ih(Kc|P__4~u8T$QSB*dA-tUpgUhkI| zhD*Pf_xl8#+wA>5smQBA4!!paM)kA)-rjFX`ZeL)`;E>MB?{fI5^t6@#GT!?fE-+H zdCAUO+@!DV4rVR6Mit=~#Yl^~L*7TNK-1#}0V2`wMns3Mu}S{lvv7V+*)4L3B>< zdVQLi^#hNhhyW2F0z`la5CI}U1c*QbBOttGR>wQIEqp_G;WH$ipuOkz+(3Ak@(<9lQ$gzASDw4B0vO)01+SpM4*8YFuW@LK~K0E z;HU(eV+;$^yibNlXZw0dQTh+EglnZS;)xQqakC=LezT%9j!cA4Gr=YOtyncNK^-1kDwXkR)eC?Oo zXAmTn&OC1b|DMunSu)elQp#JWrW3{Jr{Iy70$&^bN|p-cuNC_aymcZRMO}cIxcz4{-D|8kW=X;#2i6j|?bC|z{2?vE2h=1d53U}zynLm{~ z4}S*dH9LEI6nQVmjdy)~*C!|E*Yz+(?=gT7LVX{ynf}7^jiRLTGkrpK7HhKfz0aJi z8FY~ZcJSSk*RrD180*45dUkLHTLp@J+3a6#o540lGS-J3F6%UleF?D%tj&?@XS1hZ zBkgW%)qsO@8J|Z_Vgo%>Z(*@OVj%m)eGtCFY;&lcWO2tU?`F5aDb8f> z<#*iAW`pioEcui3A7&Gw45zcTb5=dV_QKZ=?CDKg9%ZRup(p#t%%sOyCR|IYY}uR+ zkF$5dSPwR*^4MAy3&wh~|FSz@Ws9JY{aD0-#I0;D6f%ij@zF1DvZoNAgEhO8iozW4BJw^a3j?oL_=bN)-UuUiYOou- z(r@yrvPphe8METW=0Oz1dz%ok^hpGW01+SpM1Tko0V2@Q2^de+;4}!=n;!#?K~#H% zK$`~QZf^akf(aIN{VU>K`iW=jLJk^2&>jbl-+c9hFjybPZ$5+yfj4YS)_3mp6hXY@{7j}!dx9?uCx5+(v@?Iirv?V+-pZ*zD_bS;jM9(yUaB9cjP^*j7YUkBbt8?2EsC=f?q-Uzhny8D zuBDp%VlygtHJi-IbrzdUX1yo0qzWDCJAno|cJ|HQ`m=iDU1($8>v3y^_Z0bkkQ?vV z*>4lFUV#e!F@xRM7~UcSQMrJ|#sVRtbRs|mhyW2F0z`la5CJ04XbAAV{%}{wIhmqb zIkI-qQ^QySIF>Q+mUSGvC^IYbqRg=wnYr1c$4`Kl=)wQxd*K)6d-BVj_#U=B82+L) ze_5r_HO*PV_av06`U@=f+4OV3m>q-!{Zo@0WZ+>%bEZAEFkH+@Xx)b138R8p=2}hO#=a^~HYc z`Ce=-1|(4bABk3ZV~kb)q>oiD9cz`_o@s7e#aQlv%pjWKmBxcM+bfm&_SI5^U#}xr*RS@YQq{ zbbK@i{%C!Is;in$A0~Ve&Cv(zqGZ7}6CUTZQdlig+#TI1&Cyq8>Ex3oV_g4aBLn(d znuG7p&qMo6S4nxP(^XzkGDS9*vDiZO#zYSKtd9ijyf_@;2vp`diZ@5^Cj=r!=|q4C z5CI}U1c(3;AOb|7;S#WV^WcfT9C>%8=D;3T>QB6$WKf@{ynP!!7#Wv=jx5%1~E4VQoS8$_j426hn3`aaL9mh9N zHbyX{zA-`}WgLP)eH>~)*%(HU`o;i(l+pi3eOwWavawZ!)Hk+(kTR|qM}1r|jxr7) zAYWq}2bmvNl%u|}CxcAK73P?3Y_K5HamBfjE?1nRY%CX%`o@Y7DI3c@q--qWkTNdR zKzqh244IBgEHE8cP@s%!Bv4*fD!w5PT)~d%xPl#JT)~bqu3$$QSFoduE7(!S73?U- zE}hxOI+h3THe4CD|6~ER_U#E@`J7i9h0}7gh|9T(~4i9W%z{IWu@B)nd!lKFx zz(s~lHStw=&Cx_;U@^*;o5on01K!@zgVZqI7coZH9JoV52Vuj|sD$F>@{*K$oTWJU zraAa88}iUZS2qUX9O&FM=dYz0dw&{(a*k#q8`IN`lNf_^@Ozgcw6na{VkrcU;AxKL zQaK!yjd46j3mF?JhZ1gnRzzry7@5+^V%%q(1BU=L2j6;v2k&Yp%VwoGHmEty6?*J6 z_f=Or3p}pr&RtiKe=ngl=tFJ2|MuklH|X}K!V>`^Km>>Y5g-CYfCvx)B0vO)z#ox7 zZASx!#Ne=>poha{7>i>@I2!jyWK4A?0z`la5CI}U1c(3;AOekrz>LWSd2vlLVvar5 z?d}M+DLv|~>cn8}`!-$or1sE+G?eK`1c(3;AOb{y2oM1xKm>@u??<4vvjf>QJMjB8 z9-Wy85CI}U1c(3;AOb{y2%KU9=3TsUBcL3GQxOMPs+H97wY3KUbw3h?&5_q?&5{|xQiFcxQiF+ z<1St(<1SvPkGpuGjJtTDKJMa$GVbDq`nZc1%D9Uc>f9~s*+QVJEFdcXC!gSol z3uWBJ3uWB@3iBIVTgeDx7b__n`&CKV*mO$D#?DewHa3WovauPIl&?H*Xm)K!3ntTO afuu 0 + assert 'indexer' in str(deprecation_warnings[0].message).lower() + + +class TestParameterPrecedence: + """Test that 'select' takes precedence over 'indexer'.""" + + def test_select_overrides_indexer(self, results, scenarios): + """Test that 'select' overrides 'indexer'.""" + if len(scenarios) < 2: + pytest.skip('Not enough scenarios') + + with warnings.catch_warnings(record=True): + warnings.simplefilter('always') + + ds = results['Fernwärme'].node_balance( + indexer={'scenario': scenarios[0]}, # This should be overridden + select={'scenario': scenarios[1]}, # This should win + ) + + # The scenario dimension should be dropped after selection + assert 'scenario' not in ds.dims or ds.scenario.values == scenarios[1] + + +class TestEmptyDictBehavior: + """Test behavior with empty selection dict.""" + + def test_empty_dict_no_filtering(self, results): + """Test using select={} (empty dict - no filtering).""" + results['Fernwärme'].plot_node_balance( + select={}, facet_by='scenario', animate_by='period', show=False, save=False + ) + + +class TestErrorHandling: + """Test error handling for invalid parameters.""" + + def test_unexpected_keyword_argument(self, results): + """Test unexpected kwargs are rejected.""" + with pytest.raises(TypeError, match='unexpected keyword argument'): + results['Fernwärme'].plot_node_balance(select={'scenario': 0}, unexpected_param='test', show=False) + + +# Keep the old main function for backward compatibility when run directly +def main(): + """Run tests when executed directly (non-pytest mode).""" + print('\n' + '#' * 70) + print('# SELECT PARAMETER TESTS') + print('#' * 70) + print('\nTo run with pytest, use:') + print(' pytest tests/test_select_features.py -v') + print('\nTo run specific test:') + print(' pytest tests/test_select_features.py::TestBasicSelection -v') + print('\n' + '#' * 70) + + +if __name__ == '__main__': + main() From fedd6b6abaa1d2ff2f4aaf2c43db55523a4fe848 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:46:09 +0200 Subject: [PATCH 02/36] Feature/398 feature facet plots in results heatmaps (#418) * Add animation and faceting options to plots * Adjust size of the frame * Utilize plotly express directly * Rmeocve old class * Use plotly express and modify stackgroup afterwards * Add modifications also to animations * Mkae more compact * Remove height stuff * Remove line and make set opacity =0 for area * Integrate faceting and animating into existing with_plotly method * Improve results.py * Improve results.py * Move check if dims are found to plotting.py * Fix usage of indexer * Change selection string with indexer * Change behaviout of parameter "indexing" * Update CHANGELOG.md * Add new selection parameter to plotting methods * deprectae old indexer parameter * deprectae old indexer parameter * Add test * Add test * Add test * Add test * Add heatmap support * Unify to a single heatmap method per engine * Change defaults * readd time reshaping * readd time reshaping * lengthen scenario example * Update * Improve heatmap plotting * Improve heatmap plotting * Moved reshaping to plotting.py * COmbinations are possible! * Improve 'auto'behavioour * Improve 'auto' behavioour * Improve 'auto' behavioour * Allow multiple variables in a heatmap * Update modeule level plot_heatmap() * remove code duplication * Allow Dataset instead of List of DataArrays * Allow Dataset instead of List of DataArrays * Update plot tests * FIx Missing renme in ElementResults.plot_heatmap() * Update API --- examples/04_Scenarios/scenario_example.py | 9 +- flixopt/plotting.py | 618 +++++++++++++++------- flixopt/results.py | 275 +++++++--- tests/test_plots.py | 37 +- tests/test_results_plots.py | 22 +- tests/test_select_features.py | 17 +- 6 files changed, 690 insertions(+), 288 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 6aa3c0c89..d3a20e0d5 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -9,14 +9,17 @@ if __name__ == '__main__': # Create datetime array starting from '2020-01-01' for the given time period - timesteps = pd.date_range('2020-01-01', periods=9, freq='h') + timesteps = pd.date_range('2020-01-01', periods=9 * 20, freq='h') scenarios = pd.Index(['Base Case', 'High Demand']) periods = pd.Index([2020, 2021, 2022]) # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = pd.DataFrame( - {'Base Case': [30, 0, 90, 110, 110, 20, 20, 20, 20], 'High Demand': [30, 0, 100, 118, 125, 20, 20, 20, 20]}, + { + 'Base Case': [30, 0, 90, 110, 110, 20, 20, 20, 20] * 20, + 'High Demand': [30, 0, 100, 118, 125, 20, 20, 20, 20] * 20, + }, index=timesteps, ) power_prices = np.array([0.08, 0.09, 0.10]) @@ -79,7 +82,7 @@ discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True), initial_charge_state=0, # Initial storage state: empty - relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80]) * 0.01, + relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80] * 20) * 0.01, relative_maximum_final_charge_state=0.8, eta_charge=0.9, eta_discharge=1, # Efficiency factors for charging/discharging diff --git a/flixopt/plotting.py b/flixopt/plotting.py index a5b88ffc2..a26c9ff3e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -673,134 +673,6 @@ def with_matplotlib( return fig, ax -def heat_map_matplotlib( - data: pd.DataFrame, - color_map: str = 'viridis', - title: str = '', - xlabel: str = 'Period', - ylabel: str = 'Step', - figsize: tuple[float, float] = (12, 6), -) -> tuple[plt.Figure, plt.Axes]: - """ - Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis, - the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot. - - Args: - 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. - The values in the DataFrame will be represented as colors in the heatmap. - color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc. - title: The title of the plot. - xlabel: The label for the x-axis. - ylabel: The label for the y-axis. - 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. - - Returns: - A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area - where the heatmap is drawn. These can be used for further customization or saving the plot to a file. - - Notes: - - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot. - - The color scale is normalized based on the minimum and maximum values in the DataFrame. - - The x-axis labels (periods) are placed at the top of the plot. - - The colorbar is added horizontally at the bottom of the plot, with a label. - """ - - # Get the min and max values for color normalization - color_bar_min, color_bar_max = data.min().min(), data.max().max() - - # Create the heatmap plot - fig, ax = plt.subplots(figsize=figsize) - ax.pcolormesh(data.values, cmap=color_map, shading='auto') - ax.invert_yaxis() # Flip the y-axis to start at the top - - # Adjust ticks and labels for x and y axes - ax.set_xticks(np.arange(len(data.columns)) + 0.5) - ax.set_xticklabels(data.columns, ha='center') - ax.set_yticks(np.arange(len(data.index)) + 0.5) - ax.set_yticklabels(data.index, va='center') - - # Add labels to the axes - ax.set_xlabel(xlabel, ha='center') - ax.set_ylabel(ylabel, va='center') - ax.set_title(title) - - # Position x-axis labels at the top - ax.xaxis.set_label_position('top') - ax.xaxis.set_ticks_position('top') - - # Add the colorbar - sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max)) - sm1.set_array([]) - fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal') - - fig.tight_layout() - - return fig, ax - - -def heat_map_plotly( - data: pd.DataFrame, - color_map: str = 'viridis', - title: str = '', - xlabel: str = 'Period', - ylabel: str = 'Step', - categorical_labels: bool = True, -) -> go.Figure: - """ - Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis, - and the index will be displayed on the y-axis. The values in the DataFrame will represent the 'heat' in the plot. - - Args: - 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. - The values in the DataFrame will be represented as colors in the heatmap. - color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc. - title: The title of the heatmap. Default is an empty string. - xlabel: The label for the x-axis. Default is 'Period'. - ylabel: The label for the y-axis. Default is 'Step'. - 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). - Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data. - - Returns: - A Plotly figure object containing the heatmap. This can be further customized and saved - or displayed using `fig.show()`. - - Notes: - The color bar is automatically scaled to the minimum and maximum values in the data. - The y-axis is reversed to display the first row at the top. - """ - - color_bar_min, color_bar_max = data.min().min(), data.max().max() # Min and max values for color scaling - # Define the figure - fig = go.Figure( - data=go.Heatmap( - z=data.values, - x=data.columns, - y=data.index, - colorscale=color_map, - zmin=color_bar_min, - zmax=color_bar_max, - colorbar=dict( - title=dict(text='Color Bar Label', side='right'), - orientation='h', - xref='container', - yref='container', - len=0.8, # Color bar length relative to plot - x=0.5, - y=0.1, - ), - ) - ) - - # Set axis labels and style - fig.update_layout( - title=title, - xaxis=dict(title=xlabel, side='top', type='category' if categorical_labels else None), - yaxis=dict(title=ylabel, autorange='reversed', type='category' if categorical_labels else None), - ) - - return fig - - def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarray: """ Reshapes a 1D numpy array into a 2D array suitable for plotting as a colormap. @@ -845,41 +717,110 @@ def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarra return data_2d.T -def heat_map_data_from_df( - df: pd.DataFrame, - periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], - steps_per_period: Literal['W', 'D', 'h', '15min', 'min'], - fill: Literal['ffill', 'bfill'] | None = None, -) -> pd.DataFrame: +def reshape_data_for_heatmap( + data: xr.DataArray, + reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] + | Literal['auto'] + | None = 'auto', + facet_by: str | list[str] | None = None, + animate_by: str | None = None, + fill: Literal['ffill', 'bfill'] | None = 'ffill', +) -> xr.DataArray: """ - Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting, - based on a specified sample rate. - Only specific combinations of `periods` and `steps_per_period` are supported; invalid combinations raise an assertion. + Reshape data for heatmap visualization, handling time dimension intelligently. + + This function decides whether to reshape the 'time' dimension based on the reshape_time parameter: + - 'auto': Automatically reshapes if only 'time' dimension would remain for heatmap + - Tuple: Explicitly reshapes time with specified parameters + - None: No reshaping (returns data as-is) + + All non-time dimensions are preserved during reshaping. Args: - df: A DataFrame with a DateTime index containing the data to reshape. - periods: The time interval of each period (columns of the heatmap), - such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. - steps_per_period: The time interval within each period (rows in the heatmap), - such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. - fill: Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill. + data: DataArray to reshape for heatmap visualization. + reshape_time: Reshaping configuration: + - 'auto' (default): Auto-reshape if needed based on facet_by/animate_by + - Tuple (timeframes, timesteps_per_frame): Explicit time reshaping + - None: No reshaping + facet_by: Dimension(s) used for faceting (used in 'auto' decision). + animate_by: Dimension used for animation (used in 'auto' decision). + fill: Method to fill missing values: 'ffill' or 'bfill'. Default is 'ffill'. Returns: - A DataFrame suitable for heatmap plotting, with rows representing steps within each period - and columns representing each period. + Reshaped DataArray. If time reshaping is applied, 'time' dimension is replaced + by 'timestep' and 'timeframe'. All other dimensions are preserved. + + Examples: + Auto-reshaping: + + ```python + # Will auto-reshape because only 'time' remains after faceting/animation + data = reshape_data_for_heatmap(data, reshape_time='auto', facet_by='scenario', animate_by='period') + ``` + + Explicit reshaping: + + ```python + # Explicitly reshape to daily pattern + data = reshape_data_for_heatmap(data, reshape_time=('D', 'h')) + ``` + + No reshaping: + + ```python + # Keep data as-is + data = reshape_data_for_heatmap(data, reshape_time=None) + ``` """ - assert pd.api.types.is_datetime64_any_dtype(df.index), ( - 'The index of the DataFrame must be datetime to transform it properly for a heatmap plot' - ) + # If no time dimension, return data as-is + if 'time' not in data.dims: + return data + + # Handle None (disabled) - return data as-is + if reshape_time is None: + return data + + # Determine timeframes and timesteps_per_frame based on reshape_time parameter + if reshape_time == 'auto': + # Check if we need automatic time reshaping + facet_dims_used = [] + if facet_by: + facet_dims_used = [facet_by] if isinstance(facet_by, str) else list(facet_by) + if animate_by: + facet_dims_used.append(animate_by) + + # Get dimensions that would remain for heatmap + potential_heatmap_dims = [dim for dim in data.dims if dim not in facet_dims_used] + + # Auto-reshape if only 'time' dimension remains + if len(potential_heatmap_dims) == 1 and potential_heatmap_dims[0] == 'time': + logger.info( + "Auto-applying time reshaping: Only 'time' dimension remains after faceting/animation. " + "Using default timeframes='D' and timesteps_per_frame='h'. " + "To customize, use reshape_time=('D', 'h') or disable with reshape_time=None." + ) + timeframes, timesteps_per_frame = 'D', 'h' + else: + # No reshaping needed + return data + elif isinstance(reshape_time, tuple): + # Explicit reshaping + timeframes, timesteps_per_frame = reshape_time + else: + raise ValueError(f"reshape_time must be 'auto', a tuple like ('D', 'h'), or None. Got: {reshape_time}") + + # Validate that time is datetime + if not np.issubdtype(data.coords['time'].dtype, np.datetime64): + raise ValueError(f'Time dimension must be datetime-based, got {data.coords["time"].dtype}') - # Define formats for different combinations of `periods` and `steps_per_period` + # Define formats for different combinations formats = { ('YS', 'W'): ('%Y', '%W'), ('YS', 'D'): ('%Y', '%j'), # day of year ('YS', 'h'): ('%Y', '%j %H:00'), ('MS', 'D'): ('%Y-%m', '%d'), # day of month ('MS', 'h'): ('%Y-%m', '%d %H:00'), - ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting) + ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'), ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour ('D', '15min'): ('%Y-%m-%d', '%H:%M'), # Day and minute @@ -887,43 +828,61 @@ def heat_map_data_from_df( ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour } - if df.empty: - raise ValueError('DataFrame is empty.') - diffs = df.index.to_series().diff().dropna() - minimum_time_diff_in_min = diffs.min().total_seconds() / 60 - time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60} - if time_intervals[steps_per_period] > minimum_time_diff_in_min: - logger.error( - f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to ' - f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.' - ) - - # Select the format based on the `periods` and `steps_per_period` combination - format_pair = (periods, steps_per_period) + format_pair = (timeframes, timesteps_per_frame) if format_pair not in formats: raise ValueError(f'{format_pair} is not a valid format. Choose from {list(formats.keys())}') period_format, step_format = formats[format_pair] - df = df.sort_index() # Ensure DataFrame is sorted by time index + # Check if resampling is needed + if data.sizes['time'] > 0: + time_diff = pd.Series(data.coords['time'].values).diff().dropna() + if len(time_diff) > 0: + min_time_diff_min = time_diff.min().total_seconds() / 60 + time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60} + if time_intervals[timesteps_per_frame] > min_time_diff_min: + logger.warning( + f'Resampling data from {min_time_diff_min:.2f} min to ' + f'{time_intervals[timesteps_per_frame]:.2f} min. Mean values are displayed.' + ) - resampled_data = df.resample(steps_per_period).mean() # Resample and fill any gaps with NaN + # Resample along time dimension + resampled = data.resample(time=timesteps_per_frame).mean() - if fill == 'ffill': # Apply fill method if specified - resampled_data = resampled_data.ffill() + # Apply fill if specified + if fill == 'ffill': + resampled = resampled.ffill(dim='time') elif fill == 'bfill': - resampled_data = resampled_data.bfill() + resampled = resampled.bfill(dim='time') + + # Create period and step labels + time_values = pd.to_datetime(resampled.coords['time'].values) + period_labels = time_values.strftime(period_format) + step_labels = time_values.strftime(step_format) + + # Handle special case for weekly day format + if '%w_%A' in step_format: + step_labels = pd.Series(step_labels).replace('0_Sunday', '7_Sunday').values + + # Add period and step as coordinates + resampled = resampled.assign_coords( + { + 'timeframe': ('time', period_labels), + 'timestep': ('time', step_labels), + } + ) - resampled_data['period'] = resampled_data.index.strftime(period_format) - resampled_data['step'] = resampled_data.index.strftime(step_format) - if '%w_%A' in step_format: # Shift index of strings to ensure proper sorting - resampled_data['step'] = resampled_data['step'].apply( - lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x - ) + # Convert to multi-index and unstack + resampled = resampled.set_index(time=['timeframe', 'timestep']) + result = resampled.unstack('time') + + # Ensure timestep and timeframe come first in dimension order + # Get other dimensions + other_dims = [d for d in result.dims if d not in ['timestep', 'timeframe']] - # Pivot the table so periods are columns and steps are indices - df_pivoted = resampled_data.pivot(columns='period', index='step', values=df.columns[0]) + # Reorder: timestep, timeframe, then other dimensions + result = result.transpose('timestep', 'timeframe', *other_dims) - return df_pivoted + return result def plot_network( @@ -1524,6 +1483,311 @@ def preprocess_series(series: pd.Series): return fig, axes +def heatmap_with_plotly( + data: xr.DataArray, + colors: ColorType = 'viridis', + title: str = '', + facet_by: str | list[str] | None = None, + animate_by: str | None = None, + facet_cols: int = 3, + reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] + | Literal['auto'] + | None = 'auto', + fill: Literal['ffill', 'bfill'] | None = 'ffill', +) -> go.Figure: + """ + Plot a heatmap visualization using Plotly's imshow with faceting and animation support. + + This function creates heatmap visualizations from xarray DataArrays, supporting + multi-dimensional data through faceting (subplots) and animation. It automatically + handles dimension reduction and data reshaping for optimal heatmap display. + + Automatic Time Reshaping: + If only the 'time' dimension remains after faceting/animation (making the data 1D), + the function automatically reshapes time into a 2D format using default values + (timeframes='D', timesteps_per_frame='h'). This creates a daily pattern heatmap + showing hours vs days. + + Args: + data: An xarray DataArray containing the data to visualize. Should have at least + 2 dimensions, or a 'time' dimension that can be reshaped into 2D. + colors: Color specification (colormap name, list, or dict). Common options: + 'viridis', 'plasma', 'RdBu', 'portland'. + title: The main title of the heatmap. + facet_by: Dimension to create facets for. Creates a subplot grid. + Can be a single dimension name or list (only first dimension used). + Note: px.imshow only supports single-dimension faceting. + If the dimension doesn't exist in the data, it will be silently ignored. + animate_by: Dimension to animate over. Creates animation frames. + If the dimension doesn't exist in the data, it will be silently ignored. + facet_cols: Number of columns in the facet grid (used with facet_by). + reshape_time: Time reshaping configuration: + - 'auto' (default): Automatically applies ('D', 'h') if only 'time' dimension remains + - Tuple like ('D', 'h'): Explicit time reshaping (days vs hours) + - None: Disable time reshaping (will error if only 1D time data) + fill: Method to fill missing values when reshaping time: 'ffill' or 'bfill'. Default is 'ffill'. + + Returns: + A Plotly figure object containing the heatmap visualization. + + Examples: + Simple heatmap: + + ```python + fig = heatmap_with_plotly(data_array, colors='RdBu', title='Temperature Map') + ``` + + Facet by scenario: + + ```python + fig = heatmap_with_plotly(data_array, facet_by='scenario', facet_cols=2) + ``` + + Animate by period: + + ```python + fig = heatmap_with_plotly(data_array, animate_by='period') + ``` + + Automatic time reshaping (when only time dimension remains): + + ```python + # Data with dims ['time', 'scenario', 'period'] + # After faceting and animation, only 'time' remains -> auto-reshapes to (timestep, timeframe) + fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period') + ``` + + Explicit time reshaping: + + ```python + fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period', reshape_time=('W', 'D')) + ``` + """ + # Handle empty data + if data.size == 0: + return go.Figure() + + # Apply time reshaping using the new unified function + data = reshape_data_for_heatmap( + data, reshape_time=reshape_time, facet_by=facet_by, animate_by=animate_by, fill=fill + ) + + # Get available dimensions + available_dims = list(data.dims) + + # Validate and filter facet_by dimensions + if facet_by is not None: + if isinstance(facet_by, str): + if facet_by not in available_dims: + logger.debug( + f"Dimension '{facet_by}' not found in data. Available dimensions: {available_dims}. " + f'Ignoring facet_by parameter.' + ) + facet_by = None + elif isinstance(facet_by, list): + missing_dims = [dim for dim in facet_by if dim not in available_dims] + facet_by = [dim for dim in facet_by if dim in available_dims] + if missing_dims: + logger.debug( + f'Dimensions {missing_dims} not found in data. Available dimensions: {available_dims}. ' + f'Using only existing dimensions: {facet_by if facet_by else "none"}.' + ) + if len(facet_by) == 0: + facet_by = None + + # Validate animate_by dimension + if animate_by is not None and animate_by not in available_dims: + logger.debug( + f"Dimension '{animate_by}' not found in data. Available dimensions: {available_dims}. " + f'Ignoring animate_by parameter.' + ) + animate_by = None + + # Determine which dimensions are used for faceting/animation + facet_dims = [] + if facet_by: + facet_dims = [facet_by] if isinstance(facet_by, str) else facet_by + if animate_by: + facet_dims.append(animate_by) + + # Get remaining dimensions for the heatmap itself + heatmap_dims = [dim for dim in available_dims if dim not in facet_dims] + + if len(heatmap_dims) < 2: + # Need at least 2 dimensions for a heatmap + logger.error( + f'Heatmap requires at least 2 dimensions for rows and columns. ' + f'After faceting/animation, only {len(heatmap_dims)} dimension(s) remain: {heatmap_dims}' + ) + return go.Figure() + + # Setup faceting parameters for Plotly Express + # Note: px.imshow only supports facet_col, not facet_row + facet_col_param = None + if facet_by: + if isinstance(facet_by, str): + facet_col_param = facet_by + elif len(facet_by) == 1: + facet_col_param = facet_by[0] + elif len(facet_by) >= 2: + # px.imshow doesn't support facet_row, so we can only facet by one dimension + # Use the first dimension and warn about the rest + facet_col_param = facet_by[0] + logger.warning( + f'px.imshow only supports faceting by a single dimension. ' + f'Using {facet_by[0]} for faceting. Dimensions {facet_by[1:]} will be ignored. ' + f'Consider using animate_by for additional dimensions.' + ) + + # Create the imshow plot - px.imshow can work directly with xarray DataArrays + common_args = { + 'img': data, + 'color_continuous_scale': colors if isinstance(colors, str) else 'viridis', + 'title': title, + } + + # Add faceting if specified + if facet_col_param: + common_args['facet_col'] = facet_col_param + if facet_cols: + common_args['facet_col_wrap'] = facet_cols + + # Add animation if specified + if animate_by: + common_args['animation_frame'] = animate_by + + try: + fig = px.imshow(**common_args) + except Exception as e: + logger.error(f'Error creating imshow plot: {e}. Falling back to basic heatmap.') + # Fallback: create a simple heatmap without faceting + fig = px.imshow( + data.values, + color_continuous_scale=colors if isinstance(colors, str) else 'viridis', + title=title, + ) + + # Update layout with basic styling + fig.update_layout( + plot_bgcolor='rgba(0,0,0,0)', + paper_bgcolor='rgba(0,0,0,0)', + font=dict(size=12), + ) + + return fig + + +def heatmap_with_matplotlib( + data: xr.DataArray, + colors: ColorType = 'viridis', + title: str = '', + figsize: tuple[float, float] = (12, 6), + fig: plt.Figure | None = None, + ax: plt.Axes | None = None, + reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] + | Literal['auto'] + | None = 'auto', + fill: Literal['ffill', 'bfill'] | None = 'ffill', +) -> tuple[plt.Figure, plt.Axes]: + """ + Plot a heatmap visualization using Matplotlib's imshow. + + This function creates a basic 2D heatmap from an xarray DataArray using matplotlib's + imshow function. For multi-dimensional data, only the first two dimensions are used. + + Args: + data: An xarray DataArray containing the data to visualize. Should have at least + 2 dimensions. If more than 2 dimensions exist, additional dimensions will + be reduced by taking the first slice. + colors: Color specification. Should be a colormap name (e.g., 'viridis', 'RdBu'). + title: The title of the heatmap. + figsize: The size of the figure (width, height) in inches. + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. + reshape_time: Time reshaping configuration: + - 'auto' (default): Automatically applies ('D', 'h') if only 'time' dimension + - Tuple like ('D', 'h'): Explicit time reshaping (days vs hours) + - None: Disable time reshaping + fill: Method to fill missing values when reshaping time: 'ffill' or 'bfill'. Default is 'ffill'. + + Returns: + A tuple containing the Matplotlib figure and axes objects used for the plot. + + Notes: + - Matplotlib backend doesn't support faceting or animation. Use plotly engine for those features. + - The y-axis is automatically inverted to display data with origin at top-left. + - A colorbar is added to show the value scale. + + Examples: + ```python + fig, ax = heatmap_with_matplotlib(data_array, colors='RdBu', title='Temperature') + plt.savefig('heatmap.png') + ``` + + Time reshaping: + + ```python + fig, ax = heatmap_with_matplotlib(data_array, reshape_time=('D', 'h')) + ``` + """ + # Handle empty data + if data.size == 0: + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + return fig, ax + + # Apply time reshaping using the new unified function + # Matplotlib doesn't support faceting/animation, so we pass None for those + data = reshape_data_for_heatmap(data, reshape_time=reshape_time, facet_by=None, animate_by=None, fill=fill) + + # Create figure and axes if not provided + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + + # Extract data values + # If data has more than 2 dimensions, we need to reduce it + if isinstance(data, xr.DataArray): + # Get the first 2 dimensions + dims = list(data.dims) + if len(dims) > 2: + logger.warning( + f'Data has {len(dims)} dimensions: {dims}. ' + f'Only the first 2 will be used for the heatmap. ' + f'Use the plotly engine for faceting/animation support.' + ) + # Select only the first 2 dimensions by taking first slice of others + selection = {dim: 0 for dim in dims[2:]} + data = data.isel(selection) + + values = data.values + x_labels = data.dims[1] if len(data.dims) > 1 else 'x' + y_labels = data.dims[0] if len(data.dims) > 0 else 'y' + else: + values = data + x_labels = 'x' + y_labels = 'y' + + # Process colormap + cmap = colors if isinstance(colors, str) else 'viridis' + + # Create the heatmap using imshow + im = ax.imshow(values, cmap=cmap, aspect='auto', origin='upper') + + # Add colorbar + cbar = plt.colorbar(im, ax=ax, orientation='horizontal', pad=0.1, aspect=15, fraction=0.05) + cbar.set_label('Value') + + # Set labels and title + ax.set_xlabel(str(x_labels).capitalize()) + ax.set_ylabel(str(y_labels).capitalize()) + ax.set_title(title) + + # Apply tight layout + fig.tight_layout() + + return fig, ax + + def export_figure( figure_like: go.Figure | tuple[plt.Figure, plt.Axes], default_path: pathlib.Path, diff --git a/flixopt/results.py b/flixopt/results.py index 0393f5661..e85b22b8a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -687,84 +687,105 @@ def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total'] def plot_heatmap( self, - variable_name: str, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - color_map: str = 'portland', + variable_name: str | list[str], save: bool | pathlib.Path = False, show: bool = True, + colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, + facet_by: str | list[str] | None = 'scenario', + animate_by: str | None = 'period', + facet_cols: int = 3, + reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] + | Literal['auto'] + | None = 'auto', **kwargs, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ - Plots a heatmap of the solution of a variable. + Plots a heatmap visualization of a variable using imshow or time-based reshaping. + + Supports multiple visualization features that can be combined: + - **Multi-variable**: Plot multiple variables on a single heatmap (creates 'variable' dimension) + - **Time reshaping**: Converts 'time' dimension into 2D (e.g., hours vs days) + - **Faceting**: Creates subplots for different dimension values + - **Animation**: Animates through dimension values (Plotly only) Args: - variable_name: The name of the variable to plot. - heatmap_timeframes: The timeframes to use for the heatmap. - heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. - color_map: The color map to use for the heatmap. + variable_name: The name of the variable to plot, or a list of variable names. + When a list is provided, variables are combined into a single DataArray + with a new 'variable' dimension. save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. + colors: Color scheme for the heatmap. See `flixopt.plotting.ColorType` for options. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. select: Optional data selection dict. Supports single values, lists, slices, and index arrays. - If None, uses first value for each dimension. - If empty dict {}, uses all values. + Applied BEFORE faceting/animation/reshaping. + facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) + or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. + animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through + dimension values. Only one dimension can be animated. Ignored if not found. + facet_cols: Number of columns in the facet grid layout (default: 3). + reshape_time: Time reshaping configuration (default: 'auto'): + - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains + - Tuple: Explicit reshaping, e.g. ('D', 'h') for days vs hours, + ('MS', 'D') for months vs days, ('W', 'h') for weeks vs hours + - None: Disable auto-reshaping (will error if only 1D time data) + Supported timeframes: 'YS', 'MS', 'W', 'D', 'h', '15min', 'min' Examples: - Basic usage (uses first scenario, first period, all time): + Direct imshow mode (default): - >>> results.plot_heatmap('Battery|charge_state') + >>> results.plot_heatmap('Battery|charge_state', select={'scenario': 'base'}) - Select specific scenario and period: + Facet by scenario: - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', select={'scenario': 'base', 'period': 2024}) + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', facet_by='scenario', facet_cols=2) - Time filtering (summer months only): + Animate by period: - >>> results.plot_heatmap( - ... 'Boiler(Qth)|flow_rate', - ... select={ - ... 'scenario': 'base', - ... 'time': results.solution.time[results.solution.time.dt.month.isin([6, 7, 8])], - ... }, - ... ) + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', select={'scenario': 'base'}, animate_by='period') + + Time reshape mode - daily patterns: - Save to specific location: + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', select={'scenario': 'base'}, reshape_time=('D', 'h')) + + Combined: time reshaping with faceting and animation: >>> results.plot_heatmap( - ... 'Boiler(Qth)|flow_rate', select={'scenario': 'base'}, save='path/to/my_heatmap.html' + ... 'Boiler(Qth)|flow_rate', facet_by='scenario', animate_by='period', reshape_time=('D', 'h') ... ) - """ - # Handle deprecated indexer parameter - if 'indexer' in kwargs: - import warnings - warnings.warn( - "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", - DeprecationWarning, - stacklevel=2, - ) + Multi-variable heatmap (variables as one axis): - # Check for unexpected kwargs - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + >>> results.plot_heatmap( + ... ['Boiler(Q_th)|flow_rate', 'CHP(Q_th)|flow_rate', 'HeatStorage|charge_state'], + ... select={'scenario': 'base', 'period': 1}, + ... reshape_time=None, + ... ) - dataarray = self.solution[variable_name] + Multi-variable with time reshaping: + >>> results.plot_heatmap( + ... ['Boiler(Q_th)|flow_rate', 'CHP(Q_th)|flow_rate'], + ... facet_by='scenario', + ... animate_by='period', + ... reshape_time=('D', 'h'), + ... ) + """ + # Delegate to module-level plot_heatmap function return plot_heatmap( - dataarray=dataarray, - name=variable_name, + data=self.solution[variable_name], + name=variable_name if isinstance(variable_name, str) else None, folder=self.folder, - heatmap_timeframes=heatmap_timeframes, - heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, - color_map=color_map, + colors=colors, save=save, show=show, engine=engine, select=select, + facet_by=facet_by, + animate_by=animate_by, + facet_cols=facet_cols, + reshape_time=reshape_time, **kwargs, ) @@ -1619,37 +1640,51 @@ def solution_without_overlap(self, variable_name: str) -> xr.DataArray: def plot_heatmap( self, variable_name: str, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - color_map: str = 'portland', + reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] + | Literal['auto'] + | None = ('D', 'h'), + colors: str = 'portland', save: bool | pathlib.Path = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', + facet_by: str | list[str] | None = None, + animate_by: str | None = None, + facet_cols: int = 3, + fill: Literal['ffill', 'bfill'] | None = 'ffill', ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """Plot heatmap of variable solution across segments. Args: variable_name: Variable to plot. - heatmap_timeframes: Time aggregation level. - heatmap_timesteps_per_frame: Timesteps per frame. - color_map: Color scheme. Also see plotly. + reshape_time: Time reshaping configuration: + - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains + - Tuple like ('D', 'h'): Explicit reshaping (days vs hours) + - None: Disable time reshaping + colors: Color scheme. See plotting.ColorType for options. save: Whether to save plot. show: Whether to display plot. engine: Plotting engine. + facet_by: Dimension(s) to create facets (subplots) for. + animate_by: Dimension to animate over (Plotly only). + facet_cols: Number of columns in the facet grid layout. + fill: Method to fill missing values: 'ffill' or 'bfill'. Returns: Figure object. """ return plot_heatmap( - dataarray=self.solution_without_overlap(variable_name), + data=self.solution_without_overlap(variable_name), name=variable_name, folder=self.folder, - heatmap_timeframes=heatmap_timeframes, - heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, - color_map=color_map, + reshape_time=reshape_time, + colors=colors, save=save, show=show, engine=engine, + facet_by=facet_by, + animate_by=animate_by, + facet_cols=facet_cols, + fill=fill, ) def to_file(self, folder: str | pathlib.Path | None = None, name: str | None = None, compression: int = 5): @@ -1679,31 +1714,65 @@ def to_file(self, folder: str | pathlib.Path | None = None, name: str | None = N def plot_heatmap( - dataarray: xr.DataArray, - name: str, - folder: pathlib.Path, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - color_map: str = 'portland', + data: xr.DataArray | xr.Dataset, + name: str | None = None, + folder: pathlib.Path | None = None, + colors: plotting.ColorType = 'viridis', save: bool | pathlib.Path = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', select: dict[str, Any] | None = None, + facet_by: str | list[str] | None = None, + animate_by: str | None = None, + facet_cols: int = 3, + reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] + | Literal['auto'] + | None = 'auto', **kwargs, ): - """Plot heatmap of time series data. + """Plot heatmap visualization with support for multi-variable, faceting, and animation. + + This function provides a standalone interface to the heatmap plotting capabilities, + supporting the same modern features as CalculationResults.plot_heatmap(). Args: - dataarray: Data to plot. - name: Variable name for title. - folder: Save folder. - heatmap_timeframes: Time aggregation level. - heatmap_timesteps_per_frame: Timesteps per frame. - color_map: Color scheme. Also see plotly. - save: Whether to save plot. - show: Whether to display plot. - engine: Plotting engine. + data: Data to plot. Can be a single DataArray or an xarray Dataset. + When a Dataset is provided, all data variables are combined along a new 'variable' dimension. + name: Optional name for the title. If not provided, uses the DataArray name or + generates a default title for Datasets. + folder: Save folder for the plot. Defaults to current directory if not provided. + colors: Color scheme for the heatmap. See `flixopt.plotting.ColorType` for options. + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. select: Optional data selection dict. Supports single values, lists, slices, and index arrays. + facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) + or list of dimensions. Each unique value combination creates a subplot. + animate_by: Dimension to animate over (Plotly only). Creates animation frames. + facet_cols: Number of columns in the facet grid layout (default: 3). + reshape_time: Time reshaping configuration (default: 'auto'): + - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains + - Tuple: Explicit reshaping, e.g. ('D', 'h') for days vs hours + - None: Disable auto-reshaping + + Examples: + Single DataArray with time reshaping: + + >>> plot_heatmap(data, name='Temperature', folder=Path('.'), reshape_time=('D', 'h')) + + Dataset with multiple variables (facet by variable): + + >>> dataset = xr.Dataset({'Boiler': data1, 'CHP': data2, 'Storage': data3}) + >>> plot_heatmap( + ... dataset, + ... folder=Path('.'), + ... facet_by='variable', + ... reshape_time=('D', 'h'), + ... ) + + Dataset with animation by variable: + + >>> plot_heatmap(dataset, animate_by='variable', reshape_time=('D', 'h')) """ # Handle deprecated indexer parameter if 'indexer' in kwargs: @@ -1720,32 +1789,74 @@ def plot_heatmap( if unexpected_kwargs: raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') - dataarray, suffix_parts = _apply_indexer_to_data(dataarray, select=select, drop=True, **kwargs) - suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - name = name if not suffix_parts else name + suffix + # Validate parameters + if (facet_by is not None or animate_by is not None) and engine == 'matplotlib': + raise ValueError( + f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' + ) - heatmap_data = plotting.heat_map_data_from_df( - dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill' - ) + # Convert Dataset to DataArray with 'variable' dimension + if isinstance(data, xr.Dataset): + # Extract all data variables from the Dataset + variable_names = list(data.data_vars) + dataarrays = [data[var] for var in variable_names] + + # Combine into single DataArray with 'variable' dimension + data = xr.concat(dataarrays, dim='variable') + data = data.assign_coords(variable=variable_names) - xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]' + # Use Dataset variable names for title if name not provided + if name is None: + title_name = f'Heatmap of {len(variable_names)} variables' + else: + title_name = name + else: + # Single DataArray + if name is None: + title_name = data.name if data.name else 'Heatmap' + else: + title_name = name + # Apply select filtering + data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True, **kwargs) + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' + + # Build title + title = f'{title_name}{suffix}' + if isinstance(reshape_time, tuple): + timeframes, timesteps_per_frame = reshape_time + title += f' ({timeframes} vs {timesteps_per_frame})' + + # Plot with appropriate engine if engine == 'plotly': - figure_like = plotting.heat_map_plotly( - heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel + figure_like = plotting.heatmap_with_plotly( + data=data, + facet_by=facet_by, + animate_by=animate_by, + colors=colors, + title=title, + facet_cols=facet_cols, + reshape_time=reshape_time, ) default_filetype = '.html' elif engine == 'matplotlib': - figure_like = plotting.heat_map_matplotlib( - heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel + figure_like = plotting.heatmap_with_matplotlib( + data=data, + colors=colors, + title=title, + reshape_time=reshape_time, ) default_filetype = '.png' else: raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + # Set default folder if not provided + if folder is None: + folder = pathlib.Path('.') + return plotting.export_figure( figure_like=figure_like, - default_path=folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame})', + default_path=folder / title, default_filetype=default_filetype, user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, diff --git a/tests/test_plots.py b/tests/test_plots.py index 61c26c510..d901b9ce1 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -103,13 +103,19 @@ def test_heat_map_plots(self): # Convert data for heatmap plotting using 'day' as period and 'hour' steps heatmap_data = plotting.reshape_to_2d(data.iloc[:, 0].values.flatten(), 24) - # Plotting heatmaps with Plotly and Matplotlib - _ = plotting.heat_map_plotly(pd.DataFrame(heatmap_data)) - plotting.heat_map_matplotlib(pd.DataFrame(heatmap_data)) + # Convert to xarray DataArray for the new API + import xarray as xr + + heatmap_xr = xr.DataArray(heatmap_data, dims=['timestep', 'timeframe']) + # Plotting heatmaps with Plotly and Matplotlib using new API + _ = plotting.heatmap_with_plotly(heatmap_xr, reshape_time=None) + plotting.heatmap_with_matplotlib(heatmap_xr, reshape_time=None) plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') plt.close('all') # Close all figures to prevent memory leaks def test_heat_map_plots_resampling(self): + import xarray as xr + date_range = pd.date_range(start='2023-01-01', end='2023-03-21', freq='5min') # Generate random data for the DataFrame, simulating some metric (e.g., energy consumption, temperature) @@ -125,24 +131,29 @@ def test_heat_map_plots_resampling(self): # Generate single-column data with datetime index for heatmap data = df_irregular - # Convert data for heatmap plotting using 'day' as period and 'hour' steps - heatmap_data = plotting.heat_map_data_from_df(data, 'MS', 'D') - _ = plotting.heat_map_plotly(heatmap_data) - plotting.heat_map_matplotlib(pd.DataFrame(heatmap_data)) + # Convert DataFrame to xarray DataArray for the new API + data_xr = xr.DataArray(data['value'].values, dims=['time'], coords={'time': data.index.values}, name='value') + + # Test 1: Monthly timeframes, daily timesteps + heatmap_data = plotting.reshape_data_for_heatmap(data_xr, reshape_time=('MS', 'D')) + _ = plotting.heatmap_with_plotly(heatmap_data, reshape_time=None) + plotting.heatmap_with_matplotlib(heatmap_data, reshape_time=None) plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') plt.close('all') # Close all figures to prevent memory leaks - heatmap_data = plotting.heat_map_data_from_df(data, 'W', 'h', fill='ffill') + # Test 2: Weekly timeframes, hourly timesteps with forward fill + heatmap_data = plotting.reshape_data_for_heatmap(data_xr, reshape_time=('W', 'h'), fill='ffill') # Plotting heatmaps with Plotly and Matplotlib - _ = plotting.heat_map_plotly(pd.DataFrame(heatmap_data)) - plotting.heat_map_matplotlib(pd.DataFrame(heatmap_data)) + _ = plotting.heatmap_with_plotly(heatmap_data, reshape_time=None) + plotting.heatmap_with_matplotlib(heatmap_data, reshape_time=None) plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') plt.close('all') # Close all figures to prevent memory leaks - heatmap_data = plotting.heat_map_data_from_df(data, 'D', 'h', fill='ffill') + # Test 3: Daily timeframes, hourly timesteps with forward fill + heatmap_data = plotting.reshape_data_for_heatmap(data_xr, reshape_time=('D', 'h'), fill='ffill') # Plotting heatmaps with Plotly and Matplotlib - _ = plotting.heat_map_plotly(pd.DataFrame(heatmap_data)) - plotting.heat_map_matplotlib(pd.DataFrame(heatmap_data)) + _ = plotting.heatmap_with_plotly(heatmap_data, reshape_time=None) + plotting.heatmap_with_matplotlib(heatmap_data, reshape_time=None) plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') plt.close('all') # Close all figures to prevent memory leaks diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 35a219e31..d8c83b42d 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -48,15 +48,19 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=color_spec) - results.plot_heatmap( - 'Speicher(Q_th_load)|flow_rate', - heatmap_timeframes='D', - heatmap_timesteps_per_frame='h', - color_map='viridis', # Note: heatmap only accepts string colormap - save=show, - show=save, - engine=plotting_engine, - ) + # Matplotlib doesn't support faceting/animation, so disable them for matplotlib engine + heatmap_kwargs = { + 'reshape_time': ('D', 'h'), + 'colors': 'viridis', # Note: heatmap only accepts string colormap + 'save': show, + 'show': save, + 'engine': plotting_engine, + } + if plotting_engine == 'matplotlib': + heatmap_kwargs['facet_by'] = None + heatmap_kwargs['animate_by'] = None + + results.plot_heatmap('Speicher(Q_th_load)|flow_rate', **heatmap_kwargs) results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) results['Speicher'].plot_charge_state(engine=plotting_engine) diff --git a/tests/test_select_features.py b/tests/test_select_features.py index 75f25e567..6dd39c95c 100644 --- a/tests/test_select_features.py +++ b/tests/test_select_features.py @@ -135,14 +135,23 @@ def test_plot_node_balance(self, results, scenarios): results['Fernwärme'].plot_node_balance(select={'scenario': scenarios[0]}, mode='area', show=False, save=False) def test_plot_heatmap(self, results, scenarios): - """Test plot_heatmap (expected to fail with current data structure).""" + """Test plot_heatmap with the new imshow implementation.""" var_names = list(results.solution.data_vars) if not var_names: pytest.skip('No variables found') - # This is expected to fail with the current test data - with pytest.raises(AssertionError, match='datetime'): - results.plot_heatmap(var_names[0], select={'scenario': scenarios[0]}, show=False, save=False) + # Find a variable with time dimension for proper heatmap + var_name = None + for name in var_names: + if 'time' in results.solution[name].dims: + var_name = name + break + + if var_name is None: + pytest.skip('No time-series variables found for heatmap test') + + # Test that the new heatmap implementation works + results.plot_heatmap(var_name, select={'scenario': scenarios[0]}, show=False, save=False) def test_node_balance_data_retrieval(self, results, scenarios): """Test node_balance (data retrieval).""" From 84aa03db78351839e1769b10883dc5296fc51667 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:56:48 +0200 Subject: [PATCH 03/36] Feature/398 feature facet plots in results charge state (#417) * Add animation and faceting options to plots * Adjust size of the frame * Utilize plotly express directly * Rmeocve old class * Use plotly express and modify stackgroup afterwards * Add modifications also to animations * Mkae more compact * Remove height stuff * Remove line and make set opacity =0 for area * Integrate faceting and animating into existing with_plotly method * Improve results.py * Improve results.py * Move check if dims are found to plotting.py * Fix usage of indexer * Change selection string with indexer * Change behaviout of parameter "indexing" * Update CHANGELOG.md * Add new selection parameter to plotting methods * deprectae old indexer parameter * deprectae old indexer parameter * Add test * Add test * Add test * Add test * Add heatmap support * Unify to a single heatmap method per engine * Change defaults * readd time reshaping * readd time reshaping * lengthen scenario example * Update * Improve heatmap plotting * Improve heatmap plotting * Moved reshaping to plotting.py * COmbinations are possible! * Improve 'auto'behavioour * Improve 'auto' behavioour * Improve 'auto' behavioour * Allow multiple variables in a heatmap * Update modeule level plot_heatmap() * remove code duplication * Allow Dataset instead of List of DataArrays * Allow Dataset instead of List of DataArrays * Add tests * More examples * Update plot_charge state() * Try 1 * Try 2 * Add more examples * Add more examples * Add smooth line for charge state and use "area" as default * Update scenario_example.py * Update tests --- examples/04_Scenarios/scenario_example.py | 10 +- flixopt/results.py | 111 ++- tests/test_facet_plotting.py | 899 ++++++++++++++++++++++ tests/test_overlay_line_on_area.py | 373 +++++++++ 4 files changed, 1367 insertions(+), 26 deletions(-) create mode 100644 tests/test_facet_plotting.py create mode 100644 tests/test_overlay_line_on_area.py diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index d3a20e0d5..1ef586bc3 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -83,10 +83,10 @@ capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True), initial_charge_state=0, # Initial storage state: empty relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80] * 20) * 0.01, - relative_maximum_final_charge_state=0.8, + relative_maximum_final_charge_state=np.array([0.8, 0.5, 0.1]), eta_charge=0.9, eta_discharge=1, # Efficiency factors for charging/discharging - relative_loss_per_hour=0.08, # 8% loss per hour. Absolute loss depends on current charge state + relative_loss_per_hour=np.array([0.1, 0.2]), # Assume 10% or 20% losses per hour in the scenarios prevent_simultaneous_charge_and_discharge=True, # Prevent charging and discharging at the same time ) @@ -137,11 +137,7 @@ print(df) # Plot charge state using matplotlib - fig, ax = calculation.results['Storage'].plot_charge_state(engine='matplotlib') - # Customize the plot further if needed - ax.set_title('Storage Charge State Over Time') - # Or save the figure - # fig.savefig('storage_charge_state.png') + calculation.results['Storage'].plot_charge_state() # Save results to file for later usage calculation.results.to_file() diff --git a/flixopt/results.py b/flixopt/results.py index e85b22b8a..59dbd1b12 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -10,7 +10,6 @@ import linopy import numpy as np import pandas as pd -import plotly import xarray as xr import yaml @@ -20,6 +19,7 @@ if TYPE_CHECKING: import matplotlib.pyplot as plt + import plotly import pyvis from .calculation import Calculation, SegmentedCalculation @@ -1289,11 +1289,14 @@ def plot_charge_state( show: bool = True, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', - mode: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', + mode: Literal['area', 'stacked_bar', 'line'] = 'area', select: dict[FlowSystemDimensions, Any] | None = None, + facet_by: str | list[str] | None = 'scenario', + animate_by: str | None = 'period', + facet_cols: int = 3, **kwargs, ) -> plotly.graph_objs.Figure: - """Plot storage charge state over time, combined with the node balance. + """Plot storage charge state over time, combined with the node balance with optional faceting and animation. Args: save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. @@ -1302,9 +1305,32 @@ def plot_charge_state( engine: Plotting engine to use. Only 'plotly' is implemented atm. mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, or 'area' for stacked area charts. select: Optional data selection dict. Supports single values, lists, slices, and index arrays. + Applied BEFORE faceting/animation. + facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) + or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. + animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through + dimension values. Only one dimension can be animated. Ignored if not found. + facet_cols: Number of columns in the facet grid layout (default: 3). Raises: ValueError: If component is not a storage. + + Examples: + Basic plot: + + >>> results['Storage'].plot_charge_state() + + Facet by scenario: + + >>> results['Storage'].plot_charge_state(facet_by='scenario', facet_cols=2) + + Animate by period: + + >>> results['Storage'].plot_charge_state(animate_by='period') + + Facet by scenario AND animate by period: + + >>> results['Storage'].plot_charge_state(facet_by='scenario', animate_by='period') """ # Handle deprecated indexer parameter if 'indexer' in kwargs: @@ -1324,33 +1350,70 @@ def plot_charge_state( if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - # Don't pass select/indexer to node_balance - we'll apply it afterwards + if (facet_by is not None or animate_by is not None) and engine == 'matplotlib': + raise ValueError( + f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' + ) + + # Get node balance and charge state ds = self.node_balance(with_last_timestep=True) - charge_state = self.charge_state + charge_state_da = self.charge_state + # Apply select filtering ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) - charge_state, suffix_parts = _apply_indexer_to_data(charge_state, select=select, drop=True, **kwargs) + charge_state_da, _ = _apply_indexer_to_data(charge_state_da, select=select, drop=True, **kwargs) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'Operation Balance of {self.label}{suffix}' if engine == 'plotly': - fig = plotting.with_plotly( - ds.to_dataframe(), + # Plot flows (node balance) with the specified mode + figure_like = plotting.with_plotly( + ds, + facet_by=facet_by, + animate_by=animate_by, colors=colors, mode=mode, title=title, + facet_cols=facet_cols, ) - # TODO: Use colors for charge state? + # Create a dataset with just charge_state and plot it as lines + # This ensures proper handling of facets and animation + charge_state_ds = charge_state_da.to_dataset(name=self._charge_state) - charge_state = charge_state.to_dataframe() - fig.add_trace( - plotly.graph_objs.Scatter( - x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state - ) + # Plot charge_state with mode='line' to get Scatter traces + charge_state_fig = plotting.with_plotly( + charge_state_ds, + facet_by=facet_by, + animate_by=animate_by, + colors=colors, + mode='line', # Always line for charge_state + title='', # No title needed for this temp figure + facet_cols=facet_cols, ) + + # Add charge_state traces to the main figure + # This preserves subplot assignments and animation frames + for trace in charge_state_fig.data: + trace.line.width = 2 # Make charge_state line more prominent + trace.line.shape = 'linear' # Smooth line for charge state (not stepped like flows) + figure_like.add_trace(trace) + + # Also add traces from animation frames if they exist + # Both figures use the same animate_by parameter, so they should have matching frames + if hasattr(charge_state_fig, 'frames') and charge_state_fig.frames: + # Add charge_state traces to each frame + for i, frame in enumerate(charge_state_fig.frames): + if i < len(figure_like.frames): + for trace in frame.data: + trace.line.width = 2 + trace.line.shape = 'linear' # Smooth line for charge state + figure_like.frames[i].data = figure_like.frames[i].data + (trace,) + + default_filetype = '.html' elif engine == 'matplotlib': + # For matplotlib, plot flows (node balance), then add charge_state as line fig, ax = plotting.with_matplotlib( ds.to_dataframe(), colors=colors, @@ -1358,15 +1421,25 @@ def plot_charge_state( title=title, ) - charge_state = charge_state.to_dataframe() - ax.plot(charge_state.index, charge_state.values.flatten(), label=self._charge_state) + # Add charge_state as a line overlay + charge_state_df = charge_state_da.to_dataframe() + ax.plot( + charge_state_df.index, + charge_state_df.values.flatten(), + label=self._charge_state, + linewidth=2, + color='black', + ) + ax.legend() fig.tight_layout() - fig = fig, ax + + figure_like = fig, ax + default_filetype = '.png' return plotting.export_figure( - fig, + figure_like=figure_like, default_path=self._calculation_results.folder / title, - default_filetype='.html', + default_filetype=default_filetype, user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False, diff --git a/tests/test_facet_plotting.py b/tests/test_facet_plotting.py new file mode 100644 index 000000000..7ec06d093 --- /dev/null +++ b/tests/test_facet_plotting.py @@ -0,0 +1,899 @@ +""" +Comprehensive test script demonstrating facet plotting and animation functionality. + +This script shows how to use the new facet_by and animate_by parameters +to create multidimensional plots with scenarios and periods. + +Examples 1-13: Core facet plotting features with synthetic data +Example 14: Manual approach showing how plot_charge_state() works internally +Example 15: Real flixOpt integration using plot_charge_state() method + +All figures are collected in the `all_figures` list for easy access. +""" + +import numpy as np +import pandas as pd +import xarray as xr + +from flixopt import plotting + +# List to store all generated figures for easy access +all_figures = [] + + +def create_and_save_figure(example_num, description, plot_func, *args, **kwargs): + """Helper function to reduce duplication in creating and saving figures.""" + suffix = kwargs.pop('suffix', '') + filename = f'/tmp/facet_example_{example_num}{suffix}.html' + + print('=' * 70) + print(f'Example {example_num}: {description}') + print('=' * 70) + + try: + fig = plot_func(*args, **kwargs) + fig.write_html(filename) + all_figures.append((f'Example {example_num}: {description}', fig)) + print(f'✓ Created: {filename}') + return fig + except Exception as e: + print(f'✗ Error in Example {example_num}: {e}') + import traceback + + traceback.print_exc() + return None + + +# Create synthetic multidimensional data for demonstration +# Dimensions: time, scenario, period +print('Creating synthetic multidimensional data...') + +# Time dimension +time = pd.date_range('2024-01-01', periods=24 * 7, freq='h', name='time') + +# Scenario dimension +scenarios = ['base', 'high_demand', 'renewable_focus', 'storage_heavy'] + +# Period dimension (e.g., different years or investment periods) +periods = [2024, 2030, 2040] + +# Create sample data +np.random.seed(42) + +# Create variables that will be plotted +variables = ['Solar', 'Wind', 'Gas', 'Battery_discharge', 'Battery_charge'] + +data_vars = {} +for var in variables: + # Create different patterns for each variable + base_pattern = np.sin(np.arange(len(time)) * 2 * np.pi / 24) * 50 + 100 + + # Add scenario and period variations + data = np.zeros((len(time), len(scenarios), len(periods))) + + for s_idx, _ in enumerate(scenarios): + for p_idx, period in enumerate(periods): + # Add scenario-specific variation + scenario_factor = 1.0 + s_idx * 0.3 + # Add period-specific growth + period_factor = 1.0 + (period - 2024) / 20 * 0.5 + # Add some randomness + noise = np.random.normal(0, 10, len(time)) + + data[:, s_idx, p_idx] = base_pattern * scenario_factor * period_factor + noise + + # Make battery charge negative for visualization + if 'charge' in var.lower(): + data[:, s_idx, p_idx] = -np.abs(data[:, s_idx, p_idx]) + + data_vars[var] = (['time', 'scenario', 'period'], data) + +# Create xarray Dataset +ds = xr.Dataset( + data_vars, + coords={ + 'time': time, + 'scenario': scenarios, + 'period': periods, + }, +) + +print(f'Dataset shape: {ds.dims}') +print(f'Variables: {list(ds.data_vars)}') +print(f'Coordinates: {list(ds.coords)}') +print() + +# ============================================================================ +# Example 1: Simple faceting by scenario +# ============================================================================ +print('=' * 70) +print('Example 1: Faceting by scenario (4 subplots)') +print('=' * 70) + +# Filter to just one period for simplicity +ds_filtered = ds.sel(period=2024) + +try: + fig1 = plotting.with_plotly( + ds_filtered, + facet_by='scenario', + mode='area', + colors='portland', + title='Energy Mix by Scenario (2024)', + ylabel='Power (MW)', + xlabel='Time', + facet_cols=2, # 2x2 grid + ) + fig1.write_html('/tmp/facet_example_1_scenarios.html') + all_figures.append(('Example 1: Faceting by scenario', fig1)) + print('✓ Created: /tmp/facet_example_1_scenarios.html') + print(' 4 subplots showing different scenarios') + fig1.show() +except Exception as e: + print(f'✗ Error in Example 1: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 2: Animation by period +# ============================================================================ +print('=' * 70) +print('Example 2: Animation by period') +print('=' * 70) + +# Filter to just one scenario +ds_filtered2 = ds.sel(scenario='base') + +try: + fig2 = plotting.with_plotly( + ds_filtered2, + animate_by='period', + mode='area', + colors='viridis', + title='Energy Mix Evolution Over Time (Base Scenario)', + ylabel='Power (MW)', + xlabel='Time', + ) + fig2.write_html('/tmp/facet_example_2_animation.html') + all_figures.append(('Example 2: Animation by period', fig2)) + print('✓ Created: /tmp/facet_example_2_animation.html') + print(' Animation cycling through periods: 2024, 2030, 2040') +except Exception as e: + print(f'✗ Error in Example 2: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 3: Combined faceting and animation +# ============================================================================ +print('=' * 70) +print('Example 3: Facet by scenario AND animate by period') +print('=' * 70) + +try: + fig3 = plotting.with_plotly( + ds, + facet_by='scenario', + animate_by='period', + mode='stacked_bar', + colors='portland', + title='Energy Mix: Scenarios vs. Periods', + ylabel='Power (MW)', + xlabel='Time', + facet_cols=2, + # height_per_row now auto-sizes intelligently! + ) + fig3.write_html('/tmp/facet_example_3_combined.html') + all_figures.append(('Example 3: Facet + animation combined', fig3)) + print('✓ Created: /tmp/facet_example_3_combined.html') + print(' 4 subplots (scenarios) with animation through 3 periods') + print(' Using intelligent auto-sizing (2 rows = 900px)') +except Exception as e: + print(f'✗ Error in Example 3: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 4: 2D faceting (scenario x period grid) +# ============================================================================ +print('=' * 70) +print('Example 4: 2D faceting (scenario x period)') +print('=' * 70) + +# Take just one week of data for clearer visualization +ds_week = ds.isel(time=slice(0, 24 * 7)) + +try: + fig4 = plotting.with_plotly( + ds_week, + facet_by=['scenario', 'period'], + mode='line', + colors='viridis', + title='Energy Mix: Full Grid (Scenario x Period)', + ylabel='Power (MW)', + xlabel='Time (one week)', + facet_cols=3, # 3 columns for 3 periods + ) + fig4.write_html('/tmp/facet_example_4_2d_grid.html') + all_figures.append(('Example 4: 2D faceting grid', fig4)) + print('✓ Created: /tmp/facet_example_4_2d_grid.html') + print(' 12 subplots (4 scenarios × 3 periods)') +except Exception as e: + print(f'✗ Error in Example 4: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 5: Area mode with positive AND negative values (faceted) +# ============================================================================ +print('=' * 70) +print('Example 5: Area mode with positive AND negative values') +print('=' * 70) + +# Create data with both positive and negative values for testing +print('Creating data with charging (negative) and discharging (positive)...') + +try: + fig5 = plotting.with_plotly( + ds.sel(period=2024), + facet_by='scenario', + mode='area', + colors='portland', + title='Energy Balance with Charging/Discharging (Area Mode)', + ylabel='Power (MW)', + xlabel='Time', + facet_cols=2, + ) + fig5.write_html('/tmp/facet_example_5_area_pos_neg.html') + all_figures.append(('Example 5: Area with pos/neg values', fig5)) + print('✓ Created: /tmp/facet_example_5_area_pos_neg.html') + print(' Area plot with both positive and negative values') + print(' Negative values (battery charge) should stack downwards') + print(' Positive values should stack upwards') +except Exception as e: + print(f'✗ Error in Example 5: {e}') + import traceback + + traceback.print_exc() + +# ============================================================================ +# Example 6: Stacked bar mode with animation +# ============================================================================ +print('=' * 70) +print('Example 6: Stacked bar mode with animation') +print('=' * 70) + +# Use hourly data for a few days for clearer stacked bars +ds_daily = ds.isel(time=slice(0, 24 * 3)) # 3 days + +try: + fig6 = plotting.with_plotly( + ds_daily.sel(scenario='base'), + animate_by='period', + mode='stacked_bar', + colors='portland', + title='Daily Energy Profile Evolution (Stacked Bars)', + ylabel='Power (MW)', + xlabel='Time', + ) + fig6.write_html('/tmp/facet_example_6_stacked_bar_anim.html') + all_figures.append(('Example 6: Stacked bar with animation', fig6)) + print('✓ Created: /tmp/facet_example_6_stacked_bar_anim.html') + print(' Stacked bar chart with period animation') +except Exception as e: + print(f'✗ Error in Example 6: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 7: Large facet grid (test auto-sizing) +# ============================================================================ +print('=' * 70) +print('Example 7: Large facet grid with auto-sizing') +print('=' * 70) + +try: + # Create more scenarios for a bigger grid + extended_scenarios = scenarios + ['distributed', 'centralized'] + ds_extended = ds.copy() + + # Add new scenario data + for var in variables: + # Get existing data + existing_data = ds[var].values + + # Create new scenarios with different patterns + new_data = np.zeros((len(time), 2, len(periods))) + for p_idx in range(len(periods)): + new_data[:, 0, p_idx] = existing_data[:, 0, p_idx] * 0.8 # distributed + new_data[:, 1, p_idx] = existing_data[:, 1, p_idx] * 1.2 # centralized + + # Combine old and new + combined_data = np.concatenate([existing_data, new_data], axis=1) + ds_extended[var] = (['time', 'scenario', 'period'], combined_data) + + ds_extended = ds_extended.assign_coords(scenario=extended_scenarios) + + fig7 = plotting.with_plotly( + ds_extended.sel(period=2030), + facet_by='scenario', + mode='area', + colors='viridis', + title='Large Grid: 6 Scenarios Comparison', + ylabel='Power (MW)', + xlabel='Time', + facet_cols=3, # 3 columns, 2 rows + ) + fig7.write_html('/tmp/facet_example_7_large_grid.html') + all_figures.append(('Example 7: Large grid (6 scenarios)', fig7)) + print('✓ Created: /tmp/facet_example_7_large_grid.html') + print(' 6 subplots (2x3 grid) with auto-sizing') +except Exception as e: + print(f'✗ Error in Example 7: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 8: Line mode with faceting (for clearer trend comparison) +# ============================================================================ +print('=' * 70) +print('Example 8: Line mode with faceting') +print('=' * 70) + +# Take shorter time window for clearer line plots +ds_short = ds.isel(time=slice(0, 48)) # 2 days + +try: + fig8 = plotting.with_plotly( + ds_short.sel(period=2024), + facet_by='scenario', + mode='line', + colors='tab10', + title='48-Hour Energy Generation Profiles', + ylabel='Power (MW)', + xlabel='Time', + facet_cols=2, + ) + fig8.write_html('/tmp/facet_example_8_line_facets.html') + all_figures.append(('Example 8: Line mode with faceting', fig8)) + print('✓ Created: /tmp/facet_example_8_line_facets.html') + print(' Line plots for comparing detailed trends across scenarios') +except Exception as e: + print(f'✗ Error in Example 8: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 9: Single variable across scenarios (using select parameter) +# ============================================================================ +print('=' * 70) +print('Example 9: Single variable faceted by scenario') +print('=' * 70) + +try: + # Select only Solar data + ds_solar_only = ds[['Solar']] + + fig9 = plotting.with_plotly( + ds_solar_only.sel(period=2030), + facet_by='scenario', + mode='area', + colors='YlOrRd', + title='Solar Generation Across Scenarios (2030)', + ylabel='Solar Power (MW)', + xlabel='Time', + facet_cols=4, # Single row + ) + fig9.write_html('/tmp/facet_example_9_single_var.html') + all_figures.append(('Example 9: Single variable faceting', fig9)) + print('✓ Created: /tmp/facet_example_9_single_var.html') + print(' Single variable (Solar) across 4 scenarios') +except Exception as e: + print(f'✗ Error in Example 9: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 10: Comparison plot - Different color schemes +# ============================================================================ +print('=' * 70) +print('Example 10: Testing different color schemes') +print('=' * 70) + +color_schemes = ['portland', 'viridis', 'plasma', 'turbo'] +ds_sample = ds.isel(time=slice(0, 72)).sel(period=2024) # 3 days + +for i, color_scheme in enumerate(color_schemes): + try: + scenario_to_plot = scenarios[i % len(scenarios)] + fig = plotting.with_plotly( + ds_sample.sel(scenario=scenario_to_plot), + mode='area', + colors=color_scheme, + title=f'Color Scheme: {color_scheme.upper()} ({scenario_to_plot})', + ylabel='Power (MW)', + xlabel='Time', + ) + fig.write_html(f'/tmp/facet_example_10_{color_scheme}.html') + all_figures.append((f'Example 10: Color scheme {color_scheme}', fig)) + print(f'✓ Created: /tmp/facet_example_10_{color_scheme}.html') + except Exception as e: + print(f'✗ Error with {color_scheme}: {e}') + +print() + +# ============================================================================ +# Example 11: Mixed positive/negative with 2D faceting +# ============================================================================ +print('=' * 70) +print('Example 11: 2D faceting with positive/negative values') +print('=' * 70) + +# Create subset with just 2 scenarios and 2 periods for clearer visualization +ds_mixed = ds.sel(scenario=['base', 'high_demand'], period=[2024, 2040]) +ds_mixed_short = ds_mixed.isel(time=slice(0, 48)) + +try: + fig11 = plotting.with_plotly( + ds_mixed_short, + facet_by=['scenario', 'period'], + mode='area', + colors='portland', + title='Energy Balance Grid: Scenarios × Periods', + ylabel='Power (MW)', + xlabel='Time (48h)', + facet_cols=2, + ) + fig11.write_html('/tmp/facet_example_11_2d_mixed.html') + all_figures.append(('Example 11: 2D faceting with mixed values', fig11)) + print('✓ Created: /tmp/facet_example_11_2d_mixed.html') + print(' 2x2 grid showing charging/discharging across scenarios and periods') +except Exception as e: + print(f'✗ Error in Example 11: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 12: Animation with custom frame duration +# ============================================================================ +print('=' * 70) +print('Example 12: Animation settings test') +print('=' * 70) + +try: + fig12 = plotting.with_plotly( + ds.sel(scenario='renewable_focus'), + animate_by='period', + mode='stacked_bar', + colors='greens', + title='Renewable Focus Scenario: Temporal Evolution', + ylabel='Power (MW)', + xlabel='Time', + ) + # Adjust animation speed (if the API supports it) + if hasattr(fig12, 'layout') and hasattr(fig12.layout, 'updatemenus'): + for menu in fig12.layout.updatemenus: + if 'buttons' in menu: + for button in menu.buttons: + if 'args' in button and len(button.args) > 1: + if isinstance(button.args[1], dict) and 'frame' in button.args[1]: + button.args[1]['frame']['duration'] = 1000 # 1 second per frame + + fig12.write_html('/tmp/facet_example_12_animation_settings.html') + all_figures.append(('Example 12: Animation with custom settings', fig12)) + print('✓ Created: /tmp/facet_example_12_animation_settings.html') + print(' Animation with custom frame duration settings') +except Exception as e: + print(f'✗ Error in Example 12: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 13: Edge case - Single facet value (should work like normal plot) +# ============================================================================ +print('=' * 70) +print('Example 13: Edge case - faceting with single value') +print('=' * 70) + +try: + ds_single = ds.sel(scenario='base', period=2024) + + fig13 = plotting.with_plotly( + ds_single, + mode='area', + colors='portland', + title='Single Plot (No Real Faceting)', + ylabel='Power (MW)', + xlabel='Time', + ) + fig13.write_html('/tmp/facet_example_13_single_facet.html') + all_figures.append(('Example 13: Edge case - single facet', fig13)) + print('✓ Created: /tmp/facet_example_13_single_facet.html') + print(' Should create normal plot when no facet dimension exists') +except Exception as e: + print(f'✗ Error in Example 13: {e}') + import traceback + + traceback.print_exc() + +# ============================================================================ +# Example 14: Manual charge state plotting (mimicking plot_charge_state) +# ============================================================================ +print('=' * 70) +print('Example 14: Manual charge state approach (synthetic data)') +print('=' * 70) + +try: + print('Demonstrating what plot_charge_state() does under the hood...') + print() + + # Step 1: Create "node balance" data (flows in/out) - using existing ds + print(' Step 1: Using existing node_balance-like data (flows)...') + node_balance_ds = ds.copy() # This represents flows like charging/discharging + print(f' node_balance shape: {dict(node_balance_ds.dims)}') + print(f' Variables: {list(node_balance_ds.data_vars.keys())}') + + # Step 2: Create synthetic charge state data + print(' Step 2: Creating synthetic charge_state data...') + # Charge state should be cumulative and vary by scenario/period + charge_state_data = np.zeros((len(time), len(scenarios), len(periods))) + + for s_idx, _ in enumerate(scenarios): + for p_idx, period in enumerate(periods): + # Create a charge state pattern that varies over time + # Start at 50%, oscillate based on random charging/discharging + base_charge = 50 # 50 MWh base + scenario_factor = 1.0 + s_idx * 0.2 + period_factor = 1.0 + (period - 2024) / 20 + + # Simple cumulative pattern with bounds + charge_pattern = base_charge * scenario_factor * period_factor + oscillation = 20 * np.sin(np.arange(len(time)) * 2 * np.pi / 24) + noise = np.random.normal(0, 5, len(time)) + + charge_state_data[:, s_idx, p_idx] = np.clip( + charge_pattern + oscillation + noise, 0, 100 * scenario_factor * period_factor + ) + + charge_state_da = xr.DataArray( + charge_state_data, + dims=['time', 'scenario', 'period'], + coords={'time': time, 'scenario': scenarios, 'period': periods}, + name='ChargeState', + ) + print(f' charge_state shape: {dict(charge_state_da.dims)}') + + # Step 3: Combine them into a single dataset (this is what plot_charge_state does!) + print(' Step 3: Combining flows and charge_state into one Dataset...') + combined_ds = node_balance_ds.copy() + combined_ds['ChargeState'] = charge_state_da + print(f' Variables in combined dataset: {list(combined_ds.data_vars.keys())}') + + # Step 4: Plot without faceting (single scenario/period) + print(' Step 4a: Plotting single scenario/period...') + selected_ds = combined_ds.sel(scenario='base', period=2024) + fig14a = plotting.with_plotly( + selected_ds, + mode='area', + colors='portland', + title='Storage Operation: Flows + Charge State (Base, 2024)', + ylabel='Power (MW) / Charge State (MWh)', + xlabel='Time', + ) + fig14a.write_html('/tmp/facet_example_14a_manual_single.html') + all_figures.append(('Example 14a: Manual approach - single', fig14a)) + print(' ✓ Created: /tmp/facet_example_14a_manual_single.html') + + # Step 5: Plot WITH faceting by scenario + print(' Step 4b: Plotting with faceting by scenario...') + selected_ds_scenarios = combined_ds.sel(period=2024) + fig14b = plotting.with_plotly( + selected_ds_scenarios, + facet_by='scenario', + mode='area', + colors='viridis', + title='Storage Operation with Faceting (2024)', + ylabel='Power (MW) / Charge State (MWh)', + xlabel='Time', + facet_cols=2, + ) + fig14b.write_html('/tmp/facet_example_14b_manual_faceted.html') + all_figures.append(('Example 14b: Manual with faceting', fig14b)) + print(' ✓ Created: /tmp/facet_example_14b_manual_faceted.html') + + # Step 6: Plot with 2D faceting + print(' Step 4c: Plotting with 2D faceting (scenario × period)...') + # Use shorter time window for clearer visualization + combined_ds_short = combined_ds.isel(time=slice(0, 48)) + fig14c = plotting.with_plotly( + combined_ds_short, + facet_by=['scenario', 'period'], + mode='line', + colors='tab10', + title='Storage Operation: 2D Grid (48 hours)', + ylabel='Power (MW) / Charge State (MWh)', + xlabel='Time', + facet_cols=3, + ) + fig14c.write_html('/tmp/facet_example_14c_manual_2d.html') + all_figures.append(('Example 14c: Manual with 2D faceting', fig14c)) + print(' ✓ Created: /tmp/facet_example_14c_manual_2d.html') + + print() + print(' ✓ Manual approach examples completed!') + print() + print(' KEY INSIGHT - This is what plot_charge_state() does:') + print(' 1. Get node_balance data (flows in/out)') + print(' 2. Get charge_state data (storage level)') + print(' 3. Combine them: combined_ds["ChargeState"] = charge_state') + print(' 4. Apply selection: combined_ds.sel(scenario=..., period=...)') + print(' 5. Plot with: plotting.with_plotly(combined_ds, facet_by=...)') + +except Exception as e: + print(f'✗ Error in Example 14: {e}') + import traceback + + traceback.print_exc() + +print() + +# ============================================================================ +# Example 15: Real flixOpt integration - plot_charge_state with faceting +# ============================================================================ +print('=' * 70) +print('Example 15: plot_charge_state() with facet_by and animate_by') +print('=' * 70) + +try: + from datetime import datetime + + import flixopt as fx + + # Create a simple flow system with storage for each scenario and period + print('Building flow system with storage component...') + + # Time steps for a short period + time_steps = pd.date_range('2024-01-01', periods=48, freq='h', name='time') + + # Create flow system with scenario and period dimensions + flow_system = fx.FlowSystem(time_steps, scenarios=scenarios, periods=periods, time_unit='h') + + # Create buses + electricity_bus = fx.Bus('Electricity', 'Electricity') + + # Create effects (costs) + costs = fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True) + + # Create source (power plant) - using xr.DataArray for multi-dimensional inputs + generation_profile = xr.DataArray( + np.random.uniform(50, 150, (len(time_steps), len(scenarios), len(periods))), + dims=['time', 'scenario', 'period'], + coords={'time': time_steps, 'scenario': scenarios, 'period': periods}, + ) + + power_plant = fx.Source( + 'PowerPlant', + fx.Flow( + 'PowerGeneration', + bus=electricity_bus, + size=200, + relative_maximum=generation_profile / 200, # Normalized profile + effects_per_flow_hour={costs: 30}, + ), + ) + + # Create demand - also multi-dimensional + demand_profile = xr.DataArray( + np.random.uniform(60, 140, (len(time_steps), len(scenarios), len(periods))), + dims=['time', 'scenario', 'period'], + coords={'time': time_steps, 'scenario': scenarios, 'period': periods}, + ) + + demand = fx.Sink( + 'Demand', + fx.Flow('PowerDemand', bus=electricity_bus, size=demand_profile), + ) + + # Create storage with multi-dimensional capacity + storage_capacity = xr.DataArray( + [[100, 120, 150], [120, 150, 180], [110, 130, 160], [90, 110, 140]], + dims=['scenario', 'period'], + coords={'scenario': scenarios, 'period': periods}, + ) + + battery = fx.Storage( + 'Battery', + charging=fx.Flow( + 'Charging', + bus=electricity_bus, + size=50, + effects_per_flow_hour={costs: 5}, # Small charging cost + ), + discharging=fx.Flow( + 'Discharging', + bus=electricity_bus, + size=50, + effects_per_flow_hour={costs: 0}, + ), + capacity_in_flow_hours=storage_capacity, + initial_charge_state=0.5, # Start at 50% + eta_charge=0.95, + eta_discharge=0.95, + relative_loss_per_hour=0.001, # 0.1% loss per hour + ) + + # Add all elements to the flow system + flow_system.add_elements(electricity_bus, costs, power_plant, demand, battery) + + print('Running calculation...') + calculation = fx.FullCalculation( + 'FacetPlotTest', + flow_system, + 'highs', + ) + + # Solve the system + calculation.solve(save=False) + + print('✓ Calculation successful!') + print() + + # Now demonstrate plot_charge_state with faceting + print('Creating faceted charge state plots...') + + # Example 15a: Facet by scenario + print(' a) Faceting by scenario...') + fig15a = calculation.results['Battery'].plot_charge_state( + facet_by='scenario', + mode='area', + colors='blues', + select={'period': 2024}, + save='/tmp/facet_example_15a_charge_state_scenarios.html', + show=False, + ) + all_figures.append(('Example 15a: charge_state faceted by scenario', fig15a)) + print(' ✓ Created: /tmp/facet_example_15a_charge_state_scenarios.html') + + # Example 15b: Animate by period + print(' b) Animating by period...') + fig15b = calculation.results['Battery'].plot_charge_state( + animate_by='period', + mode='area', + colors='greens', + select={'scenario': 'base'}, + save='/tmp/facet_example_15b_charge_state_animation.html', + show=False, + ) + all_figures.append(('Example 15b: charge_state animated by period', fig15b)) + print(' ✓ Created: /tmp/facet_example_15b_charge_state_animation.html') + + # Example 15c: Combined faceting and animation + print(' c) Faceting by scenario AND animating by period...') + fig15c = calculation.results['Battery'].plot_charge_state( + facet_by='scenario', + animate_by='period', + mode='area', + colors='portland', + facet_cols=2, + save='/tmp/facet_example_15c_charge_state_combined.html', + show=False, + ) + all_figures.append(('Example 15c: charge_state facet + animation', fig15c)) + print(' ✓ Created: /tmp/facet_example_15c_charge_state_combined.html') + print(' 4 subplots (scenarios) × 3 frames (periods)') + + # Example 15d: 2D faceting (scenario x period) + print(' d) 2D faceting (scenario × period grid)...') + fig15d = calculation.results['Battery'].plot_charge_state( + facet_by=['scenario', 'period'], + mode='line', + colors='viridis', + facet_cols=3, + save='/tmp/facet_example_15d_charge_state_2d.html', + show=False, + ) + all_figures.append(('Example 15d: charge_state 2D faceting', fig15d)) + print(' ✓ Created: /tmp/facet_example_15d_charge_state_2d.html') + print(' 12 subplots (4 scenarios × 3 periods)') + + print() + print('✓ All plot_charge_state examples completed successfully!') + +except ImportError as e: + print(f'✗ Skipping Example 15: flixopt not fully available ({e})') + print(' This example requires a full flixopt installation') +except Exception as e: + print(f'✗ Error in Example 15: {e}') + import traceback + + traceback.print_exc() + +print() +print('=' * 70) +print('All examples completed!') +print('=' * 70) +print() +print('Summary of examples:') +print(' 1. Simple faceting by scenario (4 subplots)') +print(' 2. Animation by period (3 frames)') +print(' 3. Combined faceting + animation (4 subplots × 3 frames)') +print(' 4. 2D faceting (12 subplots in grid)') +print(' 5. Area mode with pos/neg values') +print(' 6. Stacked bar mode with animation') +print(' 7. Large grid (6 scenarios)') +print(' 8. Line mode with faceting') +print(' 9. Single variable across scenarios') +print(' 10. Different color schemes comparison') +print(' 11. 2D faceting with mixed values') +print(' 12. Animation with custom settings') +print(' 13. Edge case - single facet value') +print(' 14. Manual charge state approach (synthetic data):') +print(' a) Single scenario/period plot') +print(' b) Faceting by scenario') +print(' c) 2D faceting (scenario × period)') +print(' Demonstrates combining flows + charge_state into one Dataset') +print(' 15. Real flixOpt integration (plot_charge_state):') +print(' a) plot_charge_state with faceting by scenario') +print(' b) plot_charge_state with animation by period') +print(' c) plot_charge_state with combined faceting + animation') +print(' d) plot_charge_state with 2D faceting (scenario × period)') +print() +print('=' * 70) +print(f'Generated {len(all_figures)} figures total') +print('=' * 70) +print() +print('To show all figures interactively:') +print('>>> for name, fig in all_figures:') +print('>>> print(name)') +print('>>> fig.show()') +print() +print('To show a specific figure by index:') +print('>>> all_figures[0][1].show() # Show first figure') +print('>>> all_figures[5][1].show() # Show sixth figure') +print() +print('To list all available figures:') +print('>>> for i, (name, _) in enumerate(all_figures):') +print('>>> print(f"{i}: {name}")') +print() +print('Next steps for testing with real flixopt data:') +print('1. Load your CalculationResults with scenario/period dimensions') +print("2. Use results['Component'].plot_node_balance(facet_by='scenario')") +print("3. Try animate_by='period' for time evolution visualization") +print("4. Combine both: facet_by='scenario', animate_by='period'") +print() +print('=' * 70) +print('Quick access: all_figures list is ready to use!') +print('=' * 70) + +for _, fig in all_figures: + fig.show() diff --git a/tests/test_overlay_line_on_area.py b/tests/test_overlay_line_on_area.py new file mode 100644 index 000000000..194b21640 --- /dev/null +++ b/tests/test_overlay_line_on_area.py @@ -0,0 +1,373 @@ +""" +Test script demonstrating how to overlay a line plot on top of area/bar plots. + +This pattern is used in plot_charge_state() where: +- Flows (charging/discharging) are plotted as area/stacked_bar +- Charge state is overlaid as a line on the same plot + +The key technique: Create two separate figures with the same faceting/animation, +then add the line traces to the area/bar figure. +""" + +import numpy as np +import pandas as pd +import xarray as xr + +from flixopt import plotting + +# List to store all generated figures +all_figures = [] + +print('=' * 70) +print('Creating synthetic data for overlay demonstration') +print('=' * 70) + +# Time dimension +time = pd.date_range('2024-01-01', periods=24 * 7, freq='h', name='time') + +# Scenario and period dimensions +scenarios = ['base', 'high_demand', 'low_cost'] +periods = [2024, 2030, 2040] + +# Seed for reproducibility +np.random.seed(42) + +# Create flow variables (generation, consumption, storage flows) +variables = { + 'Generation': np.random.uniform(50, 150, (len(time), len(scenarios), len(periods))), + 'Consumption': -np.random.uniform(40, 120, (len(time), len(scenarios), len(periods))), + 'Storage_in': -np.random.uniform(0, 30, (len(time), len(scenarios), len(periods))), + 'Storage_out': np.random.uniform(0, 30, (len(time), len(scenarios), len(periods))), +} + +# Create dataset with flows +flow_ds = xr.Dataset( + {name: (['time', 'scenario', 'period'], data) for name, data in variables.items()}, + coords={'time': time, 'scenario': scenarios, 'period': periods}, +) + +# Create a separate charge state variable (cumulative state) +# This should be plotted as a line on a secondary y-axis or overlaid +charge_state_data = np.zeros((len(time), len(scenarios), len(periods))) + +for s_idx in range(len(scenarios)): + for p_idx in range(len(periods)): + # Oscillating charge state - vary by scenario and period + base = 50 + s_idx * 15 + p_idx * 10 # Different base for each scenario/period + oscillation = (20 - s_idx * 5) * np.sin(np.arange(len(time)) * 2 * np.pi / 24) + trend = (10 + p_idx * 5) * np.sin(np.arange(len(time)) * 2 * np.pi / (24 * 7)) # Weekly trend + charge_state_data[:, s_idx, p_idx] = np.clip(base + oscillation + trend, 10, 90) + +charge_state_da = xr.DataArray( + charge_state_data, + dims=['time', 'scenario', 'period'], + coords={'time': time, 'scenario': scenarios, 'period': periods}, + name='ChargeState', +) + +print(f'Flow dataset: {dict(flow_ds.sizes)}') +print(f'Variables: {list(flow_ds.data_vars.keys())}') +print(f'Charge state: {dict(charge_state_da.sizes)}') +print() + +# ============================================================================ +# Example 1: Simple overlay - single scenario/period +# ============================================================================ +print('=' * 70) +print('Example 1: Simple overlay (no faceting)') +print('=' * 70) + +# Select single scenario and period +flow_single = flow_ds.sel(scenario='base', period=2024) +charge_single = charge_state_da.sel(scenario='base', period=2024) + +# Step 1: Plot flows as area chart +fig1 = plotting.with_plotly( + flow_single, + mode='area', + colors='portland', + title='Energy Flows with Charge State Overlay', + ylabel='Power (MW) / Charge State (%)', + xlabel='Time', +) + +# Step 2: Convert charge_state DataArray to Dataset and plot as line +charge_state_ds = charge_single.to_dataset(name='ChargeState') +charge_fig = plotting.with_plotly( + charge_state_ds, + mode='line', + colors='black', # Different color for the line + title='', + ylabel='', + xlabel='', +) + +# Step 3: Add the line trace to the area figure +for trace in charge_fig.data: + trace.line.width = 3 # Make line more prominent + trace.line.shape = 'linear' # Smooth line (not stepped like flows) + trace.line.dash = 'dash' # Optional: make it dashed + fig1.add_trace(trace) + +fig1.write_html('/tmp/overlay_example_1_simple.html') +all_figures.append(('Example 1: Simple overlay', fig1)) +print('✓ Created: /tmp/overlay_example_1_simple.html') +print(' Area plot with overlaid line (charge state)') +print() + +# ============================================================================ +# Example 2: Overlay with faceting by scenario +# ============================================================================ +print('=' * 70) +print('Example 2: Overlay with faceting by scenario') +print('=' * 70) + +# Select single period, keep all scenarios +flow_scenarios = flow_ds.sel(period=2024) +charge_scenarios = charge_state_da.sel(period=2024) + +facet_by = 'scenario' +facet_cols = 3 + +# Step 1: Plot flows as stacked bars +fig2 = plotting.with_plotly( + flow_scenarios, + facet_by=facet_by, + mode='stacked_bar', + colors='viridis', + title='Energy Flows with Charge State - Faceted by Scenario', + ylabel='Power (MW) / Charge State (%)', + xlabel='Time', + facet_cols=facet_cols, +) + +# Step 2: Plot charge_state as lines with same faceting +charge_state_ds = charge_scenarios.to_dataset(name='ChargeState') +charge_fig = plotting.with_plotly( + charge_state_ds, + facet_by=facet_by, + mode='line', + colors='Reds', + title='', + facet_cols=facet_cols, +) + +# Step 3: Add line traces to the main figure +# This preserves subplot assignments +for trace in charge_fig.data: + trace.line.width = 2.5 + trace.line.shape = 'linear' # Smooth line for charge state + fig2.add_trace(trace) + +fig2.write_html('/tmp/overlay_example_2_faceted.html') +all_figures.append(('Example 2: Overlay with faceting', fig2)) +print('✓ Created: /tmp/overlay_example_2_faceted.html') +print(' 3 subplots (scenarios) with charge state lines') +print() + +# ============================================================================ +# Example 3: Overlay with animation +# ============================================================================ +print('=' * 70) +print('Example 3: Overlay with animation by period') +print('=' * 70) + +# Select single scenario, keep all periods +flow_periods = flow_ds.sel(scenario='base') +charge_periods = charge_state_da.sel(scenario='base') + +animate_by = 'period' + +# Step 1: Plot flows as area with animation +fig3 = plotting.with_plotly( + flow_periods, + animate_by=animate_by, + mode='area', + colors='portland', + title='Energy Flows with Animation - Base Scenario', + ylabel='Power (MW) / Charge State (%)', + xlabel='Time', +) + +# Step 2: Plot charge_state as line with same animation +charge_state_ds = charge_periods.to_dataset(name='ChargeState') +charge_fig = plotting.with_plotly( + charge_state_ds, + animate_by=animate_by, + mode='line', + colors='black', + title='', +) + +# Step 3: Add charge_state traces to main figure +for trace in charge_fig.data: + trace.line.width = 3 + trace.line.shape = 'linear' # Smooth line for charge state + trace.line.dash = 'dot' + fig3.add_trace(trace) + +# Step 4: Add charge_state to animation frames +if hasattr(charge_fig, 'frames') and charge_fig.frames: + if not hasattr(fig3, 'frames') or not fig3.frames: + fig3.frames = [] + # Add charge_state traces to each frame + for i, frame in enumerate(charge_fig.frames): + if i < len(fig3.frames): + for trace in frame.data: + trace.line.width = 3 + trace.line.shape = 'linear' # Smooth line for charge state + trace.line.dash = 'dot' + fig3.frames[i].data = fig3.frames[i].data + (trace,) + +fig3.write_html('/tmp/overlay_example_3_animated.html') +all_figures.append(('Example 3: Overlay with animation', fig3)) +print('✓ Created: /tmp/overlay_example_3_animated.html') +print(' Animation through 3 periods with charge state line') +print() + +# ============================================================================ +# Example 4: Overlay with faceting AND animation +# ============================================================================ +print('=' * 70) +print('Example 4: Overlay with faceting AND animation') +print('=' * 70) + +# Use full dataset +flow_full = flow_ds +charge_full = charge_state_da + +facet_by = 'scenario' +animate_by = 'period' +facet_cols = 3 + +# Step 1: Plot flows with faceting and animation +fig4 = plotting.with_plotly( + flow_full, + facet_by=facet_by, + animate_by=animate_by, + mode='area', + colors='viridis', + title='Complete: Faceting + Animation + Overlay', + ylabel='Power (MW) / Charge State (%)', + xlabel='Time', + facet_cols=facet_cols, +) + +# Step 2: Plot charge_state with same faceting and animation +charge_state_ds = charge_full.to_dataset(name='ChargeState') +charge_fig = plotting.with_plotly( + charge_state_ds, + facet_by=facet_by, + animate_by=animate_by, + mode='line', + colors='Oranges', + title='', + facet_cols=facet_cols, +) + +# Step 3: Add line traces to base figure +for trace in charge_fig.data: + trace.line.width = 2.5 + trace.line.shape = 'linear' # Smooth line for charge state + fig4.add_trace(trace) + +# Step 4: Add to animation frames +if hasattr(charge_fig, 'frames') and charge_fig.frames: + if not hasattr(fig4, 'frames') or not fig4.frames: + fig4.frames = [] + for i, frame in enumerate(charge_fig.frames): + if i < len(fig4.frames): + for trace in frame.data: + trace.line.width = 2.5 + trace.line.shape = 'linear' # Smooth line for charge state + fig4.frames[i].data = fig4.frames[i].data + (trace,) + +fig4.write_html('/tmp/overlay_example_4_combined.html') +all_figures.append(('Example 4: Complete overlay', fig4)) +print('✓ Created: /tmp/overlay_example_4_combined.html') +print(' 3 subplots (scenarios) × 3 frames (periods) with charge state') +print() + +# ============================================================================ +# Example 5: 2D faceting with overlay +# ============================================================================ +print('=' * 70) +print('Example 5: 2D faceting (scenario × period) with overlay') +print('=' * 70) + +# Use shorter time window for clearer visualization +flow_short = flow_ds.isel(time=slice(0, 48)) +charge_short = charge_state_da.isel(time=slice(0, 48)) + +facet_by = ['scenario', 'period'] +facet_cols = 3 + +# Step 1: Plot flows as line (for clearer 2D grid) +fig5 = plotting.with_plotly( + flow_short, + facet_by=facet_by, + mode='line', + colors='tab10', + title='2D Faceting with Charge State Overlay (48h)', + ylabel='Power (MW) / Charge State (%)', + xlabel='Time', + facet_cols=facet_cols, +) + +# Step 2: Plot charge_state with same 2D faceting +charge_state_ds = charge_short.to_dataset(name='ChargeState') +charge_fig = plotting.with_plotly( + charge_state_ds, + facet_by=facet_by, + mode='line', + colors='black', + title='', + facet_cols=facet_cols, +) + +# Step 3: Add charge state as thick dashed line +for trace in charge_fig.data: + trace.line.width = 3 + trace.line.shape = 'linear' # Smooth line for charge state + trace.line.dash = 'dashdot' + fig5.add_trace(trace) + +fig5.write_html('/tmp/overlay_example_5_2d_faceting.html') +all_figures.append(('Example 5: 2D faceting with overlay', fig5)) +print('✓ Created: /tmp/overlay_example_5_2d_faceting.html') +print(' 9 subplots (3 scenarios × 3 periods) with charge state') +print() + +# ============================================================================ +# Summary +# ============================================================================ +print('=' * 70) +print('All examples completed!') +print('=' * 70) +print() +print('Summary of overlay technique:') +print(' 1. Plot main data (flows) with desired mode (area/stacked_bar)') +print(' 2. Convert overlay data to Dataset: overlay_ds = da.to_dataset(name="Name")') +print(' 3. Plot overlay with mode="line" using SAME facet_by/animate_by') +print(' 4. Add traces with customization:') +print(' for trace in overlay_fig.data:') +print(' trace.line.width = 2 # Make prominent') +print(' trace.line.shape = "linear" # Smooth line (not stepped)') +print(' main_fig.add_trace(trace)') +print(' 5. Add to frames: for i, frame in enumerate(overlay_fig.frames): ...') +print() +print('Key insight: Both figures must use identical faceting/animation parameters') +print(' to ensure traces are assigned to correct subplots/frames') +print() +print(f'Generated {len(all_figures)} figures total') +print() +print('To show all figures:') +print('>>> for name, fig in all_figures:') +print('>>> print(name)') +print('>>> fig.show()') +print() + +# Optional: Uncomment to show all figures in browser at the end +# for name, fig in all_figures: +# print(f'Showing: {name}') +# fig.show() From 51da8449f264ff92ffa7ffffa4df73579a972a8b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:27:55 +0200 Subject: [PATCH 04/36] Fix Error handling in plot_heatmap() --- flixopt/results.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 59dbd1b12..8fc0370b4 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1862,12 +1862,6 @@ def plot_heatmap( if unexpected_kwargs: raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') - # Validate parameters - if (facet_by is not None or animate_by is not None) and engine == 'matplotlib': - raise ValueError( - f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' - ) - # Convert Dataset to DataArray with 'variable' dimension if isinstance(data, xr.Dataset): # Extract all data variables from the Dataset @@ -1894,6 +1888,21 @@ def plot_heatmap( data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True, **kwargs) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' + # Check if faceting/animating would actually happen based on available dimensions + if engine == 'matplotlib': + dims_to_facet = [] + if facet_by is not None: + dims_to_facet.extend([facet_by] if isinstance(facet_by, str) else facet_by) + if animate_by is not None: + dims_to_facet.append(animate_by) + + # Only raise error if any of the specified dimensions actually exist in the data + existing_dims = [dim for dim in dims_to_facet if dim in data.dims] + if existing_dims: + raise ValueError( + f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' + ) + # Build title title = f'{title_name}{suffix}' if isinstance(reshape_time, tuple): From b94f2235d1e32cf5e85f8f522dc8f1c0f1a128af Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:31:38 +0200 Subject: [PATCH 05/36] Feature/398 feature facet plots in results pie (#421) * Add animation and faceting options to plots * Adjust size of the frame * Utilize plotly express directly * Rmeocve old class * Use plotly express and modify stackgroup afterwards * Add modifications also to animations * Mkae more compact * Remove height stuff * Remove line and make set opacity =0 for area * Integrate faceting and animating into existing with_plotly method * Improve results.py * Improve results.py * Move check if dims are found to plotting.py * Fix usage of indexer * Change selection string with indexer * Change behaviout of parameter "indexing" * Update CHANGELOG.md * Add new selection parameter to plotting methods * deprectae old indexer parameter * deprectae old indexer parameter * Add test * Add test * Add test * Add test * Add heatmap support * Unify to a single heatmap method per engine * Change defaults * readd time reshaping * readd time reshaping * lengthen scenario example * Update * Improve heatmap plotting * Improve heatmap plotting * Moved reshaping to plotting.py * COmbinations are possible! * Improve 'auto'behavioour * Improve 'auto' behavioour * Improve 'auto' behavioour * Allow multiple variables in a heatmap * Update modeule level plot_heatmap() * remove code duplication * Allow Dataset instead of List of DataArrays * Allow Dataset instead of List of DataArrays * Add tests * More examples * Update plot_charge state() * Try 1 * Try 2 * Add more examples * Add more examples * Add smooth line for charge state and use "area" as default * Update scenario_example.py * Update tests * Handle extra dims in pie plots by selecting the first --- flixopt/results.py | 55 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 8fc0370b4..801c81fc8 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1109,6 +1109,14 @@ def plot_node_balance_pie( **kwargs, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]: """Plot pie chart of flow hours distribution. + + Note: + Pie charts require scalar data (no extra dimensions beyond time). + If your data has dimensions like 'scenario' or 'period', either: + + - Use `select` to choose specific values: `select={'scenario': 'base', 'period': 2024}` + - Let auto-selection choose the first value (a warning will be logged) + Args: lower_percentage_group: Percentage threshold for "Others" grouping. colors: Color scheme. Also see plotly. @@ -1117,6 +1125,16 @@ def plot_node_balance_pie( show: Whether to display plot. engine: Plotting engine ('plotly' or 'matplotlib'). select: Optional data selection dict. Supports single values, lists, slices, and index arrays. + Use this to select specific scenario/period before creating the pie chart. + + Examples: + Basic usage (auto-selects first scenario/period if present): + + >>> results['Bus'].plot_node_balance_pie() + + Explicitly select a scenario and period: + + >>> results['Bus'].plot_node_balance_pie(select={'scenario': 'high_demand', 'period': 2030}) """ # Handle deprecated indexer parameter if 'indexer' in kwargs: @@ -1152,13 +1170,44 @@ def plot_node_balance_pie( inputs, suffix_parts = _apply_indexer_to_data(inputs, select=select, drop=True, **kwargs) outputs, suffix_parts = _apply_indexer_to_data(outputs, select=select, drop=True, **kwargs) - suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - - title = f'{self.label} (total flow hours){suffix}' + # Sum over time dimension inputs = inputs.sum('time') outputs = outputs.sum('time') + # Auto-select first value for any remaining dimensions (scenario, period, etc.) + # Pie charts need scalar data, so we automatically reduce extra dimensions + extra_dims_inputs = [dim for dim in inputs.dims if dim != 'time'] + extra_dims_outputs = [dim for dim in outputs.dims if dim != 'time'] + extra_dims = list(set(extra_dims_inputs + extra_dims_outputs)) + + if extra_dims: + auto_select = {} + for dim in extra_dims: + # Get first value of this dimension + if dim in inputs.coords: + first_val = inputs.coords[dim].values[0] + elif dim in outputs.coords: + first_val = outputs.coords[dim].values[0] + else: + continue + auto_select[dim] = first_val + logger.info( + f'Pie chart auto-selected {dim}={first_val} (first value). ' + f'Use select={{"{dim}": value}} to choose a different value.' + ) + + # Apply auto-selection + inputs = inputs.sel(auto_select) + outputs = outputs.sel(auto_select) + + # Update suffix with auto-selected values + auto_suffix_parts = [f'{dim}={val}' for dim, val in auto_select.items()] + suffix_parts.extend(auto_suffix_parts) + + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' + title = f'{self.label} (total flow hours){suffix}' + if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs.to_pandas(), From b2b8eb760ff9d454114d4e797062d2ad6b5e6d0e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:41:57 +0200 Subject: [PATCH 06/36] 6. Optimized time-step check - Replaced pandas Series diff() with NumPy np.diff() for better performance - Changed check from > 0 to > 1 (can't calculate diff with 0 or 1 element) - Converted to seconds first, then to minutes to avoid pandas timedelta conversion issues --- flixopt/plotting.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index a26c9ff3e..7e954425b 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -834,10 +834,13 @@ def reshape_data_for_heatmap( period_format, step_format = formats[format_pair] # Check if resampling is needed - if data.sizes['time'] > 0: - time_diff = pd.Series(data.coords['time'].values).diff().dropna() - if len(time_diff) > 0: - min_time_diff_min = time_diff.min().total_seconds() / 60 + if data.sizes['time'] > 1: + # Use NumPy for more efficient timedelta computation + time_values = data.coords['time'].values # Already numpy datetime64[ns] + # Calculate differences and convert to minutes + time_diffs = np.diff(time_values).astype('timedelta64[s]').astype(float) / 60.0 + if time_diffs.size > 0: + min_time_diff_min = np.nanmin(time_diffs) time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60} if time_intervals[timesteps_per_frame] > min_time_diff_min: logger.warning( From c747faf100efd3ba4a3805843a2dc092d0a848da Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:42:13 +0200 Subject: [PATCH 07/36] Typo --- flixopt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 801c81fc8..b02e6bd47 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -195,8 +195,8 @@ def __init__( if 'flow_system' in kwargs and flow_system_data is None: flow_system_data = kwargs.pop('flow_system') warnings.warn( - "The 'flow_system' parameter is deprecated. Use 'flow_system_data' instead." - "Acess is now by '.flow_system_data', while '.flow_system' returns the restored FlowSystem.", + "The 'flow_system' parameter is deprecated. Use 'flow_system_data' instead. " + "Access is now via '.flow_system_data', while '.flow_system' returns the restored FlowSystem.", DeprecationWarning, stacklevel=2, ) From bf4e33d76d1e7bc15a05fdae00464eb2d369699e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:42:37 +0200 Subject: [PATCH 08/36] Improve type handling --- flixopt/results.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b02e6bd47..dbce8a315 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -2189,8 +2189,13 @@ def apply_filter(array, coord_name: str, coord_values: Any | list[Any]): if coord_name not in array.coords: raise AttributeError(f"Missing required coordinate '{coord_name}'") - # Convert single value to list - val_list = [coord_values] if isinstance(coord_values, str) else coord_values + # Normalize to list for sequence-like inputs (excluding strings) + if isinstance(coord_values, str): + val_list = [coord_values] + elif isinstance(coord_values, (list, tuple, np.ndarray, pd.Index)): + val_list = list(coord_values) + else: + val_list = [coord_values] # Verify coord_values exist available = set(array[coord_name].values) @@ -2200,7 +2205,7 @@ def apply_filter(array, coord_name: str, coord_values: Any | list[Any]): # Apply filter return array.where( - array[coord_name].isin(val_list) if isinstance(coord_values, list) else array[coord_name] == coord_values, + array[coord_name].isin(val_list) if len(val_list) > 1 else array[coord_name] == val_list[0], drop=True, ) From 0c5764c7f5c5fd96a6811620cbb87bc042e1b2d7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:45:00 +0200 Subject: [PATCH 09/36] Update other tests --- tests/test_overlay_line_on_area.py | 66 +++++++++++++++++++----------- tests/test_select_features.py | 4 +- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/tests/test_overlay_line_on_area.py b/tests/test_overlay_line_on_area.py index 194b21640..76b671627 100644 --- a/tests/test_overlay_line_on_area.py +++ b/tests/test_overlay_line_on_area.py @@ -9,6 +9,8 @@ then add the line traces to the area/bar figure. """ +import copy + import numpy as np import pandas as pd import xarray as xr @@ -104,10 +106,12 @@ # Step 3: Add the line trace to the area figure for trace in charge_fig.data: - trace.line.width = 3 # Make line more prominent - trace.line.shape = 'linear' # Smooth line (not stepped like flows) - trace.line.dash = 'dash' # Optional: make it dashed - fig1.add_trace(trace) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 3 # Make line more prominent + trace_copy.line.shape = 'linear' # Straight line (not stepped like flows) + trace_copy.line.dash = 'dash' # Optional: make it dashed + trace_copy.showlegend = False # Avoid duplicate legend entries + fig1.add_trace(trace_copy) fig1.write_html('/tmp/overlay_example_1_simple.html') all_figures.append(('Example 1: Simple overlay', fig1)) @@ -155,9 +159,11 @@ # Step 3: Add line traces to the main figure # This preserves subplot assignments for trace in charge_fig.data: - trace.line.width = 2.5 - trace.line.shape = 'linear' # Smooth line for charge state - fig2.add_trace(trace) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 2.5 + trace_copy.line.shape = 'linear' # Straight line for charge state + trace_copy.showlegend = False # Avoid duplicate legend entries + fig2.add_trace(trace_copy) fig2.write_html('/tmp/overlay_example_2_faceted.html') all_figures.append(('Example 2: Overlay with faceting', fig2)) @@ -201,10 +207,12 @@ # Step 3: Add charge_state traces to main figure for trace in charge_fig.data: - trace.line.width = 3 - trace.line.shape = 'linear' # Smooth line for charge state - trace.line.dash = 'dot' - fig3.add_trace(trace) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 3 + trace_copy.line.shape = 'linear' # Straight line for charge state + trace_copy.line.dash = 'dot' + trace_copy.showlegend = False # Avoid duplicate legend entries + fig3.add_trace(trace_copy) # Step 4: Add charge_state to animation frames if hasattr(charge_fig, 'frames') and charge_fig.frames: @@ -214,10 +222,12 @@ for i, frame in enumerate(charge_fig.frames): if i < len(fig3.frames): for trace in frame.data: - trace.line.width = 3 - trace.line.shape = 'linear' # Smooth line for charge state - trace.line.dash = 'dot' - fig3.frames[i].data = fig3.frames[i].data + (trace,) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 3 + trace_copy.line.shape = 'linear' # Straight line for charge state + trace_copy.line.dash = 'dot' + trace_copy.showlegend = False # Avoid duplicate legend entries + fig3.frames[i].data = fig3.frames[i].data + (trace_copy,) fig3.write_html('/tmp/overlay_example_3_animated.html') all_figures.append(('Example 3: Overlay with animation', fig3)) @@ -267,9 +277,11 @@ # Step 3: Add line traces to base figure for trace in charge_fig.data: - trace.line.width = 2.5 - trace.line.shape = 'linear' # Smooth line for charge state - fig4.add_trace(trace) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 2.5 + trace_copy.line.shape = 'linear' # Straight line for charge state + trace_copy.showlegend = False # Avoid duplicate legend entries + fig4.add_trace(trace_copy) # Step 4: Add to animation frames if hasattr(charge_fig, 'frames') and charge_fig.frames: @@ -278,9 +290,11 @@ for i, frame in enumerate(charge_fig.frames): if i < len(fig4.frames): for trace in frame.data: - trace.line.width = 2.5 - trace.line.shape = 'linear' # Smooth line for charge state - fig4.frames[i].data = fig4.frames[i].data + (trace,) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 2.5 + trace_copy.line.shape = 'linear' # Straight line for charge state + trace_copy.showlegend = False # Avoid duplicate legend entries + fig4.frames[i].data = fig4.frames[i].data + (trace_copy,) fig4.write_html('/tmp/overlay_example_4_combined.html') all_figures.append(('Example 4: Complete overlay', fig4)) @@ -327,10 +341,12 @@ # Step 3: Add charge state as thick dashed line for trace in charge_fig.data: - trace.line.width = 3 - trace.line.shape = 'linear' # Smooth line for charge state - trace.line.dash = 'dashdot' - fig5.add_trace(trace) + trace_copy = copy.deepcopy(trace) + trace_copy.line.width = 3 + trace_copy.line.shape = 'linear' # Straight line for charge state + trace_copy.line.dash = 'dashdot' + trace_copy.showlegend = False # Avoid duplicate legend entries + fig5.add_trace(trace_copy) fig5.write_html('/tmp/overlay_example_5_2d_faceting.html') all_figures.append(('Example 5: 2D faceting with overlay', fig5)) diff --git a/tests/test_select_features.py b/tests/test_select_features.py index 6dd39c95c..d5e7df7ac 100644 --- a/tests/test_select_features.py +++ b/tests/test_select_features.py @@ -11,8 +11,8 @@ import flixopt as fx -# Set default renderer to browser -pio.renderers.default = 'browser' +# Set default renderer to json for tests (safe for headless CI) +pio.renderers.default = 'json' @pytest.fixture(scope='module') From 59ada649021c5401af19d1616555a8d3d43edb47 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:02:04 +0200 Subject: [PATCH 10/36] Handle backwards compatability --- flixopt/results.py | 59 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index dbce8a315..2b3c875fa 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1773,6 +1773,10 @@ def plot_heatmap( animate_by: str | None = None, facet_cols: int = 3, fill: Literal['ffill', 'bfill'] | None = 'ffill', + # Deprecated parameters (kept for backwards compatibility) + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, + color_map: str | None = None, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """Plot heatmap of variable solution across segments. @@ -1790,10 +1794,37 @@ def plot_heatmap( animate_by: Dimension to animate over (Plotly only). facet_cols: Number of columns in the facet grid layout. fill: Method to fill missing values: 'ffill' or 'bfill'. + heatmap_timeframes: (Deprecated) Use reshape_time instead. + heatmap_timesteps_per_frame: (Deprecated) Use reshape_time instead. + color_map: (Deprecated) Use colors instead. Returns: Figure object. """ + # Handle deprecated parameters + if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: + import warnings + + warnings.warn( + "The 'heatmap_timeframes' and 'heatmap_timesteps_per_frame' parameters are deprecated. " + "Use 'reshape_time=(timeframes, timesteps_per_frame)' instead.", + DeprecationWarning, + stacklevel=2, + ) + # Override reshape_time if old parameters provided + if heatmap_timeframes is not None and heatmap_timesteps_per_frame is not None: + reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) + + if color_map is not None: + import warnings + + warnings.warn( + "The 'color_map' parameter is deprecated. Use 'colors' instead.", + DeprecationWarning, + stacklevel=2, + ) + colors = color_map + return plot_heatmap( data=self.solution_without_overlap(variable_name), name=variable_name, @@ -1906,7 +1937,33 @@ def plot_heatmap( stacklevel=2, ) - # Check for unexpected kwargs + # Handle deprecated heatmap parameters + if 'heatmap_timeframes' in kwargs or 'heatmap_timesteps_per_frame' in kwargs: + import warnings + + warnings.warn( + "The 'heatmap_timeframes' and 'heatmap_timesteps_per_frame' parameters are deprecated. " + "Use 'reshape_time=(timeframes, timesteps_per_frame)' instead.", + DeprecationWarning, + stacklevel=2, + ) + # Override reshape_time if old parameters provided + heatmap_timeframes = kwargs.pop('heatmap_timeframes', None) + heatmap_timesteps_per_frame = kwargs.pop('heatmap_timesteps_per_frame', None) + if heatmap_timeframes is not None and heatmap_timesteps_per_frame is not None: + reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) + + if 'color_map' in kwargs: + import warnings + + warnings.warn( + "The 'color_map' parameter is deprecated. Use 'colors' instead.", + DeprecationWarning, + stacklevel=2, + ) + colors = kwargs.pop('color_map') + + # Check for unexpected kwargs (after removing deprecated ones) unexpected_kwargs = set(kwargs.keys()) - {'indexer'} if unexpected_kwargs: raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') From b56ed12eec40207fe86103466b68ec734ecf3149 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:17:07 +0200 Subject: [PATCH 11/36] Add better error messages if both new and old api are used --- flixopt/results.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index 2b3c875fa..b6f56e538 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1803,6 +1803,13 @@ def plot_heatmap( """ # Handle deprecated parameters if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: + # Check for conflict with new parameter + if reshape_time != ('D', 'h'): # Check if user explicitly set reshape_time + raise ValueError( + "Cannot use both deprecated parameters 'heatmap_timeframes'/'heatmap_timesteps_per_frame' " + "and new parameter 'reshape_time'. Use only 'reshape_time'." + ) + import warnings warnings.warn( @@ -1816,6 +1823,12 @@ def plot_heatmap( reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) if color_map is not None: + # Check for conflict with new parameter + if colors != 'portland': # Check if user explicitly set colors + raise ValueError( + "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." + ) + import warnings warnings.warn( @@ -1939,6 +1952,13 @@ def plot_heatmap( # Handle deprecated heatmap parameters if 'heatmap_timeframes' in kwargs or 'heatmap_timesteps_per_frame' in kwargs: + # Check for conflict with new parameter + if reshape_time != 'auto': # User explicitly set reshape_time + raise ValueError( + "Cannot use both deprecated parameters 'heatmap_timeframes'/'heatmap_timesteps_per_frame' " + "and new parameter 'reshape_time'. Use only 'reshape_time'." + ) + import warnings warnings.warn( @@ -1954,6 +1974,12 @@ def plot_heatmap( reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) if 'color_map' in kwargs: + # Check for conflict with new parameter + if colors != 'viridis': # User explicitly set colors + raise ValueError( + "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." + ) + import warnings warnings.warn( From 980d7de6bce6add31533617d216eb7beecde1b3c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:27:04 +0200 Subject: [PATCH 12/36] Add old api explicitly --- flixopt/results.py | 51 ++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b6f56e538..44a597022 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -699,7 +699,11 @@ def plot_heatmap( reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', - **kwargs, + # Deprecated parameters (kept for backwards compatibility) + indexer: dict[FlowSystemDimensions, Any] | None = None, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, + color_map: str | None = None, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ Plots a heatmap visualization of a variable using imshow or time-based reshaping. @@ -786,7 +790,9 @@ def plot_heatmap( animate_by=animate_by, facet_cols=facet_cols, reshape_time=reshape_time, - **kwargs, + heatmap_timeframes=heatmap_timeframes, + heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, + color_map=color_map, ) def plot_network( @@ -1764,7 +1770,7 @@ def plot_heatmap( variable_name: str, reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] - | None = ('D', 'h'), + | None = 'auto', colors: str = 'portland', save: bool | pathlib.Path = False, show: bool = True, @@ -1782,7 +1788,7 @@ def plot_heatmap( Args: variable_name: Variable to plot. - reshape_time: Time reshaping configuration: + reshape_time: Time reshaping configuration (default: 'auto'): - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains - Tuple like ('D', 'h'): Explicit reshaping (days vs hours) - None: Disable time reshaping @@ -1804,7 +1810,7 @@ def plot_heatmap( # Handle deprecated parameters if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: # Check for conflict with new parameter - if reshape_time != ('D', 'h'): # Check if user explicitly set reshape_time + if reshape_time != 'auto': # Check if user explicitly set reshape_time raise ValueError( "Cannot use both deprecated parameters 'heatmap_timeframes'/'heatmap_timesteps_per_frame' " "and new parameter 'reshape_time'. Use only 'reshape_time'." @@ -1894,7 +1900,10 @@ def plot_heatmap( reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', - **kwargs, + # Deprecated parameters (kept for backwards compatibility) + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, + color_map: str | None = None, ): """Plot heatmap visualization with support for multi-variable, faceting, and animation. @@ -1940,18 +1949,8 @@ def plot_heatmap( >>> plot_heatmap(dataset, animate_by='variable', reshape_time=('D', 'h')) """ - # Handle deprecated indexer parameter - if 'indexer' in kwargs: - import warnings - - warnings.warn( - "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", - DeprecationWarning, - stacklevel=2, - ) - - # Handle deprecated heatmap parameters - if 'heatmap_timeframes' in kwargs or 'heatmap_timesteps_per_frame' in kwargs: + # Handle deprecated heatmap time parameters + if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: # Check for conflict with new parameter if reshape_time != 'auto': # User explicitly set reshape_time raise ValueError( @@ -1967,13 +1966,12 @@ def plot_heatmap( DeprecationWarning, stacklevel=2, ) - # Override reshape_time if old parameters provided - heatmap_timeframes = kwargs.pop('heatmap_timeframes', None) - heatmap_timesteps_per_frame = kwargs.pop('heatmap_timesteps_per_frame', None) + # Override reshape_time if both old parameters provided if heatmap_timeframes is not None and heatmap_timesteps_per_frame is not None: reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) - if 'color_map' in kwargs: + # Handle deprecated color_map parameter + if color_map is not None: # Check for conflict with new parameter if colors != 'viridis': # User explicitly set colors raise ValueError( @@ -1987,12 +1985,7 @@ def plot_heatmap( DeprecationWarning, stacklevel=2, ) - colors = kwargs.pop('color_map') - - # Check for unexpected kwargs (after removing deprecated ones) - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError(f'plot_heatmap() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + colors = color_map # Convert Dataset to DataArray with 'variable' dimension if isinstance(data, xr.Dataset): @@ -2017,7 +2010,7 @@ def plot_heatmap( title_name = name # Apply select filtering - data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True, **kwargs) + data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' # Check if faceting/animating would actually happen based on available dimensions From 9aea60ee5d60137e19c440326739c8fb6175ae46 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:36:48 +0200 Subject: [PATCH 13/36] Add old api explicitly --- flixopt/results.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index 44a597022..52a500c7c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -790,6 +790,7 @@ def plot_heatmap( animate_by=animate_by, facet_cols=facet_cols, reshape_time=reshape_time, + indexer=indexer, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, color_map=color_map, @@ -1901,6 +1902,7 @@ def plot_heatmap( | Literal['auto'] | None = 'auto', # Deprecated parameters (kept for backwards compatibility) + indexer: dict[str, Any] | None = None, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, color_map: str | None = None, @@ -1987,6 +1989,23 @@ def plot_heatmap( ) colors = color_map + # Handle deprecated indexer parameter + if indexer is not None: + # Check for conflict with new parameter + if select is not None: # User explicitly set select + raise ValueError( + "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." + ) + + import warnings + + warnings.warn( + "The 'indexer' parameter is deprecated. Use 'select' instead.", + DeprecationWarning, + stacklevel=2, + ) + select = indexer + # Convert Dataset to DataArray with 'variable' dimension if isinstance(data, xr.Dataset): # Extract all data variables from the Dataset From 922a95ff530a890e089b24d275076ffbe706b2d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:51:19 +0200 Subject: [PATCH 14/36] Improve consistency and properly deprectae the indexer parameter --- flixopt/results.py | 110 +++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 60 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 52a500c7c..2306c4838 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -972,7 +972,8 @@ def plot_node_balance( facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', facet_cols: int = 3, - **kwargs, + # Deprecated parameter (kept for backwards compatibility) + indexer: dict[FlowSystemDimensions, Any] | None = None, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ Plots the node balance of the Component or Bus with optional faceting and animation. @@ -1033,7 +1034,13 @@ def plot_node_balance( >>> results['Boiler'].plot_node_balance(select={'time': slice('2024-06', '2024-08')}, facet_by='scenario') """ # Handle deprecated indexer parameter - if 'indexer' in kwargs: + if indexer is not None: + # Check for conflict with new parameter + if select is not None: + raise ValueError( + "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." + ) + import warnings warnings.warn( @@ -1041,11 +1048,7 @@ def plot_node_balance( DeprecationWarning, stacklevel=2, ) - - # Check for unexpected kwargs - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError(f'plot_node_balance() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + select = indexer if engine not in {'plotly', 'matplotlib'}: raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]') @@ -1053,7 +1056,7 @@ def plot_node_balance( # Don't pass select/indexer to node_balance - we'll apply it afterwards ds = self.node_balance(with_last_timestep=True, unit_type=unit_type, drop_suffix=drop_suffix) - ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) + ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True) # Check if faceting/animating would actually happen based on available dimensions if engine == 'matplotlib': @@ -1113,7 +1116,8 @@ def plot_node_balance_pie( show: bool = True, engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, - **kwargs, + # Deprecated parameter (kept for backwards compatibility) + indexer: dict[FlowSystemDimensions, Any] | None = None, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]: """Plot pie chart of flow hours distribution. @@ -1144,7 +1148,13 @@ def plot_node_balance_pie( >>> results['Bus'].plot_node_balance_pie(select={'scenario': 'high_demand', 'period': 2030}) """ # Handle deprecated indexer parameter - if 'indexer' in kwargs: + if indexer is not None: + # Check for conflict with new parameter + if select is not None: + raise ValueError( + "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." + ) + import warnings warnings.warn( @@ -1152,13 +1162,7 @@ def plot_node_balance_pie( DeprecationWarning, stacklevel=2, ) - - # Check for unexpected kwargs - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError( - f'plot_node_balance_pie() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}' - ) + select = indexer inputs = sanitize_dataset( ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, @@ -1175,8 +1179,8 @@ def plot_node_balance_pie( drop_suffix='|', ) - inputs, suffix_parts = _apply_indexer_to_data(inputs, select=select, drop=True, **kwargs) - outputs, suffix_parts = _apply_indexer_to_data(outputs, select=select, drop=True, **kwargs) + inputs, suffix_parts = _apply_indexer_to_data(inputs, select=select, drop=True) + outputs, suffix_parts = _apply_indexer_to_data(outputs, select=select, drop=True) # Sum over time dimension inputs = inputs.sum('time') @@ -1260,7 +1264,8 @@ def node_balance( unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', drop_suffix: bool = False, select: dict[FlowSystemDimensions, Any] | None = None, - **kwargs, + # Deprecated parameter (kept for backwards compatibility) + indexer: dict[FlowSystemDimensions, Any] | None = None, ) -> xr.Dataset: """ Returns a dataset with the node balance of the Component or Bus. @@ -1276,7 +1281,13 @@ def node_balance( select: Optional data selection dict. Supports single values, lists, slices, and index arrays. """ # Handle deprecated indexer parameter - if 'indexer' in kwargs: + if indexer is not None: + # Check for conflict with new parameter + if select is not None: + raise ValueError( + "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." + ) + import warnings warnings.warn( @@ -1284,11 +1295,7 @@ def node_balance( DeprecationWarning, stacklevel=2, ) - - # Check for unexpected kwargs - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError(f'node_balance() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + select = indexer ds = self.solution[self.inputs + self.outputs] @@ -1308,7 +1315,7 @@ def node_balance( drop_suffix='|' if drop_suffix else None, ) - ds, _ = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) + ds, _ = _apply_indexer_to_data(ds, select=select, drop=True) if unit_type == 'flow_hours': ds = ds * self._calculation_results.hours_per_timestep @@ -1350,7 +1357,8 @@ def plot_charge_state( facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', facet_cols: int = 3, - **kwargs, + # Deprecated parameter (kept for backwards compatibility) + indexer: dict[FlowSystemDimensions, Any] | None = None, ) -> plotly.graph_objs.Figure: """Plot storage charge state over time, combined with the node balance with optional faceting and animation. @@ -1389,7 +1397,13 @@ def plot_charge_state( >>> results['Storage'].plot_charge_state(facet_by='scenario', animate_by='period') """ # Handle deprecated indexer parameter - if 'indexer' in kwargs: + if indexer is not None: + # Check for conflict with new parameter + if select is not None: + raise ValueError( + "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." + ) + import warnings warnings.warn( @@ -1397,11 +1411,7 @@ def plot_charge_state( DeprecationWarning, stacklevel=2, ) - - # Check for unexpected kwargs - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError(f'plot_charge_state() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') + select = indexer if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') @@ -1416,8 +1426,8 @@ def plot_charge_state( charge_state_da = self.charge_state # Apply select filtering - ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True, **kwargs) - charge_state_da, _ = _apply_indexer_to_data(charge_state_da, select=select, drop=True, **kwargs) + ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True) + charge_state_da, _ = _apply_indexer_to_data(charge_state_da, select=select, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'Operation Balance of {self.label}{suffix}' @@ -2323,14 +2333,13 @@ def _apply_indexer_to_data( data: xr.DataArray | xr.Dataset, select: dict[str, Any] | None = None, drop=False, - **kwargs, ) -> tuple[xr.DataArray | xr.Dataset, list[str]]: """ Apply selection to data. Args: data: xarray Dataset or DataArray - select: Optional selection dict (takes precedence over indexer) + select: Optional selection dict drop: Whether to drop dimensions after selection Returns: @@ -2338,27 +2347,8 @@ def _apply_indexer_to_data( """ selection_string = [] - # Handle deprecated indexer parameter - indexer = kwargs.get('indexer') - if indexer is not None: - import warnings - - warnings.warn( - "The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead.", - DeprecationWarning, - stacklevel=3, - ) - - # Check for unexpected kwargs - unexpected_kwargs = set(kwargs.keys()) - {'indexer'} - if unexpected_kwargs: - raise TypeError(f'_apply_indexer_to_data() got unexpected keyword argument(s): {", ".join(unexpected_kwargs)}') - - # Merge both dicts, select takes precedence - selection = {**(indexer or {}), **(select or {})} - - if selection: - data = data.sel(selection, drop=drop) - selection_string.extend(f'{dim}={val}' for dim, val in selection.items()) + if select: + data = data.sel(select, drop=drop) + selection_string.extend(f'{dim}={val}' for dim, val in select.items()) return data, selection_string From bd88fb123cf72980d27445d42db5f1555133cad5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:55:28 +0200 Subject: [PATCH 15/36] Remove amount of new tests --- tests/test_overlay_line_on_area.py | 389 ----------------------------- tests/test_results_plots.py | 9 +- 2 files changed, 8 insertions(+), 390 deletions(-) delete mode 100644 tests/test_overlay_line_on_area.py diff --git a/tests/test_overlay_line_on_area.py b/tests/test_overlay_line_on_area.py deleted file mode 100644 index 76b671627..000000000 --- a/tests/test_overlay_line_on_area.py +++ /dev/null @@ -1,389 +0,0 @@ -""" -Test script demonstrating how to overlay a line plot on top of area/bar plots. - -This pattern is used in plot_charge_state() where: -- Flows (charging/discharging) are plotted as area/stacked_bar -- Charge state is overlaid as a line on the same plot - -The key technique: Create two separate figures with the same faceting/animation, -then add the line traces to the area/bar figure. -""" - -import copy - -import numpy as np -import pandas as pd -import xarray as xr - -from flixopt import plotting - -# List to store all generated figures -all_figures = [] - -print('=' * 70) -print('Creating synthetic data for overlay demonstration') -print('=' * 70) - -# Time dimension -time = pd.date_range('2024-01-01', periods=24 * 7, freq='h', name='time') - -# Scenario and period dimensions -scenarios = ['base', 'high_demand', 'low_cost'] -periods = [2024, 2030, 2040] - -# Seed for reproducibility -np.random.seed(42) - -# Create flow variables (generation, consumption, storage flows) -variables = { - 'Generation': np.random.uniform(50, 150, (len(time), len(scenarios), len(periods))), - 'Consumption': -np.random.uniform(40, 120, (len(time), len(scenarios), len(periods))), - 'Storage_in': -np.random.uniform(0, 30, (len(time), len(scenarios), len(periods))), - 'Storage_out': np.random.uniform(0, 30, (len(time), len(scenarios), len(periods))), -} - -# Create dataset with flows -flow_ds = xr.Dataset( - {name: (['time', 'scenario', 'period'], data) for name, data in variables.items()}, - coords={'time': time, 'scenario': scenarios, 'period': periods}, -) - -# Create a separate charge state variable (cumulative state) -# This should be plotted as a line on a secondary y-axis or overlaid -charge_state_data = np.zeros((len(time), len(scenarios), len(periods))) - -for s_idx in range(len(scenarios)): - for p_idx in range(len(periods)): - # Oscillating charge state - vary by scenario and period - base = 50 + s_idx * 15 + p_idx * 10 # Different base for each scenario/period - oscillation = (20 - s_idx * 5) * np.sin(np.arange(len(time)) * 2 * np.pi / 24) - trend = (10 + p_idx * 5) * np.sin(np.arange(len(time)) * 2 * np.pi / (24 * 7)) # Weekly trend - charge_state_data[:, s_idx, p_idx] = np.clip(base + oscillation + trend, 10, 90) - -charge_state_da = xr.DataArray( - charge_state_data, - dims=['time', 'scenario', 'period'], - coords={'time': time, 'scenario': scenarios, 'period': periods}, - name='ChargeState', -) - -print(f'Flow dataset: {dict(flow_ds.sizes)}') -print(f'Variables: {list(flow_ds.data_vars.keys())}') -print(f'Charge state: {dict(charge_state_da.sizes)}') -print() - -# ============================================================================ -# Example 1: Simple overlay - single scenario/period -# ============================================================================ -print('=' * 70) -print('Example 1: Simple overlay (no faceting)') -print('=' * 70) - -# Select single scenario and period -flow_single = flow_ds.sel(scenario='base', period=2024) -charge_single = charge_state_da.sel(scenario='base', period=2024) - -# Step 1: Plot flows as area chart -fig1 = plotting.with_plotly( - flow_single, - mode='area', - colors='portland', - title='Energy Flows with Charge State Overlay', - ylabel='Power (MW) / Charge State (%)', - xlabel='Time', -) - -# Step 2: Convert charge_state DataArray to Dataset and plot as line -charge_state_ds = charge_single.to_dataset(name='ChargeState') -charge_fig = plotting.with_plotly( - charge_state_ds, - mode='line', - colors='black', # Different color for the line - title='', - ylabel='', - xlabel='', -) - -# Step 3: Add the line trace to the area figure -for trace in charge_fig.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 3 # Make line more prominent - trace_copy.line.shape = 'linear' # Straight line (not stepped like flows) - trace_copy.line.dash = 'dash' # Optional: make it dashed - trace_copy.showlegend = False # Avoid duplicate legend entries - fig1.add_trace(trace_copy) - -fig1.write_html('/tmp/overlay_example_1_simple.html') -all_figures.append(('Example 1: Simple overlay', fig1)) -print('✓ Created: /tmp/overlay_example_1_simple.html') -print(' Area plot with overlaid line (charge state)') -print() - -# ============================================================================ -# Example 2: Overlay with faceting by scenario -# ============================================================================ -print('=' * 70) -print('Example 2: Overlay with faceting by scenario') -print('=' * 70) - -# Select single period, keep all scenarios -flow_scenarios = flow_ds.sel(period=2024) -charge_scenarios = charge_state_da.sel(period=2024) - -facet_by = 'scenario' -facet_cols = 3 - -# Step 1: Plot flows as stacked bars -fig2 = plotting.with_plotly( - flow_scenarios, - facet_by=facet_by, - mode='stacked_bar', - colors='viridis', - title='Energy Flows with Charge State - Faceted by Scenario', - ylabel='Power (MW) / Charge State (%)', - xlabel='Time', - facet_cols=facet_cols, -) - -# Step 2: Plot charge_state as lines with same faceting -charge_state_ds = charge_scenarios.to_dataset(name='ChargeState') -charge_fig = plotting.with_plotly( - charge_state_ds, - facet_by=facet_by, - mode='line', - colors='Reds', - title='', - facet_cols=facet_cols, -) - -# Step 3: Add line traces to the main figure -# This preserves subplot assignments -for trace in charge_fig.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 2.5 - trace_copy.line.shape = 'linear' # Straight line for charge state - trace_copy.showlegend = False # Avoid duplicate legend entries - fig2.add_trace(trace_copy) - -fig2.write_html('/tmp/overlay_example_2_faceted.html') -all_figures.append(('Example 2: Overlay with faceting', fig2)) -print('✓ Created: /tmp/overlay_example_2_faceted.html') -print(' 3 subplots (scenarios) with charge state lines') -print() - -# ============================================================================ -# Example 3: Overlay with animation -# ============================================================================ -print('=' * 70) -print('Example 3: Overlay with animation by period') -print('=' * 70) - -# Select single scenario, keep all periods -flow_periods = flow_ds.sel(scenario='base') -charge_periods = charge_state_da.sel(scenario='base') - -animate_by = 'period' - -# Step 1: Plot flows as area with animation -fig3 = plotting.with_plotly( - flow_periods, - animate_by=animate_by, - mode='area', - colors='portland', - title='Energy Flows with Animation - Base Scenario', - ylabel='Power (MW) / Charge State (%)', - xlabel='Time', -) - -# Step 2: Plot charge_state as line with same animation -charge_state_ds = charge_periods.to_dataset(name='ChargeState') -charge_fig = plotting.with_plotly( - charge_state_ds, - animate_by=animate_by, - mode='line', - colors='black', - title='', -) - -# Step 3: Add charge_state traces to main figure -for trace in charge_fig.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 3 - trace_copy.line.shape = 'linear' # Straight line for charge state - trace_copy.line.dash = 'dot' - trace_copy.showlegend = False # Avoid duplicate legend entries - fig3.add_trace(trace_copy) - -# Step 4: Add charge_state to animation frames -if hasattr(charge_fig, 'frames') and charge_fig.frames: - if not hasattr(fig3, 'frames') or not fig3.frames: - fig3.frames = [] - # Add charge_state traces to each frame - for i, frame in enumerate(charge_fig.frames): - if i < len(fig3.frames): - for trace in frame.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 3 - trace_copy.line.shape = 'linear' # Straight line for charge state - trace_copy.line.dash = 'dot' - trace_copy.showlegend = False # Avoid duplicate legend entries - fig3.frames[i].data = fig3.frames[i].data + (trace_copy,) - -fig3.write_html('/tmp/overlay_example_3_animated.html') -all_figures.append(('Example 3: Overlay with animation', fig3)) -print('✓ Created: /tmp/overlay_example_3_animated.html') -print(' Animation through 3 periods with charge state line') -print() - -# ============================================================================ -# Example 4: Overlay with faceting AND animation -# ============================================================================ -print('=' * 70) -print('Example 4: Overlay with faceting AND animation') -print('=' * 70) - -# Use full dataset -flow_full = flow_ds -charge_full = charge_state_da - -facet_by = 'scenario' -animate_by = 'period' -facet_cols = 3 - -# Step 1: Plot flows with faceting and animation -fig4 = plotting.with_plotly( - flow_full, - facet_by=facet_by, - animate_by=animate_by, - mode='area', - colors='viridis', - title='Complete: Faceting + Animation + Overlay', - ylabel='Power (MW) / Charge State (%)', - xlabel='Time', - facet_cols=facet_cols, -) - -# Step 2: Plot charge_state with same faceting and animation -charge_state_ds = charge_full.to_dataset(name='ChargeState') -charge_fig = plotting.with_plotly( - charge_state_ds, - facet_by=facet_by, - animate_by=animate_by, - mode='line', - colors='Oranges', - title='', - facet_cols=facet_cols, -) - -# Step 3: Add line traces to base figure -for trace in charge_fig.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 2.5 - trace_copy.line.shape = 'linear' # Straight line for charge state - trace_copy.showlegend = False # Avoid duplicate legend entries - fig4.add_trace(trace_copy) - -# Step 4: Add to animation frames -if hasattr(charge_fig, 'frames') and charge_fig.frames: - if not hasattr(fig4, 'frames') or not fig4.frames: - fig4.frames = [] - for i, frame in enumerate(charge_fig.frames): - if i < len(fig4.frames): - for trace in frame.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 2.5 - trace_copy.line.shape = 'linear' # Straight line for charge state - trace_copy.showlegend = False # Avoid duplicate legend entries - fig4.frames[i].data = fig4.frames[i].data + (trace_copy,) - -fig4.write_html('/tmp/overlay_example_4_combined.html') -all_figures.append(('Example 4: Complete overlay', fig4)) -print('✓ Created: /tmp/overlay_example_4_combined.html') -print(' 3 subplots (scenarios) × 3 frames (periods) with charge state') -print() - -# ============================================================================ -# Example 5: 2D faceting with overlay -# ============================================================================ -print('=' * 70) -print('Example 5: 2D faceting (scenario × period) with overlay') -print('=' * 70) - -# Use shorter time window for clearer visualization -flow_short = flow_ds.isel(time=slice(0, 48)) -charge_short = charge_state_da.isel(time=slice(0, 48)) - -facet_by = ['scenario', 'period'] -facet_cols = 3 - -# Step 1: Plot flows as line (for clearer 2D grid) -fig5 = plotting.with_plotly( - flow_short, - facet_by=facet_by, - mode='line', - colors='tab10', - title='2D Faceting with Charge State Overlay (48h)', - ylabel='Power (MW) / Charge State (%)', - xlabel='Time', - facet_cols=facet_cols, -) - -# Step 2: Plot charge_state with same 2D faceting -charge_state_ds = charge_short.to_dataset(name='ChargeState') -charge_fig = plotting.with_plotly( - charge_state_ds, - facet_by=facet_by, - mode='line', - colors='black', - title='', - facet_cols=facet_cols, -) - -# Step 3: Add charge state as thick dashed line -for trace in charge_fig.data: - trace_copy = copy.deepcopy(trace) - trace_copy.line.width = 3 - trace_copy.line.shape = 'linear' # Straight line for charge state - trace_copy.line.dash = 'dashdot' - trace_copy.showlegend = False # Avoid duplicate legend entries - fig5.add_trace(trace_copy) - -fig5.write_html('/tmp/overlay_example_5_2d_faceting.html') -all_figures.append(('Example 5: 2D faceting with overlay', fig5)) -print('✓ Created: /tmp/overlay_example_5_2d_faceting.html') -print(' 9 subplots (3 scenarios × 3 periods) with charge state') -print() - -# ============================================================================ -# Summary -# ============================================================================ -print('=' * 70) -print('All examples completed!') -print('=' * 70) -print() -print('Summary of overlay technique:') -print(' 1. Plot main data (flows) with desired mode (area/stacked_bar)') -print(' 2. Convert overlay data to Dataset: overlay_ds = da.to_dataset(name="Name")') -print(' 3. Plot overlay with mode="line" using SAME facet_by/animate_by') -print(' 4. Add traces with customization:') -print(' for trace in overlay_fig.data:') -print(' trace.line.width = 2 # Make prominent') -print(' trace.line.shape = "linear" # Smooth line (not stepped)') -print(' main_fig.add_trace(trace)') -print(' 5. Add to frames: for i, frame in enumerate(overlay_fig.frames): ...') -print() -print('Key insight: Both figures must use identical faceting/animation parameters') -print(' to ensure traces are assigned to correct subplots/frames') -print() -print(f'Generated {len(all_figures)} figures total') -print() -print('To show all figures:') -print('>>> for name, fig in all_figures:') -print('>>> print(name)') -print('>>> fig.show()') -print() - -# Optional: Uncomment to show all figures in browser at the end -# for name, fig in all_figures: -# print(f'Showing: {name}') -# fig.show() diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index d8c83b42d..e13d4c1dc 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -63,7 +63,14 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): results.plot_heatmap('Speicher(Q_th_load)|flow_rate', **heatmap_kwargs) results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) - results['Speicher'].plot_charge_state(engine=plotting_engine) + + # Matplotlib doesn't support faceting/animation for plot_charge_state, and 'area' mode + charge_state_kwargs = {'engine': plotting_engine} + if plotting_engine == 'matplotlib': + charge_state_kwargs['facet_by'] = None + charge_state_kwargs['animate_by'] = None + charge_state_kwargs['mode'] = 'stacked_bar' # 'area' not supported by matplotlib + results['Speicher'].plot_charge_state(**charge_state_kwargs) plt.close('all') From f156d3ae84325f9e1f459c902041382bd419e19f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:03:55 +0200 Subject: [PATCH 16/36] Remove amount of new tests --- tests/test_facet_plotting.py | 899 ---------------------------------- tests/test_select_features.py | 231 --------- 2 files changed, 1130 deletions(-) delete mode 100644 tests/test_facet_plotting.py delete mode 100644 tests/test_select_features.py diff --git a/tests/test_facet_plotting.py b/tests/test_facet_plotting.py deleted file mode 100644 index 7ec06d093..000000000 --- a/tests/test_facet_plotting.py +++ /dev/null @@ -1,899 +0,0 @@ -""" -Comprehensive test script demonstrating facet plotting and animation functionality. - -This script shows how to use the new facet_by and animate_by parameters -to create multidimensional plots with scenarios and periods. - -Examples 1-13: Core facet plotting features with synthetic data -Example 14: Manual approach showing how plot_charge_state() works internally -Example 15: Real flixOpt integration using plot_charge_state() method - -All figures are collected in the `all_figures` list for easy access. -""" - -import numpy as np -import pandas as pd -import xarray as xr - -from flixopt import plotting - -# List to store all generated figures for easy access -all_figures = [] - - -def create_and_save_figure(example_num, description, plot_func, *args, **kwargs): - """Helper function to reduce duplication in creating and saving figures.""" - suffix = kwargs.pop('suffix', '') - filename = f'/tmp/facet_example_{example_num}{suffix}.html' - - print('=' * 70) - print(f'Example {example_num}: {description}') - print('=' * 70) - - try: - fig = plot_func(*args, **kwargs) - fig.write_html(filename) - all_figures.append((f'Example {example_num}: {description}', fig)) - print(f'✓ Created: {filename}') - return fig - except Exception as e: - print(f'✗ Error in Example {example_num}: {e}') - import traceback - - traceback.print_exc() - return None - - -# Create synthetic multidimensional data for demonstration -# Dimensions: time, scenario, period -print('Creating synthetic multidimensional data...') - -# Time dimension -time = pd.date_range('2024-01-01', periods=24 * 7, freq='h', name='time') - -# Scenario dimension -scenarios = ['base', 'high_demand', 'renewable_focus', 'storage_heavy'] - -# Period dimension (e.g., different years or investment periods) -periods = [2024, 2030, 2040] - -# Create sample data -np.random.seed(42) - -# Create variables that will be plotted -variables = ['Solar', 'Wind', 'Gas', 'Battery_discharge', 'Battery_charge'] - -data_vars = {} -for var in variables: - # Create different patterns for each variable - base_pattern = np.sin(np.arange(len(time)) * 2 * np.pi / 24) * 50 + 100 - - # Add scenario and period variations - data = np.zeros((len(time), len(scenarios), len(periods))) - - for s_idx, _ in enumerate(scenarios): - for p_idx, period in enumerate(periods): - # Add scenario-specific variation - scenario_factor = 1.0 + s_idx * 0.3 - # Add period-specific growth - period_factor = 1.0 + (period - 2024) / 20 * 0.5 - # Add some randomness - noise = np.random.normal(0, 10, len(time)) - - data[:, s_idx, p_idx] = base_pattern * scenario_factor * period_factor + noise - - # Make battery charge negative for visualization - if 'charge' in var.lower(): - data[:, s_idx, p_idx] = -np.abs(data[:, s_idx, p_idx]) - - data_vars[var] = (['time', 'scenario', 'period'], data) - -# Create xarray Dataset -ds = xr.Dataset( - data_vars, - coords={ - 'time': time, - 'scenario': scenarios, - 'period': periods, - }, -) - -print(f'Dataset shape: {ds.dims}') -print(f'Variables: {list(ds.data_vars)}') -print(f'Coordinates: {list(ds.coords)}') -print() - -# ============================================================================ -# Example 1: Simple faceting by scenario -# ============================================================================ -print('=' * 70) -print('Example 1: Faceting by scenario (4 subplots)') -print('=' * 70) - -# Filter to just one period for simplicity -ds_filtered = ds.sel(period=2024) - -try: - fig1 = plotting.with_plotly( - ds_filtered, - facet_by='scenario', - mode='area', - colors='portland', - title='Energy Mix by Scenario (2024)', - ylabel='Power (MW)', - xlabel='Time', - facet_cols=2, # 2x2 grid - ) - fig1.write_html('/tmp/facet_example_1_scenarios.html') - all_figures.append(('Example 1: Faceting by scenario', fig1)) - print('✓ Created: /tmp/facet_example_1_scenarios.html') - print(' 4 subplots showing different scenarios') - fig1.show() -except Exception as e: - print(f'✗ Error in Example 1: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 2: Animation by period -# ============================================================================ -print('=' * 70) -print('Example 2: Animation by period') -print('=' * 70) - -# Filter to just one scenario -ds_filtered2 = ds.sel(scenario='base') - -try: - fig2 = plotting.with_plotly( - ds_filtered2, - animate_by='period', - mode='area', - colors='viridis', - title='Energy Mix Evolution Over Time (Base Scenario)', - ylabel='Power (MW)', - xlabel='Time', - ) - fig2.write_html('/tmp/facet_example_2_animation.html') - all_figures.append(('Example 2: Animation by period', fig2)) - print('✓ Created: /tmp/facet_example_2_animation.html') - print(' Animation cycling through periods: 2024, 2030, 2040') -except Exception as e: - print(f'✗ Error in Example 2: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 3: Combined faceting and animation -# ============================================================================ -print('=' * 70) -print('Example 3: Facet by scenario AND animate by period') -print('=' * 70) - -try: - fig3 = plotting.with_plotly( - ds, - facet_by='scenario', - animate_by='period', - mode='stacked_bar', - colors='portland', - title='Energy Mix: Scenarios vs. Periods', - ylabel='Power (MW)', - xlabel='Time', - facet_cols=2, - # height_per_row now auto-sizes intelligently! - ) - fig3.write_html('/tmp/facet_example_3_combined.html') - all_figures.append(('Example 3: Facet + animation combined', fig3)) - print('✓ Created: /tmp/facet_example_3_combined.html') - print(' 4 subplots (scenarios) with animation through 3 periods') - print(' Using intelligent auto-sizing (2 rows = 900px)') -except Exception as e: - print(f'✗ Error in Example 3: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 4: 2D faceting (scenario x period grid) -# ============================================================================ -print('=' * 70) -print('Example 4: 2D faceting (scenario x period)') -print('=' * 70) - -# Take just one week of data for clearer visualization -ds_week = ds.isel(time=slice(0, 24 * 7)) - -try: - fig4 = plotting.with_plotly( - ds_week, - facet_by=['scenario', 'period'], - mode='line', - colors='viridis', - title='Energy Mix: Full Grid (Scenario x Period)', - ylabel='Power (MW)', - xlabel='Time (one week)', - facet_cols=3, # 3 columns for 3 periods - ) - fig4.write_html('/tmp/facet_example_4_2d_grid.html') - all_figures.append(('Example 4: 2D faceting grid', fig4)) - print('✓ Created: /tmp/facet_example_4_2d_grid.html') - print(' 12 subplots (4 scenarios × 3 periods)') -except Exception as e: - print(f'✗ Error in Example 4: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 5: Area mode with positive AND negative values (faceted) -# ============================================================================ -print('=' * 70) -print('Example 5: Area mode with positive AND negative values') -print('=' * 70) - -# Create data with both positive and negative values for testing -print('Creating data with charging (negative) and discharging (positive)...') - -try: - fig5 = plotting.with_plotly( - ds.sel(period=2024), - facet_by='scenario', - mode='area', - colors='portland', - title='Energy Balance with Charging/Discharging (Area Mode)', - ylabel='Power (MW)', - xlabel='Time', - facet_cols=2, - ) - fig5.write_html('/tmp/facet_example_5_area_pos_neg.html') - all_figures.append(('Example 5: Area with pos/neg values', fig5)) - print('✓ Created: /tmp/facet_example_5_area_pos_neg.html') - print(' Area plot with both positive and negative values') - print(' Negative values (battery charge) should stack downwards') - print(' Positive values should stack upwards') -except Exception as e: - print(f'✗ Error in Example 5: {e}') - import traceback - - traceback.print_exc() - -# ============================================================================ -# Example 6: Stacked bar mode with animation -# ============================================================================ -print('=' * 70) -print('Example 6: Stacked bar mode with animation') -print('=' * 70) - -# Use hourly data for a few days for clearer stacked bars -ds_daily = ds.isel(time=slice(0, 24 * 3)) # 3 days - -try: - fig6 = plotting.with_plotly( - ds_daily.sel(scenario='base'), - animate_by='period', - mode='stacked_bar', - colors='portland', - title='Daily Energy Profile Evolution (Stacked Bars)', - ylabel='Power (MW)', - xlabel='Time', - ) - fig6.write_html('/tmp/facet_example_6_stacked_bar_anim.html') - all_figures.append(('Example 6: Stacked bar with animation', fig6)) - print('✓ Created: /tmp/facet_example_6_stacked_bar_anim.html') - print(' Stacked bar chart with period animation') -except Exception as e: - print(f'✗ Error in Example 6: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 7: Large facet grid (test auto-sizing) -# ============================================================================ -print('=' * 70) -print('Example 7: Large facet grid with auto-sizing') -print('=' * 70) - -try: - # Create more scenarios for a bigger grid - extended_scenarios = scenarios + ['distributed', 'centralized'] - ds_extended = ds.copy() - - # Add new scenario data - for var in variables: - # Get existing data - existing_data = ds[var].values - - # Create new scenarios with different patterns - new_data = np.zeros((len(time), 2, len(periods))) - for p_idx in range(len(periods)): - new_data[:, 0, p_idx] = existing_data[:, 0, p_idx] * 0.8 # distributed - new_data[:, 1, p_idx] = existing_data[:, 1, p_idx] * 1.2 # centralized - - # Combine old and new - combined_data = np.concatenate([existing_data, new_data], axis=1) - ds_extended[var] = (['time', 'scenario', 'period'], combined_data) - - ds_extended = ds_extended.assign_coords(scenario=extended_scenarios) - - fig7 = plotting.with_plotly( - ds_extended.sel(period=2030), - facet_by='scenario', - mode='area', - colors='viridis', - title='Large Grid: 6 Scenarios Comparison', - ylabel='Power (MW)', - xlabel='Time', - facet_cols=3, # 3 columns, 2 rows - ) - fig7.write_html('/tmp/facet_example_7_large_grid.html') - all_figures.append(('Example 7: Large grid (6 scenarios)', fig7)) - print('✓ Created: /tmp/facet_example_7_large_grid.html') - print(' 6 subplots (2x3 grid) with auto-sizing') -except Exception as e: - print(f'✗ Error in Example 7: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 8: Line mode with faceting (for clearer trend comparison) -# ============================================================================ -print('=' * 70) -print('Example 8: Line mode with faceting') -print('=' * 70) - -# Take shorter time window for clearer line plots -ds_short = ds.isel(time=slice(0, 48)) # 2 days - -try: - fig8 = plotting.with_plotly( - ds_short.sel(period=2024), - facet_by='scenario', - mode='line', - colors='tab10', - title='48-Hour Energy Generation Profiles', - ylabel='Power (MW)', - xlabel='Time', - facet_cols=2, - ) - fig8.write_html('/tmp/facet_example_8_line_facets.html') - all_figures.append(('Example 8: Line mode with faceting', fig8)) - print('✓ Created: /tmp/facet_example_8_line_facets.html') - print(' Line plots for comparing detailed trends across scenarios') -except Exception as e: - print(f'✗ Error in Example 8: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 9: Single variable across scenarios (using select parameter) -# ============================================================================ -print('=' * 70) -print('Example 9: Single variable faceted by scenario') -print('=' * 70) - -try: - # Select only Solar data - ds_solar_only = ds[['Solar']] - - fig9 = plotting.with_plotly( - ds_solar_only.sel(period=2030), - facet_by='scenario', - mode='area', - colors='YlOrRd', - title='Solar Generation Across Scenarios (2030)', - ylabel='Solar Power (MW)', - xlabel='Time', - facet_cols=4, # Single row - ) - fig9.write_html('/tmp/facet_example_9_single_var.html') - all_figures.append(('Example 9: Single variable faceting', fig9)) - print('✓ Created: /tmp/facet_example_9_single_var.html') - print(' Single variable (Solar) across 4 scenarios') -except Exception as e: - print(f'✗ Error in Example 9: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 10: Comparison plot - Different color schemes -# ============================================================================ -print('=' * 70) -print('Example 10: Testing different color schemes') -print('=' * 70) - -color_schemes = ['portland', 'viridis', 'plasma', 'turbo'] -ds_sample = ds.isel(time=slice(0, 72)).sel(period=2024) # 3 days - -for i, color_scheme in enumerate(color_schemes): - try: - scenario_to_plot = scenarios[i % len(scenarios)] - fig = plotting.with_plotly( - ds_sample.sel(scenario=scenario_to_plot), - mode='area', - colors=color_scheme, - title=f'Color Scheme: {color_scheme.upper()} ({scenario_to_plot})', - ylabel='Power (MW)', - xlabel='Time', - ) - fig.write_html(f'/tmp/facet_example_10_{color_scheme}.html') - all_figures.append((f'Example 10: Color scheme {color_scheme}', fig)) - print(f'✓ Created: /tmp/facet_example_10_{color_scheme}.html') - except Exception as e: - print(f'✗ Error with {color_scheme}: {e}') - -print() - -# ============================================================================ -# Example 11: Mixed positive/negative with 2D faceting -# ============================================================================ -print('=' * 70) -print('Example 11: 2D faceting with positive/negative values') -print('=' * 70) - -# Create subset with just 2 scenarios and 2 periods for clearer visualization -ds_mixed = ds.sel(scenario=['base', 'high_demand'], period=[2024, 2040]) -ds_mixed_short = ds_mixed.isel(time=slice(0, 48)) - -try: - fig11 = plotting.with_plotly( - ds_mixed_short, - facet_by=['scenario', 'period'], - mode='area', - colors='portland', - title='Energy Balance Grid: Scenarios × Periods', - ylabel='Power (MW)', - xlabel='Time (48h)', - facet_cols=2, - ) - fig11.write_html('/tmp/facet_example_11_2d_mixed.html') - all_figures.append(('Example 11: 2D faceting with mixed values', fig11)) - print('✓ Created: /tmp/facet_example_11_2d_mixed.html') - print(' 2x2 grid showing charging/discharging across scenarios and periods') -except Exception as e: - print(f'✗ Error in Example 11: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 12: Animation with custom frame duration -# ============================================================================ -print('=' * 70) -print('Example 12: Animation settings test') -print('=' * 70) - -try: - fig12 = plotting.with_plotly( - ds.sel(scenario='renewable_focus'), - animate_by='period', - mode='stacked_bar', - colors='greens', - title='Renewable Focus Scenario: Temporal Evolution', - ylabel='Power (MW)', - xlabel='Time', - ) - # Adjust animation speed (if the API supports it) - if hasattr(fig12, 'layout') and hasattr(fig12.layout, 'updatemenus'): - for menu in fig12.layout.updatemenus: - if 'buttons' in menu: - for button in menu.buttons: - if 'args' in button and len(button.args) > 1: - if isinstance(button.args[1], dict) and 'frame' in button.args[1]: - button.args[1]['frame']['duration'] = 1000 # 1 second per frame - - fig12.write_html('/tmp/facet_example_12_animation_settings.html') - all_figures.append(('Example 12: Animation with custom settings', fig12)) - print('✓ Created: /tmp/facet_example_12_animation_settings.html') - print(' Animation with custom frame duration settings') -except Exception as e: - print(f'✗ Error in Example 12: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 13: Edge case - Single facet value (should work like normal plot) -# ============================================================================ -print('=' * 70) -print('Example 13: Edge case - faceting with single value') -print('=' * 70) - -try: - ds_single = ds.sel(scenario='base', period=2024) - - fig13 = plotting.with_plotly( - ds_single, - mode='area', - colors='portland', - title='Single Plot (No Real Faceting)', - ylabel='Power (MW)', - xlabel='Time', - ) - fig13.write_html('/tmp/facet_example_13_single_facet.html') - all_figures.append(('Example 13: Edge case - single facet', fig13)) - print('✓ Created: /tmp/facet_example_13_single_facet.html') - print(' Should create normal plot when no facet dimension exists') -except Exception as e: - print(f'✗ Error in Example 13: {e}') - import traceback - - traceback.print_exc() - -# ============================================================================ -# Example 14: Manual charge state plotting (mimicking plot_charge_state) -# ============================================================================ -print('=' * 70) -print('Example 14: Manual charge state approach (synthetic data)') -print('=' * 70) - -try: - print('Demonstrating what plot_charge_state() does under the hood...') - print() - - # Step 1: Create "node balance" data (flows in/out) - using existing ds - print(' Step 1: Using existing node_balance-like data (flows)...') - node_balance_ds = ds.copy() # This represents flows like charging/discharging - print(f' node_balance shape: {dict(node_balance_ds.dims)}') - print(f' Variables: {list(node_balance_ds.data_vars.keys())}') - - # Step 2: Create synthetic charge state data - print(' Step 2: Creating synthetic charge_state data...') - # Charge state should be cumulative and vary by scenario/period - charge_state_data = np.zeros((len(time), len(scenarios), len(periods))) - - for s_idx, _ in enumerate(scenarios): - for p_idx, period in enumerate(periods): - # Create a charge state pattern that varies over time - # Start at 50%, oscillate based on random charging/discharging - base_charge = 50 # 50 MWh base - scenario_factor = 1.0 + s_idx * 0.2 - period_factor = 1.0 + (period - 2024) / 20 - - # Simple cumulative pattern with bounds - charge_pattern = base_charge * scenario_factor * period_factor - oscillation = 20 * np.sin(np.arange(len(time)) * 2 * np.pi / 24) - noise = np.random.normal(0, 5, len(time)) - - charge_state_data[:, s_idx, p_idx] = np.clip( - charge_pattern + oscillation + noise, 0, 100 * scenario_factor * period_factor - ) - - charge_state_da = xr.DataArray( - charge_state_data, - dims=['time', 'scenario', 'period'], - coords={'time': time, 'scenario': scenarios, 'period': periods}, - name='ChargeState', - ) - print(f' charge_state shape: {dict(charge_state_da.dims)}') - - # Step 3: Combine them into a single dataset (this is what plot_charge_state does!) - print(' Step 3: Combining flows and charge_state into one Dataset...') - combined_ds = node_balance_ds.copy() - combined_ds['ChargeState'] = charge_state_da - print(f' Variables in combined dataset: {list(combined_ds.data_vars.keys())}') - - # Step 4: Plot without faceting (single scenario/period) - print(' Step 4a: Plotting single scenario/period...') - selected_ds = combined_ds.sel(scenario='base', period=2024) - fig14a = plotting.with_plotly( - selected_ds, - mode='area', - colors='portland', - title='Storage Operation: Flows + Charge State (Base, 2024)', - ylabel='Power (MW) / Charge State (MWh)', - xlabel='Time', - ) - fig14a.write_html('/tmp/facet_example_14a_manual_single.html') - all_figures.append(('Example 14a: Manual approach - single', fig14a)) - print(' ✓ Created: /tmp/facet_example_14a_manual_single.html') - - # Step 5: Plot WITH faceting by scenario - print(' Step 4b: Plotting with faceting by scenario...') - selected_ds_scenarios = combined_ds.sel(period=2024) - fig14b = plotting.with_plotly( - selected_ds_scenarios, - facet_by='scenario', - mode='area', - colors='viridis', - title='Storage Operation with Faceting (2024)', - ylabel='Power (MW) / Charge State (MWh)', - xlabel='Time', - facet_cols=2, - ) - fig14b.write_html('/tmp/facet_example_14b_manual_faceted.html') - all_figures.append(('Example 14b: Manual with faceting', fig14b)) - print(' ✓ Created: /tmp/facet_example_14b_manual_faceted.html') - - # Step 6: Plot with 2D faceting - print(' Step 4c: Plotting with 2D faceting (scenario × period)...') - # Use shorter time window for clearer visualization - combined_ds_short = combined_ds.isel(time=slice(0, 48)) - fig14c = plotting.with_plotly( - combined_ds_short, - facet_by=['scenario', 'period'], - mode='line', - colors='tab10', - title='Storage Operation: 2D Grid (48 hours)', - ylabel='Power (MW) / Charge State (MWh)', - xlabel='Time', - facet_cols=3, - ) - fig14c.write_html('/tmp/facet_example_14c_manual_2d.html') - all_figures.append(('Example 14c: Manual with 2D faceting', fig14c)) - print(' ✓ Created: /tmp/facet_example_14c_manual_2d.html') - - print() - print(' ✓ Manual approach examples completed!') - print() - print(' KEY INSIGHT - This is what plot_charge_state() does:') - print(' 1. Get node_balance data (flows in/out)') - print(' 2. Get charge_state data (storage level)') - print(' 3. Combine them: combined_ds["ChargeState"] = charge_state') - print(' 4. Apply selection: combined_ds.sel(scenario=..., period=...)') - print(' 5. Plot with: plotting.with_plotly(combined_ds, facet_by=...)') - -except Exception as e: - print(f'✗ Error in Example 14: {e}') - import traceback - - traceback.print_exc() - -print() - -# ============================================================================ -# Example 15: Real flixOpt integration - plot_charge_state with faceting -# ============================================================================ -print('=' * 70) -print('Example 15: plot_charge_state() with facet_by and animate_by') -print('=' * 70) - -try: - from datetime import datetime - - import flixopt as fx - - # Create a simple flow system with storage for each scenario and period - print('Building flow system with storage component...') - - # Time steps for a short period - time_steps = pd.date_range('2024-01-01', periods=48, freq='h', name='time') - - # Create flow system with scenario and period dimensions - flow_system = fx.FlowSystem(time_steps, scenarios=scenarios, periods=periods, time_unit='h') - - # Create buses - electricity_bus = fx.Bus('Electricity', 'Electricity') - - # Create effects (costs) - costs = fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True) - - # Create source (power plant) - using xr.DataArray for multi-dimensional inputs - generation_profile = xr.DataArray( - np.random.uniform(50, 150, (len(time_steps), len(scenarios), len(periods))), - dims=['time', 'scenario', 'period'], - coords={'time': time_steps, 'scenario': scenarios, 'period': periods}, - ) - - power_plant = fx.Source( - 'PowerPlant', - fx.Flow( - 'PowerGeneration', - bus=electricity_bus, - size=200, - relative_maximum=generation_profile / 200, # Normalized profile - effects_per_flow_hour={costs: 30}, - ), - ) - - # Create demand - also multi-dimensional - demand_profile = xr.DataArray( - np.random.uniform(60, 140, (len(time_steps), len(scenarios), len(periods))), - dims=['time', 'scenario', 'period'], - coords={'time': time_steps, 'scenario': scenarios, 'period': periods}, - ) - - demand = fx.Sink( - 'Demand', - fx.Flow('PowerDemand', bus=electricity_bus, size=demand_profile), - ) - - # Create storage with multi-dimensional capacity - storage_capacity = xr.DataArray( - [[100, 120, 150], [120, 150, 180], [110, 130, 160], [90, 110, 140]], - dims=['scenario', 'period'], - coords={'scenario': scenarios, 'period': periods}, - ) - - battery = fx.Storage( - 'Battery', - charging=fx.Flow( - 'Charging', - bus=electricity_bus, - size=50, - effects_per_flow_hour={costs: 5}, # Small charging cost - ), - discharging=fx.Flow( - 'Discharging', - bus=electricity_bus, - size=50, - effects_per_flow_hour={costs: 0}, - ), - capacity_in_flow_hours=storage_capacity, - initial_charge_state=0.5, # Start at 50% - eta_charge=0.95, - eta_discharge=0.95, - relative_loss_per_hour=0.001, # 0.1% loss per hour - ) - - # Add all elements to the flow system - flow_system.add_elements(electricity_bus, costs, power_plant, demand, battery) - - print('Running calculation...') - calculation = fx.FullCalculation( - 'FacetPlotTest', - flow_system, - 'highs', - ) - - # Solve the system - calculation.solve(save=False) - - print('✓ Calculation successful!') - print() - - # Now demonstrate plot_charge_state with faceting - print('Creating faceted charge state plots...') - - # Example 15a: Facet by scenario - print(' a) Faceting by scenario...') - fig15a = calculation.results['Battery'].plot_charge_state( - facet_by='scenario', - mode='area', - colors='blues', - select={'period': 2024}, - save='/tmp/facet_example_15a_charge_state_scenarios.html', - show=False, - ) - all_figures.append(('Example 15a: charge_state faceted by scenario', fig15a)) - print(' ✓ Created: /tmp/facet_example_15a_charge_state_scenarios.html') - - # Example 15b: Animate by period - print(' b) Animating by period...') - fig15b = calculation.results['Battery'].plot_charge_state( - animate_by='period', - mode='area', - colors='greens', - select={'scenario': 'base'}, - save='/tmp/facet_example_15b_charge_state_animation.html', - show=False, - ) - all_figures.append(('Example 15b: charge_state animated by period', fig15b)) - print(' ✓ Created: /tmp/facet_example_15b_charge_state_animation.html') - - # Example 15c: Combined faceting and animation - print(' c) Faceting by scenario AND animating by period...') - fig15c = calculation.results['Battery'].plot_charge_state( - facet_by='scenario', - animate_by='period', - mode='area', - colors='portland', - facet_cols=2, - save='/tmp/facet_example_15c_charge_state_combined.html', - show=False, - ) - all_figures.append(('Example 15c: charge_state facet + animation', fig15c)) - print(' ✓ Created: /tmp/facet_example_15c_charge_state_combined.html') - print(' 4 subplots (scenarios) × 3 frames (periods)') - - # Example 15d: 2D faceting (scenario x period) - print(' d) 2D faceting (scenario × period grid)...') - fig15d = calculation.results['Battery'].plot_charge_state( - facet_by=['scenario', 'period'], - mode='line', - colors='viridis', - facet_cols=3, - save='/tmp/facet_example_15d_charge_state_2d.html', - show=False, - ) - all_figures.append(('Example 15d: charge_state 2D faceting', fig15d)) - print(' ✓ Created: /tmp/facet_example_15d_charge_state_2d.html') - print(' 12 subplots (4 scenarios × 3 periods)') - - print() - print('✓ All plot_charge_state examples completed successfully!') - -except ImportError as e: - print(f'✗ Skipping Example 15: flixopt not fully available ({e})') - print(' This example requires a full flixopt installation') -except Exception as e: - print(f'✗ Error in Example 15: {e}') - import traceback - - traceback.print_exc() - -print() -print('=' * 70) -print('All examples completed!') -print('=' * 70) -print() -print('Summary of examples:') -print(' 1. Simple faceting by scenario (4 subplots)') -print(' 2. Animation by period (3 frames)') -print(' 3. Combined faceting + animation (4 subplots × 3 frames)') -print(' 4. 2D faceting (12 subplots in grid)') -print(' 5. Area mode with pos/neg values') -print(' 6. Stacked bar mode with animation') -print(' 7. Large grid (6 scenarios)') -print(' 8. Line mode with faceting') -print(' 9. Single variable across scenarios') -print(' 10. Different color schemes comparison') -print(' 11. 2D faceting with mixed values') -print(' 12. Animation with custom settings') -print(' 13. Edge case - single facet value') -print(' 14. Manual charge state approach (synthetic data):') -print(' a) Single scenario/period plot') -print(' b) Faceting by scenario') -print(' c) 2D faceting (scenario × period)') -print(' Demonstrates combining flows + charge_state into one Dataset') -print(' 15. Real flixOpt integration (plot_charge_state):') -print(' a) plot_charge_state with faceting by scenario') -print(' b) plot_charge_state with animation by period') -print(' c) plot_charge_state with combined faceting + animation') -print(' d) plot_charge_state with 2D faceting (scenario × period)') -print() -print('=' * 70) -print(f'Generated {len(all_figures)} figures total') -print('=' * 70) -print() -print('To show all figures interactively:') -print('>>> for name, fig in all_figures:') -print('>>> print(name)') -print('>>> fig.show()') -print() -print('To show a specific figure by index:') -print('>>> all_figures[0][1].show() # Show first figure') -print('>>> all_figures[5][1].show() # Show sixth figure') -print() -print('To list all available figures:') -print('>>> for i, (name, _) in enumerate(all_figures):') -print('>>> print(f"{i}: {name}")') -print() -print('Next steps for testing with real flixopt data:') -print('1. Load your CalculationResults with scenario/period dimensions') -print("2. Use results['Component'].plot_node_balance(facet_by='scenario')") -print("3. Try animate_by='period' for time evolution visualization") -print("4. Combine both: facet_by='scenario', animate_by='period'") -print() -print('=' * 70) -print('Quick access: all_figures list is ready to use!') -print('=' * 70) - -for _, fig in all_figures: - fig.show() diff --git a/tests/test_select_features.py b/tests/test_select_features.py deleted file mode 100644 index d5e7df7ac..000000000 --- a/tests/test_select_features.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Comprehensive test file demonstrating the select parameter capabilities. - -This file tests various plotting methods and shows what's possible with the new 'select' parameter. -""" - -import warnings - -import plotly.io as pio -import pytest - -import flixopt as fx - -# Set default renderer to json for tests (safe for headless CI) -pio.renderers.default = 'json' - - -@pytest.fixture(scope='module') -def results(): - """Load results once for all tests.""" - return fx.results.CalculationResults.from_file('tests/ressources/', 'Sim1') - - -@pytest.fixture(scope='module') -def scenarios(results): - """Get available scenarios.""" - return results.solution.scenario.values.tolist() - - -@pytest.fixture(scope='module') -def periods(results): - """Get available periods.""" - return results.solution.period.values.tolist() - - -class TestBasicSelection: - """Test basic single-value selection.""" - - def test_plot_node_balance_single_scenario(self, results, scenarios): - """Test plot_node_balance with single scenario.""" - results['Fernwärme'].plot_node_balance(select={'scenario': scenarios[0]}, show=False, save=False) - - def test_node_balance_method_single_scenario(self, results, scenarios): - """Test node_balance method with single scenario.""" - ds = results['Fernwärme'].node_balance(select={'scenario': scenarios[0]}) - assert 'time' in ds.dims - assert 'period' in ds.dims - - -class TestMultiValueSelection: - """Test selection with multiple values (lists).""" - - def test_plot_with_multiple_scenarios(self, results, scenarios): - """Test plot_node_balance with multiple scenarios + faceting.""" - if len(scenarios) < 2: - pytest.skip('Not enough scenarios in dataset') - - results['Fernwärme'].plot_node_balance( - select={'scenario': scenarios}, facet_by='scenario', animate_by=None, show=False, save=False - ) - - def test_plot_with_scenario_subset(self, results, scenarios): - """Test with partial list selection.""" - if len(scenarios) < 2: - pytest.skip('Not enough scenarios in dataset') - - selected = scenarios[:2] - results['Fernwärme'].plot_node_balance( - select={'scenario': selected}, facet_by='scenario', show=False, save=False - ) - - -class TestIndexBasedSelection: - """Test selection using index positions.""" - - def test_integer_index_selection(self, results): - """Test with integer index (should fail with current xarray behavior).""" - with pytest.raises(KeyError, match='not all values found'): - results['Fernwärme'].plot_node_balance(select={'scenario': 0}, show=False, save=False) - - def test_list_of_indices_selection(self, results): - """Test with multiple indices (should fail with current xarray behavior).""" - with pytest.raises(KeyError, match='not all values found'): - results['Fernwärme'].plot_node_balance( - select={'scenario': [0, 1]}, facet_by='scenario', show=False, save=False - ) - - -class TestCombinedSelection: - """Test combining multiple dimension selections.""" - - def test_select_scenario_and_period(self, results, scenarios, periods): - """Test selecting both scenario and period.""" - ds = results['Fernwärme'].node_balance(select={'scenario': scenarios[0], 'period': periods[0]}) - assert 'time' in ds.dims - # scenario and period should be dropped after selection - assert 'scenario' not in ds.dims - assert 'period' not in ds.dims - - def test_scenario_list_period_single(self, results, scenarios, periods): - """Test with one dimension as list, another as single value.""" - results['Fernwärme'].plot_node_balance( - select={'scenario': scenarios, 'period': periods[0]}, facet_by='scenario', show=False, save=False - ) - - -class TestFacetingAndAnimation: - """Test combining select with faceting and animation.""" - - def test_select_scenario_facet_by_period(self, results, scenarios): - """Test: Select specific scenarios, then facet by period.""" - results['Fernwärme'].plot_node_balance( - select={'scenario': scenarios[0]}, facet_by='period', animate_by=None, show=False, save=False - ) - - def test_facet_and_animate(self, results, periods): - """Test: Facet by scenario, animate by period.""" - if len(periods) <= 1: - pytest.skip('Only one period available') - - results['Fernwärme'].plot_node_balance( - select={}, # No filtering - use all data - facet_by='scenario', - animate_by='period', - show=False, - save=False, - ) - - -class TestDifferentPlottingMethods: - """Test select parameter across different plotting methods.""" - - def test_plot_node_balance(self, results, scenarios): - """Test plot_node_balance.""" - results['Fernwärme'].plot_node_balance(select={'scenario': scenarios[0]}, mode='area', show=False, save=False) - - def test_plot_heatmap(self, results, scenarios): - """Test plot_heatmap with the new imshow implementation.""" - var_names = list(results.solution.data_vars) - if not var_names: - pytest.skip('No variables found') - - # Find a variable with time dimension for proper heatmap - var_name = None - for name in var_names: - if 'time' in results.solution[name].dims: - var_name = name - break - - if var_name is None: - pytest.skip('No time-series variables found for heatmap test') - - # Test that the new heatmap implementation works - results.plot_heatmap(var_name, select={'scenario': scenarios[0]}, show=False, save=False) - - def test_node_balance_data_retrieval(self, results, scenarios): - """Test node_balance (data retrieval).""" - ds = results['Fernwärme'].node_balance(select={'scenario': scenarios[0]}, unit_type='flow_hours') - assert 'time' in ds.dims or 'period' in ds.dims - - -class TestBackwardCompatibility: - """Test that old 'indexer' parameter still works with deprecation warning.""" - - def test_indexer_parameter_deprecated(self, results, scenarios): - """Test using deprecated 'indexer' parameter.""" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') - - results['Fernwärme'].plot_node_balance(indexer={'scenario': scenarios[0]}, show=False, save=False) - - # Check if deprecation warning was raised - deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] - assert len(deprecation_warnings) > 0 - assert 'indexer' in str(deprecation_warnings[0].message).lower() - - -class TestParameterPrecedence: - """Test that 'select' takes precedence over 'indexer'.""" - - def test_select_overrides_indexer(self, results, scenarios): - """Test that 'select' overrides 'indexer'.""" - if len(scenarios) < 2: - pytest.skip('Not enough scenarios') - - with warnings.catch_warnings(record=True): - warnings.simplefilter('always') - - ds = results['Fernwärme'].node_balance( - indexer={'scenario': scenarios[0]}, # This should be overridden - select={'scenario': scenarios[1]}, # This should win - ) - - # The scenario dimension should be dropped after selection - assert 'scenario' not in ds.dims or ds.scenario.values == scenarios[1] - - -class TestEmptyDictBehavior: - """Test behavior with empty selection dict.""" - - def test_empty_dict_no_filtering(self, results): - """Test using select={} (empty dict - no filtering).""" - results['Fernwärme'].plot_node_balance( - select={}, facet_by='scenario', animate_by='period', show=False, save=False - ) - - -class TestErrorHandling: - """Test error handling for invalid parameters.""" - - def test_unexpected_keyword_argument(self, results): - """Test unexpected kwargs are rejected.""" - with pytest.raises(TypeError, match='unexpected keyword argument'): - results['Fernwärme'].plot_node_balance(select={'scenario': 0}, unexpected_param='test', show=False) - - -# Keep the old main function for backward compatibility when run directly -def main(): - """Run tests when executed directly (non-pytest mode).""" - print('\n' + '#' * 70) - print('# SELECT PARAMETER TESTS') - print('#' * 70) - print('\nTo run with pytest, use:') - print(' pytest tests/test_select_features.py -v') - print('\nTo run specific test:') - print(' pytest tests/test_select_features.py::TestBasicSelection -v') - print('\n' + '#' * 70) - - -if __name__ == '__main__': - main() From ab9e4a8ee091a439c80e32fd19a2e4ca4d46dcfa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:18:19 +0200 Subject: [PATCH 17/36] Fix CONTRIBUTING.md --- .github/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2a51618d9..e9876c089 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -12,7 +12,7 @@ Thanks for your interest in contributing to FlixOpt! 🚀 2. **Install for Development** ```bash - pip install -e ".[full]" + pip install -e ".[full, dev, docs]" ``` 3. **Make Changes & Submit PR** From a77b94211459ab97e2523cf5d0f69be92568621a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:20:11 +0200 Subject: [PATCH 18/36] Remove old test file --- tests/test_plots.py | 162 -------------------------------------------- 1 file changed, 162 deletions(-) delete mode 100644 tests/test_plots.py diff --git a/tests/test_plots.py b/tests/test_plots.py deleted file mode 100644 index d901b9ce1..000000000 --- a/tests/test_plots.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -Manual test script for plots -""" - -import unittest - -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import pytest - -from flixopt import plotting - - -@pytest.mark.slow -class TestPlots(unittest.TestCase): - def setUp(self): - np.random.seed(72) - - def tearDown(self): - """Cleanup matplotlib and plotly resources""" - plt.close('all') - # Force garbage collection to cleanup any lingering resources - import gc - - gc.collect() - - @staticmethod - def get_sample_data( - nr_of_columns: int = 7, - nr_of_periods: int = 10, - time_steps_per_period: int = 24, - drop_fraction_of_indices: float | None = None, - only_pos_or_neg: bool = True, - column_prefix: str = '', - ): - columns = [f'Region {i + 1}{column_prefix}' for i in range(nr_of_columns)] # More realistic column labels - values_per_column = nr_of_periods * time_steps_per_period - if only_pos_or_neg: - positive_data = np.abs(np.random.rand(values_per_column, nr_of_columns) * 100) - negative_data = -np.abs(np.random.rand(values_per_column, nr_of_columns) * 100) - data = pd.DataFrame( - np.concatenate([positive_data, negative_data], axis=1), - columns=[f'Region {i + 1}' for i in range(nr_of_columns)] - + [f'Region {i + 1} Negative' for i in range(nr_of_columns)], - ) - else: - data = pd.DataFrame( - np.random.randn(values_per_column, nr_of_columns) * 50 + 20, columns=columns - ) # Random data with both positive and negative values - data.index = pd.date_range('2023-01-01', periods=values_per_column, freq='h') - - if drop_fraction_of_indices: - # Randomly drop a percentage of rows to create irregular intervals - drop_indices = np.random.choice(data.index, int(len(data) * drop_fraction_of_indices), replace=False) - data = data.drop(drop_indices) - return data - - def test_bar_plots(self): - data = self.get_sample_data(nr_of_columns=10, nr_of_periods=1, time_steps_per_period=24) - # Create plotly figure (json renderer doesn't need .show()) - _ = plotting.with_plotly(data, 'stacked_bar') - plotting.with_matplotlib(data, 'stacked_bar') - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - data = self.get_sample_data( - nr_of_columns=10, nr_of_periods=5, time_steps_per_period=24, drop_fraction_of_indices=0.3 - ) - # Create plotly figure (json renderer doesn't need .show()) - _ = plotting.with_plotly(data, 'stacked_bar') - plotting.with_matplotlib(data, 'stacked_bar') - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - def test_line_plots(self): - data = self.get_sample_data(nr_of_columns=10, nr_of_periods=1, time_steps_per_period=24) - _ = plotting.with_plotly(data, 'line') - plotting.with_matplotlib(data, 'line') - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - data = self.get_sample_data( - nr_of_columns=10, nr_of_periods=5, time_steps_per_period=24, drop_fraction_of_indices=0.3 - ) - _ = plotting.with_plotly(data, 'line') - plotting.with_matplotlib(data, 'line') - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - def test_stacked_line_plots(self): - data = self.get_sample_data(nr_of_columns=10, nr_of_periods=1, time_steps_per_period=24) - _ = plotting.with_plotly(data, 'area') - - data = self.get_sample_data( - nr_of_columns=10, nr_of_periods=5, time_steps_per_period=24, drop_fraction_of_indices=0.3 - ) - _ = plotting.with_plotly(data, 'area') - - def test_heat_map_plots(self): - # Generate single-column data with datetime index for heatmap - data = self.get_sample_data(nr_of_columns=1, nr_of_periods=10, time_steps_per_period=24, only_pos_or_neg=False) - - # Convert data for heatmap plotting using 'day' as period and 'hour' steps - heatmap_data = plotting.reshape_to_2d(data.iloc[:, 0].values.flatten(), 24) - # Convert to xarray DataArray for the new API - import xarray as xr - - heatmap_xr = xr.DataArray(heatmap_data, dims=['timestep', 'timeframe']) - # Plotting heatmaps with Plotly and Matplotlib using new API - _ = plotting.heatmap_with_plotly(heatmap_xr, reshape_time=None) - plotting.heatmap_with_matplotlib(heatmap_xr, reshape_time=None) - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - def test_heat_map_plots_resampling(self): - import xarray as xr - - date_range = pd.date_range(start='2023-01-01', end='2023-03-21', freq='5min') - - # Generate random data for the DataFrame, simulating some metric (e.g., energy consumption, temperature) - data = np.random.rand(len(date_range)) - - # Create the DataFrame with a datetime index - df = pd.DataFrame(data, index=date_range, columns=['value']) - - # Randomly drop a percentage of rows to create irregular intervals - drop_fraction = 0.3 # Fraction of data points to drop (30% in this case) - drop_indices = np.random.choice(df.index, int(len(df) * drop_fraction), replace=False) - df_irregular = df.drop(drop_indices) - - # Generate single-column data with datetime index for heatmap - data = df_irregular - # Convert DataFrame to xarray DataArray for the new API - data_xr = xr.DataArray(data['value'].values, dims=['time'], coords={'time': data.index.values}, name='value') - - # Test 1: Monthly timeframes, daily timesteps - heatmap_data = plotting.reshape_data_for_heatmap(data_xr, reshape_time=('MS', 'D')) - _ = plotting.heatmap_with_plotly(heatmap_data, reshape_time=None) - plotting.heatmap_with_matplotlib(heatmap_data, reshape_time=None) - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - # Test 2: Weekly timeframes, hourly timesteps with forward fill - heatmap_data = plotting.reshape_data_for_heatmap(data_xr, reshape_time=('W', 'h'), fill='ffill') - # Plotting heatmaps with Plotly and Matplotlib - _ = plotting.heatmap_with_plotly(heatmap_data, reshape_time=None) - plotting.heatmap_with_matplotlib(heatmap_data, reshape_time=None) - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - # Test 3: Daily timeframes, hourly timesteps with forward fill - heatmap_data = plotting.reshape_data_for_heatmap(data_xr, reshape_time=('D', 'h'), fill='ffill') - # Plotting heatmaps with Plotly and Matplotlib - _ = plotting.heatmap_with_plotly(heatmap_data, reshape_time=None) - plotting.heatmap_with_matplotlib(heatmap_data, reshape_time=None) - plt.savefig(f'test_plot_{self._testMethodName}.png', bbox_inches='tight') - plt.close('all') # Close all figures to prevent memory leaks - - -if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) From 18ba2715ef7c75d111f56a09a95d37c45c5dd908 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:25:22 +0200 Subject: [PATCH 19/36] Add tests/test_heatmap_reshape.py --- tests/test_heatmap_reshape.py | 213 ++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/test_heatmap_reshape.py diff --git a/tests/test_heatmap_reshape.py b/tests/test_heatmap_reshape.py new file mode 100644 index 000000000..a1ffbec68 --- /dev/null +++ b/tests/test_heatmap_reshape.py @@ -0,0 +1,213 @@ +"""Test reshape_data_for_heatmap() function.""" + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from flixopt.plotting import reshape_data_for_heatmap + + +@pytest.fixture +def regular_timeseries(): + """Create regular time series data (hourly for 3 days).""" + time = pd.date_range('2024-01-01', periods=72, freq='h', name='time') + data = np.random.rand(72) * 100 + return xr.DataArray(data, dims=['time'], coords={'time': time}, name='power') + + +@pytest.fixture +def irregular_timeseries(): + """Create irregular time series data with missing timestamps.""" + time = pd.date_range('2024-01-01', periods=240, freq='5min', name='time') + data = np.random.rand(240) * 100 + da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='temperature') + # Drop random 30% of data points to create irregularity + np.random.seed(42) + keep_indices = np.random.choice(240, int(240 * 0.7), replace=False) + keep_indices.sort() + return da.isel(time=keep_indices) + + +@pytest.fixture +def multidim_timeseries(): + """Create multi-dimensional time series (time × scenario × period).""" + time = pd.date_range('2024-01-01', periods=48, freq='h', name='time') + scenarios = ['base', 'high', 'low'] + periods = [2024, 2030] + data = np.random.rand(48, 3, 2) * 100 + return xr.DataArray( + data, + dims=['time', 'scenario', 'period'], + coords={'time': time, 'scenario': scenarios, 'period': periods}, + name='demand', + ) + + +class TestBasicReshaping: + """Test basic reshaping functionality.""" + + def test_daily_hourly_reshape(self, regular_timeseries): + """Test reshaping into days × hours.""" + result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) + + assert result.dims == ('timestep', 'timeframe') + assert result.sizes['timeframe'] == 3 # 3 days + assert result.sizes['timestep'] == 24 # 24 hours per day + assert result.name == 'power' + + def test_weekly_daily_reshape(self, regular_timeseries): + """Test reshaping into weeks × days.""" + result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('W', 'D')) + + assert result.dims == ('timestep', 'timeframe') + assert 'timeframe' in result.dims + assert 'timestep' in result.dims + + def test_monthly_daily_reshape(self): + """Test reshaping into months × days.""" + time = pd.date_range('2024-01-01', periods=90, freq='D', name='time') + data = np.random.rand(90) * 100 + da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='monthly_data') + + result = reshape_data_for_heatmap(da, reshape_time=('MS', 'D')) + + assert result.dims == ('timestep', 'timeframe') + assert result.sizes['timeframe'] == 3 # ~3 months + assert result.name == 'monthly_data' + + def test_no_reshape(self, regular_timeseries): + """Test that reshape_time=None returns data unchanged.""" + result = reshape_data_for_heatmap(regular_timeseries, reshape_time=None) + + # Should return the same data + xr.testing.assert_equal(result, regular_timeseries) + + +class TestFillMethods: + """Test different fill methods for irregular data.""" + + def test_forward_fill(self, irregular_timeseries): + """Test forward fill for missing values.""" + result = reshape_data_for_heatmap(irregular_timeseries, reshape_time=('D', 'h'), fill='ffill') + + assert result.dims == ('timestep', 'timeframe') + # Should have no NaN values with ffill (except possibly first values) + nan_count = np.isnan(result.values).sum() + total_count = result.values.size + assert nan_count < total_count * 0.1 # Less than 10% NaN + + def test_backward_fill(self, irregular_timeseries): + """Test backward fill for missing values.""" + result = reshape_data_for_heatmap(irregular_timeseries, reshape_time=('D', 'h'), fill='bfill') + + assert result.dims == ('timestep', 'timeframe') + # Should have no NaN values with bfill (except possibly last values) + nan_count = np.isnan(result.values).sum() + total_count = result.values.size + assert nan_count < total_count * 0.1 # Less than 10% NaN + + def test_no_fill(self, irregular_timeseries): + """Test that fill=None does not automatically fill missing values.""" + result = reshape_data_for_heatmap(irregular_timeseries, reshape_time=('D', 'h'), fill=None) + + assert result.dims == ('timestep', 'timeframe') + # Note: Whether NaN values appear depends on whether data covers full time range + # Just verify the function completes without error and returns correct dims + assert result.sizes['timestep'] >= 1 + assert result.sizes['timeframe'] >= 1 + + +class TestMultidimensionalData: + """Test handling of multi-dimensional data.""" + + def test_multidim_basic_reshape(self, multidim_timeseries): + """Test reshaping multi-dimensional data.""" + result = reshape_data_for_heatmap(multidim_timeseries, reshape_time=('D', 'h')) + + # Should preserve extra dimensions + assert 'timeframe' in result.dims + assert 'timestep' in result.dims + assert 'scenario' in result.dims + assert 'period' in result.dims + assert result.sizes['scenario'] == 3 + assert result.sizes['period'] == 2 + + def test_multidim_with_selection(self, multidim_timeseries): + """Test reshaping after selecting from multi-dimensional data.""" + # Select single scenario and period + selected = multidim_timeseries.sel(scenario='base', period=2024) + result = reshape_data_for_heatmap(selected, reshape_time=('D', 'h')) + + # Should only have timeframe and timestep dimensions + assert result.dims == ('timestep', 'timeframe') + assert 'scenario' not in result.dims + assert 'period' not in result.dims + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_single_timeframe(self): + """Test with data that fits in a single timeframe.""" + time = pd.date_range('2024-01-01', periods=12, freq='h', name='time') + data = np.random.rand(12) * 100 + da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='short_data') + + result = reshape_data_for_heatmap(da, reshape_time=('D', 'h')) + + assert result.dims == ('timestep', 'timeframe') + assert result.sizes['timeframe'] == 1 # Only 1 day + assert result.sizes['timestep'] == 12 # 12 hours + + def test_preserves_name(self, regular_timeseries): + """Test that the data name is preserved.""" + result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) + + assert result.name == regular_timeseries.name + + def test_different_frequencies(self): + """Test various time frequency combinations.""" + time = pd.date_range('2024-01-01', periods=168, freq='h', name='time') + data = np.random.rand(168) * 100 + da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='week_data') + + # Test week × hour + result = reshape_data_for_heatmap(da, reshape_time=('W', 'h')) + assert result.dims == ('timestep', 'timeframe') + + # Test week × day + result = reshape_data_for_heatmap(da, reshape_time=('W', 'D')) + assert result.dims == ('timestep', 'timeframe') + + +class TestDataIntegrity: + """Test that data values are preserved correctly.""" + + def test_values_preserved(self, regular_timeseries): + """Test that no data values are lost or corrupted.""" + result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) + + # Flatten and compare non-NaN values + original_values = regular_timeseries.values + reshaped_values = result.values.flatten() + + # All original values should be present (allowing for reordering) + # Compare sums as a simple integrity check + assert np.isclose(np.nansum(original_values), np.nansum(reshaped_values), rtol=1e-10) + + def test_coordinate_alignment(self, regular_timeseries): + """Test that time coordinates are properly aligned.""" + result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) + + # Check that coordinates exist + assert 'timeframe' in result.coords + assert 'timestep' in result.coords + + # Check coordinate sizes match dimensions + assert len(result.coords['timeframe']) == result.sizes['timeframe'] + assert len(result.coords['timestep']) == result.sizes['timestep'] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) From 894533c9fea50ca97b038325c1b2fab80914a691 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:29:27 +0200 Subject: [PATCH 20/36] Add tests/test_heatmap_reshape.py --- tests/test_heatmap_reshape.py | 222 +++++++--------------------------- 1 file changed, 44 insertions(+), 178 deletions(-) diff --git a/tests/test_heatmap_reshape.py b/tests/test_heatmap_reshape.py index a1ffbec68..41b00c5a2 100644 --- a/tests/test_heatmap_reshape.py +++ b/tests/test_heatmap_reshape.py @@ -1,4 +1,4 @@ -"""Test reshape_data_for_heatmap() function.""" +"""Test reshape_data_for_heatmap() for common use cases.""" import numpy as np import pandas as pd @@ -9,204 +9,70 @@ @pytest.fixture -def regular_timeseries(): - """Create regular time series data (hourly for 3 days).""" - time = pd.date_range('2024-01-01', periods=72, freq='h', name='time') - data = np.random.rand(72) * 100 +def hourly_week_data(): + """Typical use case: hourly data for a week.""" + time = pd.date_range('2024-01-01', periods=168, freq='h') + data = np.random.rand(168) * 100 return xr.DataArray(data, dims=['time'], coords={'time': time}, name='power') -@pytest.fixture -def irregular_timeseries(): - """Create irregular time series data with missing timestamps.""" - time = pd.date_range('2024-01-01', periods=240, freq='5min', name='time') - data = np.random.rand(240) * 100 - da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='temperature') - # Drop random 30% of data points to create irregularity - np.random.seed(42) - keep_indices = np.random.choice(240, int(240 * 0.7), replace=False) - keep_indices.sort() - return da.isel(time=keep_indices) - - -@pytest.fixture -def multidim_timeseries(): - """Create multi-dimensional time series (time × scenario × period).""" - time = pd.date_range('2024-01-01', periods=48, freq='h', name='time') - scenarios = ['base', 'high', 'low'] - periods = [2024, 2030] - data = np.random.rand(48, 3, 2) * 100 - return xr.DataArray( - data, - dims=['time', 'scenario', 'period'], - coords={'time': time, 'scenario': scenarios, 'period': periods}, - name='demand', - ) - - -class TestBasicReshaping: - """Test basic reshaping functionality.""" - - def test_daily_hourly_reshape(self, regular_timeseries): - """Test reshaping into days × hours.""" - result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) - - assert result.dims == ('timestep', 'timeframe') - assert result.sizes['timeframe'] == 3 # 3 days - assert result.sizes['timestep'] == 24 # 24 hours per day - assert result.name == 'power' - - def test_weekly_daily_reshape(self, regular_timeseries): - """Test reshaping into weeks × days.""" - result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('W', 'D')) - - assert result.dims == ('timestep', 'timeframe') - assert 'timeframe' in result.dims - assert 'timestep' in result.dims - - def test_monthly_daily_reshape(self): - """Test reshaping into months × days.""" - time = pd.date_range('2024-01-01', periods=90, freq='D', name='time') - data = np.random.rand(90) * 100 - da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='monthly_data') - - result = reshape_data_for_heatmap(da, reshape_time=('MS', 'D')) - - assert result.dims == ('timestep', 'timeframe') - assert result.sizes['timeframe'] == 3 # ~3 months - assert result.name == 'monthly_data' - - def test_no_reshape(self, regular_timeseries): - """Test that reshape_time=None returns data unchanged.""" - result = reshape_data_for_heatmap(regular_timeseries, reshape_time=None) - - # Should return the same data - xr.testing.assert_equal(result, regular_timeseries) - - -class TestFillMethods: - """Test different fill methods for irregular data.""" - - def test_forward_fill(self, irregular_timeseries): - """Test forward fill for missing values.""" - result = reshape_data_for_heatmap(irregular_timeseries, reshape_time=('D', 'h'), fill='ffill') - - assert result.dims == ('timestep', 'timeframe') - # Should have no NaN values with ffill (except possibly first values) - nan_count = np.isnan(result.values).sum() - total_count = result.values.size - assert nan_count < total_count * 0.1 # Less than 10% NaN - - def test_backward_fill(self, irregular_timeseries): - """Test backward fill for missing values.""" - result = reshape_data_for_heatmap(irregular_timeseries, reshape_time=('D', 'h'), fill='bfill') - - assert result.dims == ('timestep', 'timeframe') - # Should have no NaN values with bfill (except possibly last values) - nan_count = np.isnan(result.values).sum() - total_count = result.values.size - assert nan_count < total_count * 0.1 # Less than 10% NaN - - def test_no_fill(self, irregular_timeseries): - """Test that fill=None does not automatically fill missing values.""" - result = reshape_data_for_heatmap(irregular_timeseries, reshape_time=('D', 'h'), fill=None) - - assert result.dims == ('timestep', 'timeframe') - # Note: Whether NaN values appear depends on whether data covers full time range - # Just verify the function completes without error and returns correct dims - assert result.sizes['timestep'] >= 1 - assert result.sizes['timeframe'] >= 1 - - -class TestMultidimensionalData: - """Test handling of multi-dimensional data.""" - - def test_multidim_basic_reshape(self, multidim_timeseries): - """Test reshaping multi-dimensional data.""" - result = reshape_data_for_heatmap(multidim_timeseries, reshape_time=('D', 'h')) - - # Should preserve extra dimensions - assert 'timeframe' in result.dims - assert 'timestep' in result.dims - assert 'scenario' in result.dims - assert 'period' in result.dims - assert result.sizes['scenario'] == 3 - assert result.sizes['period'] == 2 +def test_daily_hourly_pattern(): + """Most common use case: reshape hourly data into days × hours for daily patterns.""" + time = pd.date_range('2024-01-01', periods=72, freq='h') + data = np.random.rand(72) * 100 + da = xr.DataArray(data, dims=['time'], coords={'time': time}) - def test_multidim_with_selection(self, multidim_timeseries): - """Test reshaping after selecting from multi-dimensional data.""" - # Select single scenario and period - selected = multidim_timeseries.sel(scenario='base', period=2024) - result = reshape_data_for_heatmap(selected, reshape_time=('D', 'h')) + result = reshape_data_for_heatmap(da, reshape_time=('D', 'h')) - # Should only have timeframe and timestep dimensions - assert result.dims == ('timestep', 'timeframe') - assert 'scenario' not in result.dims - assert 'period' not in result.dims + assert 'timeframe' in result.dims and 'timestep' in result.dims + assert result.sizes['timeframe'] == 3 # 3 days + assert result.sizes['timestep'] == 24 # 24 hours -class TestEdgeCases: - """Test edge cases and error handling.""" +def test_weekly_daily_pattern(hourly_week_data): + """Common use case: reshape hourly data into weeks × days.""" + result = reshape_data_for_heatmap(hourly_week_data, reshape_time=('W', 'D')) - def test_single_timeframe(self): - """Test with data that fits in a single timeframe.""" - time = pd.date_range('2024-01-01', periods=12, freq='h', name='time') - data = np.random.rand(12) * 100 - da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='short_data') + assert 'timeframe' in result.dims and 'timestep' in result.dims - result = reshape_data_for_heatmap(da, reshape_time=('D', 'h')) - assert result.dims == ('timestep', 'timeframe') - assert result.sizes['timeframe'] == 1 # Only 1 day - assert result.sizes['timestep'] == 12 # 12 hours +def test_with_irregular_data(): + """Real-world use case: data with missing timestamps needs filling.""" + time = pd.date_range('2024-01-01', periods=100, freq='15min') + data = np.random.rand(100) + # Randomly drop 30% to simulate real data gaps + keep = np.sort(np.random.choice(100, 70, replace=False)) # Must be sorted + da = xr.DataArray(data[keep], dims=['time'], coords={'time': time[keep]}) - def test_preserves_name(self, regular_timeseries): - """Test that the data name is preserved.""" - result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) + result = reshape_data_for_heatmap(da, reshape_time=('h', 'min'), fill='ffill') - assert result.name == regular_timeseries.name + assert 'timeframe' in result.dims and 'timestep' in result.dims + # Should handle irregular data without errors - def test_different_frequencies(self): - """Test various time frequency combinations.""" - time = pd.date_range('2024-01-01', periods=168, freq='h', name='time') - data = np.random.rand(168) * 100 - da = xr.DataArray(data, dims=['time'], coords={'time': time}, name='week_data') - # Test week × hour - result = reshape_data_for_heatmap(da, reshape_time=('W', 'h')) - assert result.dims == ('timestep', 'timeframe') +def test_multidimensional_scenarios(): + """Use case: data with scenarios/periods that need to be preserved.""" + time = pd.date_range('2024-01-01', periods=48, freq='h') + scenarios = ['base', 'high'] + data = np.random.rand(48, 2) * 100 - # Test week × day - result = reshape_data_for_heatmap(da, reshape_time=('W', 'D')) - assert result.dims == ('timestep', 'timeframe') + da = xr.DataArray(data, dims=['time', 'scenario'], coords={'time': time, 'scenario': scenarios}, name='demand') + result = reshape_data_for_heatmap(da, reshape_time=('D', 'h')) -class TestDataIntegrity: - """Test that data values are preserved correctly.""" + # Should preserve scenario dimension + assert 'scenario' in result.dims + assert result.sizes['scenario'] == 2 - def test_values_preserved(self, regular_timeseries): - """Test that no data values are lost or corrupted.""" - result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) - - # Flatten and compare non-NaN values - original_values = regular_timeseries.values - reshaped_values = result.values.flatten() - - # All original values should be present (allowing for reordering) - # Compare sums as a simple integrity check - assert np.isclose(np.nansum(original_values), np.nansum(reshaped_values), rtol=1e-10) - def test_coordinate_alignment(self, regular_timeseries): - """Test that time coordinates are properly aligned.""" - result = reshape_data_for_heatmap(regular_timeseries, reshape_time=('D', 'h')) +def test_no_reshape_returns_unchanged(): + """Use case: when reshape_time=None, return data as-is.""" + time = pd.date_range('2024-01-01', periods=24, freq='h') + da = xr.DataArray(np.random.rand(24), dims=['time'], coords={'time': time}) - # Check that coordinates exist - assert 'timeframe' in result.coords - assert 'timestep' in result.coords + result = reshape_data_for_heatmap(da, reshape_time=None) - # Check coordinate sizes match dimensions - assert len(result.coords['timeframe']) == result.sizes['timeframe'] - assert len(result.coords['timestep']) == result.sizes['timestep'] + xr.testing.assert_equal(result, da) if __name__ == '__main__': From 30ab7ec869f9a63a749584b126275212c094858c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:51:54 +0200 Subject: [PATCH 21/36] Remove unused method --- flixopt/plotting.py | 44 -------------------------------------------- 1 file changed, 44 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 7e954425b..c52bf4629 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -673,50 +673,6 @@ def with_matplotlib( return fig, ax -def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarray: - """ - Reshapes a 1D numpy array into a 2D array suitable for plotting as a colormap. - - The reshaped array will have the number of rows corresponding to the steps per column - (e.g., 24 hours per day) and columns representing time periods (e.g., days or months). - - Args: - data_1d: A 1D numpy array with the data to reshape. - nr_of_steps_per_column: The number of steps (rows) per column in the resulting 2D array. For example, - this could be 24 (for hours) or 31 (for days in a month). - - Returns: - The reshaped 2D array. Each internal array corresponds to one column, with the specified number of steps. - Each column might represents a time period (e.g., day, month, etc.). - """ - - # Step 1: Ensure the input is a 1D array. - if data_1d.ndim != 1: - raise ValueError('Input must be a 1D array') - - # Step 2: Convert data to float type to allow NaN padding - if data_1d.dtype != np.float64: - data_1d = data_1d.astype(np.float64) - - # Step 3: Calculate the number of columns required - total_steps = len(data_1d) - cols = len(data_1d) // nr_of_steps_per_column # Base number of columns - - # If there's a remainder, add an extra column to hold the remaining values - if total_steps % nr_of_steps_per_column != 0: - cols += 1 - - # Step 4: Pad the 1D data to match the required number of rows and columns - padded_data = np.pad( - data_1d, (0, cols * nr_of_steps_per_column - total_steps), mode='constant', constant_values=np.nan - ) - - # Step 5: Reshape the padded data into a 2D array - data_2d = padded_data.reshape(cols, nr_of_steps_per_column) - - return data_2d.T - - def reshape_data_for_heatmap( data: xr.DataArray, reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] From 4763c290b747f6f4d1b387e1af6d3901c6da0b78 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:55:56 +0200 Subject: [PATCH 22/36] - Implemented dashed line styling for "mixed" variables (variables with both positive and negative values) - Only stack "positive" and "negative" classifications, not "mixed" or "zero" --- flixopt/plotting.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index c52bf4629..390651074 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -554,12 +554,22 @@ def with_plotly( all_traces.extend(frame.data) for trace in all_traces: - trace.stackgroup = variable_classification.get(trace.name, None) - # No opacity and no line for stacked areas - if trace.stackgroup is not None: + cls = variable_classification.get(trace.name, None) + # Only stack positive and negative, not mixed or zero + trace.stackgroup = cls if cls in ('positive', 'negative') else None + + if cls in ('positive', 'negative'): + # Stacked area: add opacity to avoid hiding layers, remove line border if hasattr(trace, 'line') and trace.line.color: - trace.fillcolor = trace.line.color # Will be solid by default + trace.fillcolor = trace.line.color trace.line.width = 0 + elif cls == 'mixed': + # Mixed variables: show as dashed line, not stacked + if hasattr(trace, 'line'): + trace.line.width = 2 + trace.line.dash = 'dash' + if hasattr(trace, 'fill'): + trace.fill = None # Update layout with basic styling (Plotly Express handles sizing automatically) fig.update_layout( From e180e88e86d54c3db2d2fd5297695892247a5606 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:56:39 +0200 Subject: [PATCH 23/36] - Added fill parameter to module-level plot_heatmap function (line 1914) - Added fill parameter to CalculationResults.plot_heatmap method (line 702) - Forwarded fill parameter to both heatmap_with_plotly and heatmap_with_matplotlib functions --- flixopt/results.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index 2306c4838..b36b63814 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -699,6 +699,7 @@ def plot_heatmap( reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', + fill: Literal['ffill', 'bfill'] | None = 'ffill', # Deprecated parameters (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, @@ -735,6 +736,8 @@ def plot_heatmap( ('MS', 'D') for months vs days, ('W', 'h') for weeks vs hours - None: Disable auto-reshaping (will error if only 1D time data) Supported timeframes: 'YS', 'MS', 'W', 'D', 'h', '15min', 'min' + fill: Method to fill missing values after reshape: 'ffill' (forward fill) or 'bfill' (backward fill). + Default is 'ffill'. Examples: Direct imshow mode (default): @@ -790,6 +793,7 @@ def plot_heatmap( animate_by=animate_by, facet_cols=facet_cols, reshape_time=reshape_time, + fill=fill, indexer=indexer, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, @@ -1911,6 +1915,7 @@ def plot_heatmap( reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', + fill: Literal['ffill', 'bfill'] | None = 'ffill', # Deprecated parameters (kept for backwards compatibility) indexer: dict[str, Any] | None = None, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, @@ -1941,6 +1946,8 @@ def plot_heatmap( - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains - Tuple: Explicit reshaping, e.g. ('D', 'h') for days vs hours - None: Disable auto-reshaping + fill: Method to fill missing values after reshape: 'ffill' (forward fill) or 'bfill' (backward fill). + Default is 'ffill'. Examples: Single DataArray with time reshaping: @@ -2073,6 +2080,7 @@ def plot_heatmap( title=title, facet_cols=facet_cols, reshape_time=reshape_time, + fill=fill, ) default_filetype = '.html' elif engine == 'matplotlib': @@ -2081,6 +2089,7 @@ def plot_heatmap( colors=colors, title=title, reshape_time=reshape_time, + fill=fill, ) default_filetype = '.png' else: From 9c3c58017c280c8d80c0f26245617e8df7234530 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:57:05 +0200 Subject: [PATCH 24/36] =?UTF-8?q?=20=20-=20Added=20np.random.seed(42)=20fo?= =?UTF-8?q?r=20reproducible=20test=20results=20=20=20-=20Added=20specific?= =?UTF-8?q?=20size=20assertions=20to=20all=20tests:=20=20=20=20=20-=20Dail?= =?UTF-8?q?y/hourly=20pattern:=203=20days=20=C3=97=2024=20hours=20=20=20?= =?UTF-8?q?=20=20-=20Weekly/daily=20pattern:=201=20week=20=C3=97=207=20day?= =?UTF-8?q?s=20=20=20=20=20-=20Irregular=20data:=2025=20hours=20=C3=97=206?= =?UTF-8?q?0=20minutes=20=20=20=20=20-=20Multidimensional:=202=20days=20?= =?UTF-8?q?=C3=97=2024=20hours=20with=20preserved=20scenario=20dimension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_heatmap_reshape.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_heatmap_reshape.py b/tests/test_heatmap_reshape.py index 41b00c5a2..092adff4e 100644 --- a/tests/test_heatmap_reshape.py +++ b/tests/test_heatmap_reshape.py @@ -7,6 +7,9 @@ from flixopt.plotting import reshape_data_for_heatmap +# Set random seed for reproducible tests +np.random.seed(42) + @pytest.fixture def hourly_week_data(): @@ -34,6 +37,9 @@ def test_weekly_daily_pattern(hourly_week_data): result = reshape_data_for_heatmap(hourly_week_data, reshape_time=('W', 'D')) assert 'timeframe' in result.dims and 'timestep' in result.dims + # 168 hours = 7 days = 1 week + assert result.sizes['timeframe'] == 1 # 1 week + assert result.sizes['timestep'] == 7 # 7 days def test_with_irregular_data(): @@ -47,6 +53,9 @@ def test_with_irregular_data(): result = reshape_data_for_heatmap(da, reshape_time=('h', 'min'), fill='ffill') assert 'timeframe' in result.dims and 'timestep' in result.dims + # 100 * 15min = 1500min = 25h; reshaped to hours × minutes + assert result.sizes['timeframe'] == 25 # 25 hours + assert result.sizes['timestep'] == 60 # 60 minutes per hour # Should handle irregular data without errors @@ -63,6 +72,9 @@ def test_multidimensional_scenarios(): # Should preserve scenario dimension assert 'scenario' in result.dims assert result.sizes['scenario'] == 2 + # 48 hours = 2 days × 24 hours + assert result.sizes['timeframe'] == 2 # 2 days + assert result.sizes['timestep'] == 24 # 24 hours def test_no_reshape_returns_unchanged(): From 5938829243ba304bab0e11183bced43d6ed5c49e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:04:27 +0200 Subject: [PATCH 25/36] Improve Error Message if too many dims for matplotlib --- flixopt/results.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b36b63814..984bf8080 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1062,19 +1062,13 @@ def plot_node_balance( ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True) - # Check if faceting/animating would actually happen based on available dimensions + # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': - dims_to_facet = [] - if facet_by is not None: - dims_to_facet.extend([facet_by] if isinstance(facet_by, str) else facet_by) - if animate_by is not None: - dims_to_facet.append(animate_by) - - # Only raise error if any of the specified dimensions actually exist in the data - existing_dims = [dim for dim in dims_to_facet if dim in ds.dims] - if existing_dims: + extra_dims = [d for d in ds.dims if d != 'time'] + if extra_dims: raise ValueError( - f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' + f'Matplotlib engine only supports a single time axis, but found extra dimensions: {extra_dims}. ' + f'Please use select={{...}} to reduce dimensions or switch to engine="plotly" for faceting/animation.' ) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' @@ -1420,11 +1414,6 @@ def plot_charge_state( if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - if (facet_by is not None or animate_by is not None) and engine == 'matplotlib': - raise ValueError( - f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' - ) - # Get node balance and charge state ds = self.node_balance(with_last_timestep=True) charge_state_da = self.charge_state @@ -1483,6 +1472,13 @@ def plot_charge_state( default_filetype = '.html' elif engine == 'matplotlib': + # Matplotlib requires only 'time' dimension; check for extras after selection + extra_dims = [d for d in ds.dims if d != 'time'] + if extra_dims: + raise ValueError( + f'Matplotlib engine only supports a single time axis, but found extra dimensions: {extra_dims}. ' + f'Please use select={{...}} to reduce dimensions or switch to engine="plotly" for faceting/animation.' + ) # For matplotlib, plot flows (node balance), then add charge_state as line fig, ax = plotting.with_matplotlib( ds.to_dataframe(), From 33cd72a471b928300dfc0c7c723697fcb5e6e7f5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:08:33 +0200 Subject: [PATCH 26/36] Improve Error Message if too many dims for matplotlib --- flixopt/results.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 984bf8080..77fea438b 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -2045,19 +2045,20 @@ def plot_heatmap( data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - # Check if faceting/animating would actually happen based on available dimensions + # Matplotlib doesn't support faceting or animation for heatmaps + # Only raise error if the specified dimensions actually exist in the data if engine == 'matplotlib': - dims_to_facet = [] + dims_to_check = [] if facet_by is not None: - dims_to_facet.extend([facet_by] if isinstance(facet_by, str) else facet_by) + dims_to_check.extend([facet_by] if isinstance(facet_by, str) else facet_by) if animate_by is not None: - dims_to_facet.append(animate_by) + dims_to_check.append(animate_by) - # Only raise error if any of the specified dimensions actually exist in the data - existing_dims = [dim for dim in dims_to_facet if dim in data.dims] - if existing_dims: + existing_facet_dims = [dim for dim in dims_to_check if dim in data.dims] + if existing_facet_dims: raise ValueError( - f'Faceting and animating are not supported by the plotting engine {engine}. Use Plotly instead' + f'Matplotlib engine does not support faceting/animation, but found dimensions: {existing_facet_dims}. ' + f'Use engine="plotly" or reduce these dimensions via select={{...}}.' ) # Build title From 505edca6cfecd4a163dccef141dcceacf84690d4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:12:13 +0200 Subject: [PATCH 27/36] Improve Error Message if too many dims for matplotlib --- flixopt/results.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 77fea438b..d4cc21222 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -2045,20 +2045,24 @@ def plot_heatmap( data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - # Matplotlib doesn't support faceting or animation for heatmaps - # Only raise error if the specified dimensions actually exist in the data + # Matplotlib heatmaps require at most 2D data + # Time dimension will be reshaped to 2D (timeframe × timestep), so can't have other dims alongside it if engine == 'matplotlib': - dims_to_check = [] - if facet_by is not None: - dims_to_check.extend([facet_by] if isinstance(facet_by, str) else facet_by) - if animate_by is not None: - dims_to_check.append(animate_by) - - existing_facet_dims = [dim for dim in dims_to_check if dim in data.dims] - if existing_facet_dims: + dims = list(data.dims) + + # If 'time' dimension exists and will be reshaped, we can't have any other dimensions + if 'time' in dims and len(dims) > 1 and reshape_time is not None: + extra_dims = [d for d in dims if d != 'time'] + raise ValueError( + f'Matplotlib heatmaps with time reshaping cannot have additional dimensions. ' + f'Found extra dimensions: {extra_dims}. ' + f'Use select={{...}} to reduce to time only, use "reshape_time=None" or switch to engine="plotly" or use for multi-dimensional support.' + ) + # If no 'time' dimension (already reshaped or different data), allow at most 2 dimensions + elif 'time' not in dims and len(dims) > 2: raise ValueError( - f'Matplotlib engine does not support faceting/animation, but found dimensions: {existing_facet_dims}. ' - f'Use engine="plotly" or reduce these dimensions via select={{...}}.' + f'Matplotlib heatmaps support at most 2 dimensions, but data has {len(dims)}: {dims}. ' + f'Use select={{...}} to reduce dimensions or switch to engine="plotly".' ) # Build title From 33c4bec0fdc2d0483c3704ee4666259d4ba83367 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:36:00 +0200 Subject: [PATCH 28/36] Rename _apply_indexer_to_data() to _apply_selection_to_data() --- flixopt/results.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index d4cc21222..a58f0dc1e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1060,7 +1060,7 @@ def plot_node_balance( # Don't pass select/indexer to node_balance - we'll apply it afterwards ds = self.node_balance(with_last_timestep=True, unit_type=unit_type, drop_suffix=drop_suffix) - ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True) + ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1177,8 +1177,8 @@ def plot_node_balance_pie( drop_suffix='|', ) - inputs, suffix_parts = _apply_indexer_to_data(inputs, select=select, drop=True) - outputs, suffix_parts = _apply_indexer_to_data(outputs, select=select, drop=True) + inputs, suffix_parts = _apply_selection_to_data(inputs, select=select, drop=True) + outputs, suffix_parts = _apply_selection_to_data(outputs, select=select, drop=True) # Sum over time dimension inputs = inputs.sum('time') @@ -1313,7 +1313,7 @@ def node_balance( drop_suffix='|' if drop_suffix else None, ) - ds, _ = _apply_indexer_to_data(ds, select=select, drop=True) + ds, _ = _apply_selection_to_data(ds, select=select, drop=True) if unit_type == 'flow_hours': ds = ds * self._calculation_results.hours_per_timestep @@ -1419,8 +1419,8 @@ def plot_charge_state( charge_state_da = self.charge_state # Apply select filtering - ds, suffix_parts = _apply_indexer_to_data(ds, select=select, drop=True) - charge_state_da, _ = _apply_indexer_to_data(charge_state_da, select=select, drop=True) + ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) + charge_state_da, _ = _apply_selection_to_data(charge_state_da, select=select, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'Operation Balance of {self.label}{suffix}' @@ -2042,7 +2042,7 @@ def plot_heatmap( title_name = name # Apply select filtering - data, suffix_parts = _apply_indexer_to_data(data, select=select, drop=True) + data, suffix_parts = _apply_selection_to_data(data, select=select, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' # Matplotlib heatmaps require at most 2D data @@ -2339,7 +2339,7 @@ def apply_filter(array, coord_name: str, coord_values: Any | list[Any]): return da -def _apply_indexer_to_data( +def _apply_selection_to_data( data: xr.DataArray | xr.Dataset, select: dict[str, Any] | None = None, drop=False, From b37dc6a8e95c5651e23e8fe4a085f5a6725c0c9e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:45:43 +0200 Subject: [PATCH 29/36] Bugfix --- tests/test_results_plots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index e13d4c1dc..1fd6cf7f5 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -52,8 +52,8 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): heatmap_kwargs = { 'reshape_time': ('D', 'h'), 'colors': 'viridis', # Note: heatmap only accepts string colormap - 'save': show, - 'show': save, + 'save': save, + 'show': show, 'engine': plotting_engine, } if plotting_engine == 'matplotlib': From 9ce25ab2361d679181a7887b34df473fc05d7f16 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:50:54 +0200 Subject: [PATCH 30/36] Update CHANGELOG.md --- CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b580e6b88..78ab58397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,18 +54,24 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### ✨ Added -- Added faceting and animation options to plotting methods +- **Faceting and animation support for plots**: All plotting methods now support `facet_by` and `animate_by` parameters for creating subplot grids and animations with multidimensional data (scenarios, periods, etc.) +- **New `select` parameter**: Added to all plotting methods for flexible data selection using single values, lists, slices, and index arrays +- **Heatmap `fill` parameter**: Added `fill` parameter to heatmap plotting methods to control how missing values are filled after reshaping ('ffill' or 'bfill') +- **Dashed line styling**: Area plots now automatically style "mixed" variables (containing both positive and negative values) with dashed lines, while only stacking purely positive or negative variables ### 💥 Breaking Changes ### ♻️ Changed -- Changed indexer behaviour. Defaults to not indexing instead of the first value except for time. Also changed naming when indexing. +- **Selection behavior**: Changed default selection behavior in plotting methods - no longer automatically selects first value for non-time dimensions. Use `select` parameter for explicit selection +- **Improved error messages**: Enhanced error messages when using matplotlib engine with multidimensional data, providing clearer guidance on dimension requirements ### 🗑️ Deprecated +- **`indexer` parameter**: The `indexer` parameter in all plotting methods is deprecated in favor of the new `select` parameter with enhanced functionality ### 🔥 Removed ### 🐛 Fixed +- Fixed error handling in `plot_heatmap()` method for better dimension validation ### 🔒 Security @@ -74,6 +80,7 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### 📝 Docs ### 👷 Development +- Renamed `_apply_indexer_to_data()` to `_apply_selection_to_data()` for consistency with new API ### 🚧 Known Issues From bbad6cbe174c3032f8afea7196ff61fab6df9994 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:51:47 +0200 Subject: [PATCH 31/36] Catch edge case in with_plotly() --- flixopt/plotting.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 390651074..e0e81c3c7 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -427,7 +427,13 @@ def with_plotly( df_long = df_long.rename(columns={data.name: 'value'}) else: # Unnamed DataArray, find the value column - value_col = [col for col in df_long.columns if col not in data.dims][0] + non_dim_cols = [col for col in df_long.columns if col not in data.dims] + if len(non_dim_cols) != 1: + raise ValueError( + f'Expected exactly one non-dimension column for unnamed DataArray, ' + f'but found {len(non_dim_cols)}: {non_dim_cols}' + ) + value_col = non_dim_cols[0] df_long = df_long.rename(columns={value_col: 'value'}) df_long['variable'] = data.name or 'data' else: From 92d05904c53481e837f0dff6299b4fb822d47d9a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:52:29 +0200 Subject: [PATCH 32/36] Add strict=True --- flixopt/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index e0e81c3c7..64722db3e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -503,7 +503,7 @@ def with_plotly( # Process colors all_vars = df_long['variable'].unique().tolist() processed_colors = ColorProcessor(engine='plotly').process_colors(colors, all_vars) - color_discrete_map = {var: color for var, color in zip(all_vars, processed_colors, strict=False)} + color_discrete_map = {var: color for var, color in zip(all_vars, processed_colors, strict=True)} # Create plot using Plotly Express based on mode common_args = { From ae05346c2e749125445e8cb928eb9c815314e33e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:59:42 +0200 Subject: [PATCH 33/36] Improve scenario_example.py --- examples/04_Scenarios/scenario_example.py | 194 +++++++++++++++++++--- 1 file changed, 172 insertions(+), 22 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 1ef586bc3..d75f62c46 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -8,23 +8,160 @@ import flixopt as fx if __name__ == '__main__': - # Create datetime array starting from '2020-01-01' for the given time period + # Create datetime array starting from '2020-01-01' for the given time period (7.5 days) timesteps = pd.date_range('2020-01-01', periods=9 * 20, freq='h') scenarios = pd.Index(['Base Case', 'High Demand']) periods = pd.Index([2020, 2021, 2022]) # --- Create Time Series Data --- - # Heat demand profile (e.g., kW) over time and corresponding power prices + # Realistic heat demand profile (kW) with daily patterns: + # - Peak demand: morning (6-9am) and evening (6-10pm) + # - Low demand: night (11pm-5am) and midday + # - Base Case: typical residential/commercial heating pattern + # - High Demand: 15-25% higher demand with different peak characteristics + + # Create a realistic daily heating pattern (24 hours) + hours_in_day = np.arange(24) + + # Base Case: Standard demand pattern + # Night (0-5): low demand ~20-25 kW + # Morning ramp (6-8): rising to ~90 kW + # Morning peak (9): ~110 kW + # Midday decline (10-16): ~60-70 kW + # Evening ramp (17-18): rising to ~100 kW + # Evening peak (19-21): ~120-130 kW + # Night decline (22-23): falling to ~40-30 kW + base_daily_pattern = np.array( + [ + 22, + 20, + 18, + 18, + 20, + 25, # 0-5: Night low + 40, + 70, + 95, + 110, + 85, + 65, # 6-11: Morning peak + 60, + 58, + 62, + 68, + 75, + 88, # 12-17: Midday and ramp + 105, + 125, + 130, + 122, + 95, + 35, # 18-23: Evening peak and decline + ] + ) + + # High Demand: 15-25% higher with shifted peaks + high_daily_pattern = np.array( + [ + 28, + 25, + 22, + 22, + 24, + 30, # 0-5: Night low (slightly higher) + 52, + 88, + 118, + 135, + 105, + 80, # 6-11: Morning peak (higher and sharper) + 75, + 72, + 75, + 82, + 92, + 108, # 12-17: Midday (higher baseline) + 128, + 148, + 155, + 145, + 115, + 48, # 18-23: Evening peak (significantly higher) + ] + ) + + # Repeat pattern for 7.5 days and add realistic variation + np.random.seed(42) # For reproducibility + n_hours = len(timesteps) + + base_demand = np.tile(base_daily_pattern, n_hours // 24 + 1)[:n_hours] + high_demand = np.tile(high_daily_pattern, n_hours // 24 + 1)[:n_hours] + + # Add realistic noise/variation (±5% for base, ±7% for high demand) + base_demand = base_demand * (1 + np.random.uniform(-0.05, 0.05, n_hours)) + high_demand = high_demand * (1 + np.random.uniform(-0.07, 0.07, n_hours)) + heat_demand_per_h = pd.DataFrame( { - 'Base Case': [30, 0, 90, 110, 110, 20, 20, 20, 20] * 20, - 'High Demand': [30, 0, 100, 118, 125, 20, 20, 20, 20] * 20, + 'Base Case': base_demand, + 'High Demand': high_demand, }, index=timesteps, ) - power_prices = np.array([0.08, 0.09, 0.10]) - flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=np.array([0.5, 0.6])) + # Realistic power prices (€/kWh) varying by period and time of day + # Period differences: 2020: lower, 2021: medium, 2022: higher (reflecting market trends) + # Prices vary more realistically throughout the day + base_price_2020 = 0.075 + base_price_2021 = 0.095 + base_price_2022 = 0.135 + + # Create hourly price modifiers based on typical electricity market patterns + hourly_price_factors = np.array( + [ + 0.70, + 0.65, + 0.62, + 0.60, + 0.62, + 0.70, # 0-5: Night (lowest prices) + 0.95, + 1.15, + 1.30, + 1.25, + 1.10, + 1.00, # 6-11: Morning peak + 0.95, + 0.90, + 0.88, + 0.92, + 1.00, + 1.10, # 12-17: Midday and ramp + 1.25, + 1.40, + 1.35, + 1.20, + 0.95, + 0.80, # 18-23: Evening peak + ] + ) + + # Generate price series with realistic hourly and daily variation + price_series = np.zeros((n_hours, 3)) # 3 periods + for period_idx, base_price in enumerate([base_price_2020, base_price_2021, base_price_2022]): + hourly_prices = np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours] * base_price + # Add small random variation (±3%) + hourly_prices *= 1 + np.random.uniform(-0.03, 0.03, n_hours) + price_series[:, period_idx] = hourly_prices + + # Average prices per period for the flow (simplified representation) + power_prices = price_series.mean(axis=0) + + # Scenario weights: probability of each scenario occurring + # Base Case: 60% probability, High Demand: 40% probability + scenario_weights = np.array([0.6, 0.4]) + + flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=scenario_weights) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) @@ -38,22 +175,24 @@ description='Kosten', is_standard=True, # standard effect: no explicit value needed for costs is_objective=True, # Minimizing costs as the optimization objective - share_from_temporal={'CO2': 0.2}, + share_from_temporal={'CO2': 0.2}, # Carbon price: 0.2 €/kg CO2 (e.g., carbon tax) ) - # CO2 emissions effect with an associated cost impact + # CO2 emissions effect with constraint + # Maximum of 1000 kg CO2/hour represents a regulatory or voluntary emissions limit CO2 = fx.Effect( label='CO2', unit='kg', description='CO2_e-Emissionen', - maximum_per_hour=1000, # Max CO2 emissions per hour + maximum_per_hour=1000, # Regulatory emissions limit: 1000 kg CO2/hour ) # --- Define Flow System Components --- # Boiler: Converts fuel (gas) into thermal energy (heat) + # Modern condensing gas boiler with realistic efficiency boiler = fx.linear_converters.Boiler( label='Boiler', - eta=0.5, + eta=0.92, # Realistic efficiency for modern condensing gas boiler (92%) Q_th=fx.Flow( label='Q_th', bus='Fernwärme', @@ -66,16 +205,18 @@ ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel + # Modern CHP unit with realistic efficiencies (total efficiency ~88%) chp = fx.linear_converters.CHP( label='CHP', - eta_th=0.5, - eta_el=0.4, + eta_th=0.48, # Realistic thermal efficiency (48%) + eta_el=0.40, # Realistic electrical efficiency (40%) P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), Q_th=fx.Flow('Q_th', bus='Fernwärme'), Q_fu=fx.Flow('Q_fu', bus='Gas'), ) - # Storage: Energy storage system with charging and discharging capabilities + # Storage: Thermal energy storage system with charging and discharging capabilities + # Realistic thermal storage parameters (e.g., insulated hot water tank) storage = fx.Storage( label='Storage', charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1000), @@ -84,9 +225,9 @@ initial_charge_state=0, # Initial storage state: empty relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80] * 20) * 0.01, relative_maximum_final_charge_state=np.array([0.8, 0.5, 0.1]), - eta_charge=0.9, - eta_discharge=1, # Efficiency factors for charging/discharging - relative_loss_per_hour=np.array([0.1, 0.2]), # Assume 10% or 20% losses per hour in the scenarios + eta_charge=0.95, # Realistic charging efficiency (~95%) + eta_discharge=0.98, # Realistic discharging efficiency (~98%) + relative_loss_per_hour=np.array([0.008, 0.015]), # Realistic thermal losses: 0.8-1.5% per hour prevent_simultaneous_charge_and_discharge=True, # Prevent charging and discharging at the same time ) @@ -97,10 +238,22 @@ ) # Gas Source: Gas tariff source with associated costs and CO2 emissions + # Realistic gas prices varying by period (reflecting 2020-2022 energy crisis) + # 2020: 0.04 €/kWh, 2021: 0.06 €/kWh, 2022: 0.11 €/kWh + gas_prices_per_period = np.array([0.04, 0.06, 0.11]) + + # CO2 emissions factor for natural gas: ~0.202 kg CO2/kWh (realistic value) + gas_co2_emissions = 0.202 + gas_source = fx.Source( label='Gastarif', outputs=[ - fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: 0.04, CO2.label: 0.3}) + fx.Flow( + label='Q_Gas', + bus='Gas', + size=1000, + effects_per_flow_hour={costs.label: gas_prices_per_period, CO2.label: gas_co2_emissions}, + ) ], ) @@ -127,17 +280,14 @@ calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # --- Analyze Results --- - calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance(mode='stacked_bar') - calculation.results['Storage'].plot_node_balance() calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') + calculation.results['Storage'].plot_charge_state() + calculation.results['Fernwärme'].plot_node_balance_pie(select={'period': 2020, 'scenario': 'Base Case'}) # Convert the results for the storage component to a dataframe and display df = calculation.results['Storage'].node_balance_with_charge_state() print(df) - # Plot charge state using matplotlib - calculation.results['Storage'].plot_charge_state() - # Save results to file for later usage calculation.results.to_file() From 904be279973185eb82134af67c15a0c733516921 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:03:16 +0200 Subject: [PATCH 34/36] Improve scenario_example.py --- examples/04_Scenarios/scenario_example.py | 137 +++++----------------- 1 file changed, 28 insertions(+), 109 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index d75f62c46..834e55782 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -8,115 +8,35 @@ import flixopt as fx if __name__ == '__main__': - # Create datetime array starting from '2020-01-01' for the given time period (7.5 days) - timesteps = pd.date_range('2020-01-01', periods=9 * 20, freq='h') + # Create datetime array starting from '2020-01-01' for one week + timesteps = pd.date_range('2020-01-01', periods=24 * 7, freq='h') scenarios = pd.Index(['Base Case', 'High Demand']) periods = pd.Index([2020, 2021, 2022]) # --- Create Time Series Data --- - # Realistic heat demand profile (kW) with daily patterns: - # - Peak demand: morning (6-9am) and evening (6-10pm) - # - Low demand: night (11pm-5am) and midday - # - Base Case: typical residential/commercial heating pattern - # - High Demand: 15-25% higher demand with different peak characteristics - - # Create a realistic daily heating pattern (24 hours) - hours_in_day = np.arange(24) + # Realistic daily patterns: morning/evening peaks, night/midday lows + np.random.seed(42) + n_hours = len(timesteps) - # Base Case: Standard demand pattern - # Night (0-5): low demand ~20-25 kW - # Morning ramp (6-8): rising to ~90 kW - # Morning peak (9): ~110 kW - # Midday decline (10-16): ~60-70 kW - # Evening ramp (17-18): rising to ~100 kW - # Evening peak (19-21): ~120-130 kW - # Night decline (22-23): falling to ~40-30 kW + # Heat demand: 24-hour patterns (kW) for Base Case and High Demand scenarios base_daily_pattern = np.array( - [ - 22, - 20, - 18, - 18, - 20, - 25, # 0-5: Night low - 40, - 70, - 95, - 110, - 85, - 65, # 6-11: Morning peak - 60, - 58, - 62, - 68, - 75, - 88, # 12-17: Midday and ramp - 105, - 125, - 130, - 122, - 95, - 35, # 18-23: Evening peak and decline - ] + [22, 20, 18, 18, 20, 25, 40, 70, 95, 110, 85, 65, 60, 58, 62, 68, 75, 88, 105, 125, 130, 122, 95, 35] ) - - # High Demand: 15-25% higher with shifted peaks high_daily_pattern = np.array( - [ - 28, - 25, - 22, - 22, - 24, - 30, # 0-5: Night low (slightly higher) - 52, - 88, - 118, - 135, - 105, - 80, # 6-11: Morning peak (higher and sharper) - 75, - 72, - 75, - 82, - 92, - 108, # 12-17: Midday (higher baseline) - 128, - 148, - 155, - 145, - 115, - 48, # 18-23: Evening peak (significantly higher) - ] + [28, 25, 22, 22, 24, 30, 52, 88, 118, 135, 105, 80, 75, 72, 75, 82, 92, 108, 128, 148, 155, 145, 115, 48] ) - # Repeat pattern for 7.5 days and add realistic variation - np.random.seed(42) # For reproducibility - n_hours = len(timesteps) - - base_demand = np.tile(base_daily_pattern, n_hours // 24 + 1)[:n_hours] - high_demand = np.tile(high_daily_pattern, n_hours // 24 + 1)[:n_hours] - - # Add realistic noise/variation (±5% for base, ±7% for high demand) - base_demand = base_demand * (1 + np.random.uniform(-0.05, 0.05, n_hours)) - high_demand = high_demand * (1 + np.random.uniform(-0.07, 0.07, n_hours)) - - heat_demand_per_h = pd.DataFrame( - { - 'Base Case': base_demand, - 'High Demand': high_demand, - }, - index=timesteps, + # Tile and add variation + base_demand = np.tile(base_daily_pattern, n_hours // 24 + 1)[:n_hours] * ( + 1 + np.random.uniform(-0.05, 0.05, n_hours) + ) + high_demand = np.tile(high_daily_pattern, n_hours // 24 + 1)[:n_hours] * ( + 1 + np.random.uniform(-0.07, 0.07, n_hours) ) - # Realistic power prices (€/kWh) varying by period and time of day - # Period differences: 2020: lower, 2021: medium, 2022: higher (reflecting market trends) - # Prices vary more realistically throughout the day - base_price_2020 = 0.075 - base_price_2021 = 0.095 - base_price_2022 = 0.135 + heat_demand_per_h = pd.DataFrame({'Base Case': base_demand, 'High Demand': high_demand}, index=timesteps) - # Create hourly price modifiers based on typical electricity market patterns + # Power prices: hourly factors (night low, peak high) and period escalation (2020-2022) hourly_price_factors = np.array( [ 0.70, @@ -124,37 +44,37 @@ 0.62, 0.60, 0.62, - 0.70, # 0-5: Night (lowest prices) + 0.70, 0.95, 1.15, 1.30, 1.25, 1.10, - 1.00, # 6-11: Morning peak + 1.00, 0.95, 0.90, 0.88, 0.92, 1.00, - 1.10, # 12-17: Midday and ramp + 1.10, 1.25, 1.40, 1.35, 1.20, 0.95, - 0.80, # 18-23: Evening peak + 0.80, ] ) + period_base_prices = np.array([0.075, 0.095, 0.135]) # €/kWh for 2020, 2021, 2022 - # Generate price series with realistic hourly and daily variation - price_series = np.zeros((n_hours, 3)) # 3 periods - for period_idx, base_price in enumerate([base_price_2020, base_price_2021, base_price_2022]): - hourly_prices = np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours] * base_price - # Add small random variation (±3%) - hourly_prices *= 1 + np.random.uniform(-0.03, 0.03, n_hours) - price_series[:, period_idx] = hourly_prices + price_series = np.zeros((n_hours, 3)) + for period_idx, base_price in enumerate(period_base_prices): + price_series[:, period_idx] = ( + np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours] + * base_price + * (1 + np.random.uniform(-0.03, 0.03, n_hours)) + ) - # Average prices per period for the flow (simplified representation) power_prices = price_series.mean(axis=0) # Scenario weights: probability of each scenario occurring @@ -223,7 +143,6 @@ discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True), initial_charge_state=0, # Initial storage state: empty - relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80] * 20) * 0.01, relative_maximum_final_charge_state=np.array([0.8, 0.5, 0.1]), eta_charge=0.95, # Realistic charging efficiency (~95%) eta_discharge=0.98, # Realistic discharging efficiency (~98%) From 2c8bd7fcf350bc4f1d9b8412095e336709f74128 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:05:02 +0200 Subject: [PATCH 35/36] Change logging level in essage about time reshape --- flixopt/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 64722db3e..bd1f3c2c4 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -766,7 +766,7 @@ def reshape_data_for_heatmap( # Auto-reshape if only 'time' dimension remains if len(potential_heatmap_dims) == 1 and potential_heatmap_dims[0] == 'time': - logger.info( + logger.debug( "Auto-applying time reshaping: Only 'time' dimension remains after faceting/animation. " "Using default timeframes='D' and timesteps_per_frame='h'. " "To customize, use reshape_time=('D', 'h') or disable with reshape_time=None." From 55dfde9f4ace41d59b749edcccfd236cf53d7db8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:05:58 +0200 Subject: [PATCH 36/36] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ab58397..9a7df11e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### ♻️ Changed - **Selection behavior**: Changed default selection behavior in plotting methods - no longer automatically selects first value for non-time dimensions. Use `select` parameter for explicit selection - **Improved error messages**: Enhanced error messages when using matplotlib engine with multidimensional data, providing clearer guidance on dimension requirements +- Improved `scenario_example.py` ### 🗑️ Deprecated - **`indexer` parameter**: The `indexer` parameter in all plotting methods is deprecated in favor of the new `select` parameter with enhanced functionality