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 001/173] 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 002/173] 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 003/173] 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 004/173] 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 005/173] 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 006/173] 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 007/173] 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 008/173] 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 009/173] 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 010/173] 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 011/173] 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 012/173] 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 013/173] 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 014/173] 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 015/173] 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 016/173] 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 017/173] 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 018/173] 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 019/173] 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 020/173] 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 021/173] 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 022/173] - 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 023/173] - 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 024/173] =?UTF-8?q?=20=20-=20Added=20np.random.seed(42)=20?= =?UTF-8?q?for=20reproducible=20test=20results=20=20=20-=20Added=20specifi?= =?UTF-8?q?c=20size=20assertions=20to=20all=20tests:=20=20=20=20=20-=20Dai?= =?UTF-8?q?ly/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 025/173] 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 026/173] 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 027/173] 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 028/173] 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 029/173] 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 030/173] 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 031/173] 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 032/173] 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 033/173] 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 034/173] 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 035/173] 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 036/173] 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 From 770615c8a837c01a20d51b10f0eb034d19f3d1b9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:18:07 +0200 Subject: [PATCH 037/173] Add XarrayColorMapper --- flixopt/plotting.py | 387 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 386 insertions(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index bd1f3c2c4..1a284f302 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -8,7 +8,8 @@ Key Features: **Dual Backend Support**: Seamless switching between Plotly and Matplotlib **Energy System Focus**: Specialized plots for power flows, storage states, emissions - **Color Management**: Intelligent color processing and palette management + **Color Management**: Intelligent color processing with ColorProcessor and pattern-based + XarrayColorMapper for grouped coloring **Export Capabilities**: High-quality export for reports and publications **Integration Ready**: Designed for use with CalculationResults and standalone analysis @@ -25,10 +26,12 @@ from __future__ import annotations +import fnmatch import itertools import logging import os import pathlib +import re from typing import TYPE_CHECKING, Any, Literal import matplotlib @@ -326,6 +329,388 @@ def process_colors( return color_list +# Type aliases for XarrayColorMapper +MatchType = Literal['prefix', 'suffix', 'contains', 'glob', 'regex'] +CoordValues = xr.DataArray | np.ndarray | list[Any] + + +class XarrayColorMapper: + """Map xarray coordinate values to colors based on naming patterns. + + A simple, maintainable utility class for mapping xarray coordinate values + to colors based on naming patterns. Enables visual grouping in plots where + similar items get similar colors. + + Key Features: + - Pattern-based color assignment (prefix, suffix, contains, glob, regex) + - ColorBrewer sequential palettes for grouped coloring + - Override support for special cases + - Coordinate reordering for visual grouping in plots + - Full type hints and comprehensive documentation + + Example Usage: + ```python + mapper = ( + XarrayColorMapper() + .add_rule('Product_A', 'blues', 'prefix') + .add_rule('Product_B', 'greens', 'prefix') + .add_override({'Special': '#FFD700'}) + ) + + # Reorder for visual grouping + da_reordered = mapper.reorder_coordinate(da, 'product') + + # Get color mapping + color_map = mapper.apply_to_dataarray(da_reordered, 'product') + + # Plot with Plotly + fig = px.bar(df, x='product', y='value', color='product', color_discrete_map=color_map) + ``` + """ + + # Class-level defaults (easy to update in one place) + DEFAULT_FAMILIES = { + 'blues': px.colors.sequential.Blues[2:7], + 'greens': px.colors.sequential.Greens[2:7], + 'reds': px.colors.sequential.Reds[2:7], + 'purples': px.colors.sequential.Purples[2:7], + 'oranges': px.colors.sequential.Oranges[2:7], + 'teals': px.colors.sequential.Teal[2:7], + 'greys': px.colors.sequential.Greys[2:7], + 'pinks': px.colors.sequential.Pinkyl[2:7], + } + + def __init__(self, color_families: dict[str, list[str]] | None = None, sort_within_groups: bool = True) -> None: + """ + Initialize with ColorBrewer families. + + Parameters: + ----------- + color_families : dict, optional + Custom color families. If None, uses DEFAULT_FAMILIES + sort_within_groups : bool, default True + Whether to sort values within groups by default + """ + if color_families is None: + self.color_families = self.DEFAULT_FAMILIES.copy() + else: + self.color_families = color_families.copy() + + self.sort_within_groups = sort_within_groups + self.rules: list[dict[str, str]] = [] + self.overrides: dict[str, str] = {} + + def add_custom_family(self, name: str, colors: list[str]) -> XarrayColorMapper: + """ + Add a custom color family. + + Parameters: + ----------- + name : str + Name for the color family + colors : List[str] + List of hex color codes + + Returns: + -------- + XarrayColorMapper : Self for method chaining + """ + self.color_families[name] = colors + return self + + def add_rule(self, pattern: str, family: str, match_type: MatchType = 'prefix') -> XarrayColorMapper: + """ + Add a pattern-based rule to assign color families. + + Parameters: + ----------- + pattern : str + Pattern to match against coordinate values + family : str + Color family name to assign + match_type : {'prefix', 'suffix', 'contains', 'glob', 'regex'}, default 'prefix' + Type of pattern matching to use: + - 'prefix': Match if value starts with pattern + - 'suffix': Match if value ends with pattern + - 'contains': Match if pattern appears anywhere in value + - 'glob': Unix-style wildcards (* matches anything, ? matches one char) + - 'regex': Match using regular expression + + Returns: + -------- + XarrayColorMapper : Self for method chaining + + Examples: + --------- + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('_test', 'greens', 'suffix') + mapper.add_rule('control', 'reds', 'contains') + mapper.add_rule('exp_A*', 'purples', 'glob') + mapper.add_rule(r'^exp_[AB]\\d+', 'oranges', 'regex') + """ + if family not in self.color_families: + raise ValueError(f"Unknown family '{family}'. Available: {list(self.color_families.keys())}") + + valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') + if match_type not in valid_types: + raise ValueError(f"match_type must be one of {valid_types}, got '{match_type}'") + + self.rules.append({'pattern': pattern, 'family': family, 'match_type': match_type}) + return self + + def add_override(self, color_dict: dict[str, str]) -> XarrayColorMapper: + """ + Override colors for specific values (takes precedence over rules). + + Parameters: + ----------- + color_dict : Dict[str, str] + Mapping of {value: hex_color} + + Returns: + -------- + XarrayColorMapper : Self for method chaining + + Examples: + --------- + mapper.add_override({'Special': '#FFD700'}) + mapper.add_override({ + 'Product_A1': '#FF00FF', + 'Product_B2': '#00FFFF' + }) + """ + for val, col in color_dict.items(): + self.overrides[str(val)] = col + return self + + def create_color_map( + self, + coord_values: CoordValues, + sort_within_groups: bool | None = None, + fallback_family: str = 'greys', + ) -> dict[str, str]: + """ + Create color mapping for coordinate values. + + Parameters: + ----------- + coord_values : xr.DataArray, np.ndarray, or list + Coordinate values to map + sort_within_groups : bool, optional + Sort values within each group. If None, uses instance default + fallback_family : str, default 'greys' + Color family for unmatched values + + Returns: + -------- + Dict[str, str] : Mapping of {value: hex_color} + """ + if sort_within_groups is None: + sort_within_groups = self.sort_within_groups + + # Convert to string list + if isinstance(coord_values, xr.DataArray): + categories: list[str] = [str(val) for val in coord_values.values] + elif isinstance(coord_values, np.ndarray): + categories = [str(val) for val in coord_values] + else: + categories = [str(val) for val in coord_values] + + # Remove duplicates while preserving order + seen: set = set() + categories = [x for x in categories if not (x in seen or seen.add(x))] + + # Group by rules + groups = self._group_categories(categories) + color_map: dict[str, str] = {} + + # Assign colors to groups + for group_name, group_categories in groups.items(): + if group_name == '_unmatched': + family = self.color_families.get(fallback_family, self.color_families['greys']) + else: + family = self.color_families[group_name] + + if sort_within_groups: + group_categories = sorted(group_categories) + + for idx, category in enumerate(group_categories): + color_map[category] = family[idx % len(family)] + + # Apply overrides + color_map.update(self.overrides) + + return color_map + + def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str]: + """ + Create color map for a DataArray coordinate dimension. + + Parameters: + ----------- + da : xr.DataArray + The data array + coord_dim : str + Coordinate dimension name + + Returns: + -------- + Dict[str, str] : Color mapping for that dimension + + Raises: + ------- + ValueError : If coord_dim is not found in the DataArray + """ + if coord_dim not in da.coords: + raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") + + return self.create_color_map(da.coords[coord_dim]) + + def reorder_coordinate( + self, da: xr.DataArray, coord_dim: str, sort_within_groups: bool | None = None + ) -> xr.DataArray: + """ + Reorder a DataArray coordinate so values with the same color are adjacent. + + This is useful for creating plots where similar items (same color group) + appear next to each other, making visual groupings clear. + + Parameters: + ----------- + da : xr.DataArray + The data array to reorder + coord_dim : str + The coordinate dimension to reorder + sort_within_groups : bool, optional + Whether to sort values within each group. If None, uses instance default + + Returns: + -------- + xr.DataArray : New DataArray with reordered coordinate + + Examples: + --------- + # Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] + # After reorder: ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2'] + + mapper = XarrayColorMapper() + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('Product_B', 'greens', 'prefix') + + da_reordered = mapper.reorder_coordinate(da, 'product') + """ + if coord_dim not in da.coords: + raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") + + if sort_within_groups is None: + sort_within_groups = self.sort_within_groups + + # Get coordinate values + coord_values = da.coords[coord_dim].values + categories = [str(val) for val in coord_values] + + # Group categories + groups = self._group_categories(categories) + + # Build new order: group by group, optionally sorted within each + new_order = [] + for group_name in groups.keys(): + group_categories = groups[group_name] + if sort_within_groups: + group_categories = sorted(group_categories) + new_order.extend(group_categories) + + # Convert back to original dtype if needed + original_values = list(coord_values) + # Map string back to original values + str_to_original = {str(v): v for v in original_values} + reordered_values = [str_to_original[cat] for cat in new_order] + + # Reindex the DataArray + return da.sel({coord_dim: reordered_values}) + + def get_rules(self) -> list[dict[str, str]]: + """Return a copy of current rules for inspection.""" + return self.rules.copy() + + def get_overrides(self) -> dict[str, str]: + """Return a copy of current overrides for inspection.""" + return self.overrides.copy() + + def get_families(self) -> dict[str, list[str]]: + """Return a copy of available color families.""" + return self.color_families.copy() + + def _match_rule(self, value: str, rule: dict[str, str]) -> bool: + """ + Check if value matches a rule. + + Parameters: + ----------- + value : str + Value to check + rule : dict + Rule dictionary with 'pattern' and 'match_type' keys + + Returns: + -------- + bool : True if value matches the rule + """ + pattern = rule['pattern'] + match_type = rule['match_type'] + + if match_type == 'prefix': + return value.startswith(pattern) + elif match_type == 'suffix': + return value.endswith(pattern) + elif match_type == 'contains': + return pattern in value + elif match_type == 'glob': + return fnmatch.fnmatch(value, pattern) + elif match_type == 'regex': + try: + return bool(re.match(pattern, value)) + except re.error as e: + raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e + + return False + + def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: + """ + Group categories by matching rules. + + Parameters: + ----------- + categories : List[str] + List of category values to group + + Returns: + -------- + Dict[str, List[str]] : Mapping of {family_name: [matching_values]} + """ + groups: dict[str, list[str]] = {} + unmatched: list[str] = [] + + for category in categories: + matched = False + for rule in self.rules: + if self._match_rule(category, rule): + family = rule['family'] + if family not in groups: + groups[family] = [] + groups[family].append(category) + matched = True + break # First match wins + + if not matched: + unmatched.append(category) + + if unmatched: + groups['_unmatched'] = unmatched + + return groups + + def with_plotly( data: pd.DataFrame | xr.DataArray | xr.Dataset, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', From f1a395f30bf2de0beddec36fbce10bf569868f68 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:45:42 +0200 Subject: [PATCH 038/173] Add XarrayColorMapper to CalculationResults --- flixopt/results.py | 104 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index a58f0dc1e..f84cd615c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -239,6 +239,9 @@ def __init__( self._sizes = None self._effects_per_component = None + # Color mapper for intelligent plot coloring + self.color_mapper: plotting.XarrayColorMapper | None = None + def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults: if key in self.components: return self.components[key] @@ -250,6 +253,74 @@ def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults return self.flows[key] raise KeyError(f'No element with label {key} found.') + def _resolve_colors( + self, + data: xr.DataArray | xr.Dataset, + colors: plotting.ColorType | Literal['auto'], + coord_dim: str = 'variable', + engine: plotting.PlottingEngine = 'plotly', + ) -> dict[str, str]: + """Resolve colors parameter to a color mapping dict. + + This internal helper handles all color parameter types and applies the color mapper + intelligently based on the data structure. + + Args: + data: DataArray or Dataset to create colors for + colors: Color specification or 'auto' + coord_dim: Coordinate dimension to map colors to + engine: Plotting engine ('plotly' or 'matplotlib') + + Returns: + Dictionary mapping coordinate values to colors + """ + # If explicit dict provided, use it directly + if isinstance(colors, dict): + return colors + + # If 'auto', use class mapper if available, else fall back to default + if colors == 'auto': + if self.color_mapper is not None: + # Apply reordering if configured in mapper + if self.color_mapper.sort_within_groups: + # Check if coord_dim exists and reorder + if isinstance(data, xr.DataArray) and coord_dim in data.coords: + data = self.color_mapper.reorder_coordinate(data, coord_dim) + elif isinstance(data, xr.Dataset): + # For Dataset, we'll work with the variables directly + pass + + # Apply color mapper to get dict + if isinstance(data, xr.DataArray): + if coord_dim in data.coords: + return self.color_mapper.apply_to_dataarray(data, coord_dim) + elif isinstance(data, xr.Dataset): + # For Dataset, map colors to variable names + labels = [str(v) for v in data.data_vars] + return self.color_mapper.create_color_map(labels) + + # No mapper configured, fall back to default colormap + colors = 'viridis' + + # If string or list, use ColorProcessor (traditional behavior) + if isinstance(colors, (str, list)): + if isinstance(data, xr.DataArray): + if coord_dim in data.coords: + labels = [str(v) for v in data.coords[coord_dim].values] + else: + labels = [] + elif isinstance(data, xr.Dataset): + labels = [str(v) for v in data.data_vars] + else: + labels = [] + + if labels: + processor = plotting.ColorProcessor(engine=engine) + return processor.process_colors(colors, labels, return_mapping=True) + + # Safe fallback + return {} + @property def storages(self) -> list[ComponentResults]: """Get all storage components in the results.""" @@ -967,7 +1038,7 @@ def plot_node_balance( self, save: bool | pathlib.Path = False, show: bool = True, - colors: plotting.ColorType = 'viridis', + colors: plotting.ColorType | Literal['auto'] = 'auto', engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', @@ -985,7 +1056,12 @@ def plot_node_balance( 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. + colors: The colors to use for the plot. Options: + - 'auto' (default): Use `self.color_mapper` if configured, else fall back to 'viridis' + - Colormap name string (e.g., 'viridis', 'plasma') + - List of color strings + - Dict mapping variable names to colors + Set `results.color_mapper` to an `XarrayColorMapper` for automatic pattern-based grouping. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. select: Optional data selection dict. Supports: - Single values: {'scenario': 'base', 'period': 2024} @@ -1062,6 +1138,9 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) + # Resolve colors to a dict (handles auto, mapper, etc.) + color_dict = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) + # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': extra_dims = [d for d in ds.dims if d != 'time'] @@ -1081,7 +1160,7 @@ def plot_node_balance( ds, facet_by=facet_by, animate_by=animate_by, - colors=colors, + colors=color_dict, mode=mode, title=title, facet_cols=facet_cols, @@ -1090,7 +1169,7 @@ def plot_node_balance( else: figure_like = plotting.with_matplotlib( ds.to_dataframe(), - colors=colors, + colors=color_dict, mode=mode, title=title, ) @@ -1108,7 +1187,7 @@ def plot_node_balance( def plot_node_balance_pie( self, lower_percentage_group: float = 5, - colors: plotting.ColorType = 'viridis', + colors: plotting.ColorType | Literal['auto'] = 'auto', text_info: str = 'percent+label+value', save: bool | pathlib.Path = False, show: bool = True, @@ -1217,11 +1296,15 @@ def plot_node_balance_pie( suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'{self.label} (total flow hours){suffix}' + # Combine inputs and outputs to resolve colors for all variables + combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) + color_dict = self._calculation_results._resolve_colors(combined_ds, colors, coord_dim='variable', engine=engine) + if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs.to_pandas(), data_right=outputs.to_pandas(), - colors=colors, + colors=color_dict, title=title, text_info=text_info, subtitles=('Inputs', 'Outputs'), @@ -1234,7 +1317,7 @@ def plot_node_balance_pie( figure_like = plotting.dual_pie_with_matplotlib( data_left=inputs.to_pandas(), data_right=outputs.to_pandas(), - colors=colors, + colors=color_dict, title=title, subtitles=('Inputs', 'Outputs'), legend_title='Flows', @@ -1348,7 +1431,7 @@ def plot_charge_state( self, save: bool | pathlib.Path = False, show: bool = True, - colors: plotting.ColorType = 'viridis', + colors: plotting.ColorType | Literal['auto'] = 'auto', engine: plotting.PlottingEngine = 'plotly', mode: Literal['area', 'stacked_bar', 'line'] = 'area', select: dict[FlowSystemDimensions, Any] | None = None, @@ -1425,13 +1508,16 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' + # Resolve colors to a dict (handles auto, mapper, etc.) + color_dict = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) + if engine == 'plotly': # Plot flows (node balance) with the specified mode figure_like = plotting.with_plotly( ds, facet_by=facet_by, animate_by=animate_by, - colors=colors, + colors=color_dict, mode=mode, title=title, facet_cols=facet_cols, From 00010afcbcf31aaca65f199d91aeeb4396333755 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:48:44 +0200 Subject: [PATCH 039/173] Renamed variable --- flixopt/results.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index f84cd615c..47442d41a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1139,7 +1139,7 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) # Resolve colors to a dict (handles auto, mapper, etc.) - color_dict = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) + resolved_colors = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1160,7 +1160,7 @@ def plot_node_balance( ds, facet_by=facet_by, animate_by=animate_by, - colors=color_dict, + colors=resolved_colors, mode=mode, title=title, facet_cols=facet_cols, @@ -1169,7 +1169,7 @@ def plot_node_balance( else: figure_like = plotting.with_matplotlib( ds.to_dataframe(), - colors=color_dict, + colors=resolved_colors, mode=mode, title=title, ) @@ -1298,13 +1298,15 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) - color_dict = self._calculation_results._resolve_colors(combined_ds, colors, coord_dim='variable', engine=engine) + resolved_colors = self._calculation_results._resolve_colors( + combined_ds, colors, coord_dim='variable', engine=engine + ) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs.to_pandas(), data_right=outputs.to_pandas(), - colors=color_dict, + colors=resolved_colors, title=title, text_info=text_info, subtitles=('Inputs', 'Outputs'), @@ -1317,7 +1319,7 @@ def plot_node_balance_pie( figure_like = plotting.dual_pie_with_matplotlib( data_left=inputs.to_pandas(), data_right=outputs.to_pandas(), - colors=color_dict, + colors=resolved_colors, title=title, subtitles=('Inputs', 'Outputs'), legend_title='Flows', @@ -1509,7 +1511,7 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' # Resolve colors to a dict (handles auto, mapper, etc.) - color_dict = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) + resolved_colors = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) if engine == 'plotly': # Plot flows (node balance) with the specified mode @@ -1517,7 +1519,7 @@ def plot_charge_state( ds, facet_by=facet_by, animate_by=animate_by, - colors=color_dict, + colors=resolved_colors, mode=mode, title=title, facet_cols=facet_cols, From 0e42a17d13f2e3b8257460ee8f17b0a2acae1327 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:54:02 +0200 Subject: [PATCH 040/173] Add test file --- tests/test_color_mapper.py | 493 +++++++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 tests/test_color_mapper.py diff --git a/tests/test_color_mapper.py b/tests/test_color_mapper.py new file mode 100644 index 000000000..b25fcc186 --- /dev/null +++ b/tests/test_color_mapper.py @@ -0,0 +1,493 @@ +"""Tests for XarrayColorMapper functionality.""" + +import numpy as np +import pytest +import xarray as xr + +from flixopt.plotting import XarrayColorMapper + + +class TestBasicFunctionality: + """Test basic XarrayColorMapper functionality.""" + + def test_initialization_default(self): + """Test default initialization.""" + mapper = XarrayColorMapper() + assert len(mapper.get_families()) == 8 # Default families + assert 'blues' in mapper.get_families() + assert mapper.sort_within_groups is True + + def test_initialization_custom_families(self): + """Test initialization with custom color families.""" + custom_families = { + 'custom1': ['#FF0000', '#00FF00', '#0000FF'], + 'custom2': ['#FFFF00', '#FF00FF', '#00FFFF'], + } + mapper = XarrayColorMapper(color_families=custom_families, sort_within_groups=False) + assert len(mapper.get_families()) == 2 + assert 'custom1' in mapper.get_families() + assert mapper.sort_within_groups is False + + def test_add_custom_family(self): + """Test adding a custom color family.""" + mapper = XarrayColorMapper() + mapper.add_custom_family('ocean', ['#003f5c', '#2f4b7c', '#665191']) + assert 'ocean' in mapper.get_families() + assert len(mapper.get_families()['ocean']) == 3 + + +class TestPatternMatching: + """Test pattern matching functionality.""" + + def test_prefix_matching(self): + """Test prefix pattern matching.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('Product_B', 'greens', 'prefix') + + categories = ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2', 'Other'] + groups = mapper._group_categories(categories) + + assert 'blues' in groups + assert 'greens' in groups + assert '_unmatched' in groups + assert 'Product_A1' in groups['blues'] + assert 'Product_B1' in groups['greens'] + assert 'Other' in groups['_unmatched'] + + def test_suffix_matching(self): + """Test suffix pattern matching.""" + mapper = XarrayColorMapper() + mapper.add_rule('_test', 'blues', 'suffix') + mapper.add_rule('_prod', 'greens', 'suffix') + + categories = ['system_test', 'system_prod', 'development'] + groups = mapper._group_categories(categories) + + assert 'system_test' in groups['blues'] + assert 'system_prod' in groups['greens'] + assert 'development' in groups['_unmatched'] + + def test_contains_matching(self): + """Test contains pattern matching.""" + mapper = XarrayColorMapper() + mapper.add_rule('renewable', 'greens', 'contains') + mapper.add_rule('fossil', 'reds', 'contains') + + categories = ['renewable_wind', 'fossil_gas', 'renewable_solar', 'battery'] + groups = mapper._group_categories(categories) + + assert 'renewable_wind' in groups['greens'] + assert 'fossil_gas' in groups['reds'] + assert 'battery' in groups['_unmatched'] + + def test_glob_matching(self): + """Test glob pattern matching.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product_A*', 'blues', 'glob') + mapper.add_rule('*_test', 'greens', 'glob') + + categories = ['Product_A1', 'Product_A2', 'system_test', 'Other'] + groups = mapper._group_categories(categories) + + assert 'Product_A1' in groups['blues'] + assert 'system_test' in groups['greens'] + assert 'Other' in groups['_unmatched'] + + def test_regex_matching(self): + """Test regex pattern matching.""" + mapper = XarrayColorMapper() + mapper.add_rule(r'^exp_[AB]\d+$', 'blues', 'regex') + + categories = ['exp_A1', 'exp_B2', 'exp_C1', 'test'] + groups = mapper._group_categories(categories) + + assert 'exp_A1' in groups['blues'] + assert 'exp_B2' in groups['blues'] + assert 'exp_C1' in groups['_unmatched'] + + def test_invalid_regex(self): + """Test that invalid regex raises error.""" + mapper = XarrayColorMapper() + mapper.add_rule('[invalid', 'blues', 'regex') + + with pytest.raises(ValueError, match='Invalid regex pattern'): + mapper._match_rule('test', mapper.rules[0]) + + +class TestColorMapping: + """Test color mapping creation.""" + + def test_create_color_map_with_list(self): + """Test creating color map from a list.""" + mapper = XarrayColorMapper() + mapper.add_rule('A', 'blues', 'prefix') + mapper.add_rule('B', 'greens', 'prefix') + + categories = ['A1', 'A2', 'B1', 'B2'] + color_map = mapper.create_color_map(categories) + + assert len(color_map) == 4 + assert all(key in color_map for key in categories) + # A items should have blue colors, B items should have green colors + # (We can't assert exact colors as they come from plotly, but we can check they exist) + + def test_create_color_map_with_numpy_array(self): + """Test creating color map from numpy array.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product', 'blues', 'prefix') + + categories = np.array(['Product_A', 'Product_B', 'Product_C']) + color_map = mapper.create_color_map(categories) + + assert len(color_map) == 3 + assert 'Product_A' in color_map + + def test_create_color_map_with_xarray(self): + """Test creating color map from xarray DataArray.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product', 'blues', 'prefix') + + da = xr.DataArray([1, 2, 3], coords={'product': ['Product_A', 'Product_B', 'Product_C']}, dims=['product']) + color_map = mapper.create_color_map(da.coords['product']) + + assert len(color_map) == 3 + assert 'Product_A' in color_map + + def test_sorting_within_groups(self): + """Test that sorting within groups works correctly.""" + mapper = XarrayColorMapper(sort_within_groups=True) + mapper.add_rule('Product', 'blues', 'prefix') + + categories = ['Product_C', 'Product_A', 'Product_B'] + color_map = mapper.create_color_map(categories) + + # With sorting, the order should be alphabetical + keys = list(color_map.keys()) + assert keys == ['Product_A', 'Product_B', 'Product_C'] + + def test_no_sorting_within_groups(self): + """Test that disabling sorting preserves order.""" + mapper = XarrayColorMapper(sort_within_groups=False) + mapper.add_rule('Product', 'blues', 'prefix') + + categories = ['Product_C', 'Product_A', 'Product_B'] + color_map = mapper.create_color_map(categories, sort_within_groups=False) + + # Without sorting, order should match rules order, then input order within group + keys = list(color_map.keys()) + assert keys == ['Product_C', 'Product_A', 'Product_B'] + + +class TestOverrides: + """Test override functionality.""" + + def test_override_simple(self): + """Test simple override.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product', 'blues', 'prefix') + mapper.add_override({'Product_A': '#FF0000'}) + + categories = ['Product_A', 'Product_B'] + color_map = mapper.create_color_map(categories) + + assert color_map['Product_A'] == '#FF0000' + assert color_map['Product_B'] != '#FF0000' # Should use blues + + def test_override_multiple(self): + """Test multiple overrides.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product', 'blues', 'prefix') + mapper.add_override({'Product_A': '#FF0000', 'Product_B': '#00FF00'}) + + categories = ['Product_A', 'Product_B', 'Product_C'] + color_map = mapper.create_color_map(categories) + + assert color_map['Product_A'] == '#FF0000' + assert color_map['Product_B'] == '#00FF00' + # Product_C should use the rule + + def test_override_precedence(self): + """Test that overrides take precedence over rules.""" + mapper = XarrayColorMapper() + mapper.add_rule('Special', 'blues', 'prefix') + mapper.add_override({'Special_Case': '#FFD700'}) + + categories = ['Special_Case', 'Special_Normal'] + color_map = mapper.create_color_map(categories) + + # Override should take precedence + assert color_map['Special_Case'] == '#FFD700' + + +class TestXarrayIntegration: + """Test integration with xarray DataArrays.""" + + def test_apply_to_dataarray(self): + """Test applying mapper to a DataArray.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('Product_B', 'greens', 'prefix') + + da = xr.DataArray( + np.random.rand(5, 4), + coords={'time': range(5), 'product': ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2']}, + dims=['time', 'product'], + ) + + color_map = mapper.apply_to_dataarray(da, 'product') + + assert len(color_map) == 4 + assert all(prod in color_map for prod in da.product.values) + + def test_apply_to_dataarray_missing_coord(self): + """Test that applying to missing coordinate raises error.""" + mapper = XarrayColorMapper() + da = xr.DataArray(np.random.rand(5), coords={'time': range(5)}, dims=['time']) + + with pytest.raises(ValueError, match="Coordinate 'product' not found"): + mapper.apply_to_dataarray(da, 'product') + + def test_reorder_coordinate(self): + """Test reordering coordinates.""" + mapper = XarrayColorMapper() + mapper.add_rule('A', 'blues', 'prefix') + mapper.add_rule('B', 'greens', 'prefix') + + da = xr.DataArray( + np.random.rand(4), + coords={'product': ['B2', 'A1', 'B1', 'A2']}, + dims=['product'], + ) + + da_reordered = mapper.reorder_coordinate(da, 'product') + + # With sorting, items are grouped by family (order of first occurrence in input), + # then sorted within each group + # B items are encountered first, so greens group comes first + expected_order = ['B1', 'B2', 'A1', 'A2'] + assert [str(v) for v in da_reordered.product.values] == expected_order + + def test_reorder_coordinate_preserves_data(self): + """Test that reordering preserves data values.""" + mapper = XarrayColorMapper() + mapper.add_rule('A', 'blues', 'prefix') + + original_data = np.array([10, 20, 30, 40]) + da = xr.DataArray(original_data, coords={'product': ['A4', 'A1', 'A3', 'A2']}, dims=['product']) + + da_reordered = mapper.reorder_coordinate(da, 'product') + + # Check that the data is correctly reordered with the coordinates + assert da_reordered.sel(product='A1').values == 20 + assert da_reordered.sel(product='A2').values == 40 + assert da_reordered.sel(product='A3').values == 30 + assert da_reordered.sel(product='A4').values == 10 + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_categories(self): + """Test with empty categories list.""" + mapper = XarrayColorMapper() + color_map = mapper.create_color_map([]) + assert color_map == {} + + def test_duplicate_categories(self): + """Test that duplicates are handled correctly.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product', 'blues', 'prefix') + + # Duplicates should be removed + categories = ['Product_A', 'Product_B', 'Product_A', 'Product_B'] + color_map = mapper.create_color_map(categories) + + assert len(color_map) == 2 + assert 'Product_A' in color_map + assert 'Product_B' in color_map + + def test_unknown_family(self): + """Test that adding rule with unknown family raises error.""" + mapper = XarrayColorMapper() + with pytest.raises(ValueError, match='Unknown family'): + mapper.add_rule('Product', 'unknown_family', 'prefix') + + def test_invalid_match_type(self): + """Test that invalid match type raises error.""" + mapper = XarrayColorMapper() + with pytest.raises(ValueError, match='match_type must be one of'): + mapper.add_rule('Product', 'blues', 'invalid_type') + + def test_first_match_wins(self): + """Test that first matching rule wins.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product', 'blues', 'prefix') + mapper.add_rule('Product_A', 'greens', 'prefix') # More specific rule added second + + categories = ['Product_A1', 'Product_B1'] + groups = mapper._group_categories(categories) + + # Both should match the first rule (Product) since it's added first + assert 'Product_A1' in groups['blues'] + assert 'Product_B1' in groups['blues'] + + def test_more_items_than_colors(self): + """Test behavior when there are more items than colors in a family.""" + mapper = XarrayColorMapper() + mapper.add_rule('Item', 'blues', 'prefix') + + # Create many items (more than the 5 colors in blues family) + categories = [f'Item_{i}' for i in range(10)] + color_map = mapper.create_color_map(categories) + + # Should cycle through colors + assert len(color_map) == 10 + # First and 6th item should have the same color (cycling) + assert color_map['Item_0'] == color_map['Item_5'] + + +class TestInspectionMethods: + """Test inspection methods.""" + + def test_get_rules(self): + """Test getting rules.""" + mapper = XarrayColorMapper() + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('Product_B', 'greens', 'suffix') + + rules = mapper.get_rules() + assert len(rules) == 2 + assert rules[0]['pattern'] == 'Product_A' + assert rules[0]['family'] == 'blues' + assert rules[0]['match_type'] == 'prefix' + + def test_get_overrides(self): + """Test getting overrides.""" + mapper = XarrayColorMapper() + mapper.add_override({'Special': '#FFD700', 'Other': '#FF0000'}) + + overrides = mapper.get_overrides() + assert len(overrides) == 2 + assert overrides['Special'] == '#FFD700' + + def test_get_families(self): + """Test getting color families.""" + mapper = XarrayColorMapper() + families = mapper.get_families() + + assert 'blues' in families + assert 'greens' in families + assert len(families['blues']) == 5 # Blues[2:7] has 5 colors + + +class TestMethodChaining: + """Test method chaining.""" + + def test_chaining_add_rule(self): + """Test that add_rule returns self for chaining.""" + mapper = XarrayColorMapper() + result = mapper.add_rule('Product', 'blues', 'prefix') + assert result is mapper + + def test_chaining_add_override(self): + """Test that add_override returns self for chaining.""" + mapper = XarrayColorMapper() + result = mapper.add_override({'Special': '#FFD700'}) + assert result is mapper + + def test_chaining_add_custom_family(self): + """Test that add_custom_family returns self for chaining.""" + mapper = XarrayColorMapper() + result = mapper.add_custom_family('custom', ['#FF0000']) + assert result is mapper + + def test_full_chaining(self): + """Test full method chaining.""" + mapper = ( + XarrayColorMapper() + .add_custom_family('ocean', ['#003f5c', '#2f4b7c']) + .add_rule('Product_A', 'blues', 'prefix') + .add_rule('Product_B', 'greens', 'prefix') + .add_override({'Special': '#FFD700'}) + ) + + assert len(mapper.get_rules()) == 2 + assert len(mapper.get_overrides()) == 1 + assert 'ocean' in mapper.get_families() + + +class TestRealWorldScenarios: + """Test real-world usage scenarios.""" + + def test_energy_system_components(self): + """Test color mapping for energy system components.""" + mapper = ( + XarrayColorMapper() + .add_rule('Solar', 'oranges', 'prefix') + .add_rule('Wind', 'blues', 'prefix') + .add_rule('Gas', 'reds', 'prefix') + .add_rule('Battery', 'greens', 'prefix') + .add_override({'Grid_Import': '#808080'}) + ) + + components = ['Solar_PV', 'Wind_Turbine', 'Gas_Turbine', 'Battery_Storage', 'Grid_Import'] + da = xr.DataArray( + np.random.rand(24, len(components)), + coords={'time': range(24), 'component': components}, + dims=['time', 'component'], + ) + + color_map = mapper.apply_to_dataarray(da, 'component') + + assert color_map['Grid_Import'] == '#808080' # Override + assert all(comp in color_map for comp in components) + + def test_scenario_analysis(self): + """Test color mapping for scenario analysis.""" + mapper = ( + XarrayColorMapper() + .add_rule('baseline*', 'greys', 'glob') + .add_rule('renewable_high*', 'greens', 'glob') + .add_rule('renewable_low*', 'teals', 'glob') + .add_rule('fossil*', 'reds', 'glob') + ) + + scenarios = [ + 'baseline_2030', + 'baseline_2050', + 'renewable_high_2030', + 'renewable_low_2050', + 'fossil_phase_out_2040', + ] + + color_map = mapper.create_color_map(scenarios) + assert len(color_map) == 5 + + def test_product_tiers(self): + """Test color mapping for product tiers.""" + mapper = ( + XarrayColorMapper() + .add_rule('Premium_', 'purples', 'prefix') + .add_rule('Standard_', 'blues', 'prefix') + .add_rule('Budget_', 'greens', 'prefix') + ) + + products = ['Premium_A', 'Premium_B', 'Standard_A', 'Standard_B', 'Budget_A', 'Budget_B'] + da = xr.DataArray( + np.random.rand(10, 6), coords={'time': range(10), 'product': products}, dims=['time', 'product'] + ) + + da_reordered = mapper.reorder_coordinate(da, 'product') + mapper.apply_to_dataarray(da_reordered, 'product') + + # Check grouping: all Premium together, then Standard, then Budget + reordered_products = list(da_reordered.product.values) + premium_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Premium_')] + standard_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Standard_')] + budget_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Budget_')] + + # Check that groups are contiguous + assert premium_indices == list(range(min(premium_indices), max(premium_indices) + 1)) + assert standard_indices == list(range(min(standard_indices), max(standard_indices) + 1)) + assert budget_indices == list(range(min(budget_indices), max(budget_indices) + 1)) From 2aeecd8b5ebada4f92502403a6e66f3356267ae8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:55:18 +0200 Subject: [PATCH 041/173] Improve integration of the ColorMapper --- flixopt/results.py | 47 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 47442d41a..b2665192f 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -257,35 +257,50 @@ def _resolve_colors( self, data: xr.DataArray | xr.Dataset, colors: plotting.ColorType | Literal['auto'], + color_mapper: plotting.XarrayColorMapper | None = None, coord_dim: str = 'variable', engine: plotting.PlottingEngine = 'plotly', ) -> dict[str, str]: """Resolve colors parameter to a color mapping dict. - This internal helper handles all color parameter types and applies the color mapper - intelligently based on the data structure. + This helper handles all color parameter types and applies the color mapper + intelligently based on the data structure. Can be used standalone or as part + of CalculationResults. Args: data: DataArray or Dataset to create colors for colors: Color specification or 'auto' + color_mapper: Optional XarrayColorMapper to use. If None and colors='auto', + falls back to default colormap coord_dim: Coordinate dimension to map colors to engine: Plotting engine ('plotly' or 'matplotlib') Returns: Dictionary mapping coordinate values to colors + + Examples: + With CalculationResults (uses instance color_mapper): + + >>> resolved_colors = results._resolve_colors(data, 'auto', results.color_mapper) + + Standalone usage: + + >>> mapper = XarrayColorMapper() + >>> mapper.add_rule('Solar', 'oranges', 'prefix') + >>> resolved_colors = results._resolve_colors(data, 'auto', mapper) """ # If explicit dict provided, use it directly if isinstance(colors, dict): return colors - # If 'auto', use class mapper if available, else fall back to default + # If 'auto', use provided mapper if available, else fall back to default if colors == 'auto': - if self.color_mapper is not None: + if color_mapper is not None: # Apply reordering if configured in mapper - if self.color_mapper.sort_within_groups: + if color_mapper.sort_within_groups: # Check if coord_dim exists and reorder if isinstance(data, xr.DataArray) and coord_dim in data.coords: - data = self.color_mapper.reorder_coordinate(data, coord_dim) + data = color_mapper.reorder_coordinate(data, coord_dim) elif isinstance(data, xr.Dataset): # For Dataset, we'll work with the variables directly pass @@ -293,13 +308,13 @@ def _resolve_colors( # Apply color mapper to get dict if isinstance(data, xr.DataArray): if coord_dim in data.coords: - return self.color_mapper.apply_to_dataarray(data, coord_dim) + return color_mapper.apply_to_dataarray(data, coord_dim) elif isinstance(data, xr.Dataset): # For Dataset, map colors to variable names labels = [str(v) for v in data.data_vars] - return self.color_mapper.create_color_map(labels) + return color_mapper.create_color_map(labels) - # No mapper configured, fall back to default colormap + # No mapper provided, fall back to default colormap colors = 'viridis' # If string or list, use ColorProcessor (traditional behavior) @@ -1139,7 +1154,9 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) # Resolve colors to a dict (handles auto, mapper, etc.) - resolved_colors = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) + resolved_colors = self._calculation_results._resolve_colors( + ds, colors, color_mapper=self._calculation_results.color_mapper, coord_dim='variable', engine=engine + ) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1299,7 +1316,11 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) resolved_colors = self._calculation_results._resolve_colors( - combined_ds, colors, coord_dim='variable', engine=engine + combined_ds, + colors, + color_mapper=self._calculation_results.color_mapper, + coord_dim='variable', + engine=engine, ) if engine == 'plotly': @@ -1511,7 +1532,9 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' # Resolve colors to a dict (handles auto, mapper, etc.) - resolved_colors = self._calculation_results._resolve_colors(ds, colors, coord_dim='variable', engine=engine) + resolved_colors = self._calculation_results._resolve_colors( + ds, colors, color_mapper=self._calculation_results.color_mapper, coord_dim='variable', engine=engine + ) if engine == 'plotly': # Plot flows (node balance) with the specified mode From 3c1a9c0288c42d1780141a3ecdfcd90cc157e802 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:00:06 +0200 Subject: [PATCH 042/173] Improve integration of the ColorMapper --- flixopt/results.py | 93 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b2665192f..67390bc7e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -35,6 +35,93 @@ class _FlowSystemRestorationError(Exception): pass +def resolve_colors( + data: xr.DataArray | xr.Dataset, + colors: plotting.ColorType | Literal['auto'], + color_mapper: plotting.XarrayColorMapper | None = None, + coord_dim: str = 'variable', + engine: plotting.PlottingEngine = 'plotly', +) -> dict[str, str]: + """Resolve colors parameter to a color mapping dict. + + This public utility function handles all color parameter types and applies the + color mapper intelligently based on the data structure. Can be used standalone + or as part of CalculationResults. + + Args: + data: DataArray or Dataset to create colors for + colors: Color specification or 'auto' + color_mapper: Optional XarrayColorMapper to use. If None and colors='auto', + falls back to default colormap + coord_dim: Coordinate dimension to map colors to + engine: Plotting engine ('plotly' or 'matplotlib') + + Returns: + Dictionary mapping coordinate values to colors + + Examples: + With CalculationResults: + + >>> resolved_colors = resolve_colors(data, 'auto', results.color_mapper) + + Standalone usage: + + >>> mapper = plotting.XarrayColorMapper() + >>> mapper.add_rule('Solar', 'oranges', 'prefix') + >>> resolved_colors = resolve_colors(data, 'auto', mapper) + + Without mapper: + + >>> resolved_colors = resolve_colors(data, 'viridis') + """ + # If explicit dict provided, use it directly + if isinstance(colors, dict): + return colors + + # If string or list, use ColorProcessor (traditional behavior) + if isinstance(colors, (str, list)): + if isinstance(data, xr.DataArray): + if coord_dim in data.coords: + labels = [str(v) for v in data.coords[coord_dim].values] + else: + labels = [] + elif isinstance(data, xr.Dataset): + labels = [str(v) for v in data.data_vars] + else: + labels = [] + + if labels: + processor = plotting.ColorProcessor(engine=engine) + return processor.process_colors(colors, labels, return_mapping=True) + + # If 'auto', use provided mapper if available, else fall back to default + if colors == 'auto': + if color_mapper is not None: + # Apply reordering if configured in mapper + if color_mapper.sort_within_groups: + # Check if coord_dim exists and reorder + if isinstance(data, xr.DataArray) and coord_dim in data.coords: + data = color_mapper.reorder_coordinate(data, coord_dim) + elif isinstance(data, xr.Dataset): + # For Dataset, we'll work with the variables directly + pass + + # Apply color mapper to get dict + if isinstance(data, xr.DataArray): + if coord_dim in data.coords: + return color_mapper.apply_to_dataarray(data, coord_dim) + elif isinstance(data, xr.Dataset): + # For Dataset, map colors to variable names + labels = [str(v) for v in data.data_vars] + return color_mapper.create_color_map(labels) + + # No mapper provided, fall back to default colormap + colors = 'viridis' + + # Safe fallback + return {} + + class CalculationResults: """Comprehensive container for optimization calculation results and analysis tools. @@ -1154,7 +1241,7 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) # Resolve colors to a dict (handles auto, mapper, etc.) - resolved_colors = self._calculation_results._resolve_colors( + resolved_colors = resolve_colors( ds, colors, color_mapper=self._calculation_results.color_mapper, coord_dim='variable', engine=engine ) @@ -1315,7 +1402,7 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) - resolved_colors = self._calculation_results._resolve_colors( + resolved_colors = resolve_colors( combined_ds, colors, color_mapper=self._calculation_results.color_mapper, @@ -1532,7 +1619,7 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' # Resolve colors to a dict (handles auto, mapper, etc.) - resolved_colors = self._calculation_results._resolve_colors( + resolved_colors = resolve_colors( ds, colors, color_mapper=self._calculation_results.color_mapper, coord_dim='variable', engine=engine ) From 8c2c93343819faf9a898d163bfad3e463fce18d7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:09:27 +0200 Subject: [PATCH 043/173] Update resolve_colors and move to plotting.py --- flixopt/plotting.py | 78 +++++++++++++++++ flixopt/results.py | 201 +++++--------------------------------------- 2 files changed, 99 insertions(+), 180 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 1a284f302..49262826b 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -711,6 +711,84 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: return groups +def resolve_colors( + data: xr.DataArray | xr.Dataset, + colors: ColorType | XarrayColorMapper, + coord_dim: str = 'variable', + engine: PlottingEngine = 'plotly', +) -> dict[str, str]: + """Resolve colors parameter to a color mapping dict. + + This public utility function handles all color parameter types and applies the + color mapper intelligently based on the data structure. Can be used standalone + or as part of CalculationResults. + + Args: + data: DataArray or Dataset to create colors for + colors: Color specification or a XarrayColorMapper to use + coord_dim: Coordinate dimension to map colors to + engine: Plotting engine ('plotly' or 'matplotlib') + + Returns: + Dictionary mapping coordinate values to colors + + Examples: + With CalculationResults: + + >>> resolved_colors = resolve_colors(data, results.color_mapper) + + Standalone usage: + + >>> mapper = plotting.XarrayColorMapper() + >>> mapper.add_rule('Solar', 'oranges', 'prefix') + >>> resolved_colors = resolve_colors(data, mapper) + + Without mapper: + + >>> resolved_colors = resolve_colors(data, 'viridis') + """ + # If explicit dict provided, use it directly + if isinstance(colors, dict): + return colors + + # If string or list, use ColorProcessor (traditional behavior) + if isinstance(colors, (str, list)): + if isinstance(data, xr.DataArray): + if coord_dim in data.coords: + labels = [str(v) for v in data.coords[coord_dim].values] + else: + labels = [] + elif isinstance(data, xr.Dataset): + labels = [str(v) for v in data.data_vars] + else: + labels = [] + + if labels: + processor = ColorProcessor(engine=engine) + return processor.process_colors(colors, labels, return_mapping=True) + + if isinstance(colors, XarrayColorMapper): + color_mapper = colors + if color_mapper.sort_within_groups: + # Check if coord_dim exists and reorder + if isinstance(data, xr.DataArray) and coord_dim in data.coords: + data = color_mapper.reorder_coordinate(data, coord_dim) + elif isinstance(data, xr.Dataset): + # For Dataset, we'll work with the variables directly + pass + + # Apply color mapper to get dict + if isinstance(data, xr.DataArray): + if coord_dim in data.coords: + return color_mapper.apply_to_dataarray(data, coord_dim) + elif isinstance(data, xr.Dataset): + # For Dataset, map colors to variable names + labels = [str(v) for v in data.data_vars] + return color_mapper.create_color_map(labels) + + raise TypeError(f'Wrong type passed to resolve_colors(): {type(colors)}') + + def with_plotly( data: pd.DataFrame | xr.DataArray | xr.Dataset, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', diff --git a/flixopt/results.py b/flixopt/results.py index 67390bc7e..74a042367 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -35,93 +35,6 @@ class _FlowSystemRestorationError(Exception): pass -def resolve_colors( - data: xr.DataArray | xr.Dataset, - colors: plotting.ColorType | Literal['auto'], - color_mapper: plotting.XarrayColorMapper | None = None, - coord_dim: str = 'variable', - engine: plotting.PlottingEngine = 'plotly', -) -> dict[str, str]: - """Resolve colors parameter to a color mapping dict. - - This public utility function handles all color parameter types and applies the - color mapper intelligently based on the data structure. Can be used standalone - or as part of CalculationResults. - - Args: - data: DataArray or Dataset to create colors for - colors: Color specification or 'auto' - color_mapper: Optional XarrayColorMapper to use. If None and colors='auto', - falls back to default colormap - coord_dim: Coordinate dimension to map colors to - engine: Plotting engine ('plotly' or 'matplotlib') - - Returns: - Dictionary mapping coordinate values to colors - - Examples: - With CalculationResults: - - >>> resolved_colors = resolve_colors(data, 'auto', results.color_mapper) - - Standalone usage: - - >>> mapper = plotting.XarrayColorMapper() - >>> mapper.add_rule('Solar', 'oranges', 'prefix') - >>> resolved_colors = resolve_colors(data, 'auto', mapper) - - Without mapper: - - >>> resolved_colors = resolve_colors(data, 'viridis') - """ - # If explicit dict provided, use it directly - if isinstance(colors, dict): - return colors - - # If string or list, use ColorProcessor (traditional behavior) - if isinstance(colors, (str, list)): - if isinstance(data, xr.DataArray): - if coord_dim in data.coords: - labels = [str(v) for v in data.coords[coord_dim].values] - else: - labels = [] - elif isinstance(data, xr.Dataset): - labels = [str(v) for v in data.data_vars] - else: - labels = [] - - if labels: - processor = plotting.ColorProcessor(engine=engine) - return processor.process_colors(colors, labels, return_mapping=True) - - # If 'auto', use provided mapper if available, else fall back to default - if colors == 'auto': - if color_mapper is not None: - # Apply reordering if configured in mapper - if color_mapper.sort_within_groups: - # Check if coord_dim exists and reorder - if isinstance(data, xr.DataArray) and coord_dim in data.coords: - data = color_mapper.reorder_coordinate(data, coord_dim) - elif isinstance(data, xr.Dataset): - # For Dataset, we'll work with the variables directly - pass - - # Apply color mapper to get dict - if isinstance(data, xr.DataArray): - if coord_dim in data.coords: - return color_mapper.apply_to_dataarray(data, coord_dim) - elif isinstance(data, xr.Dataset): - # For Dataset, map colors to variable names - labels = [str(v) for v in data.data_vars] - return color_mapper.create_color_map(labels) - - # No mapper provided, fall back to default colormap - colors = 'viridis' - - # Safe fallback - return {} - - class CalculationResults: """Comprehensive container for optimization calculation results and analysis tools. @@ -340,89 +253,6 @@ def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults return self.flows[key] raise KeyError(f'No element with label {key} found.') - def _resolve_colors( - self, - data: xr.DataArray | xr.Dataset, - colors: plotting.ColorType | Literal['auto'], - color_mapper: plotting.XarrayColorMapper | None = None, - coord_dim: str = 'variable', - engine: plotting.PlottingEngine = 'plotly', - ) -> dict[str, str]: - """Resolve colors parameter to a color mapping dict. - - This helper handles all color parameter types and applies the color mapper - intelligently based on the data structure. Can be used standalone or as part - of CalculationResults. - - Args: - data: DataArray or Dataset to create colors for - colors: Color specification or 'auto' - color_mapper: Optional XarrayColorMapper to use. If None and colors='auto', - falls back to default colormap - coord_dim: Coordinate dimension to map colors to - engine: Plotting engine ('plotly' or 'matplotlib') - - Returns: - Dictionary mapping coordinate values to colors - - Examples: - With CalculationResults (uses instance color_mapper): - - >>> resolved_colors = results._resolve_colors(data, 'auto', results.color_mapper) - - Standalone usage: - - >>> mapper = XarrayColorMapper() - >>> mapper.add_rule('Solar', 'oranges', 'prefix') - >>> resolved_colors = results._resolve_colors(data, 'auto', mapper) - """ - # If explicit dict provided, use it directly - if isinstance(colors, dict): - return colors - - # If 'auto', use provided mapper if available, else fall back to default - if colors == 'auto': - if color_mapper is not None: - # Apply reordering if configured in mapper - if color_mapper.sort_within_groups: - # Check if coord_dim exists and reorder - if isinstance(data, xr.DataArray) and coord_dim in data.coords: - data = color_mapper.reorder_coordinate(data, coord_dim) - elif isinstance(data, xr.Dataset): - # For Dataset, we'll work with the variables directly - pass - - # Apply color mapper to get dict - if isinstance(data, xr.DataArray): - if coord_dim in data.coords: - return color_mapper.apply_to_dataarray(data, coord_dim) - elif isinstance(data, xr.Dataset): - # For Dataset, map colors to variable names - labels = [str(v) for v in data.data_vars] - return color_mapper.create_color_map(labels) - - # No mapper provided, fall back to default colormap - colors = 'viridis' - - # If string or list, use ColorProcessor (traditional behavior) - if isinstance(colors, (str, list)): - if isinstance(data, xr.DataArray): - if coord_dim in data.coords: - labels = [str(v) for v in data.coords[coord_dim].values] - else: - labels = [] - elif isinstance(data, xr.Dataset): - labels = [str(v) for v in data.data_vars] - else: - labels = [] - - if labels: - processor = plotting.ColorProcessor(engine=engine) - return processor.process_colors(colors, labels, return_mapping=True) - - # Safe fallback - return {} - @property def storages(self) -> list[ComponentResults]: """Get all storage components in the results.""" @@ -1241,9 +1071,14 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) # Resolve colors to a dict (handles auto, mapper, etc.) - resolved_colors = resolve_colors( - ds, colors, color_mapper=self._calculation_results.color_mapper, coord_dim='variable', engine=engine + colors_to_use = ( + self._calculation_results.color_mapper + if colors == 'auto' and self._calculation_results.color_mapper is not None + else 'viridis' + if colors == 'auto' + else colors ) + resolved_colors = plotting.resolve_colors(ds, colors_to_use, coord_dim='variable', engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1402,13 +1237,14 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) - resolved_colors = resolve_colors( - combined_ds, - colors, - color_mapper=self._calculation_results.color_mapper, - coord_dim='variable', - engine=engine, + colors_to_use = ( + self._calculation_results.color_mapper + if colors == 'auto' and self._calculation_results.color_mapper is not None + else 'viridis' + if colors == 'auto' + else colors ) + resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, coord_dim='variable', engine=engine) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( @@ -1619,9 +1455,14 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' # Resolve colors to a dict (handles auto, mapper, etc.) - resolved_colors = resolve_colors( - ds, colors, color_mapper=self._calculation_results.color_mapper, coord_dim='variable', engine=engine + colors_to_use = ( + self._calculation_results.color_mapper + if colors == 'auto' and self._calculation_results.color_mapper is not None + else 'viridis' + if colors == 'auto' + else colors ) + resolved_colors = plotting.resolve_colors(ds, colors_to_use, coord_dim='variable', engine=engine) if engine == 'plotly': # Plot flows (node balance) with the specified mode From 0644e9f980cbf75bf1cebb44ff50a732336ce7a3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:16:53 +0200 Subject: [PATCH 044/173] Temporalily add example script to show/document intended usage --- examples/color_mapper_example.py | 294 +++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 examples/color_mapper_example.py diff --git a/examples/color_mapper_example.py b/examples/color_mapper_example.py new file mode 100644 index 000000000..fde7e3c86 --- /dev/null +++ b/examples/color_mapper_example.py @@ -0,0 +1,294 @@ +""" +XarrayColorMapper Usage Example +================================ + +This example demonstrates how to use the XarrayColorMapper for creating +meaningful, visually-grouped color schemes in your plots. Instead of random +colors, it assigns color families (like shades of blue, green, red) based on +coordinate value patterns, making plots more intuitive. + +INTEGRATION WITH CALCULATIONRESULTS: +The XarrayColorMapper integrates seamlessly with flixopt's CalculationResults +plotting methods. Use results.create_color_mapper() to get started, then pass +the generated color maps directly to any plotting method via the `colors` parameter. +""" + +import numpy as np +import plotly.express as px +import xarray as xr + +from flixopt.plotting import XarrayColorMapper, with_plotly + +print('=' * 70) +print('XarrayColorMapper - Comprehensive Usage Example') +print('=' * 70) +print() + +# ============================================================================ +# Example 1: Basic Pattern-Based Coloring +# ============================================================================ +print('Example 1: Basic Pattern-Based Coloring') +print('-' * 70) + +# Create sample data with product categories +np.random.seed(42) +time_coords = np.arange(0, 24) +products = ['Premium_A', 'Premium_B', 'Standard_A', 'Standard_B', 'Budget_A', 'Budget_B'] + +data = xr.DataArray( + np.random.rand(24, 6) * 100 + np.array([100, 90, 70, 65, 40, 35]), # Different base values + coords={'time': time_coords, 'product': products}, + dims=['time', 'product'], + name='sales', +) + +# Setup color mapper with rules based on product tiers +mapper = ( + XarrayColorMapper() + .add_rule('Premium_', 'purples', 'prefix') # Premium products get purple shades + .add_rule('Standard_', 'blues', 'prefix') # Standard products get blue shades + .add_rule('Budget_', 'greens', 'prefix') +) # Budget products get green shades + +# Reorder products by tier for better visual grouping +data_reordered = mapper.reorder_coordinate(data, 'product') +print('Products reordered by tier:', data_reordered.product.values) + +# Get color mapping +color_map = mapper.apply_to_dataarray(data_reordered, 'product') +print('\nColor assignments:') +for product, color in color_map.items(): + print(f' {product}: {color}') + +print('\n✓ Data prepared with pattern-based color grouping') +print() + +# ============================================================================ +# Example 2: Using with Plotly Express +# ============================================================================ +print('Example 2: Integration with Plotly Express') +print('-' * 70) + +# Convert to DataFrame for plotting +df = data_reordered.to_dataframe(name='sales').reset_index() + +# Create a line plot with the custom color mapping +fig = px.line( + df, + x='time', + y='sales', + color='product', + color_discrete_map=color_map, # Use our custom color mapping + title='Product Sales Over Time (Grouped by Tier)', + labels={'time': 'Hour of Day', 'sales': 'Sales ($)', 'product': 'Product'}, + markers=True, +) + +print('✓ Plotly figure created with grouped colors') +print(' - Premium products: Purple shades') +print(' - Standard products: Blue shades') +print(' - Budget products: Green shades') +print() + +# To save or show the plot: +# fig.write_html('product_sales.html') +# fig.show() + +# ============================================================================ +# Example 3: Using with flixopt's with_plotly +# ============================================================================ +print('Example 3: Integration with flixopt.plotting.with_plotly') +print('-' * 70) + +# The color_map can be passed directly to with_plotly +fig2 = with_plotly( + data_reordered, + mode='area', + colors=color_map, # Pass the color mapping directly + title='Product Sales - Stacked Area Chart', + ylabel='Sales ($)', + xlabel='Hour of Day', +) + +print('✓ Created stacked area chart with grouped colors using with_plotly()') +print() + +# ============================================================================ +# Example 4: Advanced Pattern Matching +# ============================================================================ +print('Example 4: Advanced Pattern Matching (Glob and Regex)') +print('-' * 70) + +# Create scenario data with complex naming +scenarios = [ + 'baseline_2020', + 'baseline_2030', + 'baseline_2050', + 'renewable_high_2030', + 'renewable_high_2050', + 'renewable_low_2030', + 'renewable_low_2050', + 'fossil_phase_out_2030', + 'fossil_phase_out_2050', +] + +scenario_data = xr.DataArray( + np.random.rand(10, len(scenarios)), + coords={'time': np.arange(10), 'scenario': scenarios}, + dims=['time', 'scenario'], + name='emissions', +) + +# Setup mapper with different pattern types +scenario_mapper = ( + XarrayColorMapper() + .add_rule('baseline*', 'greys', 'glob') # Glob pattern for baseline scenarios + .add_rule('renewable_high*', 'greens', 'glob') # High renewable scenarios + .add_rule('renewable_low*', 'teals', 'glob') # Low renewable scenarios + .add_rule('fossil*', 'reds', 'glob') +) # Fossil phase-out scenarios + +# Apply coloring +scenario_data_reordered = scenario_mapper.reorder_coordinate(scenario_data, 'scenario') +scenario_colors = scenario_mapper.apply_to_dataarray(scenario_data_reordered, 'scenario') + +print('Scenarios grouped by type:') +print(' Reordered scenarios:', list(scenario_data_reordered.scenario.values)) +print('\nColor assignments:') +for scenario, color in scenario_colors.items(): + print(f' {scenario}: {color}') + +print('\n✓ Complex pattern matching with glob patterns') +print() + +# ============================================================================ +# Example 5: Using Overrides for Special Cases +# ============================================================================ +print('Example 5: Using Overrides for Special Cases') +print('-' * 70) + +# Create component data +components = ['Solar_PV', 'Wind_Turbine', 'Gas_Turbine', 'Battery_Storage', 'Grid_Import'] + +component_data = xr.DataArray( + np.random.rand(20, len(components)), + coords={'time': np.arange(20), 'component': components}, + dims=['time', 'component'], + name='power', +) + +# Setup mapper with rules and special overrides +component_mapper = ( + XarrayColorMapper() + .add_rule('Solar', 'oranges', 'prefix') # Solar components + .add_rule('Wind', 'blues', 'prefix') # Wind components + .add_rule('Gas', 'reds', 'prefix') # Gas components + .add_rule('Battery', 'greens', 'prefix') # Battery components + .add_override( + { # Special cases override the rules + 'Grid_Import': '#808080' # Grey for grid import + } + ) +) + +component_colors = component_mapper.apply_to_dataarray(component_data, 'component') + +print('Component color assignments:') +for component, color in component_colors.items(): + override_marker = ' [OVERRIDE]' if component == 'Grid_Import' else '' + print(f' {component}: {color}{override_marker}') + +print('\n✓ Rules with override for special cases') +print() + +# ============================================================================ +# Example 6: Custom Color Families +# ============================================================================ +print('Example 6: Creating Custom Color Families') +print('-' * 70) + +# Create custom color families for specific use cases +custom_mapper = XarrayColorMapper() +custom_mapper.add_custom_family('ocean', ['#003f5c', '#2f4b7c', '#665191', '#a05195', '#d45087']) +custom_mapper.add_custom_family('sunset', ['#ffa600', '#ff7c43', '#f95d6a', '#d45087', '#a05195']) + +custom_mapper.add_rule('ocean_', 'ocean', 'prefix') +custom_mapper.add_rule('land_', 'sunset', 'prefix') + +zones = ['ocean_shallow', 'ocean_deep', 'ocean_reef', 'land_forest', 'land_desert', 'land_urban'] +zone_data = xr.DataArray( + np.random.rand(10, len(zones)), + coords={'time': np.arange(10), 'zone': zones}, + dims=['time', 'zone'], + name='temperature', +) + +zone_colors = custom_mapper.apply_to_dataarray(zone_data, 'zone') + +print('Custom color families:') +print(' Available families:', list(custom_mapper.get_families().keys())) +print('\nZone color assignments:') +for zone, color in zone_colors.items(): + print(f' {zone}: {color}') + +print('\n✓ Custom color families defined and applied') +print() + +# ============================================================================ +# Example 7: Integration with CalculationResults (Pattern) +# ============================================================================ +print('Example 7: Integration with CalculationResults') +print('-' * 70) + +print(""" +USAGE PATTERN WITH CALCULATIONRESULTS: + +# METHOD 1: Automatic usage (recommended) +# Create and configure mapper - it's automatically used by all plots +mapper = results.create_color_mapper() +mapper.add_rule('Solar', 'oranges', 'prefix') +mapper.add_rule('Wind', 'blues', 'prefix') +mapper.add_rule('Gas', 'reds', 'prefix') +mapper.add_rule('Battery', 'greens', 'prefix') + +# All plotting methods automatically use the mapper (colors='auto' is default) +results['ElectricityBus'].plot_node_balance() # Automatically uses mapper! +results['Battery'].plot_charge_state() # Also uses mapper! + +# METHOD 2: Manual color map generation +# Get data and generate colors manually for full control +data = results['ElectricityBus'].node_balance() +data_reordered = mapper.reorder_coordinate(data, 'variable') +colors = mapper.apply_to_dataarray(data_reordered, 'variable') + +# Pass colors explicitly to plotting functions +results['ElectricityBus'].plot_node_balance(colors=colors) +fig = plotting.with_plotly(data_reordered, colors=colors) + +# METHOD 3: Direct assignment of existing mapper +my_mapper = XarrayColorMapper() +my_mapper.add_rule('Renewable', 'greens', 'prefix') +results.color_mapper = my_mapper # Direct assignment works too! +""") + +print('✓ Pattern demonstrated for CalculationResults integration') +print() + +# ============================================================================ +# Summary +# ============================================================================ +print('=' * 70) +print('Summary: XarrayColorMapper Key Benefits') +print('=' * 70) +print() +print('1. Visual Grouping: Similar items get similar colors automatically') +print('2. Pattern Matching: Flexible matching with prefix, suffix, glob, regex') +print('3. Coordinate Reordering: Group similar items together in plots') +print('4. Override Support: Handle special cases with explicit colors') +print('5. Custom Families: Define your own color schemes') +print('6. Easy Integration: Works seamlessly with Plotly and flixopt plotting') +print('7. CalculationResults Support: Convenience method for quick setup') +print() +print('=' * 70) +print('All examples completed successfully!') +print('=' * 70) From 78e1894fffcda626ab24b2d0da9e21d13c6eb925 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:17:36 +0200 Subject: [PATCH 045/173] Add method create_color_mapper --- flixopt/results.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index 74a042367..c41e318fe 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -308,6 +308,39 @@ def flow_system(self) -> FlowSystem: logger.level = old_level return self._flow_system + def create_color_mapper(self) -> plotting.XarrayColorMapper: + """Create and assign a new XarrayColorMapper for this results instance. + + The color mapper is automatically used by all plotting methods when colors='auto' + (the default). Configure it with rules to define pattern-based color grouping. + + You can also assign an existing mapper directly via `results.color_mapper = mapper`. + + Returns: + The newly created XarrayColorMapper, ready to be configured with rules. + + Examples: + Create and configure a new mapper: + + >>> mapper = results.create_color_mapper() + >>> mapper.add_rule('Solar', 'oranges', 'prefix') + >>> mapper.add_rule('Wind', 'blues', 'prefix') + >>> mapper.add_rule('Gas', 'reds', 'prefix') + >>> results['ElectricityBus'].plot_node_balance() # Uses mapper automatically + + Or assign an existing mapper: + + >>> my_mapper = plotting.XarrayColorMapper() + >>> my_mapper.add_rule('Renewable', 'greens', 'prefix') + >>> results.color_mapper = my_mapper + + Override with explicit colors if needed: + + >>> results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores mapper + """ + self.color_mapper = plotting.XarrayColorMapper() + return self.color_mapper + def filter_solution( self, variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, From 3d4788a3ecdc41baecf5456878df87943992d21f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:19:09 +0200 Subject: [PATCH 046/173] Improve docstring --- flixopt/results.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index c41e318fe..b025fb3eb 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -69,6 +69,10 @@ class CalculationResults: effects: Dictionary mapping effect names to EffectResults objects timesteps_extra: Extended time index including boundary conditions hours_per_timestep: Duration of each timestep for proper energy calculations + color_mapper: Optional XarrayColorMapper for automatic pattern-based coloring in plots. + When set, all plotting methods automatically use this mapper when colors='auto' + (the default). Use `create_color_mapper()` to create and configure one, or assign + an existing mapper directly. Set to None to disable automatic coloring. Examples: Load and analyze saved results: @@ -107,6 +111,24 @@ class CalculationResults: ).mean() ``` + Configure automatic color mapping for plots: + + ```python + # Create and configure a color mapper for pattern-based coloring + mapper = results.create_color_mapper() + mapper.add_rule('Solar', 'oranges', 'prefix') # Solar components get orange shades + mapper.add_rule('Wind', 'blues', 'prefix') # Wind components get blue shades + mapper.add_rule('Battery', 'greens', 'prefix') # Battery components get green shades + mapper.add_rule('Gas', 'reds', 'prefix') # Gas components get red shades + + # All plots automatically use the mapper (colors='auto' is the default) + results['ElectricityBus'].plot_node_balance() # Uses configured colors + results['Battery'].plot_charge_state() # Also uses configured colors + + # Override when needed + results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores mapper + ``` + Design Patterns: **Factory Methods**: Use `from_file()` and `from_calculation()` for creation or access directly from `Calculation.results` **Dictionary Access**: Use `results[element_label]` for element-specific results From 60c44a9f4e60c5cfd2ee7f978fbb58134aa41d7f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:20:38 +0200 Subject: [PATCH 047/173] Remove example file again --- examples/color_mapper_example.py | 294 ------------------------------- 1 file changed, 294 deletions(-) delete mode 100644 examples/color_mapper_example.py diff --git a/examples/color_mapper_example.py b/examples/color_mapper_example.py deleted file mode 100644 index fde7e3c86..000000000 --- a/examples/color_mapper_example.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -XarrayColorMapper Usage Example -================================ - -This example demonstrates how to use the XarrayColorMapper for creating -meaningful, visually-grouped color schemes in your plots. Instead of random -colors, it assigns color families (like shades of blue, green, red) based on -coordinate value patterns, making plots more intuitive. - -INTEGRATION WITH CALCULATIONRESULTS: -The XarrayColorMapper integrates seamlessly with flixopt's CalculationResults -plotting methods. Use results.create_color_mapper() to get started, then pass -the generated color maps directly to any plotting method via the `colors` parameter. -""" - -import numpy as np -import plotly.express as px -import xarray as xr - -from flixopt.plotting import XarrayColorMapper, with_plotly - -print('=' * 70) -print('XarrayColorMapper - Comprehensive Usage Example') -print('=' * 70) -print() - -# ============================================================================ -# Example 1: Basic Pattern-Based Coloring -# ============================================================================ -print('Example 1: Basic Pattern-Based Coloring') -print('-' * 70) - -# Create sample data with product categories -np.random.seed(42) -time_coords = np.arange(0, 24) -products = ['Premium_A', 'Premium_B', 'Standard_A', 'Standard_B', 'Budget_A', 'Budget_B'] - -data = xr.DataArray( - np.random.rand(24, 6) * 100 + np.array([100, 90, 70, 65, 40, 35]), # Different base values - coords={'time': time_coords, 'product': products}, - dims=['time', 'product'], - name='sales', -) - -# Setup color mapper with rules based on product tiers -mapper = ( - XarrayColorMapper() - .add_rule('Premium_', 'purples', 'prefix') # Premium products get purple shades - .add_rule('Standard_', 'blues', 'prefix') # Standard products get blue shades - .add_rule('Budget_', 'greens', 'prefix') -) # Budget products get green shades - -# Reorder products by tier for better visual grouping -data_reordered = mapper.reorder_coordinate(data, 'product') -print('Products reordered by tier:', data_reordered.product.values) - -# Get color mapping -color_map = mapper.apply_to_dataarray(data_reordered, 'product') -print('\nColor assignments:') -for product, color in color_map.items(): - print(f' {product}: {color}') - -print('\n✓ Data prepared with pattern-based color grouping') -print() - -# ============================================================================ -# Example 2: Using with Plotly Express -# ============================================================================ -print('Example 2: Integration with Plotly Express') -print('-' * 70) - -# Convert to DataFrame for plotting -df = data_reordered.to_dataframe(name='sales').reset_index() - -# Create a line plot with the custom color mapping -fig = px.line( - df, - x='time', - y='sales', - color='product', - color_discrete_map=color_map, # Use our custom color mapping - title='Product Sales Over Time (Grouped by Tier)', - labels={'time': 'Hour of Day', 'sales': 'Sales ($)', 'product': 'Product'}, - markers=True, -) - -print('✓ Plotly figure created with grouped colors') -print(' - Premium products: Purple shades') -print(' - Standard products: Blue shades') -print(' - Budget products: Green shades') -print() - -# To save or show the plot: -# fig.write_html('product_sales.html') -# fig.show() - -# ============================================================================ -# Example 3: Using with flixopt's with_plotly -# ============================================================================ -print('Example 3: Integration with flixopt.plotting.with_plotly') -print('-' * 70) - -# The color_map can be passed directly to with_plotly -fig2 = with_plotly( - data_reordered, - mode='area', - colors=color_map, # Pass the color mapping directly - title='Product Sales - Stacked Area Chart', - ylabel='Sales ($)', - xlabel='Hour of Day', -) - -print('✓ Created stacked area chart with grouped colors using with_plotly()') -print() - -# ============================================================================ -# Example 4: Advanced Pattern Matching -# ============================================================================ -print('Example 4: Advanced Pattern Matching (Glob and Regex)') -print('-' * 70) - -# Create scenario data with complex naming -scenarios = [ - 'baseline_2020', - 'baseline_2030', - 'baseline_2050', - 'renewable_high_2030', - 'renewable_high_2050', - 'renewable_low_2030', - 'renewable_low_2050', - 'fossil_phase_out_2030', - 'fossil_phase_out_2050', -] - -scenario_data = xr.DataArray( - np.random.rand(10, len(scenarios)), - coords={'time': np.arange(10), 'scenario': scenarios}, - dims=['time', 'scenario'], - name='emissions', -) - -# Setup mapper with different pattern types -scenario_mapper = ( - XarrayColorMapper() - .add_rule('baseline*', 'greys', 'glob') # Glob pattern for baseline scenarios - .add_rule('renewable_high*', 'greens', 'glob') # High renewable scenarios - .add_rule('renewable_low*', 'teals', 'glob') # Low renewable scenarios - .add_rule('fossil*', 'reds', 'glob') -) # Fossil phase-out scenarios - -# Apply coloring -scenario_data_reordered = scenario_mapper.reorder_coordinate(scenario_data, 'scenario') -scenario_colors = scenario_mapper.apply_to_dataarray(scenario_data_reordered, 'scenario') - -print('Scenarios grouped by type:') -print(' Reordered scenarios:', list(scenario_data_reordered.scenario.values)) -print('\nColor assignments:') -for scenario, color in scenario_colors.items(): - print(f' {scenario}: {color}') - -print('\n✓ Complex pattern matching with glob patterns') -print() - -# ============================================================================ -# Example 5: Using Overrides for Special Cases -# ============================================================================ -print('Example 5: Using Overrides for Special Cases') -print('-' * 70) - -# Create component data -components = ['Solar_PV', 'Wind_Turbine', 'Gas_Turbine', 'Battery_Storage', 'Grid_Import'] - -component_data = xr.DataArray( - np.random.rand(20, len(components)), - coords={'time': np.arange(20), 'component': components}, - dims=['time', 'component'], - name='power', -) - -# Setup mapper with rules and special overrides -component_mapper = ( - XarrayColorMapper() - .add_rule('Solar', 'oranges', 'prefix') # Solar components - .add_rule('Wind', 'blues', 'prefix') # Wind components - .add_rule('Gas', 'reds', 'prefix') # Gas components - .add_rule('Battery', 'greens', 'prefix') # Battery components - .add_override( - { # Special cases override the rules - 'Grid_Import': '#808080' # Grey for grid import - } - ) -) - -component_colors = component_mapper.apply_to_dataarray(component_data, 'component') - -print('Component color assignments:') -for component, color in component_colors.items(): - override_marker = ' [OVERRIDE]' if component == 'Grid_Import' else '' - print(f' {component}: {color}{override_marker}') - -print('\n✓ Rules with override for special cases') -print() - -# ============================================================================ -# Example 6: Custom Color Families -# ============================================================================ -print('Example 6: Creating Custom Color Families') -print('-' * 70) - -# Create custom color families for specific use cases -custom_mapper = XarrayColorMapper() -custom_mapper.add_custom_family('ocean', ['#003f5c', '#2f4b7c', '#665191', '#a05195', '#d45087']) -custom_mapper.add_custom_family('sunset', ['#ffa600', '#ff7c43', '#f95d6a', '#d45087', '#a05195']) - -custom_mapper.add_rule('ocean_', 'ocean', 'prefix') -custom_mapper.add_rule('land_', 'sunset', 'prefix') - -zones = ['ocean_shallow', 'ocean_deep', 'ocean_reef', 'land_forest', 'land_desert', 'land_urban'] -zone_data = xr.DataArray( - np.random.rand(10, len(zones)), - coords={'time': np.arange(10), 'zone': zones}, - dims=['time', 'zone'], - name='temperature', -) - -zone_colors = custom_mapper.apply_to_dataarray(zone_data, 'zone') - -print('Custom color families:') -print(' Available families:', list(custom_mapper.get_families().keys())) -print('\nZone color assignments:') -for zone, color in zone_colors.items(): - print(f' {zone}: {color}') - -print('\n✓ Custom color families defined and applied') -print() - -# ============================================================================ -# Example 7: Integration with CalculationResults (Pattern) -# ============================================================================ -print('Example 7: Integration with CalculationResults') -print('-' * 70) - -print(""" -USAGE PATTERN WITH CALCULATIONRESULTS: - -# METHOD 1: Automatic usage (recommended) -# Create and configure mapper - it's automatically used by all plots -mapper = results.create_color_mapper() -mapper.add_rule('Solar', 'oranges', 'prefix') -mapper.add_rule('Wind', 'blues', 'prefix') -mapper.add_rule('Gas', 'reds', 'prefix') -mapper.add_rule('Battery', 'greens', 'prefix') - -# All plotting methods automatically use the mapper (colors='auto' is default) -results['ElectricityBus'].plot_node_balance() # Automatically uses mapper! -results['Battery'].plot_charge_state() # Also uses mapper! - -# METHOD 2: Manual color map generation -# Get data and generate colors manually for full control -data = results['ElectricityBus'].node_balance() -data_reordered = mapper.reorder_coordinate(data, 'variable') -colors = mapper.apply_to_dataarray(data_reordered, 'variable') - -# Pass colors explicitly to plotting functions -results['ElectricityBus'].plot_node_balance(colors=colors) -fig = plotting.with_plotly(data_reordered, colors=colors) - -# METHOD 3: Direct assignment of existing mapper -my_mapper = XarrayColorMapper() -my_mapper.add_rule('Renewable', 'greens', 'prefix') -results.color_mapper = my_mapper # Direct assignment works too! -""") - -print('✓ Pattern demonstrated for CalculationResults integration') -print() - -# ============================================================================ -# Summary -# ============================================================================ -print('=' * 70) -print('Summary: XarrayColorMapper Key Benefits') -print('=' * 70) -print() -print('1. Visual Grouping: Similar items get similar colors automatically') -print('2. Pattern Matching: Flexible matching with prefix, suffix, glob, regex') -print('3. Coordinate Reordering: Group similar items together in plots') -print('4. Override Support: Handle special cases with explicit colors') -print('5. Custom Families: Define your own color schemes') -print('6. Easy Integration: Works seamlessly with Plotly and flixopt plotting') -print('7. CalculationResults Support: Convenience method for quick setup') -print() -print('=' * 70) -print('All examples completed successfully!') -print('=' * 70) From a269ed7636f259a77905e5d1d0434f8dd7c067c0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:21:35 +0200 Subject: [PATCH 048/173] Update CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a7df11e3..c08eb9c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,14 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### ✨ Added +- **Pattern-based color mapping with `XarrayColorMapper`**: New color mapping system for automatic, semantically meaningful plot colors based on component naming patterns + - `XarrayColorMapper` class provides pattern-based color assignment using prefix, suffix, contains, glob, and regex matching + - Automatic grouping of similar items with coordinated color families (blues, greens, oranges, etc.) from ColorBrewer sequential palettes + - Support for custom color families and explicit color overrides for special cases + - Coordinate reordering for visual grouping in plots + - `CalculationResults.create_color_mapper()` factory method for easy setup + - `CalculationResults.color_mapper` attribute automatically applies colors to all plots when `colors='auto'` (the default) + - `resolve_colors()` utility function in `plotting` module for standalone color resolution - **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') @@ -62,6 +70,7 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### 💥 Breaking Changes ### ♻️ Changed +- **Plotting color defaults**: All plotting methods now default to `colors='auto'`, which uses `CalculationResults.color_mapper` if configured, otherwise falls back to 'viridis'. Explicit colors (dict, string, list) still work as before - **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` From c4a675c65da25b7b1679a94e3dfb64d713602283 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:04:21 +0200 Subject: [PATCH 049/173] Add create_color_mapper to SegmentedResults --- flixopt/results.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index b025fb3eb..4a2adabae 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1713,6 +1713,19 @@ class SegmentedCalculationResults: - Flow rate transitions at segment boundaries - Aggregated results over the full time horizon + Attributes: + segment_results: List of CalculationResults for each segment + all_timesteps: Complete time index spanning all segments + timesteps_per_segment: Number of timesteps in each segment + overlap_timesteps: Number of overlapping timesteps between segments + name: Identifier for this segmented calculation + folder: Directory path for result storage and loading + hours_per_timestep: Duration of each timestep + color_mapper: Optional XarrayColorMapper for automatic pattern-based coloring in plots. + When set, it is automatically propagated to all segment results, ensuring + consistent coloring across segments. Use `create_color_mapper()` to create + and configure one, or assign an existing mapper directly. + Examples: Load and analyze segmented results: @@ -1768,6 +1781,20 @@ class SegmentedCalculationResults: storage_continuity = results.check_storage_continuity('Battery') ``` + Configure color mapping for consistent plotting across segments: + + ```python + # Create and configure a color mapper + mapper = results.create_color_mapper() + mapper.add_rule('Solar', 'oranges', 'prefix') + mapper.add_rule('Wind', 'blues', 'prefix') + mapper.add_rule('Battery', 'greens', 'prefix') + + # Plot using any segment - colors are consistent across all segments + results.segment_results[0]['ElectricityBus'].plot_node_balance() + results.segment_results[1]['ElectricityBus'].plot_node_balance() + ``` + Design Considerations: **Boundary Effects**: Monitor solution quality at segment interfaces where foresight is limited compared to full-horizon optimization. @@ -1842,6 +1869,9 @@ def __init__( self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps) + # Color mapper for intelligent plot coloring + self.color_mapper: plotting.XarrayColorMapper | None = None + @property def meta_data(self) -> dict[str, int | list[str]]: return { @@ -1855,6 +1885,30 @@ def meta_data(self) -> dict[str, int | list[str]]: def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] + def create_color_mapper(self) -> plotting.XarrayColorMapper: + """Create and assign a new XarrayColorMapper for this segmented results instance. + + The color mapper is automatically propagated to all segment results, + ensuring consistent coloring across all segments when using plotting methods. + + Returns: + The newly created XarrayColorMapper, ready to be configured with rules. + + Examples: + Create and configure a mapper for segmented results: + + >>> mapper = segmented_results.create_color_mapper() + >>> mapper.add_rule('Solar', 'oranges', 'prefix') + >>> mapper.add_rule('Wind', 'blues', 'prefix') + >>> # The mapper is now available on all segments + >>> segmented_results.segment_results[0]['ElectricityBus'].plot_node_balance() + """ + self.color_mapper = plotting.XarrayColorMapper() + # Propagate to all segment results for consistent coloring + for segment in self.segment_results: + segment.color_mapper = self.color_mapper + return self.color_mapper + def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Get variable solution removing segment overlaps. From 60bc43bd4f636e717b3c5cfc1eddd4b69ce98f13 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:11:46 +0200 Subject: [PATCH 050/173] Add create_color_mapper to complex_example --- examples/02_Complex/complex_example.py | 19 ++++++++++++++----- .../02_Complex/complex_example_results.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 805cb08f6..490c92465 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -205,8 +205,17 @@ # You can analyze results directly or save them to file and reload them later. calculation.results.to_file() - # But let's plot some results anyway - calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') - calculation.results['BHKW2'].plot_node_balance() - calculation.results['Speicher'].plot_charge_state() - calculation.results['Fernwärme'].plot_node_balance_pie() + # Configure color mapping for consistent plot colors + mapper = calculation.results.create_color_mapper() + mapper.add_rule('BHKW', 'oranges', 'prefix') # CHP units get orange shades + mapper.add_rule('Kessel', 'reds', 'prefix') # Boilers get red shades + mapper.add_rule('Speicher', 'greens', 'prefix') # Storage gets green shades + mapper.add_rule('last', 'blues', 'contains') # Loads/demands get blue shades + mapper.add_rule('tarif', 'greys', 'contains') # Tariffs/sources get grey shades + mapper.add_rule('Einspeisung', 'purples', 'prefix') # Feed-in gets purple shades + + # Plot results with automatic color mapping + calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorMapper) + calculation.results['BHKW2'].plot_node_balance() # Uses ColorMapper + calculation.results['Speicher'].plot_charge_state() # Uses ColorMapper + calculation.results['Fernwärme'].plot_node_balance_pie() # Uses ColorMapper diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 5020f71fe..e35cf241e 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -18,8 +18,20 @@ f'Original error: {e}' ) from e + # --- Configure Color Mapping for Consistent Plot Colors --- + # Create a color mapper to automatically assign consistent colors to components + # based on naming patterns. This ensures visual grouping in all plots. + mapper = results.create_color_mapper() + mapper.add_rule('BHKW', 'oranges', 'prefix') # CHP units get orange shades + mapper.add_rule('Kessel', 'reds', 'prefix') # Boilers get red shades + mapper.add_rule('Speicher', 'greens', 'prefix') # Storage gets green shades + mapper.add_rule('last', 'blues', 'contains') # Loads/demands get blue shades (Wärmelast) + mapper.add_rule('tarif', 'greys', 'contains') # Tariffs/sources get grey shades (Gastarif) + mapper.add_rule('Einspeisung', 'purples', 'prefix') # Feed-in gets purple shades + # --- Basic overview --- results.plot_network(show=True) + # All plots below automatically use the color mapper (colors='auto' is the default) results['Fernwärme'].plot_node_balance() # --- Detailed Plots --- From f5cc7abac59f24c9f6f3679d6a2f8977bb0bfde7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:23:22 +0200 Subject: [PATCH 051/173] Missed some renames --- flixopt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 4a2adabae..6f22ee9a0 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1540,7 +1540,7 @@ def plot_charge_state( charge_state_ds, facet_by=facet_by, animate_by=animate_by, - colors=colors, + colors=resolved_colors, mode='line', # Always line for charge_state title='', # No title needed for this temp figure facet_cols=facet_cols, @@ -1576,7 +1576,7 @@ def plot_charge_state( # For matplotlib, plot flows (node balance), then add charge_state as line fig, ax = plotting.with_matplotlib( ds.to_dataframe(), - colors=colors, + colors=resolved_colors, mode=mode, title=title, ) From 601f849febab6b373225912fa46c7716b0e352db Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:24:24 +0200 Subject: [PATCH 052/173] Fix warning in plot_charge_state() --- flixopt/results.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index 6f22ee9a0..6a8473616 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1517,7 +1517,9 @@ def plot_charge_state( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(ds, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors( + xr.merge([ds, charge_state_da.to_dataset()]), colors_to_use, coord_dim='variable', engine=engine + ) if engine == 'plotly': # Plot flows (node balance) with the specified mode From 9ad5c24d3c39bc52b40e1b227b3647a98fa1632a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:59:18 +0200 Subject: [PATCH 053/173] Allow for discrete color assignments with rules --- flixopt/plotting.py | 185 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 152 insertions(+), 33 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 49262826b..efaf41cbd 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -343,28 +343,39 @@ class XarrayColorMapper: Key Features: - Pattern-based color assignment (prefix, suffix, contains, glob, regex) - - ColorBrewer sequential palettes for grouped coloring + - ColorBrewer sequential palettes for grouped coloring (cycles through shades) + - Discrete color support for exact color matching across all items - Override support for special cases - Coordinate reordering for visual grouping in plots - Full type hints and comprehensive documentation Example Usage: + Using color families (items cycle through shades): + ```python mapper = ( XarrayColorMapper() - .add_rule('Product_A', 'blues', 'prefix') - .add_rule('Product_B', 'greens', 'prefix') + .add_rule('Product_A', 'blues', 'prefix') # Product_A1, A2, A3 get different blue shades + .add_rule('Product_B', 'greens', 'prefix') # Product_B1, B2, B3 get different green shades .add_override({'Special': '#FFD700'}) ) + ``` - # Reorder for visual grouping - da_reordered = mapper.reorder_coordinate(da, 'product') + Using discrete colors (all matching items get the same color): - # Get color mapping - color_map = mapper.apply_to_dataarray(da_reordered, 'product') + ```python + mapper = ( + XarrayColorMapper() + .add_rule('Solar', '#FFA500', 'prefix') # All Solar* items get exact orange + .add_rule('Wind', 'skyblue', 'prefix') # All Wind* items get exact skyblue + .add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green + ) + + # Apply to data + color_map = mapper.apply_to_dataarray(da, 'component') # Plot with Plotly - fig = px.bar(df, x='product', y='value', color='product', color_discrete_map=color_map) + fig = px.bar(df, x='component', y='value', color='component', color_discrete_map=color_map) ``` """ @@ -418,16 +429,20 @@ def add_custom_family(self, name: str, colors: list[str]) -> XarrayColorMapper: self.color_families[name] = colors return self - def add_rule(self, pattern: str, family: str, match_type: MatchType = 'prefix') -> XarrayColorMapper: + def add_rule(self, pattern: str, family_or_color: str, match_type: MatchType = 'prefix') -> XarrayColorMapper: """ - Add a pattern-based rule to assign color families. + Add a pattern-based rule to assign color families or discrete colors. Parameters: ----------- pattern : str Pattern to match against coordinate values - family : str - Color family name to assign + family_or_color : str + Either a color family name (e.g., 'blues', 'greens') or a discrete color. + Discrete colors can be: + - Hex colors: '#FF0000', '#00FF00' + - RGB/RGBA strings: 'rgb(255,0,0)', 'rgba(255,0,0,0.5)' + - Named colors: 'red', 'blue', 'skyblue' match_type : {'prefix', 'suffix', 'contains', 'glob', 'regex'}, default 'prefix' Type of pattern matching to use: - 'prefix': Match if value starts with pattern @@ -442,22 +457,104 @@ def add_rule(self, pattern: str, family: str, match_type: MatchType = 'prefix') Examples: --------- + Using color families (cycles through shades): + mapper.add_rule('Product_A', 'blues', 'prefix') mapper.add_rule('_test', 'greens', 'suffix') - mapper.add_rule('control', 'reds', 'contains') - mapper.add_rule('exp_A*', 'purples', 'glob') - mapper.add_rule(r'^exp_[AB]\\d+', 'oranges', 'regex') - """ - if family not in self.color_families: - raise ValueError(f"Unknown family '{family}'. Available: {list(self.color_families.keys())}") + Using discrete colors (all matches get the same color): + + mapper.add_rule('Solar', '#FFA500', 'prefix') # All Solar* items get orange + mapper.add_rule('Wind', 'skyblue', 'prefix') # All Wind* items get skyblue + mapper.add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green + """ valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') if match_type not in valid_types: raise ValueError(f"match_type must be one of {valid_types}, got '{match_type}'") - self.rules.append({'pattern': pattern, 'family': family, 'match_type': match_type}) + # Check if family_or_color is a discrete color or a family name + is_discrete_color = self._is_discrete_color(family_or_color) + + if is_discrete_color: + # Store as discrete color rule + self.rules.append( + {'pattern': pattern, 'discrete_color': family_or_color, 'match_type': match_type, 'is_discrete': True} + ) + else: + # Validate that it's a known family + if family_or_color not in self.color_families: + raise ValueError( + f"Unknown family '{family_or_color}'. " + f'Available families: {list(self.color_families.keys())}. ' + f"If you meant to use a discrete color, ensure it's a valid color format " + f"(hex like '#FF0000', rgb like 'rgb(255,0,0)', or named color)." + ) + # Store as family rule + self.rules.append( + {'pattern': pattern, 'family': family_or_color, 'match_type': match_type, 'is_discrete': False} + ) + return self + def _is_discrete_color(self, color_str: str) -> bool: + """Check if a string is a discrete color (hex, rgb, or named color).""" + # Check for hex color + if color_str.startswith('#'): + return True + # Check for rgb/rgba + if color_str.startswith('rgb'): + return True + # Check if it's NOT a known family - assume it's a named color + # Common named colors won't conflict with family names + if color_str not in self.color_families: + # List of common CSS named colors that are likely discrete colors + common_colors = { + 'red', + 'blue', + 'green', + 'yellow', + 'orange', + 'purple', + 'pink', + 'brown', + 'black', + 'white', + 'gray', + 'grey', + 'cyan', + 'magenta', + 'lime', + 'navy', + 'teal', + 'olive', + 'maroon', + 'aqua', + 'fuchsia', + 'silver', + 'gold', + 'skyblue', + 'lightblue', + 'darkblue', + 'lightgreen', + 'darkgreen', + 'lightgray', + 'darkgray', + 'coral', + 'salmon', + 'khaki', + 'lavender', + 'violet', + 'indigo', + 'turquoise', + 'tan', + 'beige', + 'ivory', + 'crimson', + } + if color_str.lower() in common_colors: + return True + return False + def add_override(self, color_dict: dict[str, str]) -> XarrayColorMapper: """ Override colors for specific values (takes precedence over rules). @@ -525,17 +622,30 @@ def create_color_map( color_map: dict[str, str] = {} # Assign colors to groups - for group_name, group_categories in groups.items(): - if group_name == '_unmatched': + for group_key, group_categories in groups.items(): + if group_key == '_unmatched': + # Unmatched items use fallback family family = self.color_families.get(fallback_family, self.color_families['greys']) - else: - family = self.color_families[group_name] - - if sort_within_groups: - group_categories = sorted(group_categories) + if sort_within_groups: + group_categories = sorted(group_categories) + for idx, category in enumerate(group_categories): + color_map[category] = family[idx % len(family)] + + elif group_key.startswith('_discrete_'): + # Discrete color group - all items get the same color + discrete_color = group_key.replace('_discrete_', '', 1) + if sort_within_groups: + group_categories = sorted(group_categories) + for category in group_categories: + color_map[category] = discrete_color - for idx, category in enumerate(group_categories): - color_map[category] = family[idx % len(family)] + else: + # Family-based group - cycle through family colors + family = self.color_families[group_key] + if sort_within_groups: + group_categories = sorted(group_categories) + for idx, category in enumerate(group_categories): + color_map[category] = family[idx % len(family)] # Apply overrides color_map.update(self.overrides) @@ -686,7 +796,10 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: Returns: -------- - Dict[str, List[str]] : Mapping of {family_name: [matching_values]} + Dict[str, List[str]] : Mapping of {group_key: [matching_values]} + + Note: For discrete color rules, group_key is '_discrete_', + for family rules, group_key is the family name. """ groups: dict[str, list[str]] = {} unmatched: list[str] = [] @@ -695,10 +808,16 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: matched = False for rule in self.rules: if self._match_rule(category, rule): - family = rule['family'] - if family not in groups: - groups[family] = [] - groups[family].append(category) + if rule.get('is_discrete', False): + # For discrete colors, use a special group key + group_key = f'_discrete_{rule["discrete_color"]}' + else: + # For families, use the family name + group_key = rule['family'] + + if group_key not in groups: + groups[group_key] = [] + groups[group_key].append(category) matched = True break # First match wins From ced84d4fcea6e21fbc3fc0a0cef819c3cce053a4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:02:28 +0200 Subject: [PATCH 054/173] Remove some half baked validation --- flixopt/plotting.py | 79 +++------------------------------------------ 1 file changed, 5 insertions(+), 74 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index efaf41cbd..0fd34acac 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -473,88 +473,19 @@ def add_rule(self, pattern: str, family_or_color: str, match_type: MatchType = ' raise ValueError(f"match_type must be one of {valid_types}, got '{match_type}'") # Check if family_or_color is a discrete color or a family name - is_discrete_color = self._is_discrete_color(family_or_color) - - if is_discrete_color: - # Store as discrete color rule + if family_or_color in self.color_families: + # It's a known family - store as family rule self.rules.append( - {'pattern': pattern, 'discrete_color': family_or_color, 'match_type': match_type, 'is_discrete': True} + {'pattern': pattern, 'family': family_or_color, 'match_type': match_type, 'is_discrete': False} ) else: - # Validate that it's a known family - if family_or_color not in self.color_families: - raise ValueError( - f"Unknown family '{family_or_color}'. " - f'Available families: {list(self.color_families.keys())}. ' - f"If you meant to use a discrete color, ensure it's a valid color format " - f"(hex like '#FF0000', rgb like 'rgb(255,0,0)', or named color)." - ) - # Store as family rule + # Otherwise treat as discrete color - no error handling of invalid colors! self.rules.append( - {'pattern': pattern, 'family': family_or_color, 'match_type': match_type, 'is_discrete': False} + {'pattern': pattern, 'discrete_color': family_or_color, 'match_type': match_type, 'is_discrete': True} ) return self - def _is_discrete_color(self, color_str: str) -> bool: - """Check if a string is a discrete color (hex, rgb, or named color).""" - # Check for hex color - if color_str.startswith('#'): - return True - # Check for rgb/rgba - if color_str.startswith('rgb'): - return True - # Check if it's NOT a known family - assume it's a named color - # Common named colors won't conflict with family names - if color_str not in self.color_families: - # List of common CSS named colors that are likely discrete colors - common_colors = { - 'red', - 'blue', - 'green', - 'yellow', - 'orange', - 'purple', - 'pink', - 'brown', - 'black', - 'white', - 'gray', - 'grey', - 'cyan', - 'magenta', - 'lime', - 'navy', - 'teal', - 'olive', - 'maroon', - 'aqua', - 'fuchsia', - 'silver', - 'gold', - 'skyblue', - 'lightblue', - 'darkblue', - 'lightgreen', - 'darkgreen', - 'lightgray', - 'darkgray', - 'coral', - 'salmon', - 'khaki', - 'lavender', - 'violet', - 'indigo', - 'turquoise', - 'tan', - 'beige', - 'ivory', - 'crimson', - } - if color_str.lower() in common_colors: - return True - return False - def add_override(self, color_dict: dict[str, str]) -> XarrayColorMapper: """ Override colors for specific values (takes precedence over rules). From 7ac378144da6e69979c4d850669d0b9e3d8d2213 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:12:42 +0200 Subject: [PATCH 055/173] Add more color families --- flixopt/plotting.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 0fd34acac..d01ea2f77 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -343,12 +343,19 @@ class XarrayColorMapper: Key Features: - Pattern-based color assignment (prefix, suffix, contains, glob, regex) - - ColorBrewer sequential palettes for grouped coloring (cycles through shades) + - Plotly sequential color palettes for grouped coloring (cycles through shades) - Discrete color support for exact color matching across all items - Override support for special cases - Coordinate reordering for visual grouping in plots - Full type hints and comprehensive documentation + Available Color Families (14 single-hue palettes): + Cool colors: blues, greens, teals, purples, mint, emrld, darkmint + Warm colors: reds, oranges, peach, pinks, burg, sunsetdark + Neutral: greys + + See: https://plotly.com/python/builtin-colorscales/ + Example Usage: Using color families (items cycle through shades): @@ -389,11 +396,17 @@ class XarrayColorMapper: 'teals': px.colors.sequential.Teal[2:7], 'greys': px.colors.sequential.Greys[2:7], 'pinks': px.colors.sequential.Pinkyl[2:7], + 'peach': px.colors.sequential.Peach[2:7], + 'burg': px.colors.sequential.Burg[2:7], + 'sunsetdark': px.colors.sequential.Sunsetdark[2:7], + 'mint': px.colors.sequential.Mint[2:7], + 'emrld': px.colors.sequential.Emrld[2:7], + 'darkmint': px.colors.sequential.Darkmint[2:7], } def __init__(self, color_families: dict[str, list[str]] | None = None, sort_within_groups: bool = True) -> None: """ - Initialize with ColorBrewer families. + Initialize with Plotly sequential color families. Parameters: ----------- From 483b8df0cbcd503a55c6ac81f6d4d44fce03c60b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:16:49 +0200 Subject: [PATCH 056/173] Use 1:7 colors for more distinct colors --- flixopt/plotting.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index d01ea2f77..8dfae7b14 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -388,20 +388,20 @@ class XarrayColorMapper: # Class-level defaults (easy to update in one place) DEFAULT_FAMILIES = { - 'blues': px.colors.sequential.Blues[2:7], - 'greens': px.colors.sequential.Greens[2:7], - 'reds': px.colors.sequential.Reds[2:7], - 'purples': px.colors.sequential.Purples[2:7], - 'oranges': px.colors.sequential.Oranges[2:7], - 'teals': px.colors.sequential.Teal[2:7], - 'greys': px.colors.sequential.Greys[2:7], - 'pinks': px.colors.sequential.Pinkyl[2:7], - 'peach': px.colors.sequential.Peach[2:7], - 'burg': px.colors.sequential.Burg[2:7], - 'sunsetdark': px.colors.sequential.Sunsetdark[2:7], - 'mint': px.colors.sequential.Mint[2:7], - 'emrld': px.colors.sequential.Emrld[2:7], - 'darkmint': px.colors.sequential.Darkmint[2:7], + 'blues': px.colors.sequential.Blues[1:8], + 'greens': px.colors.sequential.Greens[1:8], + 'reds': px.colors.sequential.Reds[1:8], + 'purples': px.colors.sequential.Purples[1:8], + 'oranges': px.colors.sequential.Oranges[1:8], + 'teals': px.colors.sequential.Teal[1:8], + 'greys': px.colors.sequential.Greys[1:8], + 'pinks': px.colors.sequential.Pinkyl[1:8], + 'peach': px.colors.sequential.Peach[1:8], + 'burg': px.colors.sequential.Burg[1:8], + 'sunsetdark': px.colors.sequential.Sunsetdark[1:8], + 'mint': px.colors.sequential.Mint[1:8], + 'emrld': px.colors.sequential.Emrld[1:8], + 'darkmint': px.colors.sequential.Darkmint[1:8], } def __init__(self, color_families: dict[str, list[str]] | None = None, sort_within_groups: bool = True) -> None: From 6a5293c4a141a1842c15304476c02b32304a4c44 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:22:32 +0200 Subject: [PATCH 057/173] Add color mapper to complex example --- examples/02_Complex/complex_example.py | 12 ++++++------ examples/02_Complex/complex_example_results.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 490c92465..36437b4d0 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -207,12 +207,12 @@ # Configure color mapping for consistent plot colors mapper = calculation.results.create_color_mapper() - mapper.add_rule('BHKW', 'oranges', 'prefix') # CHP units get orange shades - mapper.add_rule('Kessel', 'reds', 'prefix') # Boilers get red shades - mapper.add_rule('Speicher', 'greens', 'prefix') # Storage gets green shades - mapper.add_rule('last', 'blues', 'contains') # Loads/demands get blue shades - mapper.add_rule('tarif', 'greys', 'contains') # Tariffs/sources get grey shades - mapper.add_rule('Einspeisung', 'purples', 'prefix') # Feed-in gets purple shades + mapper.add_rule('BHKW', '#FF8C00', 'prefix') # All CHP units: dark orange + mapper.add_rule('Kessel', '#DC143C', 'prefix') # All boilers: crimson + mapper.add_rule('Speicher', '#32CD32', 'prefix') # All storage: lime green + mapper.add_rule('last', 'skyblue', 'contains') # All loads/demands: skyblue + mapper.add_rule('tarif', 'greys', 'contains') # Tariffs cycle through grey shades + mapper.add_rule('Einspeisung', '#9370DB', 'prefix') # Feed-in: medium purple # Plot results with automatic color mapping calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorMapper) diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index e35cf241e..aa7012fd5 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -22,12 +22,12 @@ # Create a color mapper to automatically assign consistent colors to components # based on naming patterns. This ensures visual grouping in all plots. mapper = results.create_color_mapper() - mapper.add_rule('BHKW', 'oranges', 'prefix') # CHP units get orange shades - mapper.add_rule('Kessel', 'reds', 'prefix') # Boilers get red shades - mapper.add_rule('Speicher', 'greens', 'prefix') # Storage gets green shades - mapper.add_rule('last', 'blues', 'contains') # Loads/demands get blue shades (Wärmelast) - mapper.add_rule('tarif', 'greys', 'contains') # Tariffs/sources get grey shades (Gastarif) - mapper.add_rule('Einspeisung', 'purples', 'prefix') # Feed-in gets purple shades + mapper.add_rule('BHKW', '#FF8C00', 'prefix') # All CHP units: dark orange + mapper.add_rule('Kessel', '#DC143C', 'prefix') # All boilers: crimson + mapper.add_rule('Speicher', '#32CD32', 'prefix') # All storage: lime green + mapper.add_rule('last', 'skyblue', 'contains') # All loads/demands: skyblue + mapper.add_rule('tarif', 'greys', 'contains') # Tariffs cycle through grey shades + mapper.add_rule('Einspeisung', '#9370DB', 'prefix') # Feed-in: medium purple # --- Basic overview --- results.plot_network(show=True) From 2589819ffa7c0d5cb1d08a21c14d452dcf0d8ac6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:23:30 +0200 Subject: [PATCH 058/173] Update CHANGELOG.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c08eb9c44..39444cc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,11 +56,13 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### ✨ Added - **Pattern-based color mapping with `XarrayColorMapper`**: New color mapping system for automatic, semantically meaningful plot colors based on component naming patterns - `XarrayColorMapper` class provides pattern-based color assignment using prefix, suffix, contains, glob, and regex matching - - Automatic grouping of similar items with coordinated color families (blues, greens, oranges, etc.) from ColorBrewer sequential palettes + - **Discrete color support**: Directly assign single colors (hex, rgb, named) to patterns for consistent coloring across all matching items (e.g., all Solar components get exact same orange) + - Color families from Plotly sequential palettes: 14 single-hue families (blues, greens, reds, purples, oranges, teals, greys, pinks, peach, burg, sunsetdark, mint, emrld, darkmint) - Support for custom color families and explicit color overrides for special cases - Coordinate reordering for visual grouping in plots - `CalculationResults.create_color_mapper()` factory method for easy setup - `CalculationResults.color_mapper` attribute automatically applies colors to all plots when `colors='auto'` (the default) + - **`SegmentedCalculationResults.create_color_mapper()`**: ColorMapper support for segmented results, automatically propagates to all segments for consistent coloring - `resolve_colors()` utility function in `plotting` module for standalone color resolution - **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 @@ -88,6 +90,7 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix ### 📦 Dependencies ### 📝 Docs +- Updated `complex_example.py` and `complex_example_results.py` to demonstrate ColorMapper usage with discrete colors ### 👷 Development - Renamed `_apply_indexer_to_data()` to `_apply_selection_to_data()` for consistency with new API From 26d27b6d938bcfc407d9ff553640140d8e00d1d6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:28:33 +0200 Subject: [PATCH 059/173] Convert numpy style docstrings to google style --- flixopt/plotting.py | 209 ++++++++++++++++++-------------------------- 1 file changed, 83 insertions(+), 126 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 8dfae7b14..b131f086e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -405,15 +405,11 @@ class XarrayColorMapper: } def __init__(self, color_families: dict[str, list[str]] | None = None, sort_within_groups: bool = True) -> None: - """ - Initialize with Plotly sequential color families. - - Parameters: - ----------- - color_families : dict, optional - Custom color families. If None, uses DEFAULT_FAMILIES - sort_within_groups : bool, default True - Whether to sort values within groups by default + """Initialize with Plotly sequential color families. + + Args: + color_families: Custom color families. If None, uses DEFAULT_FAMILIES. + sort_within_groups: Whether to sort values within groups by default. Default is True. """ if color_families is None: self.color_families = self.DEFAULT_FAMILIES.copy() @@ -425,61 +421,53 @@ def __init__(self, color_families: dict[str, list[str]] | None = None, sort_with self.overrides: dict[str, str] = {} def add_custom_family(self, name: str, colors: list[str]) -> XarrayColorMapper: - """ - Add a custom color family. + """Add a custom color family. - Parameters: - ----------- - name : str - Name for the color family - colors : List[str] - List of hex color codes + Args: + name: Name for the color family. + colors: List of hex color codes. Returns: - -------- - XarrayColorMapper : Self for method chaining + Self for method chaining. """ self.color_families[name] = colors return self def add_rule(self, pattern: str, family_or_color: str, match_type: MatchType = 'prefix') -> XarrayColorMapper: - """ - Add a pattern-based rule to assign color families or discrete colors. - - Parameters: - ----------- - pattern : str - Pattern to match against coordinate values - family_or_color : str - Either a color family name (e.g., 'blues', 'greens') or a discrete color. - Discrete colors can be: - - Hex colors: '#FF0000', '#00FF00' - - RGB/RGBA strings: 'rgb(255,0,0)', 'rgba(255,0,0,0.5)' - - Named colors: 'red', 'blue', 'skyblue' - match_type : {'prefix', 'suffix', 'contains', 'glob', 'regex'}, default 'prefix' - Type of pattern matching to use: - - 'prefix': Match if value starts with pattern - - 'suffix': Match if value ends with pattern - - 'contains': Match if pattern appears anywhere in value - - 'glob': Unix-style wildcards (* matches anything, ? matches one char) - - 'regex': Match using regular expression + """Add a pattern-based rule to assign color families or discrete colors. + + Args: + pattern: Pattern to match against coordinate values. + family_or_color: Either a color family name (e.g., 'blues', 'greens') or a discrete color. + Discrete colors can be: + - Hex colors: '#FF0000', '#00FF00' + - RGB/RGBA strings: 'rgb(255,0,0)', 'rgba(255,0,0,0.5)' + - Named colors: 'red', 'blue', 'skyblue' + match_type: Type of pattern matching to use. Default is 'prefix'. + - 'prefix': Match if value starts with pattern + - 'suffix': Match if value ends with pattern + - 'contains': Match if pattern appears anywhere in value + - 'glob': Unix-style wildcards (* matches anything, ? matches one char) + - 'regex': Match using regular expression Returns: - -------- - XarrayColorMapper : Self for method chaining + Self for method chaining. Examples: - --------- - Using color families (cycles through shades): + Using color families (cycles through shades): - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('_test', 'greens', 'suffix') + ```python + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('_test', 'greens', 'suffix') + ``` - Using discrete colors (all matches get the same color): + Using discrete colors (all matches get the same color): - mapper.add_rule('Solar', '#FFA500', 'prefix') # All Solar* items get orange - mapper.add_rule('Wind', 'skyblue', 'prefix') # All Wind* items get skyblue - mapper.add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green + ```python + mapper.add_rule('Solar', '#FFA500', 'prefix') # All Solar* items get orange + mapper.add_rule('Wind', 'skyblue', 'prefix') # All Wind* items get skyblue + mapper.add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green + ``` """ valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') if match_type not in valid_types: @@ -500,25 +488,19 @@ def add_rule(self, pattern: str, family_or_color: str, match_type: MatchType = ' return self def add_override(self, color_dict: dict[str, str]) -> XarrayColorMapper: - """ - Override colors for specific values (takes precedence over rules). + """Override colors for specific values (takes precedence over rules). - Parameters: - ----------- - color_dict : Dict[str, str] - Mapping of {value: hex_color} + Args: + color_dict: Mapping of {value: hex_color}. Returns: - -------- - XarrayColorMapper : Self for method chaining + Self for method chaining. Examples: - --------- - mapper.add_override({'Special': '#FFD700'}) - mapper.add_override({ - 'Product_A1': '#FF00FF', - 'Product_B2': '#00FFFF' - }) + ```python + mapper.add_override({'Special': '#FFD700'}) + mapper.add_override({'Product_A1': '#FF00FF', 'Product_B2': '#00FFFF'}) + ``` """ for val, col in color_dict.items(): self.overrides[str(val)] = col @@ -530,21 +512,15 @@ def create_color_map( sort_within_groups: bool | None = None, fallback_family: str = 'greys', ) -> dict[str, str]: - """ - Create color mapping for coordinate values. + """Create color mapping for coordinate values. - Parameters: - ----------- - coord_values : xr.DataArray, np.ndarray, or list - Coordinate values to map - sort_within_groups : bool, optional - Sort values within each group. If None, uses instance default - fallback_family : str, default 'greys' - Color family for unmatched values + Args: + coord_values: Coordinate values to map (xr.DataArray, np.ndarray, or list). + sort_within_groups: Sort values within each group. If None, uses instance default. + fallback_family: Color family for unmatched values. Default is 'greys'. Returns: - -------- - Dict[str, str] : Mapping of {value: hex_color} + Mapping of {value: hex_color}. """ if sort_within_groups is None: sort_within_groups = self.sort_within_groups @@ -597,23 +573,17 @@ def create_color_map( return color_map def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str]: - """ - Create color map for a DataArray coordinate dimension. + """Create color map for a DataArray coordinate dimension. - Parameters: - ----------- - da : xr.DataArray - The data array - coord_dim : str - Coordinate dimension name + Args: + da: The data array. + coord_dim: Coordinate dimension name. Returns: - -------- - Dict[str, str] : Color mapping for that dimension + Color mapping for that dimension. Raises: - ------- - ValueError : If coord_dim is not found in the DataArray + ValueError: If coord_dim is not found in the DataArray. """ if coord_dim not in da.coords: raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") @@ -623,35 +593,30 @@ def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str] def reorder_coordinate( self, da: xr.DataArray, coord_dim: str, sort_within_groups: bool | None = None ) -> xr.DataArray: - """ - Reorder a DataArray coordinate so values with the same color are adjacent. + """Reorder a DataArray coordinate so values with the same color are adjacent. This is useful for creating plots where similar items (same color group) appear next to each other, making visual groupings clear. - Parameters: - ----------- - da : xr.DataArray - The data array to reorder - coord_dim : str - The coordinate dimension to reorder - sort_within_groups : bool, optional - Whether to sort values within each group. If None, uses instance default + Args: + da: The data array to reorder. + coord_dim: The coordinate dimension to reorder. + sort_within_groups: Whether to sort values within each group. If None, uses instance default. Returns: - -------- - xr.DataArray : New DataArray with reordered coordinate + New DataArray with reordered coordinate. Examples: - --------- - # Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] - # After reorder: ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2'] + Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] + After reorder: ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2'] - mapper = XarrayColorMapper() - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('Product_B', 'greens', 'prefix') + ```python + mapper = XarrayColorMapper() + mapper.add_rule('Product_A', 'blues', 'prefix') + mapper.add_rule('Product_B', 'greens', 'prefix') - da_reordered = mapper.reorder_coordinate(da, 'product') + da_reordered = mapper.reorder_coordinate(da, 'product') + ``` """ if coord_dim not in da.coords: raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") @@ -696,19 +661,14 @@ def get_families(self) -> dict[str, list[str]]: return self.color_families.copy() def _match_rule(self, value: str, rule: dict[str, str]) -> bool: - """ - Check if value matches a rule. + """Check if value matches a rule. - Parameters: - ----------- - value : str - Value to check - rule : dict - Rule dictionary with 'pattern' and 'match_type' keys + Args: + value: Value to check. + rule: Rule dictionary with 'pattern' and 'match_type' keys. Returns: - -------- - bool : True if value matches the rule + True if value matches the rule. """ pattern = rule['pattern'] match_type = rule['match_type'] @@ -730,20 +690,17 @@ def _match_rule(self, value: str, rule: dict[str, str]) -> bool: return False def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: - """ - Group categories by matching rules. + """Group categories by matching rules. - Parameters: - ----------- - categories : List[str] - List of category values to group + Args: + categories: List of category values to group. Returns: - -------- - Dict[str, List[str]] : Mapping of {group_key: [matching_values]} + Mapping of {group_key: [matching_values]}. - Note: For discrete color rules, group_key is '_discrete_', - for family rules, group_key is the family name. + Note: + For discrete color rules, group_key is '_discrete_', + for family rules, group_key is the family name. """ groups: dict[str, list[str]] = {} unmatched: list[str] = [] From 78c59d1db28bf0f6a48d9ae6c780625483f949b7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:38:38 +0200 Subject: [PATCH 060/173] Use re.search instead of re.match --- flixopt/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index b131f086e..ca15c90ed 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -683,7 +683,7 @@ def _match_rule(self, value: str, rule: dict[str, str]) -> bool: return fnmatch.fnmatch(value, pattern) elif match_type == 'regex': try: - return bool(re.match(pattern, value)) + return bool(re.search(pattern, value)) except re.error as e: raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e From f6b31fbca55b96358cf6f69dee1ce4c7a981dd2a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:39:51 +0200 Subject: [PATCH 061/173] Update tests --- tests/test_color_mapper.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/test_color_mapper.py b/tests/test_color_mapper.py index b25fcc186..686bafc2d 100644 --- a/tests/test_color_mapper.py +++ b/tests/test_color_mapper.py @@ -13,7 +13,7 @@ class TestBasicFunctionality: def test_initialization_default(self): """Test default initialization.""" mapper = XarrayColorMapper() - assert len(mapper.get_families()) == 8 # Default families + assert len(mapper.get_families()) == 14 # Default families assert 'blues' in mapper.get_families() assert mapper.sort_within_groups is True @@ -307,12 +307,6 @@ def test_duplicate_categories(self): assert 'Product_A' in color_map assert 'Product_B' in color_map - def test_unknown_family(self): - """Test that adding rule with unknown family raises error.""" - mapper = XarrayColorMapper() - with pytest.raises(ValueError, match='Unknown family'): - mapper.add_rule('Product', 'unknown_family', 'prefix') - def test_invalid_match_type(self): """Test that invalid match type raises error.""" mapper = XarrayColorMapper() @@ -344,7 +338,7 @@ def test_more_items_than_colors(self): # Should cycle through colors assert len(color_map) == 10 # First and 6th item should have the same color (cycling) - assert color_map['Item_0'] == color_map['Item_5'] + assert color_map['Item_0'] == color_map['Item_7'] class TestInspectionMethods: @@ -378,7 +372,7 @@ def test_get_families(self): assert 'blues' in families assert 'greens' in families - assert len(families['blues']) == 5 # Blues[2:7] has 5 colors + assert len(families['blues']) == 7 # Blues[1:8] has 5 colors class TestMethodChaining: From 567c6a0d33011df9a409728d98ff96c16a5e7f5f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:41:23 +0200 Subject: [PATCH 062/173] Applying ordering to Dataset as well --- flixopt/plotting.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index ca15c90ed..7e08c73ab 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -794,8 +794,31 @@ def resolve_colors( if isinstance(data, xr.DataArray) and coord_dim in data.coords: data = color_mapper.reorder_coordinate(data, coord_dim) elif isinstance(data, xr.Dataset): - # For Dataset, we'll work with the variables directly - pass + # For Dataset, reorder the coordinate if it exists + if coord_dim in data.coords or coord_dim in data.dims: + # Get coordinate values + coord_values = data.coords[coord_dim].values + categories = [str(val) for val in coord_values] + + # Group categories using the color mapper's logic + groups = color_mapper._group_categories(categories) + + # Build new order: group by group, optionally sorted within each + new_order = [] + for group_name in groups.keys(): + group_categories = groups[group_name] + if color_mapper.sort_within_groups: + group_categories = sorted(group_categories) + new_order.extend(group_categories) + + # Convert back to original dtype if needed + original_values = list(coord_values) + # Map string back to original values + str_to_original = {str(v): v for v in original_values} + reordered_values = [str_to_original[cat] for cat in new_order] + + # Reindex the Dataset + data = data.sel({coord_dim: reordered_values}) # Apply color mapper to get dict if isinstance(data, xr.DataArray): From 35b6259e0cc0493bc5c1e30bbe3680540d8be13e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:43:11 +0200 Subject: [PATCH 063/173] This approach: - Prevents silent data loss when values like 1, 1.0, and "1" collide - Provides actionable error messages showing exactly which values are problematic - Allows users to fix their data rather than hiding the issue --- flixopt/plotting.py | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 7e08c73ab..a5c306c39 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -641,8 +641,26 @@ def reorder_coordinate( # Convert back to original dtype if needed original_values = list(coord_values) - # Map string back to original values - str_to_original = {str(v): v for v in original_values} + + # Build mapping from string to list of original values to detect collisions + str_to_originals: dict[str, list] = {} + for v in original_values: + key = str(v) + if key not in str_to_originals: + str_to_originals[key] = [] + str_to_originals[key].append(v) + + # Check for collisions (multiple distinct values with same string representation) + collisions = {k: vals for k, vals in str_to_originals.items() if len(vals) > 1} + if collisions: + collision_details = ', '.join(f"'{k}' -> {vals}" for k, vals in collisions.items()) + raise ValueError( + f"Coordinate '{coord_dim}' has ambiguous string representations. " + f'Multiple distinct values stringify to the same string: {collision_details}' + ) + + # No collisions - create simple mapping and reorder + str_to_original = {k: vals[0] for k, vals in str_to_originals.items()} reordered_values = [str_to_original[cat] for cat in new_order] # Reindex the DataArray @@ -813,8 +831,26 @@ def resolve_colors( # Convert back to original dtype if needed original_values = list(coord_values) - # Map string back to original values - str_to_original = {str(v): v for v in original_values} + + # Build mapping from string to list of original values to detect collisions + str_to_originals: dict[str, list] = {} + for v in original_values: + key = str(v) + if key not in str_to_originals: + str_to_originals[key] = [] + str_to_originals[key].append(v) + + # Check for collisions (multiple distinct values with same string representation) + collisions = {k: vals for k, vals in str_to_originals.items() if len(vals) > 1} + if collisions: + collision_details = ', '.join(f"'{k}' -> {vals}" for k, vals in collisions.items()) + raise ValueError( + f"Coordinate '{coord_dim}' has ambiguous string representations. " + f'Multiple distinct values stringify to the same string: {collision_details}' + ) + + # No collisions - create simple mapping and reorder + str_to_original = {k: vals[0] for k, vals in str_to_originals.items()} reordered_values = [str_to_original[cat] for cat in new_order] # Reindex the Dataset From e4c104ec8716b0552cc247919a1d5e18056937b0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 07:44:07 +0200 Subject: [PATCH 064/173] Improve Error Message --- flixopt/plotting.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index a5c306c39..b41b2a9f9 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -653,10 +653,16 @@ def reorder_coordinate( # Check for collisions (multiple distinct values with same string representation) collisions = {k: vals for k, vals in str_to_originals.items() if len(vals) > 1} if collisions: - collision_details = ', '.join(f"'{k}' -> {vals}" for k, vals in collisions.items()) + collision_details = [] + for k, vals in collisions.items(): + typed_vals = ', '.join(f'{v!r} ({type(v).__name__})' for v in vals) + collision_details.append(f" '{k}' -> [{typed_vals}]") + raise ValueError( f"Coordinate '{coord_dim}' has ambiguous string representations. " - f'Multiple distinct values stringify to the same string: {collision_details}' + f'Multiple distinct values stringify to the same string:\n' + '\n'.join(collision_details) + '\n' + 'Ensure coordinate values have unique string representations, or convert to consistent types ' + 'before plotting (e.g., using .astype()).' ) # No collisions - create simple mapping and reorder From 565ef260defe5cb2e306ebcbf542347dcbfc4537 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 07:44:56 +0200 Subject: [PATCH 065/173] Enable sorting fpr Datasets --- flixopt/plotting.py | 78 +++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index b41b2a9f9..35bd4c297 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -813,63 +813,35 @@ def resolve_colors( if isinstance(colors, XarrayColorMapper): color_mapper = colors - if color_mapper.sort_within_groups: - # Check if coord_dim exists and reorder - if isinstance(data, xr.DataArray) and coord_dim in data.coords: - data = color_mapper.reorder_coordinate(data, coord_dim) - elif isinstance(data, xr.Dataset): - # For Dataset, reorder the coordinate if it exists - if coord_dim in data.coords or coord_dim in data.dims: - # Get coordinate values - coord_values = data.coords[coord_dim].values - categories = [str(val) for val in coord_values] - - # Group categories using the color mapper's logic - groups = color_mapper._group_categories(categories) - - # Build new order: group by group, optionally sorted within each - new_order = [] - for group_name in groups.keys(): - group_categories = groups[group_name] - if color_mapper.sort_within_groups: - group_categories = sorted(group_categories) - new_order.extend(group_categories) - - # Convert back to original dtype if needed - original_values = list(coord_values) - - # Build mapping from string to list of original values to detect collisions - str_to_originals: dict[str, list] = {} - for v in original_values: - key = str(v) - if key not in str_to_originals: - str_to_originals[key] = [] - str_to_originals[key].append(v) - - # Check for collisions (multiple distinct values with same string representation) - collisions = {k: vals for k, vals in str_to_originals.items() if len(vals) > 1} - if collisions: - collision_details = ', '.join(f"'{k}' -> {vals}" for k, vals in collisions.items()) - raise ValueError( - f"Coordinate '{coord_dim}' has ambiguous string representations. " - f'Multiple distinct values stringify to the same string: {collision_details}' - ) - - # No collisions - create simple mapping and reorder - str_to_original = {k: vals[0] for k, vals in str_to_originals.items()} - reordered_values = [str_to_original[cat] for cat in new_order] - - # Reindex the Dataset - data = data.sel({coord_dim: reordered_values}) - - # Apply color mapper to get dict + + # For DataArray: reorder coordinate dimension if sorting enabled if isinstance(data, xr.DataArray): + if color_mapper.sort_within_groups and coord_dim in data.coords: + data = color_mapper.reorder_coordinate(data, coord_dim) if coord_dim in data.coords: return color_mapper.apply_to_dataarray(data, coord_dim) + + # For Dataset: reorder variable names if sorting enabled elif isinstance(data, xr.Dataset): - # For Dataset, map colors to variable names - labels = [str(v) for v in data.data_vars] - return color_mapper.create_color_map(labels) + # Get variable names + var_names = [str(v) for v in data.data_vars] + + # Apply sorting if enabled + if color_mapper.sort_within_groups: + # Group variable names using the color mapper's logic + groups = color_mapper._group_categories(var_names) + + # Build new order: group by group, sorted within each + sorted_var_names = [] + for group_name in groups.keys(): + group_vars = groups[group_name] + group_vars = sorted(group_vars) + sorted_var_names.extend(group_vars) + + var_names = sorted_var_names + + # Map colors to variable names (in sorted order if applicable) + return color_mapper.create_color_map(var_names) raise TypeError(f'Wrong type passed to resolve_colors(): {type(colors)}') From 211a7439d83683f6ff47c3ca62d61bad151d21b1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:07:30 +0200 Subject: [PATCH 066/173] completed the integration of XarrayColorMapper into both with_plotly and with_matplotlib --- flixopt/plotting.py | 83 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 35bd4c297..e940085ff 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -849,7 +849,7 @@ def resolve_colors( def with_plotly( data: pd.DataFrame | xr.DataArray | xr.Dataset, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', - colors: ColorType = 'viridis', + colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -870,7 +870,11 @@ def with_plotly( 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). + colors: Color specification. Can be: + - A colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dict mapping labels to colors (e.g., {'Solar': '#FFD700'}) + - An XarrayColorMapper instance for pattern-based color rules with grouping and sorting title: The main title of the plot. ylabel: The label for the y-axis. xlabel: The label for the x-axis. @@ -912,6 +916,16 @@ def with_plotly( ```python fig = with_plotly(ds, facet_by='scenario', animate_by='period') ``` + + Pattern-based colors with XarrayColorMapper: + + ```python + mapper = XarrayColorMapper() + mapper.add_rule('Solar', 'oranges', 'prefix') + mapper.add_rule('Wind', 'blues', 'prefix') + mapper.add_rule('Battery', 'greens', 'contains') + fig = with_plotly(ds, colors=mapper, mode='area') + ``` """ 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}") @@ -929,6 +943,9 @@ def with_plotly( logger.warning('The fig parameter is ignored when using faceting or animation. Creating a new figure.') fig = None + # Store original data for XarrayColorMapper processing (before conversion to pandas) + data_original = data + # Convert xarray to long-form DataFrame for Plotly Express if isinstance(data, (xr.DataArray, xr.Dataset)): # Convert to long-form (tidy) DataFrame @@ -1021,9 +1038,14 @@ def with_plotly( 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=True)} + # For xarray data with XarrayColorMapper: resolve using the original xarray structure + if isinstance(data_original, (xr.DataArray, xr.Dataset)) and isinstance(colors, XarrayColorMapper): + color_discrete_map = resolve_colors(data_original, colors, coord_dim='variable', engine='plotly') + else: + # Traditional behavior: use ColorProcessor on variable names from long-form DataFrame + 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=True)} # Create plot using Plotly Express based on mode common_args = { @@ -1114,9 +1136,9 @@ def with_plotly( def with_matplotlib( - data: pd.DataFrame, + data: pd.DataFrame | xr.DataArray | xr.Dataset, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', - colors: ColorType = 'viridis', + colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -1125,16 +1147,17 @@ def with_matplotlib( ax: plt.Axes | None = None, ) -> tuple[plt.Figure, plt.Axes]: """ - Plot a DataFrame with Matplotlib using stacked bars or stepped lines. + Plot data with Matplotlib using stacked bars or stepped lines. Args: - data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), - and each column represents a separate data series. + data: A DataFrame or xarray DataArray/Dataset to plot. For DataFrames and converted xarray, + the index should represent time (e.g., hours), and each column represents a separate data series. mode: Plotting mode. Use 'stacked_bar' for stacked bar charts or 'line' for stepped lines. - colors: Color specification, can be: - - A string with a colormap name (e.g., 'viridis', 'plasma') + colors: Color specification. Can be: + - A colormap 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'}) + - A dict mapping column names to colors (e.g., {'Column1': '#ff0000'}) + - An XarrayColorMapper instance for pattern-based color rules with grouping and sorting title: The title of the plot. ylabel: The ylabel of the plot. xlabel: The xlabel of the plot. @@ -1149,14 +1172,46 @@ def with_matplotlib( - If `mode` is 'stacked_bar', bars are stacked for both positive and negative values. Negative values are stacked separately without extra labels in the legend. - If `mode` is 'line', stepped lines are drawn for each data series. + - XarrayColorMapper is only applied when xarray data (DataArray/Dataset) is provided. + + Examples: + With XarrayColorMapper: + + ```python + mapper = XarrayColorMapper() + mapper.add_rule('Solar', 'oranges', 'prefix') + mapper.add_rule('Wind', 'blues', 'prefix') + fig, ax = with_matplotlib(data_array, colors=mapper, mode='line') + ``` """ if mode not in ('stacked_bar', 'line'): raise ValueError(f"'mode' must be one of {{'stacked_bar','line'}} for matplotlib, got {mode!r}") + # Store original data for XarrayColorMapper processing (before conversion to pandas) + data_original = data + + # Convert xarray to DataFrame if needed + if isinstance(data, xr.Dataset): + # Convert Dataset to DataFrame with variables as columns + data = data.to_dataframe() + elif isinstance(data, xr.DataArray): + # Convert DataArray to DataFrame + data = data.to_dataframe() + if len(data.columns) == 1: + # Single column - this is typical for a simple DataArray + pass + # else: multi-dimensional DataArray already in wide format + if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) - processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) + # Process colors - use XarrayColorMapper if provided with xarray data + if isinstance(data_original, (xr.DataArray, xr.Dataset)) and isinstance(colors, XarrayColorMapper): + color_discrete_map = resolve_colors(data_original, colors, coord_dim='variable', engine='matplotlib') + # Get colors in order of DataFrame columns + processed_colors = [color_discrete_map.get(str(col), '#808080') for col in data.columns] + else: + processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) if mode == 'stacked_bar': cumulative_positive = np.zeros(len(data)) From 8033aedda184dcc57a674489b85825c3403f9544 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:52:16 +0200 Subject: [PATCH 067/173] simplified with_matplotlib significantly --- flixopt/plotting.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index e940085ff..1d2069a87 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1187,30 +1187,24 @@ def with_matplotlib( if mode not in ('stacked_bar', 'line'): raise ValueError(f"'mode' must be one of {{'stacked_bar','line'}} for matplotlib, got {mode!r}") - # Store original data for XarrayColorMapper processing (before conversion to pandas) - data_original = data - - # Convert xarray to DataFrame if needed - if isinstance(data, xr.Dataset): - # Convert Dataset to DataFrame with variables as columns - data = data.to_dataframe() - elif isinstance(data, xr.DataArray): - # Convert DataArray to DataFrame - data = data.to_dataframe() - if len(data.columns) == 1: - # Single column - this is typical for a simple DataArray - pass - # else: multi-dimensional DataArray already in wide format - if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) - # Process colors - use XarrayColorMapper if provided with xarray data - if isinstance(data_original, (xr.DataArray, xr.Dataset)) and isinstance(colors, XarrayColorMapper): - color_discrete_map = resolve_colors(data_original, colors, coord_dim='variable', engine='matplotlib') - # Get colors in order of DataFrame columns + # Process colors while data is still xarray (if applicable) + if isinstance(data, (xr.DataArray, xr.Dataset)): + # For xarray data: resolve colors first, then convert to DataFrame + color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='matplotlib') + + # Convert to DataFrame for matplotlib plotting + if isinstance(data, xr.Dataset): + data = data.to_dataframe() + else: # DataArray + data = data.to_dataframe() + + # Get colors in column order processed_colors = [color_discrete_map.get(str(col), '#808080') for col in data.columns] else: + # Already a DataFrame: use ColorProcessor directly processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) if mode == 'stacked_bar': From 1017238a21b0b835ca6e154e366a9ba663bc7723 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:59:10 +0200 Subject: [PATCH 068/173] Update plotting methods to focus on xr.DataArray only --- flixopt/plotting.py | 247 ++++++++++++++++---------------------------- flixopt/results.py | 31 +++--- 2 files changed, 108 insertions(+), 170 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 1d2069a87..0f262ba0c 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -584,10 +584,20 @@ def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str] Raises: ValueError: If coord_dim is not found in the DataArray. + TypeError: If coord_dim values are not all strings. """ if coord_dim not in da.coords: raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") + # Validate all coordinate values are strings + coord_values = da.coords[coord_dim].values + if not all(isinstance(v, str) for v in coord_values): + non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] + raise TypeError( + f"Coordinate '{coord_dim}' must contain only strings. " + f'Found non-string values: {non_strings[:5]}' # Show first 5 + ) + return self.create_color_map(da.coords[coord_dim]) def reorder_coordinate( @@ -624,9 +634,16 @@ def reorder_coordinate( if sort_within_groups is None: sort_within_groups = self.sort_within_groups - # Get coordinate values + # Get coordinate values and validate they are strings coord_values = da.coords[coord_dim].values - categories = [str(val) for val in coord_values] + if not all(isinstance(v, str) for v in coord_values): + non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] + raise TypeError( + f"Coordinate '{coord_dim}' must contain only strings. " + f'Found non-string values: {non_strings[:5]}' # Show first 5 + ) + + categories = list(coord_values) # Group categories groups = self._group_categories(categories) @@ -639,38 +656,8 @@ def reorder_coordinate( group_categories = sorted(group_categories) new_order.extend(group_categories) - # Convert back to original dtype if needed - original_values = list(coord_values) - - # Build mapping from string to list of original values to detect collisions - str_to_originals: dict[str, list] = {} - for v in original_values: - key = str(v) - if key not in str_to_originals: - str_to_originals[key] = [] - str_to_originals[key].append(v) - - # Check for collisions (multiple distinct values with same string representation) - collisions = {k: vals for k, vals in str_to_originals.items() if len(vals) > 1} - if collisions: - collision_details = [] - for k, vals in collisions.items(): - typed_vals = ', '.join(f'{v!r} ({type(v).__name__})' for v in vals) - collision_details.append(f" '{k}' -> [{typed_vals}]") - - raise ValueError( - f"Coordinate '{coord_dim}' has ambiguous string representations. " - f'Multiple distinct values stringify to the same string:\n' + '\n'.join(collision_details) + '\n' - 'Ensure coordinate values have unique string representations, or convert to consistent types ' - 'before plotting (e.g., using .astype()).' - ) - - # No collisions - create simple mapping and reorder - str_to_original = {k: vals[0] for k, vals in str_to_originals.items()} - reordered_values = [str_to_original[cat] for cat in new_order] - # Reindex the DataArray - return da.sel({coord_dim: reordered_values}) + return da.sel({coord_dim: new_order}) def get_rules(self) -> list[dict[str, str]]: """Return a copy of current rules for inspection.""" @@ -756,7 +743,7 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: def resolve_colors( - data: xr.DataArray | xr.Dataset, + data: xr.DataArray, colors: ColorType | XarrayColorMapper, coord_dim: str = 'variable', engine: PlottingEngine = 'plotly', @@ -768,7 +755,7 @@ def resolve_colors( or as part of CalculationResults. Args: - data: DataArray or Dataset to create colors for + data: DataArray to create colors for colors: Color specification or a XarrayColorMapper to use coord_dim: Coordinate dimension to map colors to engine: Plotting engine ('plotly' or 'matplotlib') @@ -791,63 +778,43 @@ def resolve_colors( >>> resolved_colors = resolve_colors(data, 'viridis') """ + # Validate coordinate exists + if coord_dim not in data.coords: + raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(data.coords.keys())}") + + # Validate all coordinate values are strings + coord_values = data.coords[coord_dim].values + if not all(isinstance(v, str) for v in coord_values): + non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] + raise TypeError( + f"Coordinate '{coord_dim}' must contain only strings. " + f'Found non-string values: {non_strings[:5]}' # Show first 5 + ) + # If explicit dict provided, use it directly if isinstance(colors, dict): return colors # If string or list, use ColorProcessor (traditional behavior) if isinstance(colors, (str, list)): - if isinstance(data, xr.DataArray): - if coord_dim in data.coords: - labels = [str(v) for v in data.coords[coord_dim].values] - else: - labels = [] - elif isinstance(data, xr.Dataset): - labels = [str(v) for v in data.data_vars] - else: - labels = [] - - if labels: - processor = ColorProcessor(engine=engine) - return processor.process_colors(colors, labels, return_mapping=True) + labels = [str(v) for v in coord_values] + processor = ColorProcessor(engine=engine) + return processor.process_colors(colors, labels, return_mapping=True) if isinstance(colors, XarrayColorMapper): color_mapper = colors - # For DataArray: reorder coordinate dimension if sorting enabled - if isinstance(data, xr.DataArray): - if color_mapper.sort_within_groups and coord_dim in data.coords: - data = color_mapper.reorder_coordinate(data, coord_dim) - if coord_dim in data.coords: - return color_mapper.apply_to_dataarray(data, coord_dim) - - # For Dataset: reorder variable names if sorting enabled - elif isinstance(data, xr.Dataset): - # Get variable names - var_names = [str(v) for v in data.data_vars] - - # Apply sorting if enabled - if color_mapper.sort_within_groups: - # Group variable names using the color mapper's logic - groups = color_mapper._group_categories(var_names) + # Reorder coordinate dimension if sorting enabled + if color_mapper.sort_within_groups: + data = color_mapper.reorder_coordinate(data, coord_dim) - # Build new order: group by group, sorted within each - sorted_var_names = [] - for group_name in groups.keys(): - group_vars = groups[group_name] - group_vars = sorted(group_vars) - sorted_var_names.extend(group_vars) - - var_names = sorted_var_names - - # Map colors to variable names (in sorted order if applicable) - return color_mapper.create_color_map(var_names) + return color_mapper.apply_to_dataarray(data, coord_dim) raise TypeError(f'Wrong type passed to resolve_colors(): {type(colors)}') def with_plotly( - data: pd.DataFrame | xr.DataArray | xr.Dataset, + data: xr.DataArray, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', @@ -867,7 +834,7 @@ def with_plotly( For simple plots without faceting, can optionally add to an existing figure. Args: - data: A DataFrame or xarray DataArray/Dataset to plot. + data: An xarray DataArray 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. Can be: @@ -896,25 +863,25 @@ def with_plotly( Simple plot: ```python - fig = with_plotly(df, mode='area', title='Energy Mix') + fig = with_plotly(data_array, mode='area', title='Energy Mix') ``` Facet by scenario: ```python - fig = with_plotly(ds, facet_by='scenario', facet_cols=2) + fig = with_plotly(data_array, facet_by='scenario', facet_cols=2) ``` Animate by period: ```python - fig = with_plotly(ds, animate_by='period') + fig = with_plotly(data_array, animate_by='period') ``` Facet and animate: ```python - fig = with_plotly(ds, facet_by='scenario', animate_by='period') + fig = with_plotly(data_array, facet_by='scenario', animate_by='period') ``` Pattern-based colors with XarrayColorMapper: @@ -924,18 +891,14 @@ def with_plotly( mapper.add_rule('Solar', 'oranges', 'prefix') mapper.add_rule('Wind', 'blues', 'prefix') mapper.add_rule('Battery', 'greens', 'contains') - fig = with_plotly(ds, colors=mapper, mode='area') + fig = with_plotly(data_array, colors=mapper, mode='area') ``` """ 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}") # 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: + if data.size == 0: return go.Figure() # Warn if fig parameter is used with faceting @@ -943,51 +906,18 @@ def with_plotly( logger.warning('The fig parameter is ignored when using faceting or animation. Creating a new figure.') fig = None - # Store original data for XarrayColorMapper processing (before conversion to pandas) - data_original = data - - # 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 - 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' + # Convert DataArray to long-form DataFrame for Plotly Express + # Structure: time, variable, value, scenario, period, ... (all dims as columns) + # Give unnamed DataArrays a temporary name for conversion + if data.name is None: + data = data.rename('_temp_value') + temp_name = '_temp_value' 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') + temp_name = data.name + + df_long = data.to_dataframe().reset_index() + df_long = df_long.rename(columns={temp_name: 'value'}) + df_long['variable'] = data.name or 'data' # 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']] @@ -1037,15 +967,11 @@ def with_plotly( else: raise ValueError(f'facet_by can have at most 2 dimensions, got {len(facet_by)}') - # Process colors - # For xarray data with XarrayColorMapper: resolve using the original xarray structure - if isinstance(data_original, (xr.DataArray, xr.Dataset)) and isinstance(colors, XarrayColorMapper): - color_discrete_map = resolve_colors(data_original, colors, coord_dim='variable', engine='plotly') - else: - # Traditional behavior: use ColorProcessor on variable names from long-form DataFrame - 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=True)} + # Process colors using resolve_colors (handles validation and all color types) + color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='plotly') + + # Get unique variable names for area plot processing + all_vars = df_long['variable'].unique().tolist() # Create plot using Plotly Express based on mode common_args = { @@ -1136,7 +1062,7 @@ def with_plotly( def with_matplotlib( - data: pd.DataFrame | xr.DataArray | xr.Dataset, + data: xr.DataArray, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', @@ -1150,8 +1076,8 @@ def with_matplotlib( Plot data with Matplotlib using stacked bars or stepped lines. Args: - data: A DataFrame or xarray DataArray/Dataset to plot. For DataFrames and converted xarray, - the index should represent time (e.g., hours), and each column represents a separate data series. + data: An xarray DataArray to plot. After conversion to DataFrame, + the index represents time and each column represents a separate data series. mode: Plotting mode. Use 'stacked_bar' for stacked bar charts or 'line' for stepped lines. colors: Color specification. Can be: - A colormap name (e.g., 'viridis', 'plasma') @@ -1172,7 +1098,6 @@ def with_matplotlib( - If `mode` is 'stacked_bar', bars are stacked for both positive and negative values. Negative values are stacked separately without extra labels in the legend. - If `mode` is 'line', stepped lines are drawn for each data series. - - XarrayColorMapper is only applied when xarray data (DataArray/Dataset) is provided. Examples: With XarrayColorMapper: @@ -1190,22 +1115,28 @@ def with_matplotlib( if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) - # Process colors while data is still xarray (if applicable) - if isinstance(data, (xr.DataArray, xr.Dataset)): - # For xarray data: resolve colors first, then convert to DataFrame - color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='matplotlib') + # Resolve colors first (includes validation) + color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='matplotlib') - # Convert to DataFrame for matplotlib plotting - if isinstance(data, xr.Dataset): - data = data.to_dataframe() - else: # DataArray - data = data.to_dataframe() + # Convert to DataFrame for matplotlib plotting + # Give unnamed DataArrays a temporary name for conversion + if data.name is None: + data = data.rename('_temp_value') + df = data.to_dataframe() - # Get colors in column order - processed_colors = [color_discrete_map.get(str(col), '#808080') for col in data.columns] - else: - # Already a DataFrame: use ColorProcessor directly - processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) + # If 'variable' is in the index (multi-index DataFrame), pivot to wide format + # This happens when the DataArray has 'variable' as a dimension + if 'variable' in df.index.names: + # Unstack to get variables as columns + df = df.unstack('variable') + # Flatten column multi-index if present + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(-1) + + data = df + + # Get colors in column order + processed_colors = [color_discrete_map.get(str(col), '#808080') for col in data.columns] if mode == 'stacked_bar': cumulative_positive = np.zeros(len(data)) diff --git a/flixopt/results.py b/flixopt/results.py index 6a8473616..cadee8fe7 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1125,6 +1125,9 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) + # Convert Dataset to DataArray for plotting + da = ds.to_array(dim='variable') + # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( self._calculation_results.color_mapper @@ -1133,7 +1136,7 @@ def plot_node_balance( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(ds, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(da, colors_to_use, coord_dim='variable', engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1151,7 +1154,7 @@ def plot_node_balance( if engine == 'plotly': figure_like = plotting.with_plotly( - ds, + da, facet_by=facet_by, animate_by=animate_by, colors=resolved_colors, @@ -1162,7 +1165,7 @@ def plot_node_balance( default_filetype = '.html' else: figure_like = plotting.with_matplotlib( - ds.to_dataframe(), + da, colors=resolved_colors, mode=mode, title=title, @@ -1509,6 +1512,13 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' + # Convert Dataset to DataArray for plotting + da = ds.to_array(dim='variable') + + # Merge both DataArrays for color resolution + # We need to combine the flow balance and charge state data to get consistent colors + combined_da = xr.concat([da, charge_state_da.expand_dims(variable=[self._charge_state])], dim='variable') + # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( self._calculation_results.color_mapper @@ -1517,14 +1527,12 @@ def plot_charge_state( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors( - xr.merge([ds, charge_state_da.to_dataset()]), colors_to_use, coord_dim='variable', engine=engine - ) + resolved_colors = plotting.resolve_colors(combined_da, colors_to_use, coord_dim='variable', engine=engine) if engine == 'plotly': # Plot flows (node balance) with the specified mode figure_like = plotting.with_plotly( - ds, + da, facet_by=facet_by, animate_by=animate_by, colors=resolved_colors, @@ -1533,13 +1541,12 @@ def plot_charge_state( facet_cols=facet_cols, ) - # 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) + # Prepare charge_state as DataArray with variable dimension for plotting + charge_state_da_plot = charge_state_da.expand_dims(variable=[self._charge_state]) # Plot charge_state with mode='line' to get Scatter traces charge_state_fig = plotting.with_plotly( - charge_state_ds, + charge_state_da_plot, facet_by=facet_by, animate_by=animate_by, colors=resolved_colors, @@ -1577,7 +1584,7 @@ def plot_charge_state( ) # For matplotlib, plot flows (node balance), then add charge_state as line fig, ax = plotting.with_matplotlib( - ds.to_dataframe(), + da, colors=resolved_colors, mode=mode, title=title, From 329876e39a7ca307c3bd80367160164b378ba3d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:02:14 +0200 Subject: [PATCH 069/173] Remove duplication --- flixopt/plotting.py | 63 ++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 0f262ba0c..c1de9971e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -586,18 +586,7 @@ def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str] ValueError: If coord_dim is not found in the DataArray. TypeError: If coord_dim values are not all strings. """ - if coord_dim not in da.coords: - raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") - - # Validate all coordinate values are strings - coord_values = da.coords[coord_dim].values - if not all(isinstance(v, str) for v in coord_values): - non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] - raise TypeError( - f"Coordinate '{coord_dim}' must contain only strings. " - f'Found non-string values: {non_strings[:5]}' # Show first 5 - ) - + _validate_string_coordinate(da, coord_dim) return self.create_color_map(da.coords[coord_dim]) def reorder_coordinate( @@ -628,22 +617,13 @@ def reorder_coordinate( da_reordered = mapper.reorder_coordinate(da, 'product') ``` """ - if coord_dim not in da.coords: - raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") + _validate_string_coordinate(da, coord_dim) if sort_within_groups is None: sort_within_groups = self.sort_within_groups - # Get coordinate values and validate they are strings - coord_values = da.coords[coord_dim].values - if not all(isinstance(v, str) for v in coord_values): - non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] - raise TypeError( - f"Coordinate '{coord_dim}' must contain only strings. " - f'Found non-string values: {non_strings[:5]}' # Show first 5 - ) - - categories = list(coord_values) + # Get coordinate values as categories + categories = list(da.coords[coord_dim].values) # Group categories groups = self._group_categories(categories) @@ -742,6 +722,28 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: return groups +def _validate_string_coordinate(da: xr.DataArray, coord_dim: str) -> None: + """Validate that a DataArray coordinate contains only string values. + + Args: + da: DataArray to validate + coord_dim: Coordinate dimension name + + Raises: + ValueError: If coord_dim not found in DataArray + TypeError: If coordinate values are not all strings + """ + if coord_dim not in da.coords: + raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") + + coord_values = da.coords[coord_dim].values + if not all(isinstance(v, str) for v in coord_values): + non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] + raise TypeError( + f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_strings[:5]}" + ) + + def resolve_colors( data: xr.DataArray, colors: ColorType | XarrayColorMapper, @@ -778,18 +780,9 @@ def resolve_colors( >>> resolved_colors = resolve_colors(data, 'viridis') """ - # Validate coordinate exists - if coord_dim not in data.coords: - raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(data.coords.keys())}") - - # Validate all coordinate values are strings + # Validate coordinate and ensure all values are strings + _validate_string_coordinate(data, coord_dim) coord_values = data.coords[coord_dim].values - if not all(isinstance(v, str) for v in coord_values): - non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] - raise TypeError( - f"Coordinate '{coord_dim}' must contain only strings. " - f'Found non-string values: {non_strings[:5]}' # Show first 5 - ) # If explicit dict provided, use it directly if isinstance(colors, dict): From a536809c5d29650dfe99c080a4043c108424b163 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:07:50 +0200 Subject: [PATCH 070/173] Remove duplication --- flixopt/plotting.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index c1de9971e..9d165c606 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -584,9 +584,13 @@ def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str] Raises: ValueError: If coord_dim is not found in the DataArray. - TypeError: If coord_dim values are not all strings. + + Note: + Assumes coordinate values are strings. Use resolve_colors() for automatic validation. """ - _validate_string_coordinate(da, coord_dim) + if coord_dim not in da.coords: + raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") + return self.create_color_map(da.coords[coord_dim]) def reorder_coordinate( @@ -605,6 +609,9 @@ def reorder_coordinate( Returns: New DataArray with reordered coordinate. + Note: + Assumes coordinate values are strings. Use resolve_colors() for automatic validation. + Examples: Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] After reorder: ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2'] @@ -617,7 +624,8 @@ def reorder_coordinate( da_reordered = mapper.reorder_coordinate(da, 'product') ``` """ - _validate_string_coordinate(da, coord_dim) + if coord_dim not in da.coords: + raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") if sort_within_groups is None: sort_within_groups = self.sort_within_groups From 5972ffe3852eadece4399d80a9b89b1af41c4ee9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:10:24 +0200 Subject: [PATCH 071/173] Make check faster --- flixopt/plotting.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 9d165c606..8e215f5f6 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -745,10 +745,12 @@ def _validate_string_coordinate(da: xr.DataArray, coord_dim: str) -> None: raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") coord_values = da.coords[coord_dim].values - if not all(isinstance(v, str) for v in coord_values): - non_strings = [f'{v!r} ({type(v).__name__})' for v in coord_values if not isinstance(v, str)] + non_strings = [v for v in coord_values if not isinstance(v, str)] + + if non_strings: + non_string_repr = [f'{v!r} ({type(v).__name__})' for v in non_strings[:5]] raise TypeError( - f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_strings[:5]}" + f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_string_repr}" ) From 67672ec583f7c4108a2264c6ab9d3c77e7d8cf27 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:10:46 +0200 Subject: [PATCH 072/173] Make check faster --- flixopt/plotting.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 8e215f5f6..63eb1f9d6 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -745,13 +745,22 @@ def _validate_string_coordinate(da: xr.DataArray, coord_dim: str) -> None: raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") coord_values = da.coords[coord_dim].values - non_strings = [v for v in coord_values if not isinstance(v, str)] - if non_strings: - non_string_repr = [f'{v!r} ({type(v).__name__})' for v in non_strings[:5]] - raise TypeError( - f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_string_repr}" - ) + # Check if the array dtype is a string/unicode type + if not np.issubdtype(coord_values.dtype, np.str_) and not np.issubdtype(coord_values.dtype, np.object_): + raise TypeError(f"Coordinate '{coord_dim}' must contain only strings. Found dtype: {coord_values.dtype}") + + # For object arrays, verify all elements are actually strings + if coord_values.dtype == np.object_: + # Vectorized check using numpy + is_string = np.vectorize(lambda x: isinstance(x, str), otypes=[bool])(coord_values) + if not np.all(is_string): + non_string_mask = ~is_string + non_strings = coord_values[non_string_mask][:5] + non_string_repr = [f'{v!r} ({type(v).__name__})' for v in non_strings] + raise TypeError( + f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_string_repr}" + ) def resolve_colors( From 0c2bac44769f8d6eea72f69502228661b11dd7d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:13:41 +0200 Subject: [PATCH 073/173] Make check faster --- flixopt/plotting.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 63eb1f9d6..8d462a920 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -746,21 +746,21 @@ def _validate_string_coordinate(da: xr.DataArray, coord_dim: str) -> None: coord_values = da.coords[coord_dim].values - # Check if the array dtype is a string/unicode type - if not np.issubdtype(coord_values.dtype, np.str_) and not np.issubdtype(coord_values.dtype, np.object_): - raise TypeError(f"Coordinate '{coord_dim}' must contain only strings. Found dtype: {coord_values.dtype}") - - # For object arrays, verify all elements are actually strings - if coord_values.dtype == np.object_: - # Vectorized check using numpy - is_string = np.vectorize(lambda x: isinstance(x, str), otypes=[bool])(coord_values) - if not np.all(is_string): - non_string_mask = ~is_string - non_strings = coord_values[non_string_mask][:5] - non_string_repr = [f'{v!r} ({type(v).__name__})' for v in non_strings] - raise TypeError( - f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_string_repr}" - ) + # Fast path: If dtype is string, all values are guaranteed to be strings + if np.issubdtype(coord_values.dtype, np.str_): + return # All good! + + # For object arrays or other types, check elements + # Use early exit - stop after finding first 5 non-strings + non_strings = [] + for v in coord_values: + if not isinstance(v, str): + non_strings.append(f'{v!r} ({type(v).__name__})') + if len(non_strings) >= 5: # Early exit + break + + if non_strings: + raise TypeError(f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_strings}") def resolve_colors( From d26ed42d923f3379b7b6849bcc70326aa4a1df72 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:59:54 +0200 Subject: [PATCH 074/173] Fixx plotting issues --- flixopt/plotting.py | 4 +++- flixopt/results.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 8d462a920..639aa5fd7 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -929,7 +929,9 @@ def with_plotly( df_long = data.to_dataframe().reset_index() df_long = df_long.rename(columns={temp_name: 'value'}) - df_long['variable'] = data.name or 'data' + # Only add 'variable' column if it doesn't already exist (preserve actual variable names from coordinates) + if 'variable' not in df_long.columns: + df_long['variable'] = data.name or 'data' # 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']] diff --git a/flixopt/results.py b/flixopt/results.py index cadee8fe7..2168cb99e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1302,7 +1302,9 @@ def plot_node_balance_pie( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, coord_dim='variable', engine=engine) + # Convert Dataset to DataArray to create 'variable' coordinate + combined_da = combined_ds.to_array(dim='variable') + resolved_colors = plotting.resolve_colors(combined_da, colors_to_use, coord_dim='variable', engine=engine) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( From aad4fb4a8cb3f695e2140c3d72248b9af9ed9448 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:24:34 +0200 Subject: [PATCH 075/173] Switch back to Dataset first --- flixopt/plotting.py | 101 +++++++++++++++----------------------------- flixopt/results.py | 34 ++++++--------- 2 files changed, 48 insertions(+), 87 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 639aa5fd7..91c2167ac 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -764,7 +764,7 @@ def _validate_string_coordinate(da: xr.DataArray, coord_dim: str) -> None: def resolve_colors( - data: xr.DataArray, + data: xr.Dataset, colors: ColorType | XarrayColorMapper, coord_dim: str = 'variable', engine: PlottingEngine = 'plotly', @@ -776,13 +776,13 @@ def resolve_colors( or as part of CalculationResults. Args: - data: DataArray to create colors for + data: Dataset to create colors for colors: Color specification or a XarrayColorMapper to use - coord_dim: Coordinate dimension to map colors to + coord_dim: Not used (kept for API compatibility). Variable names come from data_vars. engine: Plotting engine ('plotly' or 'matplotlib') Returns: - Dictionary mapping coordinate values to colors + Dictionary mapping variable names to colors Examples: With CalculationResults: @@ -799,9 +799,8 @@ def resolve_colors( >>> resolved_colors = resolve_colors(data, 'viridis') """ - # Validate coordinate and ensure all values are strings - _validate_string_coordinate(data, coord_dim) - coord_values = data.coords[coord_dim].values + # Get variable names from Dataset (always strings and unique) + labels = list(data.data_vars.keys()) # If explicit dict provided, use it directly if isinstance(colors, dict): @@ -809,24 +808,18 @@ def resolve_colors( # If string or list, use ColorProcessor (traditional behavior) if isinstance(colors, (str, list)): - labels = [str(v) for v in coord_values] processor = ColorProcessor(engine=engine) return processor.process_colors(colors, labels, return_mapping=True) if isinstance(colors, XarrayColorMapper): - color_mapper = colors - - # Reorder coordinate dimension if sorting enabled - if color_mapper.sort_within_groups: - data = color_mapper.reorder_coordinate(data, coord_dim) - - return color_mapper.apply_to_dataarray(data, coord_dim) + # Use color mapper's create_color_map directly with variable names + return colors.create_color_map(labels) raise TypeError(f'Wrong type passed to resolve_colors(): {type(colors)}') def with_plotly( - data: xr.DataArray, + data: xr.Dataset, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', @@ -846,7 +839,7 @@ def with_plotly( For simple plots without faceting, can optionally add to an existing figure. Args: - data: An xarray DataArray to plot. + data: An xarray 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. Can be: @@ -875,25 +868,25 @@ def with_plotly( Simple plot: ```python - fig = with_plotly(data_array, mode='area', title='Energy Mix') + fig = with_plotly(dataset, mode='area', title='Energy Mix') ``` Facet by scenario: ```python - fig = with_plotly(data_array, facet_by='scenario', facet_cols=2) + fig = with_plotly(dataset, facet_by='scenario', facet_cols=2) ``` Animate by period: ```python - fig = with_plotly(data_array, animate_by='period') + fig = with_plotly(dataset, animate_by='period') ``` Facet and animate: ```python - fig = with_plotly(data_array, facet_by='scenario', animate_by='period') + fig = with_plotly(dataset, facet_by='scenario', animate_by='period') ``` Pattern-based colors with XarrayColorMapper: @@ -903,14 +896,14 @@ def with_plotly( mapper.add_rule('Solar', 'oranges', 'prefix') mapper.add_rule('Wind', 'blues', 'prefix') mapper.add_rule('Battery', 'greens', 'contains') - fig = with_plotly(data_array, colors=mapper, mode='area') + fig = with_plotly(dataset, colors=mapper, mode='area') ``` """ 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}") # Handle empty data - if data.size == 0: + if len(data.data_vars) == 0: return go.Figure() # Warn if fig parameter is used with faceting @@ -918,20 +911,10 @@ def with_plotly( logger.warning('The fig parameter is ignored when using faceting or animation. Creating a new figure.') fig = None - # Convert DataArray to long-form DataFrame for Plotly Express + # Convert Dataset to long-form DataFrame for Plotly Express # Structure: time, variable, value, scenario, period, ... (all dims as columns) - # Give unnamed DataArrays a temporary name for conversion - if data.name is None: - data = data.rename('_temp_value') - temp_name = '_temp_value' - else: - temp_name = data.name - - df_long = data.to_dataframe().reset_index() - df_long = df_long.rename(columns={temp_name: 'value'}) - # Only add 'variable' column if it doesn't already exist (preserve actual variable names from coordinates) - if 'variable' not in df_long.columns: - df_long['variable'] = data.name or 'data' + dim_names = list(data.dims) + df_long = data.to_dataframe().reset_index().melt(id_vars=dim_names, 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']] @@ -1076,7 +1059,7 @@ def with_plotly( def with_matplotlib( - data: xr.DataArray, + data: xr.Dataset, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', @@ -1090,8 +1073,8 @@ def with_matplotlib( Plot data with Matplotlib using stacked bars or stepped lines. Args: - data: An xarray DataArray to plot. After conversion to DataFrame, - the index represents time and each column represents a separate data series. + data: An xarray Dataset to plot. After conversion to DataFrame, + the index represents time and each column represents a separate data series (variables). mode: Plotting mode. Use 'stacked_bar' for stacked bar charts or 'line' for stepped lines. colors: Color specification. Can be: - A colormap name (e.g., 'viridis', 'plasma') @@ -1120,7 +1103,7 @@ def with_matplotlib( mapper = XarrayColorMapper() mapper.add_rule('Solar', 'oranges', 'prefix') mapper.add_rule('Wind', 'blues', 'prefix') - fig, ax = with_matplotlib(data_array, colors=mapper, mode='line') + fig, ax = with_matplotlib(dataset, colors=mapper, mode='line') ``` """ if mode not in ('stacked_bar', 'line'): @@ -1132,37 +1115,23 @@ def with_matplotlib( # Resolve colors first (includes validation) color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='matplotlib') - # Convert to DataFrame for matplotlib plotting - # Give unnamed DataArrays a temporary name for conversion - if data.name is None: - data = data.rename('_temp_value') + # Convert Dataset to DataFrame for matplotlib plotting (naturally wide-form) df = data.to_dataframe() - # If 'variable' is in the index (multi-index DataFrame), pivot to wide format - # This happens when the DataArray has 'variable' as a dimension - if 'variable' in df.index.names: - # Unstack to get variables as columns - df = df.unstack('variable') - # Flatten column multi-index if present - if isinstance(df.columns, pd.MultiIndex): - df.columns = df.columns.get_level_values(-1) - - data = df - # Get colors in column order - processed_colors = [color_discrete_map.get(str(col), '#808080') for col in data.columns] + processed_colors = [color_discrete_map.get(str(col), '#808080') for col in df.columns] if mode == 'stacked_bar': - cumulative_positive = np.zeros(len(data)) - cumulative_negative = np.zeros(len(data)) - width = data.index.to_series().diff().dropna().min() # Minimum time difference + cumulative_positive = np.zeros(len(df)) + cumulative_negative = np.zeros(len(df)) + width = df.index.to_series().diff().dropna().min() # Minimum time difference - for i, column in enumerate(data.columns): - positive_values = np.clip(data[column], 0, None) # Keep only positive values - negative_values = np.clip(data[column], None, 0) # Keep only negative values + for i, column in enumerate(df.columns): + positive_values = np.clip(df[column], 0, None) # Keep only positive values + negative_values = np.clip(df[column], None, 0) # Keep only negative values # Plot positive bars ax.bar( - data.index, + df.index, positive_values, bottom=cumulative_positive, color=processed_colors[i], @@ -1173,7 +1142,7 @@ def with_matplotlib( cumulative_positive += positive_values.values # Plot negative bars ax.bar( - data.index, + df.index, negative_values, bottom=cumulative_negative, color=processed_colors[i], @@ -1184,8 +1153,8 @@ def with_matplotlib( cumulative_negative += negative_values.values elif mode == 'line': - for i, column in enumerate(data.columns): - ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column) + for i, column in enumerate(df.columns): + ax.step(df.index, df[column], where='post', color=processed_colors[i], label=column) # Aesthetics ax.set_xlabel(xlabel, ha='center') diff --git a/flixopt/results.py b/flixopt/results.py index 2168cb99e..db0cd3976 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1125,9 +1125,6 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - # Convert Dataset to DataArray for plotting - da = ds.to_array(dim='variable') - # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( self._calculation_results.color_mapper @@ -1136,7 +1133,7 @@ def plot_node_balance( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(da, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(ds, colors_to_use, coord_dim='variable', engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1154,7 +1151,7 @@ def plot_node_balance( if engine == 'plotly': figure_like = plotting.with_plotly( - da, + ds, facet_by=facet_by, animate_by=animate_by, colors=resolved_colors, @@ -1165,7 +1162,7 @@ def plot_node_balance( default_filetype = '.html' else: figure_like = plotting.with_matplotlib( - da, + ds, colors=resolved_colors, mode=mode, title=title, @@ -1302,9 +1299,7 @@ def plot_node_balance_pie( if colors == 'auto' else colors ) - # Convert Dataset to DataArray to create 'variable' coordinate - combined_da = combined_ds.to_array(dim='variable') - resolved_colors = plotting.resolve_colors(combined_da, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, coord_dim='variable', engine=engine) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( @@ -1514,12 +1509,9 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' - # Convert Dataset to DataArray for plotting - da = ds.to_array(dim='variable') - - # Merge both DataArrays for color resolution - # We need to combine the flow balance and charge state data to get consistent colors - combined_da = xr.concat([da, charge_state_da.expand_dims(variable=[self._charge_state])], dim='variable') + # Combine flow balance and charge state for color resolution + # We need to include both in the color map for consistency + combined_ds = ds.assign({self._charge_state: charge_state_da}) # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( @@ -1529,12 +1521,12 @@ def plot_charge_state( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(combined_da, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, coord_dim='variable', engine=engine) if engine == 'plotly': # Plot flows (node balance) with the specified mode figure_like = plotting.with_plotly( - da, + ds, facet_by=facet_by, animate_by=animate_by, colors=resolved_colors, @@ -1543,12 +1535,12 @@ def plot_charge_state( facet_cols=facet_cols, ) - # Prepare charge_state as DataArray with variable dimension for plotting - charge_state_da_plot = charge_state_da.expand_dims(variable=[self._charge_state]) + # Prepare charge_state as Dataset for plotting + charge_state_ds = xr.Dataset({self._charge_state: charge_state_da}) # Plot charge_state with mode='line' to get Scatter traces charge_state_fig = plotting.with_plotly( - charge_state_da_plot, + charge_state_ds, facet_by=facet_by, animate_by=animate_by, colors=resolved_colors, @@ -1586,7 +1578,7 @@ def plot_charge_state( ) # For matplotlib, plot flows (node balance), then add charge_state as line fig, ax = plotting.with_matplotlib( - da, + ds, colors=resolved_colors, mode=mode, title=title, From 83c1b1df02018d426d53c272fe391627eecac08f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:44:44 +0200 Subject: [PATCH 076/173] Remove redundant code --- flixopt/plotting.py | 47 +++++++-------------------------------------- flixopt/results.py | 6 +++--- 2 files changed, 10 insertions(+), 43 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 91c2167ac..1c0782ea8 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -586,7 +586,8 @@ def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str] ValueError: If coord_dim is not found in the DataArray. Note: - Assumes coordinate values are strings. Use resolve_colors() for automatic validation. + Coordinate values must be strings. This method is used for DataArray coordinates (e.g., in heatmaps). + For Datasets, use resolve_colors() which extracts variable names directly. """ if coord_dim not in da.coords: raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") @@ -610,7 +611,8 @@ def reorder_coordinate( New DataArray with reordered coordinate. Note: - Assumes coordinate values are strings. Use resolve_colors() for automatic validation. + Coordinate values must be strings. This method is used for DataArray coordinates (e.g., in heatmaps). + For Datasets, variable names from data_vars are always strings. Examples: Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] @@ -730,43 +732,9 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: return groups -def _validate_string_coordinate(da: xr.DataArray, coord_dim: str) -> None: - """Validate that a DataArray coordinate contains only string values. - - Args: - da: DataArray to validate - coord_dim: Coordinate dimension name - - Raises: - ValueError: If coord_dim not found in DataArray - TypeError: If coordinate values are not all strings - """ - if coord_dim not in da.coords: - raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") - - coord_values = da.coords[coord_dim].values - - # Fast path: If dtype is string, all values are guaranteed to be strings - if np.issubdtype(coord_values.dtype, np.str_): - return # All good! - - # For object arrays or other types, check elements - # Use early exit - stop after finding first 5 non-strings - non_strings = [] - for v in coord_values: - if not isinstance(v, str): - non_strings.append(f'{v!r} ({type(v).__name__})') - if len(non_strings) >= 5: # Early exit - break - - if non_strings: - raise TypeError(f"Coordinate '{coord_dim}' must contain only strings. Found non-string values: {non_strings}") - - def resolve_colors( data: xr.Dataset, colors: ColorType | XarrayColorMapper, - coord_dim: str = 'variable', engine: PlottingEngine = 'plotly', ) -> dict[str, str]: """Resolve colors parameter to a color mapping dict. @@ -776,9 +744,8 @@ def resolve_colors( or as part of CalculationResults. Args: - data: Dataset to create colors for + data: Dataset to create colors for. Variable names from data_vars are used as labels. colors: Color specification or a XarrayColorMapper to use - coord_dim: Not used (kept for API compatibility). Variable names come from data_vars. engine: Plotting engine ('plotly' or 'matplotlib') Returns: @@ -965,7 +932,7 @@ def with_plotly( raise ValueError(f'facet_by can have at most 2 dimensions, got {len(facet_by)}') # Process colors using resolve_colors (handles validation and all color types) - color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='plotly') + color_discrete_map = resolve_colors(data, colors, engine='plotly') # Get unique variable names for area plot processing all_vars = df_long['variable'].unique().tolist() @@ -1113,7 +1080,7 @@ def with_matplotlib( fig, ax = plt.subplots(figsize=figsize) # Resolve colors first (includes validation) - color_discrete_map = resolve_colors(data, colors, coord_dim='variable', engine='matplotlib') + color_discrete_map = resolve_colors(data, colors, engine='matplotlib') # Convert Dataset to DataFrame for matplotlib plotting (naturally wide-form) df = data.to_dataframe() diff --git a/flixopt/results.py b/flixopt/results.py index db0cd3976..3c35e72fc 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1133,7 +1133,7 @@ def plot_node_balance( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(ds, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(ds, colors_to_use, engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1299,7 +1299,7 @@ def plot_node_balance_pie( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( @@ -1521,7 +1521,7 @@ def plot_charge_state( if colors == 'auto' else colors ) - resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, coord_dim='variable', engine=engine) + resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) if engine == 'plotly': # Plot flows (node balance) with the specified mode From e275f4edc111c64e1282e94b097f91f119ddd7eb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:57:55 +0200 Subject: [PATCH 077/173] XarrayColorMapper is now Dataset-only! --- flixopt/plotting.py | 101 ++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 65 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 1c0782ea8..14327f74e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -335,18 +335,18 @@ def process_colors( class XarrayColorMapper: - """Map xarray coordinate values to colors based on naming patterns. + """Map Dataset variable names to colors based on naming patterns. - A simple, maintainable utility class for mapping xarray coordinate values + A simple, maintainable utility class for mapping Dataset variable names to colors based on naming patterns. Enables visual grouping in plots where - similar items get similar colors. + similar variables get similar colors. Key Features: - Pattern-based color assignment (prefix, suffix, contains, glob, regex) - Plotly sequential color palettes for grouped coloring (cycles through shades) - Discrete color support for exact color matching across all items - Override support for special cases - - Coordinate reordering for visual grouping in plots + - Dataset variable reordering for visual grouping in plots - Full type hints and comprehensive documentation Available Color Families (14 single-hue palettes): @@ -357,32 +357,35 @@ class XarrayColorMapper: See: https://plotly.com/python/builtin-colorscales/ Example Usage: - Using color families (items cycle through shades): + Using color families (variables cycle through shades): ```python mapper = ( XarrayColorMapper() - .add_rule('Product_A', 'blues', 'prefix') # Product_A1, A2, A3 get different blue shades - .add_rule('Product_B', 'greens', 'prefix') # Product_B1, B2, B3 get different green shades - .add_override({'Special': '#FFD700'}) + .add_rule('Boiler', 'reds', 'prefix') # Boiler_1, Boiler_2 get different red shades + .add_rule('CHP', 'oranges', 'prefix') # CHP_1, CHP_2 get different orange shades + .add_rule('Storage', 'blues', 'contains') # *Storage* variables get blue shades + .add_override({'Special_var': '#FFD700'}) ) + + # Use with plotting + results['Component'].plot_node_balance(colors=mapper) ``` - Using discrete colors (all matching items get the same color): + Using discrete colors (all matching variables get the same color): ```python mapper = ( XarrayColorMapper() - .add_rule('Solar', '#FFA500', 'prefix') # All Solar* items get exact orange - .add_rule('Wind', 'skyblue', 'prefix') # All Wind* items get exact skyblue + .add_rule('Solar', '#FFA500', 'prefix') # All Solar* get exact orange + .add_rule('Wind', 'skyblue', 'prefix') # All Wind* get exact skyblue .add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green ) - # Apply to data - color_map = mapper.apply_to_dataarray(da, 'component') - - # Plot with Plotly - fig = px.bar(df, x='component', y='value', color='component', color_discrete_map=color_map) + # Apply to Dataset and plot + color_map = mapper.create_color_map(list(ds.data_vars.keys())) + # Or use with resolve_colors() + resolved_colors = resolve_colors(ds, mapper) ``` """ @@ -572,47 +575,18 @@ def create_color_map( return color_map - def apply_to_dataarray(self, da: xr.DataArray, coord_dim: str) -> dict[str, str]: - """Create color map for a DataArray coordinate dimension. - - Args: - da: The data array. - coord_dim: Coordinate dimension name. - - Returns: - Color mapping for that dimension. - - Raises: - ValueError: If coord_dim is not found in the DataArray. - - Note: - Coordinate values must be strings. This method is used for DataArray coordinates (e.g., in heatmaps). - For Datasets, use resolve_colors() which extracts variable names directly. - """ - if coord_dim not in da.coords: - raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") - - return self.create_color_map(da.coords[coord_dim]) - - def reorder_coordinate( - self, da: xr.DataArray, coord_dim: str, sort_within_groups: bool | None = None - ) -> xr.DataArray: - """Reorder a DataArray coordinate so values with the same color are adjacent. + def reorder_dataset(self, ds: xr.Dataset, sort_within_groups: bool | None = None) -> xr.Dataset: + """Reorder Dataset variables so variables with the same color group are adjacent. - This is useful for creating plots where similar items (same color group) - appear next to each other, making visual groupings clear. + This is useful for creating plots where similar variables (same color group) + appear next to each other, making visual groupings clear in legends and stacked plots. Args: - da: The data array to reorder. - coord_dim: The coordinate dimension to reorder. - sort_within_groups: Whether to sort values within each group. If None, uses instance default. + ds: The Dataset to reorder. + sort_within_groups: Whether to sort variables within each group. If None, uses instance default. Returns: - New DataArray with reordered coordinate. - - Note: - Coordinate values must be strings. This method is used for DataArray coordinates (e.g., in heatmaps). - For Datasets, variable names from data_vars are always strings. + New Dataset with reordered variables. Examples: Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] @@ -623,31 +597,28 @@ def reorder_coordinate( mapper.add_rule('Product_A', 'blues', 'prefix') mapper.add_rule('Product_B', 'greens', 'prefix') - da_reordered = mapper.reorder_coordinate(da, 'product') + ds_reordered = mapper.reorder_dataset(ds) ``` """ - if coord_dim not in da.coords: - raise ValueError(f"Coordinate '{coord_dim}' not found. Available: {list(da.coords.keys())}") - if sort_within_groups is None: sort_within_groups = self.sort_within_groups - # Get coordinate values as categories - categories = list(da.coords[coord_dim].values) + # Get variable names + variable_names = list(ds.data_vars.keys()) - # Group categories - groups = self._group_categories(categories) + # Group variables + groups = self._group_categories(variable_names) # Build new order: group by group, optionally sorted within each new_order = [] for group_name in groups.keys(): - group_categories = groups[group_name] + group_vars = groups[group_name] if sort_within_groups: - group_categories = sorted(group_categories) - new_order.extend(group_categories) + group_vars = sorted(group_vars) + new_order.extend(group_vars) - # Reindex the DataArray - return da.sel({coord_dim: new_order}) + # Reorder Dataset by selecting variables in new order + return ds[new_order] def get_rules(self) -> list[dict[str, str]]: """Return a copy of current rules for inspection.""" From 7623285f9d27f43f43c8d17baf6f08765aeb1f5d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:58:10 +0200 Subject: [PATCH 078/173] Update tests accordingly --- tests/test_color_mapper.py | 89 ++++++++++++++------------------------ 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/tests/test_color_mapper.py b/tests/test_color_mapper.py index 686bafc2d..803a5e16d 100644 --- a/tests/test_color_mapper.py +++ b/tests/test_color_mapper.py @@ -223,66 +223,50 @@ def test_override_precedence(self): class TestXarrayIntegration: """Test integration with xarray DataArrays.""" - def test_apply_to_dataarray(self): - """Test applying mapper to a DataArray.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('Product_B', 'greens', 'prefix') - - da = xr.DataArray( - np.random.rand(5, 4), - coords={'time': range(5), 'product': ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2']}, - dims=['time', 'product'], - ) - - color_map = mapper.apply_to_dataarray(da, 'product') - - assert len(color_map) == 4 - assert all(prod in color_map for prod in da.product.values) - - def test_apply_to_dataarray_missing_coord(self): - """Test that applying to missing coordinate raises error.""" - mapper = XarrayColorMapper() - da = xr.DataArray(np.random.rand(5), coords={'time': range(5)}, dims=['time']) - - with pytest.raises(ValueError, match="Coordinate 'product' not found"): - mapper.apply_to_dataarray(da, 'product') - - def test_reorder_coordinate(self): - """Test reordering coordinates.""" + def test_reorder_dataset(self): + """Test reordering Dataset variables.""" mapper = XarrayColorMapper() mapper.add_rule('A', 'blues', 'prefix') mapper.add_rule('B', 'greens', 'prefix') - da = xr.DataArray( - np.random.rand(4), - coords={'product': ['B2', 'A1', 'B1', 'A2']}, - dims=['product'], + ds = xr.Dataset( + { + 'B2': xr.DataArray([1, 2, 3], dims=['time']), + 'A1': xr.DataArray([4, 5, 6], dims=['time']), + 'B1': xr.DataArray([7, 8, 9], dims=['time']), + 'A2': xr.DataArray([10, 11, 12], dims=['time']), + } ) - da_reordered = mapper.reorder_coordinate(da, 'product') + ds_reordered = mapper.reorder_dataset(ds) - # With sorting, items are grouped by family (order of first occurrence in input), + # With sorting, variables are grouped by family (order of first occurrence in input), # then sorted within each group - # B items are encountered first, so greens group comes first + # B variables are encountered first, so greens group comes first expected_order = ['B1', 'B2', 'A1', 'A2'] - assert [str(v) for v in da_reordered.product.values] == expected_order + assert list(ds_reordered.data_vars.keys()) == expected_order - def test_reorder_coordinate_preserves_data(self): + def test_reorder_dataset_preserves_data(self): """Test that reordering preserves data values.""" mapper = XarrayColorMapper() mapper.add_rule('A', 'blues', 'prefix') - original_data = np.array([10, 20, 30, 40]) - da = xr.DataArray(original_data, coords={'product': ['A4', 'A1', 'A3', 'A2']}, dims=['product']) + ds = xr.Dataset( + { + 'A4': xr.DataArray([10, 20], dims=['time']), + 'A1': xr.DataArray([30, 40], dims=['time']), + 'A3': xr.DataArray([50, 60], dims=['time']), + 'A2': xr.DataArray([70, 80], dims=['time']), + } + ) - da_reordered = mapper.reorder_coordinate(da, 'product') + ds_reordered = mapper.reorder_dataset(ds) - # Check that the data is correctly reordered with the coordinates - assert da_reordered.sel(product='A1').values == 20 - assert da_reordered.sel(product='A2').values == 40 - assert da_reordered.sel(product='A3').values == 30 - assert da_reordered.sel(product='A4').values == 10 + # Check that the data is correctly preserved + assert (ds_reordered['A1'].values == np.array([30, 40])).all() + assert (ds_reordered['A2'].values == np.array([70, 80])).all() + assert (ds_reordered['A3'].values == np.array([50, 60])).all() + assert (ds_reordered['A4'].values == np.array([10, 20])).all() class TestEdgeCases: @@ -426,13 +410,8 @@ def test_energy_system_components(self): ) components = ['Solar_PV', 'Wind_Turbine', 'Gas_Turbine', 'Battery_Storage', 'Grid_Import'] - da = xr.DataArray( - np.random.rand(24, len(components)), - coords={'time': range(24), 'component': components}, - dims=['time', 'component'], - ) - color_map = mapper.apply_to_dataarray(da, 'component') + color_map = mapper.create_color_map(components) assert color_map['Grid_Import'] == '#808080' # Override assert all(comp in color_map for comp in components) @@ -468,15 +447,13 @@ def test_product_tiers(self): ) products = ['Premium_A', 'Premium_B', 'Standard_A', 'Standard_B', 'Budget_A', 'Budget_B'] - da = xr.DataArray( - np.random.rand(10, 6), coords={'time': range(10), 'product': products}, dims=['time', 'product'] - ) + ds = xr.Dataset({p: xr.DataArray(np.random.rand(10), dims=['time']) for p in products}) - da_reordered = mapper.reorder_coordinate(da, 'product') - mapper.apply_to_dataarray(da_reordered, 'product') + ds_reordered = mapper.reorder_dataset(ds) + mapper.create_color_map(list(ds_reordered.data_vars.keys())) # Check grouping: all Premium together, then Standard, then Budget - reordered_products = list(da_reordered.product.values) + reordered_products = list(ds_reordered.data_vars.keys()) premium_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Premium_')] standard_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Standard_')] budget_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Budget_')] From 5d8d83dfd121d62328291f94ce4307605f53d027 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:09:03 +0200 Subject: [PATCH 079/173] Fix issue in aggregation.py with new plotting --- flixopt/aggregation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 91ef618a9..24bf6a9bb 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -150,10 +150,12 @@ def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path df_agg = self.aggregated_data.copy().rename( columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns} ) - fig = plotting.with_plotly(df_org, 'line', colors=colormap) + fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap) for trace in fig.data: trace.update(dict(line=dict(dash='dash'))) - fig = plotting.with_plotly(df_agg, 'line', colors=colormap, fig=fig) + fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap) + for trace in fig2.data: + fig.add_trace(trace) fig.update_layout( title='Original vs Aggregated Data (original = ---)', xaxis_title='Index', yaxis_title='Value' From fc8110703db6cd79d954040f5e26943d9fc26d28 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:25:54 +0200 Subject: [PATCH 080/173] Fix issue plotting in examples (using dataframes) --- .../03_Calculation_types/example_calculation_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 3f9ae665b..137c3d100 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -202,28 +202,28 @@ def get_solutions(calcs: list, variable: str) -> xr.Dataset: # --- Plotting for comparison --- fx.plotting.with_plotly( - get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), + get_solutions(calculations, 'Speicher|charge_state'), mode='line', title='Charge State Comparison', ylabel='Charge state', ).write_html('results/Charge State.html') fx.plotting.with_plotly( - get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), + get_solutions(calculations, 'BHKW2(Q_th)|flow_rate'), mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', ).write_html('results/BHKW2 Thermal Power.html') fx.plotting.with_plotly( - get_solutions(calculations, 'costs(temporal)|per_timestep').to_dataframe(), + get_solutions(calculations, 'costs(temporal)|per_timestep'), mode='line', title='Operation Cost Comparison', ylabel='Costs [€]', ).write_html('results/Operation Costs.html') fx.plotting.with_plotly( - pd.DataFrame(get_solutions(calculations, 'costs(temporal)|per_timestep').to_dataframe().sum()).T, + get_solutions(calculations, 'costs(temporal)|per_timestep').sum('time'), mode='stacked_bar', title='Total Cost Comparison', ylabel='Costs [€]', From 6a7b6391e28ed5deb9af80c682e4e9726055cd14 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:39:20 +0200 Subject: [PATCH 081/173] Fix issue plotting in examples (using dataframes) --- flixopt/plotting.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 14327f74e..514fcc3c3 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -844,6 +844,15 @@ def with_plotly( if len(data.data_vars) == 0: return go.Figure() + # Handle all-scalar datasets (where all variables have no dimensions) + # This occurs when all variables are scalar values with dims=() + if all(len(data[var].dims) == 0 for var in data.data_vars): + # Expand scalars by adding a dummy dimension to make them plottable + expanded_data = xr.Dataset() + for var in data.data_vars: + expanded_data[var] = xr.DataArray([data[var].values], dims=['_scalar_index'], coords={'_scalar_index': [0]}) + data = expanded_data + # 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.') @@ -908,10 +917,32 @@ def with_plotly( # Get unique variable names for area plot processing all_vars = df_long['variable'].unique().tolist() + # Determine which dimension to use for x-axis + # Collect dimensions used for faceting and animation + used_dims = set() + if facet_row: + used_dims.add(facet_row) + if facet_col: + used_dims.add(facet_col) + if animate_by: + used_dims.add(animate_by) + + # Find available dimensions for x-axis (not used for faceting/animation) + x_candidates = [d for d in available_dims if d not in used_dims] + + # Use 'time' if available, otherwise use the first available dimension + if 'time' in x_candidates: + x_dim = 'time' + elif len(x_candidates) > 0: + x_dim = x_candidates[0] + else: + # Fallback: use the first dimension (shouldn't happen in normal cases) + x_dim = available_dims[0] if available_dims else 'time' + # Create plot using Plotly Express based on mode common_args = { 'data_frame': df_long, - 'x': 'time', + 'x': x_dim, 'y': 'value', 'color': 'variable', 'facet_row': facet_row, @@ -919,7 +950,7 @@ def with_plotly( 'animation_frame': animate_by, 'color_discrete_map': color_discrete_map, 'title': title, - 'labels': {'value': ylabel, 'time': xlabel, 'variable': ''}, + 'labels': {'value': ylabel, x_dim: xlabel, 'variable': ''}, } # Add facet_col_wrap for single facet dimension @@ -1050,6 +1081,15 @@ def with_matplotlib( if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) + # Handle all-scalar datasets (where all variables have no dimensions) + # This occurs when all variables are scalar values with dims=() + if all(len(data[var].dims) == 0 for var in data.data_vars): + # Expand scalars by adding a dummy dimension to make them plottable + expanded_data = xr.Dataset() + for var in data.data_vars: + expanded_data[var] = xr.DataArray([data[var].values], dims=['_scalar_index'], coords={'_scalar_index': [0]}) + data = expanded_data + # Resolve colors first (includes validation) color_discrete_map = resolve_colors(data, colors, engine='matplotlib') From 5b40a2a79c20341cefc86957c433c3bfec841012 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:09:45 +0200 Subject: [PATCH 082/173] Fix issue plotting scalar Datasets --- flixopt/plotting.py | 78 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 514fcc3c3..a8d3a0eda 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -847,11 +847,45 @@ def with_plotly( # Handle all-scalar datasets (where all variables have no dimensions) # This occurs when all variables are scalar values with dims=() if all(len(data[var].dims) == 0 for var in data.data_vars): - # Expand scalars by adding a dummy dimension to make them plottable - expanded_data = xr.Dataset() - for var in data.data_vars: - expanded_data[var] = xr.DataArray([data[var].values], dims=['_scalar_index'], coords={'_scalar_index': [0]}) - data = expanded_data + # Create a simple DataFrame with variable names as x-axis + variables = list(data.data_vars.keys()) + values = [float(data[var].values) for var in data.data_vars] + + # Resolve colors + color_discrete_map = resolve_colors(data, colors, engine='plotly') + marker_colors = [color_discrete_map.get(var, '#636EFA') for var in variables] + + # Create simple plot based on mode using go (not px) for better color control + if mode in ('stacked_bar', 'grouped_bar'): + fig = go.Figure(data=[go.Bar(x=variables, y=values, marker_color=marker_colors)]) + elif mode == 'line': + fig = go.Figure( + data=[ + go.Scatter( + x=variables, + y=values, + mode='lines+markers', + marker=dict(color=marker_colors, size=8), + line=dict(color='lightgray'), + ) + ] + ) + elif mode == 'area': + fig = go.Figure( + data=[ + go.Scatter( + x=variables, + y=values, + fill='tozeroy', + marker=dict(color=marker_colors, size=8), + line=dict(color='lightgray'), + ) + ] + ) + + # Update layout + fig.update_layout(title=title, xaxis_title=xlabel, yaxis_title=ylabel, showlegend=False) + return fig # 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): @@ -1084,11 +1118,35 @@ def with_matplotlib( # Handle all-scalar datasets (where all variables have no dimensions) # This occurs when all variables are scalar values with dims=() if all(len(data[var].dims) == 0 for var in data.data_vars): - # Expand scalars by adding a dummy dimension to make them plottable - expanded_data = xr.Dataset() - for var in data.data_vars: - expanded_data[var] = xr.DataArray([data[var].values], dims=['_scalar_index'], coords={'_scalar_index': [0]}) - data = expanded_data + # Create simple bar/line plot with variable names as x-axis + variables = list(data.data_vars.keys()) + values = [float(data[var].values) for var in data.data_vars] + + # Resolve colors + color_discrete_map = resolve_colors(data, colors, engine='matplotlib') + colors_list = [color_discrete_map.get(var, '#808080') for var in variables] + + # Create plot based on mode + if mode == 'stacked_bar': + ax.bar(variables, values, color=colors_list) + elif mode == 'line': + ax.plot(variables, values, marker='o', color=colors_list[0] if len(set(colors_list)) == 1 else None) + # If different colors, plot each point separately + if len(set(colors_list)) > 1: + ax.clear() + for i, (var, val) in enumerate(zip(variables, values, strict=False)): + ax.plot([i], [val], marker='o', color=colors_list[i], label=var) + ax.set_xticks(range(len(variables))) + ax.set_xticklabels(variables) + + # Aesthetics + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) + ax.grid(color='lightgrey', linestyle='-', linewidth=0.5, axis='y') + fig.tight_layout() + + return fig, ax # Resolve colors first (includes validation) color_discrete_map = resolve_colors(data, colors, engine='matplotlib') From 988aa10273bd48079d01840ce899498d9c07ca4a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:53:14 +0200 Subject: [PATCH 083/173] Improve labeling of plots --- flixopt/aggregation.py | 4 ++-- flixopt/plotting.py | 4 +--- flixopt/results.py | 3 +++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 24bf6a9bb..cd22c2ad7 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -150,10 +150,10 @@ def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path df_agg = self.aggregated_data.copy().rename( columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns} ) - fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap) + fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap, xlabel='Time in h') for trace in fig.data: trace.update(dict(line=dict(dash='dash'))) - fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap) + fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap, xlabel='Time in h') for trace in fig2.data: fig.add_trace(trace) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index a8d3a0eda..59ccddad3 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -762,7 +762,7 @@ def with_plotly( colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', ylabel: str = '', - xlabel: str = 'Time in h', + xlabel: str = '', fig: go.Figure | None = None, facet_by: str | list[str] | None = None, animate_by: str | None = None, @@ -883,7 +883,6 @@ def with_plotly( ] ) - # Update layout fig.update_layout(title=title, xaxis_title=xlabel, yaxis_title=ylabel, showlegend=False) return fig @@ -1139,7 +1138,6 @@ def with_matplotlib( ax.set_xticks(range(len(variables))) ax.set_xticklabels(variables) - # Aesthetics ax.set_xlabel(xlabel, ha='center') ax.set_ylabel(ylabel, va='center') ax.set_title(title) diff --git a/flixopt/results.py b/flixopt/results.py index 3c35e72fc..75f79e8d3 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1158,6 +1158,7 @@ def plot_node_balance( mode=mode, title=title, facet_cols=facet_cols, + xlabel='Time in h', ) default_filetype = '.html' else: @@ -1533,6 +1534,7 @@ def plot_charge_state( mode=mode, title=title, facet_cols=facet_cols, + xlabel='Time in h', ) # Prepare charge_state as Dataset for plotting @@ -1547,6 +1549,7 @@ def plot_charge_state( mode='line', # Always line for charge_state title='', # No title needed for this temp figure facet_cols=facet_cols, + xlabel='Time in h', ) # Add charge_state traces to the main figure From 0e5baf059d7ab7710bfac1a5b88d9885f207779e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:04:01 +0200 Subject: [PATCH 084/173] Improve handling of time reshape in plots --- flixopt/plotting.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 59ccddad3..30be40b09 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -842,6 +842,7 @@ def with_plotly( # Handle empty data if len(data.data_vars) == 0: + logger.error('"with_plotly() got an empty Dataset.') return go.Figure() # Handle all-scalar datasets (where all variables have no dimensions) @@ -2106,12 +2107,26 @@ def heatmap_with_plotly( 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() + # Handle single-dimension case by adding variable name as a dimension + if len(heatmap_dims) == 1: + # Get the variable name, or use a default + var_name = data.name if data.name else 'value' + + # Expand the DataArray by adding a new dimension with the variable name + data = data.expand_dims({'variable': [var_name]}) + + # Update available dimensions + available_dims = list(data.dims) + heatmap_dims = [dim for dim in available_dims if dim not in facet_dims] + + logger.debug(f'Only 1 dimension remaining for heatmap. Added variable dimension: {var_name}') + else: + # No dimensions at all - cannot create a heatmap + logger.error( + f'Heatmap requires at least 1 dimension. ' + f'After faceting/animation, {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 @@ -2232,6 +2247,12 @@ def heatmap_with_matplotlib( # 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) + # Handle single-dimension case by adding variable name as a dimension + if isinstance(data, xr.DataArray) and len(data.dims) == 1: + var_name = data.name if data.name else 'value' + data = data.expand_dims({'variable': [var_name]}) + logger.debug(f'Only 1 dimension in data. Added variable dimension: {var_name}') + # Create figure and axes if not provided if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) From 764b1c27d19fe416beedc425727d52ebed3443e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:14:48 +0200 Subject: [PATCH 085/173] Update usage of plotting methods --- examples/03_Calculation_types/example_calculation_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 137c3d100..0f98dec2e 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -230,7 +230,7 @@ def get_solutions(calcs: list, variable: str) -> xr.Dataset: ).update_layout(barmode='group').write_html('results/Total Costs.html') fx.plotting.with_plotly( - pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), + pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]).to_xarray(), mode='stacked_bar', ).update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)').write_html( 'results/Speed Comparison.html' From e9bbe11000b5efc89672fe0c2841b775b5844921 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:29:14 +0200 Subject: [PATCH 086/173] Update pie plots to use Dataset instead of DataFrame --- flixopt/plotting.py | 254 ++++++++++++++++++++++++-------------------- flixopt/results.py | 4 +- 2 files changed, 140 insertions(+), 118 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 30be40b09..0d66cbb10 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1463,23 +1463,24 @@ def plot_network( def pie_with_plotly( - data: pd.DataFrame, - colors: ColorType = 'viridis', + data: xr.Dataset, + colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', legend_title: str = '', hole: float = 0.0, fig: go.Figure | None = None, ) -> go.Figure: """ - Create a pie chart with Plotly to visualize the proportion of values in a DataFrame. + Create a pie chart with Plotly to visualize the proportion of values in a Dataset. Args: - data: A DataFrame containing the data to plot. If multiple rows exist, - they will be summed unless a specific index value is passed. + data: An xarray Dataset containing the data to plot. All dimensions will be summed + to get the total for each variable. 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'}) + - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) + - An XarrayColorMapper instance for pattern-based color rules title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -1490,35 +1491,54 @@ def pie_with_plotly( Notes: - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. - - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category - for better readability. - - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + - All dimensions are summed to get total values for each variable. + - Scalar variables (with no dimensions) are used directly. + + Examples: + Simple pie chart: + + ```python + fig = pie_with_plotly(dataset, colors='viridis', title='Energy Mix') + ``` + + With XarrayColorMapper: + ```python + mapper = XarrayColorMapper() + mapper.add_rule('Solar', 'oranges', 'prefix') + mapper.add_rule('Wind', 'blues', 'prefix') + fig = pie_with_plotly(dataset, colors=mapper, title='Renewable Energy') + ``` """ - if data.empty: - logger.error('Empty DataFrame provided for pie chart. Returning empty figure.') + if len(data.data_vars) == 0: + logger.error('Empty Dataset provided for pie chart. Returning empty figure.') return go.Figure() - # Create a copy to avoid modifying the original DataFrame - data_copy = data.copy() + # Sum all dimensions for each variable to get total values + labels = [] + values = [] - # Check if any negative values and warn - if (data_copy < 0).any().any(): - logger.error('Negative values detected in data. Using absolute values for pie chart.') - data_copy = data_copy.abs() + for var in data.data_vars: + var_data = data[var] - # If data has multiple rows, sum them to get total for each column - if len(data_copy) > 1: - data_sum = data_copy.sum() - else: - data_sum = data_copy.iloc[0] + # Sum across all dimensions to get total + if len(var_data.dims) > 0: + total_value = var_data.sum().item() + else: + # Scalar variable + total_value = var_data.item() - # Get labels (column names) and values - labels = data_sum.index.tolist() - values = data_sum.values.tolist() + # Check for negative values + if total_value < 0: + logger.warning(f'Negative value detected for {var}: {total_value}. Using absolute value.') + total_value = abs(total_value) - # Apply color mapping using the unified color processor - processed_colors = ColorProcessor(engine='plotly').process_colors(colors, labels) + labels.append(str(var)) + values.append(total_value) + + # Use resolve_colors for consistent color handling + color_discrete_map = resolve_colors(data, colors, engine='plotly') + processed_colors = [color_discrete_map.get(label, '#636EFA') for label in labels] # Create figure if not provided fig = fig if fig is not None else go.Figure() @@ -1549,8 +1569,8 @@ def pie_with_plotly( def pie_with_matplotlib( - data: pd.DataFrame, - colors: ColorType = 'viridis', + data: xr.Dataset, + colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', legend_title: str = 'Categories', hole: float = 0.0, @@ -1559,15 +1579,16 @@ def pie_with_matplotlib( ax: plt.Axes | None = None, ) -> tuple[plt.Figure, plt.Axes]: """ - Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame. + Create a pie chart with Matplotlib to visualize the proportion of values in a Dataset. Args: - data: A DataFrame containing the data to plot. If multiple rows exist, - they will be summed unless a specific index value is passed. + data: An xarray Dataset containing the data to plot. All dimensions will be summed + to get the total for each variable. colors: Color specification, can be: - A string with a colormap 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'}) + - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) + - An XarrayColorMapper instance for pattern-based color rules title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -1580,37 +1601,56 @@ def pie_with_matplotlib( Notes: - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. - - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category - for better readability. - - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + - All dimensions are summed to get total values for each variable. + - Scalar variables (with no dimensions) are used directly. + + Examples: + Simple pie chart: + + ```python + fig, ax = pie_with_matplotlib(dataset, colors='viridis', title='Energy Mix') + ``` + + With XarrayColorMapper: + ```python + mapper = XarrayColorMapper() + mapper.add_rule('Solar', 'oranges', 'prefix') + mapper.add_rule('Wind', 'blues', 'prefix') + fig, ax = pie_with_matplotlib(dataset, colors=mapper, title='Renewable Energy') + ``` """ - if data.empty: - logger.error('Empty DataFrame provided for pie chart. Returning empty figure.') + if len(data.data_vars) == 0: + logger.error('Empty Dataset provided for pie chart. Returning empty figure.') if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) return fig, ax - # Create a copy to avoid modifying the original DataFrame - data_copy = data.copy() + # Sum all dimensions for each variable to get total values + labels = [] + values = [] - # Check if any negative values and warn - if (data_copy < 0).any().any(): - logger.error('Negative values detected in data. Using absolute values for pie chart.') - data_copy = data_copy.abs() + for var in data.data_vars: + var_data = data[var] - # If data has multiple rows, sum them to get total for each column - if len(data_copy) > 1: - data_sum = data_copy.sum() - else: - data_sum = data_copy.iloc[0] + # Sum across all dimensions to get total + if len(var_data.dims) > 0: + total_value = var_data.sum().item() + else: + # Scalar variable + total_value = var_data.item() + + # Check for negative values + if total_value < 0: + logger.warning(f'Negative value detected for {var}: {total_value}. Using absolute value.') + total_value = abs(total_value) - # Get labels (column names) and values - labels = data_sum.index.tolist() - values = data_sum.values.tolist() + labels.append(str(var)) + values.append(total_value) - # Apply color mapping using the unified color processor - processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, labels) + # Use resolve_colors for consistent color handling + color_discrete_map = resolve_colors(data, colors, engine='matplotlib') + processed_colors = [color_discrete_map.get(label, '#808080') for label in labels] # Create figure and axis if not provided if fig is None or ax is None: @@ -1662,9 +1702,9 @@ def pie_with_matplotlib( def dual_pie_with_plotly( - data_left: pd.Series, - data_right: pd.Series, - colors: ColorType = 'viridis', + data_left: xr.Dataset, + data_right: xr.Dataset, + colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1678,12 +1718,13 @@ def dual_pie_with_plotly( Create two pie charts side by side with Plotly, with consistent coloring across both charts. Args: - data_left: Series for the left pie chart. - data_right: Series for the right pie chart. + data_left: Dataset for the left pie chart. Variables are summed across all dimensions. + data_right: Dataset for the right pie chart. Variables are summed across all dimensions. 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 category names to colors (e.g., {'Category1': '#ff0000'}) + - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) + - An XarrayColorMapper instance for pattern-based color rules title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. @@ -1700,7 +1741,7 @@ def dual_pie_with_plotly( from plotly.subplots import make_subplots # Check for empty data - if data_left.empty and data_right.empty: + if len(data_left.data_vars) == 0 and len(data_right.data_vars) == 0: logger.error('Both datasets are empty. Returning empty figure.') return go.Figure() @@ -1709,71 +1750,52 @@ def dual_pie_with_plotly( rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 ) - # Process series to handle negative values and apply minimum percentage threshold - def preprocess_series(series: pd.Series): - """ - Preprocess a series for pie chart display by handling negative values - and grouping the smallest parts together if they collectively represent - less than the specified percentage threshold. - - Args: - series: The series to preprocess - - Returns: - A preprocessed pandas Series - """ - # Handle negative values - if (series < 0).any(): - logger.error('Negative values detected in data. Using absolute values for pie chart.') - series = series.abs() - - # Remove zeros - series = series[series > 0] - - # Apply minimum percentage threshold if needed - if lower_percentage_group and not series.empty: - total = series.sum() - if total > 0: - # Sort series by value (ascending) - sorted_series = series.sort_values() - - # Calculate cumulative percentage contribution - cumulative_percent = (sorted_series.cumsum() / total) * 100 - - # Find entries that collectively make up less than lower_percentage_group - to_group = cumulative_percent <= lower_percentage_group + # Helper function to extract labels and values from Dataset + def dataset_to_pie_data(dataset): + labels = [] + values = [] - if to_group.sum() > 1: - # Create "Other" category for the smallest values that together are < threshold - other_sum = sorted_series[to_group].sum() + for var in dataset.data_vars: + var_data = dataset[var] - # Keep only values that aren't in the "Other" group - result_series = series[~series.index.isin(sorted_series[to_group].index)] + # Sum across all dimensions + if len(var_data.dims) > 0: + total_value = float(var_data.sum().values) + else: + total_value = float(var_data.values) - # Add the "Other" category if it has a value - if other_sum > 0: - result_series['Other'] = other_sum + # Handle negative values + if total_value < 0: + logger.warning(f'Negative value for {var}: {total_value}. Using absolute value.') + total_value = abs(total_value) - return result_series + # Only include if value > 0 + if total_value > 0: + labels.append(str(var)) + values.append(total_value) - return series + return labels, values - data_left_processed = preprocess_series(data_left) - data_right_processed = preprocess_series(data_right) + # Get data for left and right + left_labels, left_values = dataset_to_pie_data(data_left) + right_labels, right_values = dataset_to_pie_data(data_right) - # Get unique set of all labels for consistent coloring - all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) + # Get unique set of all labels for consistent coloring across both pies + # Merge both datasets for color resolution + combined_vars = list(set(data_left.data_vars) | set(data_right.data_vars)) + combined_ds = xr.Dataset( + {var: data_left[var] if var in data_left.data_vars else data_right[var] for var in combined_vars} + ) - # Get consistent color mapping for both charts using our unified function - color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True) + # Use resolve_colors for consistent color handling + color_discrete_map = resolve_colors(combined_ds, colors, engine='plotly') + color_map = {label: color_discrete_map.get(label, '#636EFA') for label in left_labels + right_labels} # Function to create a pie trace with consistently mapped colors - def create_pie_trace(data_series, side): - if data_series.empty: + def create_pie_trace(labels, values, side): + if not labels: return None - labels = data_series.index.tolist() - values = data_series.values.tolist() trace_colors = [color_map[label] for label in labels] return go.Pie( @@ -1790,13 +1812,13 @@ def create_pie_trace(data_series, side): ) # Add left pie if data exists - left_trace = create_pie_trace(data_left_processed, subtitles[0]) + left_trace = create_pie_trace(left_labels, left_values, subtitles[0]) if left_trace: left_trace.domain = dict(x=[0, 0.48]) fig.add_trace(left_trace, row=1, col=1) # Add right pie if data exists - right_trace = create_pie_trace(data_right_processed, subtitles[1]) + right_trace = create_pie_trace(right_labels, right_values, subtitles[1]) if right_trace: right_trace.domain = dict(x=[0.52, 1]) fig.add_trace(right_trace, row=1, col=2) diff --git a/flixopt/results.py b/flixopt/results.py index 75f79e8d3..a460cb3cf 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1304,8 +1304,8 @@ def plot_node_balance_pie( if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( - data_left=inputs.to_pandas(), - data_right=outputs.to_pandas(), + data_left=inputs, + data_right=outputs, colors=resolved_colors, title=title, text_info=text_info, From 53c1259728df1b16cc7f30cf79daff8d4684d5f7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:34:38 +0200 Subject: [PATCH 087/173] Makde charge state line in plots black always --- flixopt/results.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/results.py b/flixopt/results.py index a460cb3cf..7fc1c63c1 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1557,6 +1557,7 @@ def plot_charge_state( 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) + trace.line.color = 'black' figure_like.add_trace(trace) # Also add traces from animation frames if they exist From a0d958b9d5147e38024a81d5435d809dff51bb41 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:35:42 +0200 Subject: [PATCH 088/173] Improve examples --- examples/01_Simple/simple_example.py | 2 +- examples/03_Calculation_types/example_calculation_types.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index ee90af47a..906c24622 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -114,7 +114,7 @@ # --- Analyze Results --- calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() - calculation.results['Storage'].plot_node_balance() + calculation.results['Storage'].plot_charge_state() calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 0f98dec2e..f71a8eed7 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -36,7 +36,7 @@ data_import = pd.read_csv( pathlib.Path(__file__).parent.parent / 'resources' / 'Zeitreihen2020.csv', index_col=0 ).sort_index() - filtered_data = data_import['2020-01-01':'2020-01-02 23:45:00'] + filtered_data = data_import['2020-01-01':'2020-01-07 23:45:00'] # filtered_data = data_import[0:500] # Alternatively filter by index filtered_data.index = pd.to_datetime(filtered_data.index) From ee3b5288a177a31ecd89bab5bf7712e91168bb60 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:17:47 +0200 Subject: [PATCH 089/173] Make plotting methods much more flexible --- flixopt/plotting.py | 221 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 178 insertions(+), 43 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 0d66cbb10..a342d7313 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -703,6 +703,69 @@ def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: return groups +def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: + """ + Ensure the input data is an xarray Dataset, converting from DataFrame if needed. + + Args: + data: Input data, either xarray Dataset or pandas DataFrame. + + Returns: + xarray Dataset. + + Raises: + TypeError: If data is neither Dataset nor DataFrame. + """ + if isinstance(data, xr.Dataset): + return data + elif isinstance(data, pd.DataFrame): + # Convert DataFrame to Dataset + return data.to_xarray() + else: + raise TypeError(f'Data must be xr.Dataset or pd.DataFrame, got {type(data).__name__}') + + +def _validate_plotting_data(data: xr.Dataset, allow_empty: bool = False) -> None: + """ + Validate input data for plotting and raise clear errors for common issues. + + Args: + data: xarray Dataset to validate. + allow_empty: Whether to allow empty datasets (no variables). + + Raises: + ValueError: If data is invalid for plotting. + TypeError: If data contains non-numeric types. + """ + # Check for empty data + if not allow_empty and len(data.data_vars) == 0: + raise ValueError('Empty Dataset provided (no variables). Cannot create plot.') + + # Check if dataset has any data (xarray uses nbytes for total size) + if all(data[var].size == 0 for var in data.data_vars) if len(data.data_vars) > 0 else True: + if not allow_empty and len(data.data_vars) > 0: + raise ValueError('Dataset has zero size. Cannot create plot.') + if len(data.data_vars) == 0: + return # Empty dataset, nothing to validate + return + + # Check for non-numeric data types + for var in data.data_vars: + dtype = data[var].dtype + if not np.issubdtype(dtype, np.number): + raise TypeError( + f"Variable '{var}' has non-numeric dtype '{dtype}'. " + f'Plotting requires numeric data types (int, float, etc.).' + ) + + # Warn about NaN/Inf values + for var in data.data_vars: + if data[var].isnull().any(): + logger.warning(f"Variable '{var}' contains NaN values which may affect visualization.") + if np.isinf(data[var].values).any(): + logger.warning(f"Variable '{var}' contains Inf values which may affect visualization.") + + def resolve_colors( data: xr.Dataset, colors: ColorType | XarrayColorMapper, @@ -757,7 +820,7 @@ def resolve_colors( def with_plotly( - data: xr.Dataset, + data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', @@ -769,6 +832,9 @@ def with_plotly( facet_cols: int = 3, shared_yaxes: bool = True, shared_xaxes: bool = True, + trace_kwargs: dict[str, Any] | None = None, + layout_kwargs: dict[str, Any] | None = None, + **px_kwargs: Any, ) -> go.Figure: """ Plot data with Plotly using facets (subplots) and/or animation for multidimensional data. @@ -798,6 +864,12 @@ def with_plotly( 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. + trace_kwargs: Optional dict of parameters to pass to fig.update_traces(). + Use this to customize trace properties (e.g., marker style, line width). + layout_kwargs: Optional dict of parameters to pass to fig.update_layout(). + Use this to customize layout properties (e.g., width, height, legend position). + **px_kwargs: Additional keyword arguments passed to the underlying Plotly Express function + (px.bar, px.line, px.area). These override default arguments if provided. Returns: A Plotly figure object containing the faceted/animated plot. @@ -840,6 +912,10 @@ def with_plotly( 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}") + # Ensure data is a Dataset and validate it + data = _ensure_dataset(data) + _validate_plotting_data(data, allow_empty=True) + # Handle empty data if len(data.data_vars) == 0: logger.error('"with_plotly() got an empty Dataset.') @@ -991,6 +1067,9 @@ def with_plotly( if facet_col and not facet_row: common_args['facet_col_wrap'] = facet_cols + # Apply user-provided Plotly Express kwargs (overrides defaults) + common_args.update(px_kwargs) + if mode == 'stacked_bar': fig = px.bar(**common_args) fig.update_traces(marker_line_width=0) @@ -1058,19 +1137,24 @@ def with_plotly( if not shared_xaxes: fig.update_xaxes(matches=None) + # Apply user-provided trace and layout customizations + if trace_kwargs: + fig.update_traces(**trace_kwargs) + if layout_kwargs: + fig.update_layout(**layout_kwargs) + return fig def with_matplotlib( - data: xr.Dataset, + data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', figsize: tuple[int, int] = (12, 6), - fig: plt.Figure | None = None, - ax: plt.Axes | None = None, + plot_kwargs: dict[str, Any] | None = None, ) -> tuple[plt.Figure, plt.Axes]: """ Plot data with Matplotlib using stacked bars or stepped lines. @@ -1087,9 +1171,9 @@ def with_matplotlib( title: The title of the plot. ylabel: The ylabel of the plot. xlabel: The xlabel of the plot. - figsize: Specify the size of the figure - 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. + figsize: Specify the size of the figure (width, height) in inches. + plot_kwargs: Optional dict of parameters to pass to ax.bar() or ax.step() plotting calls. + Use this to customize plot properties (e.g., linewidth, alpha, edgecolor). Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. @@ -1112,8 +1196,16 @@ def with_matplotlib( if mode not in ('stacked_bar', 'line'): raise ValueError(f"'mode' must be one of {{'stacked_bar','line'}} for matplotlib, got {mode!r}") - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) + # Ensure data is a Dataset and validate it + data = _ensure_dataset(data) + _validate_plotting_data(data, allow_empty=True) + + # Create new figure and axes + fig, ax = plt.subplots(figsize=figsize) + + # Initialize plot_kwargs if not provided + if plot_kwargs is None: + plot_kwargs = {} # Handle all-scalar datasets (where all variables have no dimensions) # This occurs when all variables are scalar values with dims=() @@ -1128,14 +1220,20 @@ def with_matplotlib( # Create plot based on mode if mode == 'stacked_bar': - ax.bar(variables, values, color=colors_list) + ax.bar(variables, values, color=colors_list, **plot_kwargs) elif mode == 'line': - ax.plot(variables, values, marker='o', color=colors_list[0] if len(set(colors_list)) == 1 else None) + ax.plot( + variables, + values, + marker='o', + color=colors_list[0] if len(set(colors_list)) == 1 else None, + **plot_kwargs, + ) # If different colors, plot each point separately if len(set(colors_list)) > 1: ax.clear() for i, (var, val) in enumerate(zip(variables, values, strict=False)): - ax.plot([i], [val], marker='o', color=colors_list[i], label=var) + ax.plot([i], [val], marker='o', color=colors_list[i], label=var, **plot_kwargs) ax.set_xticks(range(len(variables))) ax.set_xticklabels(variables) @@ -1173,6 +1271,7 @@ def with_matplotlib( label=column, width=width, align='center', + **plot_kwargs, ) cumulative_positive += positive_values.values # Plot negative bars @@ -1184,12 +1283,13 @@ def with_matplotlib( label='', # No label for negative bars width=width, align='center', + **plot_kwargs, ) cumulative_negative += negative_values.values elif mode == 'line': for i, column in enumerate(df.columns): - ax.step(df.index, df[column], where='post', color=processed_colors[i], label=column) + ax.step(df.index, df[column], where='post', color=processed_colors[i], label=column, **plot_kwargs) # Aesthetics ax.set_xlabel(xlabel, ha='center') @@ -1463,12 +1563,15 @@ def plot_network( def pie_with_plotly( - data: xr.Dataset, + data: xr.Dataset | pd.DataFrame, colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', legend_title: str = '', hole: float = 0.0, fig: go.Figure | None = None, + hover_template: str = '%{label}: %{value} (%{percent})', + text_info: str = 'percent+label+value', + text_position: str = 'inside', ) -> go.Figure: """ Create a pie chart with Plotly to visualize the proportion of values in a Dataset. @@ -1485,6 +1588,10 @@ def pie_with_plotly( legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + hover_template: Template for hover text. Use %{label}, %{value}, %{percent}. + text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', + 'label+value', 'percent+value', 'label+percent+value', or 'none'. + text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. Returns: A Plotly figure object containing the generated pie chart. @@ -1510,6 +1617,10 @@ def pie_with_plotly( fig = pie_with_plotly(dataset, colors=mapper, title='Renewable Energy') ``` """ + # Ensure data is a Dataset and validate it + data = _ensure_dataset(data) + _validate_plotting_data(data, allow_empty=True) + if len(data.data_vars) == 0: logger.error('Empty Dataset provided for pie chart. Returning empty figure.') return go.Figure() @@ -1550,9 +1661,10 @@ def pie_with_plotly( values=values, hole=hole, marker=dict(colors=processed_colors), - textinfo='percent+label+value', - textposition='inside', + textinfo=text_info, + textposition=text_position, insidetextorientation='radial', + hovertemplate=hover_template, ) ) @@ -1569,14 +1681,12 @@ def pie_with_plotly( def pie_with_matplotlib( - data: xr.Dataset, + data: xr.Dataset | pd.DataFrame, colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', legend_title: str = 'Categories', hole: float = 0.0, figsize: tuple[int, int] = (10, 8), - fig: plt.Figure | None = None, - ax: plt.Axes | None = None, ) -> tuple[plt.Figure, plt.Axes]: """ Create a pie chart with Matplotlib to visualize the proportion of values in a Dataset. @@ -1593,8 +1703,6 @@ def pie_with_matplotlib( legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). 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. Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. @@ -1620,10 +1728,13 @@ def pie_with_matplotlib( fig, ax = pie_with_matplotlib(dataset, colors=mapper, title='Renewable Energy') ``` """ + # Ensure data is a Dataset and validate it + data = _ensure_dataset(data) + _validate_plotting_data(data, allow_empty=True) + if len(data.data_vars) == 0: logger.error('Empty Dataset provided for pie chart. Returning empty figure.') - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) + fig, ax = plt.subplots(figsize=figsize) return fig, ax # Sum all dimensions for each variable to get total values @@ -1652,9 +1763,8 @@ def pie_with_matplotlib( color_discrete_map = resolve_colors(data, colors, engine='matplotlib') processed_colors = [color_discrete_map.get(label, '#808080') for label in labels] - # Create figure and axis if not provided - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) + # Create figure and axis + fig, ax = plt.subplots(figsize=figsize) # Draw the pie chart wedges, texts, autotexts = ax.pie( @@ -1702,8 +1812,8 @@ def pie_with_matplotlib( def dual_pie_with_plotly( - data_left: xr.Dataset, - data_right: xr.Dataset, + data_left: xr.Dataset | pd.DataFrame, + data_right: xr.Dataset | pd.DataFrame, colors: ColorType | XarrayColorMapper = 'viridis', title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), @@ -1740,6 +1850,12 @@ def dual_pie_with_plotly( """ from plotly.subplots import make_subplots + # Ensure data is a Dataset and validate it + data_left = _ensure_dataset(data_left) + data_right = _ensure_dataset(data_right) + _validate_plotting_data(data_left, allow_empty=True) + _validate_plotting_data(data_right, allow_empty=True) + # Check for empty data if len(data_left.data_vars) == 0 and len(data_right.data_vars) == 0: logger.error('Both datasets are empty. Returning empty figure.') @@ -2211,12 +2327,14 @@ def heatmap_with_matplotlib( 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', + vmin: float | None = None, + vmax: float | None = None, + imshow_kwargs: dict[str, Any] | None = None, + cbar_kwargs: dict[str, Any] | None = None, ) -> tuple[plt.Figure, plt.Axes]: """ Plot a heatmap visualization using Matplotlib's imshow. @@ -2231,13 +2349,17 @@ def heatmap_with_matplotlib( 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'. + vmin: Minimum value for color scale. If None, uses data minimum. + vmax: Maximum value for color scale. If None, uses data maximum. + imshow_kwargs: Optional dict of parameters to pass to ax.imshow(). + Use this to customize image properties (e.g., interpolation, aspect). + cbar_kwargs: Optional dict of parameters to pass to plt.colorbar(). + Use this to customize colorbar properties (e.g., orientation, label). Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. @@ -2259,10 +2381,15 @@ def heatmap_with_matplotlib( fig, ax = heatmap_with_matplotlib(data_array, reshape_time=('D', 'h')) ``` """ + # Initialize kwargs if not provided + if imshow_kwargs is None: + imshow_kwargs = {} + if cbar_kwargs is None: + cbar_kwargs = {} + # Handle empty data if data.size == 0: - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) + fig, ax = plt.subplots(figsize=figsize) return fig, ax # Apply time reshaping using the new unified function @@ -2275,9 +2402,8 @@ def heatmap_with_matplotlib( data = data.expand_dims({'variable': [var_name]}) logger.debug(f'Only 1 dimension in data. Added variable dimension: {var_name}') - # Create figure and axes if not provided - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) + # Create figure and axes + fig, ax = plt.subplots(figsize=figsize) # Extract data values # If data has more than 2 dimensions, we need to reduce it @@ -2305,12 +2431,19 @@ def heatmap_with_matplotlib( # 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') + # Create the heatmap using imshow with user customizations + imshow_defaults = {'cmap': cmap, 'aspect': 'auto', 'origin': 'upper', 'vmin': vmin, 'vmax': vmax} + imshow_defaults.update(imshow_kwargs) # User kwargs override defaults + im = ax.imshow(values, **imshow_defaults) + + # Add colorbar with user customizations + cbar_defaults = {'ax': ax, 'orientation': 'horizontal', 'pad': 0.1, 'aspect': 15, 'fraction': 0.05} + cbar_defaults.update(cbar_kwargs) # User kwargs override defaults + cbar = plt.colorbar(im, **cbar_defaults) - # Add colorbar - cbar = plt.colorbar(im, ax=ax, orientation='horizontal', pad=0.1, aspect=15, fraction=0.05) - cbar.set_label('Value') + # Set colorbar label if not overridden by user + if 'label' not in cbar_kwargs: + cbar.set_label('Value') # Set labels and title ax.set_xlabel(str(x_labels).capitalize()) @@ -2330,6 +2463,7 @@ def export_figure( user_path: pathlib.Path | None = None, show: bool = True, save: bool = False, + dpi: int = 300, ) -> go.Figure | tuple[plt.Figure, plt.Axes]: """ Export a figure to a file and or show it. @@ -2341,6 +2475,7 @@ def export_figure( user_path: An optional user-specified file path. show: Whether to display the figure (default: True). save: Whether to save the figure (default: False). + dpi: DPI (dots per inch) for saving Matplotlib figures (default: 300). Only applies to matplotlib figures. Raises: ValueError: If no default filetype is provided and the path doesn't specify a filetype. @@ -2398,7 +2533,7 @@ def export_figure( plt.show() if save: - fig.savefig(str(filename), dpi=300) + fig.savefig(str(filename), dpi=dpi) plt.close(fig) # Close figure to free memory return fig, ax From 695d2592f6a9845422612f346f3d4beda6f21437 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:18:07 +0200 Subject: [PATCH 090/173] Add test --- tests/test_plotting_api.py | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/test_plotting_api.py diff --git a/tests/test_plotting_api.py b/tests/test_plotting_api.py new file mode 100644 index 000000000..a86c8131d --- /dev/null +++ b/tests/test_plotting_api.py @@ -0,0 +1,160 @@ +"""Smoke tests for plotting API robustness improvements.""" + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from flixopt import plotting + + +@pytest.fixture +def sample_dataset(): + """Create a sample xarray Dataset for testing.""" + time = np.arange(10) + data = xr.Dataset( + { + 'var1': (['time'], np.random.rand(10)), + 'var2': (['time'], np.random.rand(10)), + 'var3': (['time'], np.random.rand(10)), + }, + coords={'time': time}, + ) + return data + + +@pytest.fixture +def sample_dataframe(): + """Create a sample pandas DataFrame for testing.""" + time = np.arange(10) + df = pd.DataFrame({'var1': np.random.rand(10), 'var2': np.random.rand(10), 'var3': np.random.rand(10)}, index=time) + df.index.name = 'time' + return df + + +@pytest.mark.parametrize('engine', ['plotly', 'matplotlib']) +def test_kwargs_passthrough(sample_dataset, engine): + """Test that backend-specific kwargs are passed through correctly.""" + if engine == 'plotly': + # Test with_plotly kwargs + fig = plotting.with_plotly( + sample_dataset, + mode='line', + trace_kwargs={'line': {'width': 5}}, + layout_kwargs={'width': 1200, 'height': 600}, + ) + assert fig.layout.width == 1200 + assert fig.layout.height == 600 + + elif engine == 'matplotlib': + # Test with_matplotlib kwargs + fig, ax = plotting.with_matplotlib(sample_dataset, mode='line', plot_kwargs={'linewidth': 3, 'alpha': 0.7}) + # Verify that the plot was created (basic smoke test) + assert fig is not None + assert ax is not None + + +def test_dataframe_support_plotly(sample_dataframe): + """Test that DataFrames are accepted by plotting functions.""" + # Should not raise an error + fig = plotting.with_plotly(sample_dataframe, mode='line') + assert fig is not None + + +def test_dataframe_support_matplotlib(sample_dataframe): + """Test that DataFrames are accepted by matplotlib plotting functions.""" + # Should not raise an error + fig, ax = plotting.with_matplotlib(sample_dataframe, mode='line') + assert fig is not None + assert ax is not None + + +def test_heatmap_vmin_vmax(): + """Test that vmin/vmax parameters work for heatmaps.""" + data = xr.DataArray(np.random.rand(10, 10), dims=['x', 'y']) + + fig, ax = plotting.heatmap_with_matplotlib(data, vmin=0.2, vmax=0.8) + assert fig is not None + assert ax is not None + + # Check that the image has the correct vmin/vmax + images = [child for child in ax.get_children() if hasattr(child, 'get_clim')] + if images: + vmin, vmax = images[0].get_clim() + assert vmin == 0.2 + assert vmax == 0.8 + + +def test_heatmap_imshow_kwargs(): + """Test that imshow_kwargs are passed to imshow.""" + data = xr.DataArray(np.random.rand(10, 10), dims=['x', 'y']) + + fig, ax = plotting.heatmap_with_matplotlib(data, imshow_kwargs={'interpolation': 'nearest', 'aspect': 'equal'}) + assert fig is not None + assert ax is not None + + +def test_pie_text_customization(): + """Test that pie chart text customization parameters work.""" + data = xr.Dataset({'var1': 10, 'var2': 20, 'var3': 30}) + + fig = plotting.pie_with_plotly( + data, text_info='percent', text_position='outside', hover_template='Custom: %{label} = %{value}' + ) + assert fig is not None + + # Check that the trace has the correct parameters + assert fig.data[0].textinfo == 'percent' + assert fig.data[0].textposition == 'outside' + assert fig.data[0].hovertemplate == 'Custom: %{label} = %{value}' + + +def test_data_validation_non_numeric(): + """Test that validation catches non-numeric data.""" + # Create dataset with non-numeric data + data = xr.Dataset({'var1': (['time'], ['a', 'b', 'c'])}, coords={'time': [0, 1, 2]}) + + with pytest.raises(TypeError, match='non-numeric dtype'): + plotting.with_plotly(data) + + +def test_data_validation_nan_handling(sample_dataset): + """Test that validation handles NaN values without raising an error.""" + # Add NaN to the dataset + data = sample_dataset.copy() + data['var1'].values[0] = np.nan + + # Should not raise an error (warning is logged but we can't easily test that) + fig = plotting.with_plotly(data) + assert fig is not None + + +def test_export_figure_dpi(sample_dataset, tmp_path): + """Test that DPI parameter works for export_figure.""" + import matplotlib.pyplot as plt + + fig, ax = plotting.with_matplotlib(sample_dataset, mode='line') + + output_path = tmp_path / 'test_plot.png' + plotting.export_figure((fig, ax), default_path=output_path, save=True, show=False, dpi=150) + + assert output_path.exists() + plt.close(fig) + + +def test_ensure_dataset_invalid_type(): + """Test that _ensure_dataset raises error for invalid types.""" + with pytest.raises(TypeError, match='must be xr.Dataset or pd.DataFrame'): + plotting._ensure_dataset([1, 2, 3]) # List is not valid + + +def test_validate_plotting_data_empty(): + """Test that validation handles empty datasets appropriately.""" + empty_data = xr.Dataset() + + # Should raise ValueError when allow_empty=False + with pytest.raises(ValueError, match='Empty Dataset'): + plotting._validate_plotting_data(empty_data, allow_empty=False) + + # Should not raise when allow_empty=True + plotting._validate_plotting_data(empty_data, allow_empty=True) From 790c0fc2de8247f223de19712c7d68bf5e6aca1c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:24:01 +0200 Subject: [PATCH 091/173] Add plotting kwargs to plotting functions --- flixopt/plotting.py | 64 ++++++++++++------ flixopt/results.py | 154 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 21 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index a342d7313..36da574c2 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1962,12 +1962,9 @@ def dual_pie_with_matplotlib( hole: float = 0.2, lower_percentage_group: float = 5.0, figsize: tuple[int, int] = (14, 7), - fig: plt.Figure | None = None, - axes: list[plt.Axes] | None = None, ) -> tuple[plt.Figure, list[plt.Axes]]: """ Create two pie charts side by side with Matplotlib, with consistent coloring across both charts. - Leverages the existing pie_with_matplotlib function. Args: data_left: Series for the left pie chart. @@ -1982,23 +1979,18 @@ def dual_pie_with_matplotlib( hole: Size of the hole in the center for creating donut charts (0.0 to 1.0). lower_percentage_group: Whether to group small segments (below percentage) into an "Other" category. 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. - axes: A list of Matplotlib axes objects to plot on. If not provided, new axes will be created. Returns: A tuple containing the Matplotlib figure and list of axes objects used for the plot. """ + # Create figure and axes + fig, axes = plt.subplots(1, 2, figsize=figsize) + # Check for empty data if data_left.empty and data_right.empty: logger.error('Both datasets are empty. Returning empty figure.') - if fig is None: - fig, axes = plt.subplots(1, 2, figsize=figsize) return fig, axes - # Create figure and axes if not provided - if fig is None or axes is None: - fig, axes = plt.subplots(1, 2, figsize=figsize) - # Process series to handle negative values and apply minimum percentage threshold def preprocess_series(series: pd.Series): """ @@ -2060,19 +2052,49 @@ def preprocess_series(series: pd.Series): left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else [] right_colors = [color_map[col] for col in df_right.columns] if not df_right.empty else [] + # Helper function to draw pie chart on a specific axis + def draw_pie_on_axis(ax, data_series, colors_list, subtitle, hole_size): + """Draw a pie chart on a specific matplotlib axis.""" + if data_series.empty: + ax.set_title(subtitle) + ax.axis('off') + return + + labels = list(data_series.index) + values = list(data_series.values) + + # Draw the pie chart + wedges, texts, autotexts = ax.pie( + values, + labels=labels, + colors=colors_list, + autopct='%1.1f%%', + startangle=90, + shadow=False, + wedgeprops=dict(width=0.5) if hole_size > 0 else None, + ) + + # Adjust hole size + if hole_size > 0: + wedge_width = 1 - hole_size + for wedge in wedges: + wedge.set_width(wedge_width) + + # Customize text + for autotext in autotexts: + autotext.set_fontsize(10) + autotext.set_color('white') + + # Set aspect ratio and title + ax.set_aspect('equal') + if subtitle: + ax.set_title(subtitle, fontsize=14) + # Create left pie chart - if not df_left.empty: - pie_with_matplotlib(data=df_left, colors=left_colors, title=subtitles[0], hole=hole, fig=fig, ax=axes[0]) - else: - axes[0].set_title(subtitles[0]) - axes[0].axis('off') + draw_pie_on_axis(axes[0], data_left_processed, left_colors, subtitles[0], hole) # Create right pie chart - if not df_right.empty: - pie_with_matplotlib(data=df_right, colors=right_colors, title=subtitles[1], hole=hole, fig=fig, ax=axes[1]) - else: - axes[1].set_title(subtitles[1]) - axes[1].axis('off') + draw_pie_on_axis(axes[1], data_right_processed, right_colors, subtitles[1], hole) # Add main title if title: diff --git a/flixopt/results.py b/flixopt/results.py index 7fc1c63c1..ed3e9a559 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -763,6 +763,7 @@ def plot_heatmap( 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_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ Plots a heatmap visualization of a variable using imshow or time-based reshaping. @@ -796,6 +797,20 @@ def plot_heatmap( 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'. + **plot_kwargs: Additional plotting customization options. + Common options: + + - **dpi** (int): Export resolution for saved plots. Default: 300. + + For heatmaps specifically: + + - **vmin** (float): Minimum value for color scale (both engines). + - **vmax** (float): Maximum value for color scale (both engines). + + For Matplotlib heatmaps: + + - **imshow_kwargs** (dict): Additional kwargs for matplotlib's imshow (e.g., interpolation, aspect). + - **cbar_kwargs** (dict): Additional kwargs for colorbar customization. Examples: Direct imshow mode (default): @@ -836,6 +851,18 @@ def plot_heatmap( ... animate_by='period', ... reshape_time=('D', 'h'), ... ) + + High-resolution export with custom color range: + + >>> results.plot_heatmap('Battery|charge_state', save=True, dpi=600, vmin=0, vmax=100) + + Matplotlib heatmap with custom imshow settings: + + >>> results.plot_heatmap( + ... 'Boiler(Q_th)|flow_rate', + ... engine='matplotlib', + ... imshow_kwargs={'interpolation': 'bilinear', 'aspect': 'auto'}, + ... ) """ # Delegate to module-level plot_heatmap function return plot_heatmap( @@ -856,6 +883,7 @@ def plot_heatmap( heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, color_map=color_map, + **plot_kwargs, ) def plot_network( @@ -1036,6 +1064,7 @@ def plot_node_balance( facet_cols: int = 3, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, + **plot_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ Plots the node balance of the Component or Bus with optional faceting and animation. @@ -1068,6 +1097,27 @@ def plot_node_balance( 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). + **plot_kwargs: Additional plotting customization options passed to underlying plotting functions. + + Common options: + + - **dpi** (int): Export resolution in dots per inch. Default: 300. + + **For Plotly engine** (`engine='plotly'`): + + - **trace_kwargs** (dict): Customize traces via `fig.update_traces()`. + Example: `trace_kwargs={'line': {'width': 5, 'dash': 'dot'}}` + - **layout_kwargs** (dict): Customize layout via `fig.update_layout()`. + Example: `layout_kwargs={'width': 1200, 'height': 600, 'template': 'plotly_dark'}` + - Any Plotly Express parameter for px.bar()/px.line()/px.area() + + **For Matplotlib engine** (`engine='matplotlib'`): + + - **plot_kwargs** (dict): Customize plot via `ax.bar()` or `ax.step()`. + Example: `plot_kwargs={'linewidth': 3, 'alpha': 0.7, 'edgecolor': 'black'}` + + See :func:`flixopt.plotting.with_plotly` and :func:`flixopt.plotting.with_matplotlib` + for complete parameter reference. Examples: Basic plot (current behavior): @@ -1099,6 +1149,24 @@ def plot_node_balance( Time range selection (summer months only): >>> results['Boiler'].plot_node_balance(select={'time': slice('2024-06', '2024-08')}, facet_by='scenario') + + High-resolution export for publication: + + >>> results['Boiler'].plot_node_balance(engine='matplotlib', save='figure.png', dpi=600) + + Custom Plotly theme and layout: + + >>> results['Boiler'].plot_node_balance( + ... layout_kwargs={'template': 'plotly_dark', 'width': 1200, 'height': 600} + ... ) + + Custom line styling: + + >>> results['Boiler'].plot_node_balance(mode='line', trace_kwargs={'line': {'width': 5, 'dash': 'dot'}}) + + Custom matplotlib appearance: + + >>> results['Boiler'].plot_node_balance(engine='matplotlib', plot_kwargs={'linewidth': 3, 'alpha': 0.7}) """ # Handle deprecated indexer parameter if indexer is not None: @@ -1120,6 +1188,9 @@ def plot_node_balance( if engine not in {'plotly', 'matplotlib'}: raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]') + # Extract dpi for export_figure + dpi = plot_kwargs.pop('dpi', 300) + # 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) @@ -1159,6 +1230,7 @@ def plot_node_balance( title=title, facet_cols=facet_cols, xlabel='Time in h', + **plot_kwargs, ) default_filetype = '.html' else: @@ -1167,6 +1239,7 @@ def plot_node_balance( colors=resolved_colors, mode=mode, title=title, + **plot_kwargs, ) default_filetype = '.png' @@ -1177,6 +1250,7 @@ def plot_node_balance( user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False, + dpi=dpi, ) def plot_node_balance_pie( @@ -1190,6 +1264,7 @@ def plot_node_balance_pie( select: dict[FlowSystemDimensions, Any] | None = None, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, + **plot_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]: """Plot pie chart of flow hours distribution. @@ -1209,6 +1284,17 @@ def plot_node_balance_pie( 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. + **plot_kwargs: Additional plotting customization options. + + Common options: + + - **dpi** (int): Export resolution in dots per inch. Default: 300. + - **hover_template** (str): Hover text template (Plotly only). + Example: `hover_template='%{label}: %{value} (%{percent})'` + - **text_position** (str): Text position ('inside', 'outside', 'auto'). + - **hole** (float): Size of donut hole (0.0 to 1.0). + + See :func:`flixopt.plotting.dual_pie_with_plotly` for complete reference. Examples: Basic usage (auto-selects first scenario/period if present): @@ -1218,6 +1304,14 @@ def plot_node_balance_pie( Explicitly select a scenario and period: >>> results['Bus'].plot_node_balance_pie(select={'scenario': 'high_demand', 'period': 2030}) + + Create a donut chart with custom hover text: + + >>> results['Bus'].plot_node_balance_pie(hole=0.4, hover_template='%{label}: %{value:.2f} (%{percent})') + + High-resolution export: + + >>> results['Bus'].plot_node_balance_pie(save='figure.png', dpi=600) """ # Handle deprecated indexer parameter if indexer is not None: @@ -1236,6 +1330,9 @@ def plot_node_balance_pie( ) select = indexer + # Extract dpi for export_figure + dpi = plot_kwargs.pop('dpi', 300) + inputs = sanitize_dataset( ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, threshold=1e-5, @@ -1312,6 +1409,7 @@ def plot_node_balance_pie( subtitles=('Inputs', 'Outputs'), legend_title='Flows', lower_percentage_group=lower_percentage_group, + **plot_kwargs, ) default_filetype = '.html' elif engine == 'matplotlib': @@ -1324,6 +1422,7 @@ def plot_node_balance_pie( subtitles=('Inputs', 'Outputs'), legend_title='Flows', lower_percentage_group=lower_percentage_group, + **plot_kwargs, ) default_filetype = '.png' else: @@ -1336,6 +1435,7 @@ def plot_node_balance_pie( user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False, + dpi=dpi, ) def node_balance( @@ -1442,6 +1542,7 @@ def plot_charge_state( facet_cols: int = 3, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, + **plot_kwargs: Any, ) -> plotly.graph_objs.Figure: """Plot storage charge state over time, combined with the node balance with optional faceting and animation. @@ -1458,6 +1559,24 @@ def plot_charge_state( 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). + **plot_kwargs: Additional plotting customization options passed to underlying plotting functions. + + Common options: + + - **dpi** (int): Export resolution in dots per inch. Default: 300. + + **For Plotly engine:** + + - **trace_kwargs** (dict): Customize traces via `fig.update_traces()`. + - **layout_kwargs** (dict): Customize layout via `fig.update_layout()`. + - Any Plotly Express parameter for px.bar()/px.line()/px.area() + + **For Matplotlib engine:** + + - **plot_kwargs** (dict): Customize plot via `ax.bar()` or `ax.step()`. + + See :func:`flixopt.plotting.with_plotly` and :func:`flixopt.plotting.with_matplotlib` + for complete parameter reference. Raises: ValueError: If component is not a storage. @@ -1478,6 +1597,14 @@ def plot_charge_state( Facet by scenario AND animate by period: >>> results['Storage'].plot_charge_state(facet_by='scenario', animate_by='period') + + Custom layout: + + >>> results['Storage'].plot_charge_state(layout_kwargs={'template': 'plotly_dark', 'height': 800}) + + High-resolution export: + + >>> results['Storage'].plot_charge_state(save='storage.png', dpi=600) """ # Handle deprecated indexer parameter if indexer is not None: @@ -1496,6 +1623,9 @@ def plot_charge_state( ) select = indexer + # Extract dpi for export_figure + dpi = plot_kwargs.pop('dpi', 300) + if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') @@ -1535,6 +1665,7 @@ def plot_charge_state( title=title, facet_cols=facet_cols, xlabel='Time in h', + **plot_kwargs, ) # Prepare charge_state as Dataset for plotting @@ -1550,6 +1681,7 @@ def plot_charge_state( title='', # No title needed for this temp figure facet_cols=facet_cols, xlabel='Time in h', + **plot_kwargs, ) # Add charge_state traces to the main figure @@ -1586,6 +1718,7 @@ def plot_charge_state( colors=resolved_colors, mode=mode, title=title, + **plot_kwargs, ) # Add charge_state as a line overlay @@ -1610,6 +1743,7 @@ def plot_charge_state( user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False, + dpi=dpi, ) def node_balance_with_charge_state( @@ -1949,6 +2083,7 @@ def plot_heatmap( 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_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """Plot heatmap of variable solution across segments. @@ -1969,6 +2104,17 @@ def plot_heatmap( heatmap_timeframes: (Deprecated) Use reshape_time instead. heatmap_timesteps_per_frame: (Deprecated) Use reshape_time instead. color_map: (Deprecated) Use colors instead. + **plot_kwargs: Additional plotting customization options. + Common options: + + - **dpi** (int): Export resolution for saved plots. Default: 300. + - **vmin** (float): Minimum value for color scale. + - **vmax** (float): Maximum value for color scale. + + For Matplotlib heatmaps: + + - **imshow_kwargs** (dict): Additional kwargs for matplotlib's imshow. + - **cbar_kwargs** (dict): Additional kwargs for colorbar customization. Returns: Figure object. @@ -2023,6 +2169,7 @@ def plot_heatmap( animate_by=animate_by, facet_cols=facet_cols, fill=fill, + **plot_kwargs, ) def to_file(self, folder: str | pathlib.Path | None = None, name: str | None = None, compression: int = 5): @@ -2072,6 +2219,7 @@ def plot_heatmap( 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_kwargs: Any, ): """Plot heatmap visualization with support for multi-variable, faceting, and animation. @@ -2226,6 +2374,9 @@ def plot_heatmap( timeframes, timesteps_per_frame = reshape_time title += f' ({timeframes} vs {timesteps_per_frame})' + # Extract dpi before passing to plotting functions + dpi = plot_kwargs.pop('dpi', 300) + # Plot with appropriate engine if engine == 'plotly': figure_like = plotting.heatmap_with_plotly( @@ -2237,6 +2388,7 @@ def plot_heatmap( facet_cols=facet_cols, reshape_time=reshape_time, fill=fill, + **plot_kwargs, ) default_filetype = '.html' elif engine == 'matplotlib': @@ -2246,6 +2398,7 @@ def plot_heatmap( title=title, reshape_time=reshape_time, fill=fill, + **plot_kwargs, ) default_filetype = '.png' else: @@ -2262,6 +2415,7 @@ def plot_heatmap( user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False, + dpi=dpi, ) From 0249d60c5878f8b941f506a97fc2f5aa2aaee361 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:36:32 +0200 Subject: [PATCH 092/173] add imshow kwargs --- flixopt/plotting.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 36da574c2..6a50ba091 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -2147,6 +2147,7 @@ def heatmap_with_plotly( | Literal['auto'] | None = 'auto', fill: Literal['ffill', 'bfill'] | None = 'ffill', + **imshow_kwargs: Any, ) -> go.Figure: """ Plot a heatmap visualization using Plotly's imshow with faceting and animation support. @@ -2179,6 +2180,11 @@ def heatmap_with_plotly( - 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'. + **imshow_kwargs: Additional keyword arguments to pass to plotly.express.imshow. + Common options include: + - aspect: 'auto', 'equal', or a number for aspect ratio + - zmin, zmax: Minimum and maximum values for color scale + - labels: Dict to customize axis labels Returns: A Plotly figure object containing the heatmap visualization. @@ -2323,16 +2329,21 @@ def heatmap_with_plotly( if animate_by: common_args['animation_frame'] = animate_by + # Merge in additional imshow kwargs + common_args.update(imshow_kwargs) + 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, - ) + fallback_args = { + 'img': data.values, + 'color_continuous_scale': colors if isinstance(colors, str) else 'viridis', + 'title': title, + } + fallback_args.update(imshow_kwargs) + fig = px.imshow(**fallback_args) # Update layout with basic styling fig.update_layout( @@ -2357,6 +2368,7 @@ def heatmap_with_matplotlib( vmax: float | None = None, imshow_kwargs: dict[str, Any] | None = None, cbar_kwargs: dict[str, Any] | None = None, + **kwargs: Any, ) -> tuple[plt.Figure, plt.Axes]: """ Plot a heatmap visualization using Matplotlib's imshow. @@ -2382,6 +2394,11 @@ def heatmap_with_matplotlib( Use this to customize image properties (e.g., interpolation, aspect). cbar_kwargs: Optional dict of parameters to pass to plt.colorbar(). Use this to customize colorbar properties (e.g., orientation, label). + **kwargs: Additional keyword arguments passed to ax.imshow(). + Common options include: + - interpolation: 'nearest', 'bilinear', 'bicubic', etc. + - alpha: Transparency level (0-1) + - extent: [left, right, bottom, top] for axis limits Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. @@ -2409,6 +2426,10 @@ def heatmap_with_matplotlib( if cbar_kwargs is None: cbar_kwargs = {} + # Merge any additional kwargs into imshow_kwargs + # This allows users to pass imshow options directly + imshow_kwargs.update(kwargs) + # Handle empty data if data.size == 0: fig, ax = plt.subplots(figsize=figsize) From a2072427760d5a2300e5c3c94db2955b426f2b5d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:09:33 +0200 Subject: [PATCH 093/173] Fix nans in plots --- flixopt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index ed3e9a559..cccea7b81 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1192,7 +1192,7 @@ def plot_node_balance( dpi = plot_kwargs.pop('dpi', 300) # 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 = self.node_balance(with_last_timestep=False, unit_type=unit_type, drop_suffix=drop_suffix) ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) @@ -1630,7 +1630,7 @@ def plot_charge_state( raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') # Get node balance and charge state - ds = self.node_balance(with_last_timestep=True) + ds = self.node_balance(with_last_timestep=True).fillna(0) charge_state_da = self.charge_state # Apply select filtering From e4c5a46bac866bf3059e174bb0953dc0b48e9c70 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:20:27 +0200 Subject: [PATCH 094/173] Replace XarrayColorMapper with ComponentColorManager --- flixopt/plotting.py | 544 +++++++++++++++++++++----------------------- flixopt/results.py | 138 +++++------ 2 files changed, 329 insertions(+), 353 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 6a50ba091..5eabfddf3 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -8,8 +8,8 @@ Key Features: **Dual Backend Support**: Seamless switching between Plotly and Matplotlib **Energy System Focus**: Specialized plots for power flows, storage states, emissions - **Color Management**: Intelligent color processing with ColorProcessor and pattern-based - XarrayColorMapper for grouped coloring + **Color Management**: Intelligent color processing with ColorProcessor and component-based + ComponentColorManager for stable, pattern-matched coloring **Export Capabilities**: High-quality export for reports and publications **Integration Ready**: Designed for use with CalculationResults and standalone analysis @@ -329,67 +329,66 @@ def process_colors( return color_list -# Type aliases for XarrayColorMapper +# Type aliases for ComponentColorManager MatchType = Literal['prefix', 'suffix', 'contains', 'glob', 'regex'] -CoordValues = xr.DataArray | np.ndarray | list[Any] -class XarrayColorMapper: - """Map Dataset variable names to colors based on naming patterns. +class ComponentColorManager: + """Manage stable colors for flow system components with pattern-based grouping. - A simple, maintainable utility class for mapping Dataset variable names - to colors based on naming patterns. Enables visual grouping in plots where - similar variables get similar colors. + This class provides component-centric color management where each component gets + a stable color assigned once, ensuring consistent coloring across all plots. + Components can be grouped using pattern matching, and each group uses a different colormap. Key Features: - - Pattern-based color assignment (prefix, suffix, contains, glob, regex) - - Plotly sequential color palettes for grouped coloring (cycles through shades) - - Discrete color support for exact color matching across all items - - Override support for special cases - - Dataset variable reordering for visual grouping in plots - - Full type hints and comprehensive documentation + - **Stable colors**: Components assigned colors once based on sorted order + - **Pattern-based grouping**: Auto-group components using patterns (prefix, contains, regex, etc.) + - **Variable extraction**: Auto-extract component names from variable names + - **Flexible colormaps**: Use Plotly sequential palettes or custom colors + - **Override support**: Manually override specific component colors + - **Zero configuration**: Works automatically with sensible defaults Available Color Families (14 single-hue palettes): - Cool colors: blues, greens, teals, purples, mint, emrld, darkmint - Warm colors: reds, oranges, peach, pinks, burg, sunsetdark - Neutral: greys - - See: https://plotly.com/python/builtin-colorscales/ + Cool: blues, greens, teals, purples, mint, emrld, darkmint + Warm: reds, oranges, peach, pinks, burg, sunsetdark + Neutral: greys Example Usage: - Using color families (variables cycle through shades): + Basic usage (automatic, each component gets distinct color): ```python - mapper = ( - XarrayColorMapper() - .add_rule('Boiler', 'reds', 'prefix') # Boiler_1, Boiler_2 get different red shades - .add_rule('CHP', 'oranges', 'prefix') # CHP_1, CHP_2 get different orange shades - .add_rule('Storage', 'blues', 'contains') # *Storage* variables get blue shades - .add_override({'Special_var': '#FFD700'}) - ) + manager = ComponentColorManager(components=['Boiler1', 'Boiler2', 'CHP1']) + color = manager.get_color('Boiler1') # Always same color + ``` - # Use with plotting - results['Component'].plot_node_balance(colors=mapper) + Grouped coloring (components in same group get shades of same color): + + ```python + manager = ComponentColorManager(components=['Boiler1', 'Boiler2', 'CHP1', 'Storage1']) + manager.add_grouping_rule('Boiler', 'Heat_Producers', 'reds', 'prefix') + manager.add_grouping_rule('CHP', 'Heat_Producers', 'reds', 'prefix') + manager.add_grouping_rule('Storage', 'Storage', 'blues', 'contains') + manager.auto_group_components() + + # Boiler1, Boiler2, CHP1 get different shades of red + # Storage1 gets blue ``` - Using discrete colors (all matching variables get the same color): + Override specific components: ```python - mapper = ( - XarrayColorMapper() - .add_rule('Solar', '#FFA500', 'prefix') # All Solar* get exact orange - .add_rule('Wind', 'skyblue', 'prefix') # All Wind* get exact skyblue - .add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green - ) + manager.override({'Boiler1': '#FF0000'}) # Force Boiler1 to red + ``` + + Get colors for variables (extracts component automatically): - # Apply to Dataset and plot - color_map = mapper.create_color_map(list(ds.data_vars.keys())) - # Or use with resolve_colors() - resolved_colors = resolve_colors(ds, mapper) + ```python + colors = manager.get_variable_colors(['Boiler1(Bus_A)|flow', 'CHP1(Bus_B)|flow']) + # Returns: {'Boiler1(Bus_A)|flow': '#...', 'CHP1(Bus_B)|flow': '#...'} ``` """ - # Class-level defaults (easy to update in one place) + # Class-level color family defaults DEFAULT_FAMILIES = { 'blues': px.colors.sequential.Blues[1:8], 'greens': px.colors.sequential.Greens[1:8], @@ -407,23 +406,30 @@ class XarrayColorMapper: 'darkmint': px.colors.sequential.Darkmint[1:8], } - def __init__(self, color_families: dict[str, list[str]] | None = None, sort_within_groups: bool = True) -> None: - """Initialize with Plotly sequential color families. + def __init__(self, components: list[str], default_colormap: str = 'tab10') -> None: + """Initialize component color manager. Args: - color_families: Custom color families. If None, uses DEFAULT_FAMILIES. - sort_within_groups: Whether to sort values within groups by default. Default is True. + components: List of all component names in the system + default_colormap: Default colormap for ungrouped components (default: 'tab10') """ - if color_families is None: - self.color_families = self.DEFAULT_FAMILIES.copy() - else: - self.color_families = color_families.copy() + self.components = sorted(set(components)) # Stable sorted order, remove duplicates + self.default_colormap = default_colormap + self.color_families = self.DEFAULT_FAMILIES.copy() + + # Pattern-based grouping rules + self._grouping_rules: list[dict[str, str]] = [] + + # Computed colors: {component_name: color} + self._component_colors: dict[str, str] = {} - self.sort_within_groups = sort_within_groups - self.rules: list[dict[str, str]] = [] - self.overrides: dict[str, str] = {} + # Manual overrides (highest priority) + self._overrides: dict[str, str] = {} - def add_custom_family(self, name: str, colors: list[str]) -> XarrayColorMapper: + # Auto-assign default colors + self._assign_default_colors() + + def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManager: """Add a custom color family. Args: @@ -436,215 +442,192 @@ def add_custom_family(self, name: str, colors: list[str]) -> XarrayColorMapper: self.color_families[name] = colors return self - def add_rule(self, pattern: str, family_or_color: str, match_type: MatchType = 'prefix') -> XarrayColorMapper: - """Add a pattern-based rule to assign color families or discrete colors. + def add_grouping_rule( + self, pattern: str, group_name: str, colormap: str, match_type: MatchType = 'prefix' + ) -> ComponentColorManager: + """Add pattern rule for grouping components. + + Components matching the pattern are assigned to the specified group, + and colors are drawn from the group's colormap. Args: - pattern: Pattern to match against coordinate values. - family_or_color: Either a color family name (e.g., 'blues', 'greens') or a discrete color. - Discrete colors can be: - - Hex colors: '#FF0000', '#00FF00' - - RGB/RGBA strings: 'rgb(255,0,0)', 'rgba(255,0,0,0.5)' - - Named colors: 'red', 'blue', 'skyblue' - match_type: Type of pattern matching to use. Default is 'prefix'. - - 'prefix': Match if value starts with pattern - - 'suffix': Match if value ends with pattern - - 'contains': Match if pattern appears anywhere in value - - 'glob': Unix-style wildcards (* matches anything, ? matches one char) - - 'regex': Match using regular expression + pattern: Pattern to match component names against + group_name: Name of the group (used for organization) + colormap: Colormap name for this group ('reds', 'blues', etc.) + match_type: Type of pattern matching (default: 'prefix') + - 'prefix': Match if component starts with pattern + - 'suffix': Match if component ends with pattern + - 'contains': Match if pattern appears in component name + - 'glob': Unix wildcards (* and ?) + - 'regex': Regular expression matching Returns: - Self for method chaining. + Self for method chaining Examples: - Using color families (cycles through shades): - - ```python - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('_test', 'greens', 'suffix') - ``` - - Using discrete colors (all matches get the same color): - ```python - mapper.add_rule('Solar', '#FFA500', 'prefix') # All Solar* items get orange - mapper.add_rule('Wind', 'skyblue', 'prefix') # All Wind* items get skyblue - mapper.add_rule('Battery', 'rgb(50,205,50)', 'contains') # All *Battery* get lime green + manager.add_grouping_rule('Boiler', 'Heat_Production', 'reds', 'prefix') + manager.add_grouping_rule('CHP', 'Heat_Production', 'oranges', 'prefix') + manager.add_grouping_rule('.*Storage.*', 'Storage', 'blues', 'regex') ``` """ valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') if match_type not in valid_types: raise ValueError(f"match_type must be one of {valid_types}, got '{match_type}'") - # Check if family_or_color is a discrete color or a family name - if family_or_color in self.color_families: - # It's a known family - store as family rule - self.rules.append( - {'pattern': pattern, 'family': family_or_color, 'match_type': match_type, 'is_discrete': False} - ) - else: - # Otherwise treat as discrete color - no error handling of invalid colors! - self.rules.append( - {'pattern': pattern, 'discrete_color': family_or_color, 'match_type': match_type, 'is_discrete': True} - ) - + self._grouping_rules.append( + {'pattern': pattern, 'group_name': group_name, 'colormap': colormap, 'match_type': match_type} + ) return self - def add_override(self, color_dict: dict[str, str]) -> XarrayColorMapper: - """Override colors for specific values (takes precedence over rules). + def auto_group_components(self) -> None: + """Apply grouping rules and assign colors to all components. - Args: - color_dict: Mapping of {value: hex_color}. + This recomputes colors for all components based on current grouping rules. + Components are grouped, then within each group they get sequential colors + from the group's colormap (based on sorted order for stability). - Returns: - Self for method chaining. + Call this after adding/changing grouping rules to update colors. + """ + # Group components by matching rules + groups: dict[str, dict] = {} + + for component in self.components: + matched = False + for rule in self._grouping_rules: + if self._match_pattern(component, rule['pattern'], rule['match_type']): + group_name = rule['group_name'] + if group_name not in groups: + groups[group_name] = {'components': [], 'colormap': rule['colormap']} + groups[group_name]['components'].append(component) + matched = True + break # First match wins + + if not matched: + # Unmatched components go to default group + if '_ungrouped' not in groups: + groups['_ungrouped'] = {'components': [], 'colormap': self.default_colormap} + groups['_ungrouped']['components'].append(component) + + # Assign colors within each group (stable sorted order) + self._component_colors = {} + for group_data in groups.values(): + colormap = self._get_colormap_colors(group_data['colormap']) + sorted_components = sorted(group_data['components']) # Stable! + + for idx, component in enumerate(sorted_components): + self._component_colors[component] = colormap[idx % len(colormap)] + + # Apply overrides (highest priority) + self._component_colors.update(self._overrides) + + def override(self, component_colors: dict[str, str]) -> None: + """Override colors for specific components. + + These overrides have highest priority and persist even after regrouping. + + Args: + component_colors: Dict mapping component names to colors Examples: ```python - mapper.add_override({'Special': '#FFD700'}) - mapper.add_override({'Product_A1': '#FF00FF', 'Product_B2': '#00FFFF'}) + manager.override({'Boiler1': '#FF0000', 'CHP1': '#00FF00'}) ``` """ - for val, col in color_dict.items(): - self.overrides[str(val)] = col - return self + self._overrides.update(component_colors) + self._component_colors.update(component_colors) - def create_color_map( - self, - coord_values: CoordValues, - sort_within_groups: bool | None = None, - fallback_family: str = 'greys', - ) -> dict[str, str]: - """Create color mapping for coordinate values. + def get_color(self, component: str) -> str: + """Get color for a component. Args: - coord_values: Coordinate values to map (xr.DataArray, np.ndarray, or list). - sort_within_groups: Sort values within each group. If None, uses instance default. - fallback_family: Color family for unmatched values. Default is 'greys'. + component: Component name Returns: - Mapping of {value: hex_color}. + Hex color string (defaults to grey if component unknown) """ - if sort_within_groups is None: - sort_within_groups = self.sort_within_groups - - # Convert to string list - if isinstance(coord_values, xr.DataArray): - categories: list[str] = [str(val) for val in coord_values.values] - elif isinstance(coord_values, np.ndarray): - categories = [str(val) for val in coord_values] - else: - categories = [str(val) for val in coord_values] - - # Remove duplicates while preserving order - seen: set = set() - categories = [x for x in categories if not (x in seen or seen.add(x))] - - # Group by rules - groups = self._group_categories(categories) - color_map: dict[str, str] = {} - - # Assign colors to groups - for group_key, group_categories in groups.items(): - if group_key == '_unmatched': - # Unmatched items use fallback family - family = self.color_families.get(fallback_family, self.color_families['greys']) - if sort_within_groups: - group_categories = sorted(group_categories) - for idx, category in enumerate(group_categories): - color_map[category] = family[idx % len(family)] - - elif group_key.startswith('_discrete_'): - # Discrete color group - all items get the same color - discrete_color = group_key.replace('_discrete_', '', 1) - if sort_within_groups: - group_categories = sorted(group_categories) - for category in group_categories: - color_map[category] = discrete_color - - else: - # Family-based group - cycle through family colors - family = self.color_families[group_key] - if sort_within_groups: - group_categories = sorted(group_categories) - for idx, category in enumerate(group_categories): - color_map[category] = family[idx % len(family)] + return self._component_colors.get(component, '#808080') - # Apply overrides - color_map.update(self.overrides) + def extract_component(self, variable: str) -> str: + """Extract component name from variable name. - return color_map - - def reorder_dataset(self, ds: xr.Dataset, sort_within_groups: bool | None = None) -> xr.Dataset: - """Reorder Dataset variables so variables with the same color group are adjacent. - - This is useful for creating plots where similar variables (same color group) - appear next to each other, making visual groupings clear in legends and stacked plots. + Uses default extraction logic: split on '(' or '|' to get component. Args: - ds: The Dataset to reorder. - sort_within_groups: Whether to sort variables within each group. If None, uses instance default. + variable: Variable name (e.g., 'Boiler1(Bus_A)|flow_rate') Returns: - New Dataset with reordered variables. + Component name (e.g., 'Boiler1') Examples: - Original order: ['Product_B1', 'Product_A1', 'Product_B2', 'Product_A2'] - After reorder: ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2'] - ```python - mapper = XarrayColorMapper() - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('Product_B', 'greens', 'prefix') - - ds_reordered = mapper.reorder_dataset(ds) + extract_component('Boiler1(Bus_A)|flow') # Returns: 'Boiler1' + extract_component('CHP1|power') # Returns: 'CHP1' + extract_component('Storage') # Returns: 'Storage' ``` """ - if sort_within_groups is None: - sort_within_groups = self.sort_within_groups + # Try "Component(Bus)|type" format + if '(' in variable: + return variable.split('(')[0] + # Try "Component|type" format + elif '|' in variable: + return variable.split('|')[0] + # Just use the variable name itself + return variable + + def get_variable_color(self, variable: str) -> str: + """Get color for a variable (extracts component automatically). - # Get variable names - variable_names = list(ds.data_vars.keys()) + Args: + variable: Variable name - # Group variables - groups = self._group_categories(variable_names) + Returns: + Hex color string + """ + component = self.extract_component(variable) + return self.get_color(component) - # Build new order: group by group, optionally sorted within each - new_order = [] - for group_name in groups.keys(): - group_vars = groups[group_name] - if sort_within_groups: - group_vars = sorted(group_vars) - new_order.extend(group_vars) + def get_variable_colors(self, variables: list[str]) -> dict[str, str]: + """Get colors for multiple variables. - # Reorder Dataset by selecting variables in new order - return ds[new_order] + This is the main API used by plotting functions. - def get_rules(self) -> list[dict[str, str]]: - """Return a copy of current rules for inspection.""" - return self.rules.copy() + Args: + variables: List of variable names - def get_overrides(self) -> dict[str, str]: - """Return a copy of current overrides for inspection.""" - return self.overrides.copy() + Returns: + Dict mapping variable names to colors + """ + return {var: self.get_variable_color(var) for var in variables} + + def to_dict(self) -> dict[str, str]: + """Get complete component→color mapping. + + Returns: + Dict of all components and their assigned colors + """ + return self._component_colors.copy() - def get_families(self) -> dict[str, list[str]]: - """Return a copy of available color families.""" - return self.color_families.copy() + # ==================== INTERNAL METHODS ==================== - def _match_rule(self, value: str, rule: dict[str, str]) -> bool: - """Check if value matches a rule. + def _assign_default_colors(self) -> None: + """Assign default colors to all components (no grouping).""" + colormap = self._get_colormap_colors(self.default_colormap) + + for idx, component in enumerate(self.components): + self._component_colors[component] = colormap[idx % len(colormap)] + + def _match_pattern(self, value: str, pattern: str, match_type: str) -> bool: + """Check if value matches pattern. Args: - value: Value to check. - rule: Rule dictionary with 'pattern' and 'match_type' keys. + value: String to test + pattern: Pattern to match against + match_type: Type of matching Returns: - True if value matches the rule. + True if matches """ - pattern = rule['pattern'] - match_type = rule['match_type'] - if match_type == 'prefix': return value.startswith(pattern) elif match_type == 'suffix': @@ -658,49 +641,30 @@ def _match_rule(self, value: str, rule: dict[str, str]) -> bool: return bool(re.search(pattern, value)) except re.error as e: raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e - return False - def _group_categories(self, categories: list[str]) -> dict[str, list[str]]: - """Group categories by matching rules. + def _get_colormap_colors(self, colormap_name: str) -> list[str]: + """Get list of colors from colormap name. Args: - categories: List of category values to group. + colormap_name: Name of colormap ('reds', 'blues', 'tab10', 'viridis', etc.) Returns: - Mapping of {group_key: [matching_values]}. - - Note: - For discrete color rules, group_key is '_discrete_', - for family rules, group_key is the family name. + List of hex color strings """ - groups: dict[str, list[str]] = {} - unmatched: list[str] = [] - - for category in categories: - matched = False - for rule in self.rules: - if self._match_rule(category, rule): - if rule.get('is_discrete', False): - # For discrete colors, use a special group key - group_key = f'_discrete_{rule["discrete_color"]}' - else: - # For families, use the family name - group_key = rule['family'] - - if group_key not in groups: - groups[group_key] = [] - groups[group_key].append(category) - matched = True - break # First match wins + # Check if it's a known family + if colormap_name in self.color_families: + return self.color_families[colormap_name] - if not matched: - unmatched.append(category) - - if unmatched: - groups['_unmatched'] = unmatched - - return groups + # Otherwise use ColorProcessor to generate from matplotlib/plotly colormaps + processor = ColorProcessor(engine='plotly') + try: + colors = processor._generate_colors_from_colormap(colormap_name, 10) + return colors + except Exception: + # Fallback to greys if colormap not found + logger.warning(f"Colormap '{colormap_name}' not found, using 'greys' instead") + return self.color_families['greys'] def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: @@ -768,18 +732,18 @@ def _validate_plotting_data(data: xr.Dataset, allow_empty: bool = False) -> None def resolve_colors( data: xr.Dataset, - colors: ColorType | XarrayColorMapper, + colors: ColorType | ComponentColorManager, engine: PlottingEngine = 'plotly', ) -> dict[str, str]: """Resolve colors parameter to a color mapping dict. This public utility function handles all color parameter types and applies the - color mapper intelligently based on the data structure. Can be used standalone + color manager intelligently based on the data structure. Can be used standalone or as part of CalculationResults. Args: data: Dataset to create colors for. Variable names from data_vars are used as labels. - colors: Color specification or a XarrayColorMapper to use + colors: Color specification or a ComponentColorManager to use engine: Plotting engine ('plotly' or 'matplotlib') Returns: @@ -788,15 +752,15 @@ def resolve_colors( Examples: With CalculationResults: - >>> resolved_colors = resolve_colors(data, results.color_mapper) + >>> resolved_colors = resolve_colors(data, results.color_manager) Standalone usage: - >>> mapper = plotting.XarrayColorMapper() - >>> mapper.add_rule('Solar', 'oranges', 'prefix') - >>> resolved_colors = resolve_colors(data, mapper) + >>> manager = plotting.ComponentColorManager(['Solar', 'Wind', 'Coal']) + >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + >>> resolved_colors = resolve_colors(data, manager) - Without mapper: + Without manager: >>> resolved_colors = resolve_colors(data, 'viridis') """ @@ -812,9 +776,9 @@ def resolve_colors( processor = ColorProcessor(engine=engine) return processor.process_colors(colors, labels, return_mapping=True) - if isinstance(colors, XarrayColorMapper): - # Use color mapper's create_color_map directly with variable names - return colors.create_color_map(labels) + if isinstance(colors, ComponentColorManager): + # Use color manager to resolve colors for variables + return colors.get_variable_colors(labels) raise TypeError(f'Wrong type passed to resolve_colors(): {type(colors)}') @@ -822,7 +786,7 @@ def resolve_colors( def with_plotly( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', - colors: ColorType | XarrayColorMapper = 'viridis', + colors: ColorType | ComponentColorManager = 'viridis', title: str = '', ylabel: str = '', xlabel: str = '', @@ -850,7 +814,7 @@ def with_plotly( - A colormap name (e.g., 'viridis', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dict mapping labels to colors (e.g., {'Solar': '#FFD700'}) - - An XarrayColorMapper instance for pattern-based color rules with grouping and sorting + - A ComponentColorManager instance for pattern-based color rules with component grouping title: The main title of the plot. ylabel: The label for the y-axis. xlabel: The label for the x-axis. @@ -899,14 +863,15 @@ def with_plotly( fig = with_plotly(dataset, facet_by='scenario', animate_by='period') ``` - Pattern-based colors with XarrayColorMapper: + Pattern-based colors with ComponentColorManager: ```python - mapper = XarrayColorMapper() - mapper.add_rule('Solar', 'oranges', 'prefix') - mapper.add_rule('Wind', 'blues', 'prefix') - mapper.add_rule('Battery', 'greens', 'contains') - fig = with_plotly(dataset, colors=mapper, mode='area') + manager = ComponentColorManager(['Solar', 'Wind', 'Battery', 'Gas']) + manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='contains') + manager.auto_group_components() + fig = with_plotly(dataset, colors=manager, mode='area') ``` """ if mode not in ('stacked_bar', 'line', 'area', 'grouped_bar'): @@ -1149,7 +1114,7 @@ def with_plotly( def with_matplotlib( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', - colors: ColorType | XarrayColorMapper = 'viridis', + colors: ColorType | ComponentColorManager = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -1167,7 +1132,7 @@ def with_matplotlib( - A colormap name (e.g., 'viridis', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dict mapping column names to colors (e.g., {'Column1': '#ff0000'}) - - An XarrayColorMapper instance for pattern-based color rules with grouping and sorting + - A ComponentColorManager instance for pattern-based color rules with grouping and sorting title: The title of the plot. ylabel: The ylabel of the plot. xlabel: The xlabel of the plot. @@ -1184,13 +1149,14 @@ def with_matplotlib( - If `mode` is 'line', stepped lines are drawn for each data series. Examples: - With XarrayColorMapper: + With ComponentColorManager: ```python - mapper = XarrayColorMapper() - mapper.add_rule('Solar', 'oranges', 'prefix') - mapper.add_rule('Wind', 'blues', 'prefix') - fig, ax = with_matplotlib(dataset, colors=mapper, mode='line') + manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) + manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + manager.auto_group_components() + fig, ax = with_matplotlib(dataset, colors=manager, mode='line') ``` """ if mode not in ('stacked_bar', 'line'): @@ -1564,7 +1530,7 @@ def plot_network( def pie_with_plotly( data: xr.Dataset | pd.DataFrame, - colors: ColorType | XarrayColorMapper = 'viridis', + colors: ColorType | ComponentColorManager = 'viridis', title: str = '', legend_title: str = '', hole: float = 0.0, @@ -1583,7 +1549,7 @@ def pie_with_plotly( - A string with a colorscale name (e.g., 'viridis', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - - An XarrayColorMapper instance for pattern-based color rules + - A ComponentColorManager instance for pattern-based color rules title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -1608,13 +1574,14 @@ def pie_with_plotly( fig = pie_with_plotly(dataset, colors='viridis', title='Energy Mix') ``` - With XarrayColorMapper: + With ComponentColorManager: ```python - mapper = XarrayColorMapper() - mapper.add_rule('Solar', 'oranges', 'prefix') - mapper.add_rule('Wind', 'blues', 'prefix') - fig = pie_with_plotly(dataset, colors=mapper, title='Renewable Energy') + manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) + manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + manager.auto_group_components() + fig = pie_with_plotly(dataset, colors=manager, title='Renewable Energy') ``` """ # Ensure data is a Dataset and validate it @@ -1682,7 +1649,7 @@ def pie_with_plotly( def pie_with_matplotlib( data: xr.Dataset | pd.DataFrame, - colors: ColorType | XarrayColorMapper = 'viridis', + colors: ColorType | ComponentColorManager = 'viridis', title: str = '', legend_title: str = 'Categories', hole: float = 0.0, @@ -1698,7 +1665,7 @@ def pie_with_matplotlib( - A string with a colormap name (e.g., 'viridis', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - - An XarrayColorMapper instance for pattern-based color rules + - A ComponentColorManager instance for pattern-based color rules title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -1719,13 +1686,14 @@ def pie_with_matplotlib( fig, ax = pie_with_matplotlib(dataset, colors='viridis', title='Energy Mix') ``` - With XarrayColorMapper: + With ComponentColorManager: ```python - mapper = XarrayColorMapper() - mapper.add_rule('Solar', 'oranges', 'prefix') - mapper.add_rule('Wind', 'blues', 'prefix') - fig, ax = pie_with_matplotlib(dataset, colors=mapper, title='Renewable Energy') + manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) + manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + manager.auto_group_components() + fig, ax = pie_with_matplotlib(dataset, colors=manager, title='Renewable Energy') ``` """ # Ensure data is a Dataset and validate it @@ -1814,7 +1782,7 @@ def pie_with_matplotlib( def dual_pie_with_plotly( data_left: xr.Dataset | pd.DataFrame, data_right: xr.Dataset | pd.DataFrame, - colors: ColorType | XarrayColorMapper = 'viridis', + colors: ColorType | ComponentColorManager = 'viridis', title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1834,7 +1802,7 @@ def dual_pie_with_plotly( - A string with a colorscale name (e.g., 'viridis', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - - An XarrayColorMapper instance for pattern-based color rules + - A ComponentColorManager instance for pattern-based color rules title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. diff --git a/flixopt/results.py b/flixopt/results.py index cccea7b81..6d52b56de 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -69,10 +69,10 @@ class CalculationResults: effects: Dictionary mapping effect names to EffectResults objects timesteps_extra: Extended time index including boundary conditions hours_per_timestep: Duration of each timestep for proper energy calculations - color_mapper: Optional XarrayColorMapper for automatic pattern-based coloring in plots. - When set, all plotting methods automatically use this mapper when colors='auto' - (the default). Use `create_color_mapper()` to create and configure one, or assign - an existing mapper directly. Set to None to disable automatic coloring. + color_manager: Optional ComponentColorManager for automatic component-based coloring in plots. + When set, all plotting methods automatically use this manager when colors='auto' + (the default). Use `create_color_manager()` to create and configure one, or assign + an existing manager directly. Set to None to disable automatic coloring. Examples: Load and analyze saved results: @@ -111,17 +111,18 @@ class CalculationResults: ).mean() ``` - Configure automatic color mapping for plots: + Configure automatic color management for plots: ```python - # Create and configure a color mapper for pattern-based coloring - mapper = results.create_color_mapper() - mapper.add_rule('Solar', 'oranges', 'prefix') # Solar components get orange shades - mapper.add_rule('Wind', 'blues', 'prefix') # Wind components get blue shades - mapper.add_rule('Battery', 'greens', 'prefix') # Battery components get green shades - mapper.add_rule('Gas', 'reds', 'prefix') # Gas components get red shades - - # All plots automatically use the mapper (colors='auto' is the default) + # Create and configure a color manager for pattern-based coloring + manager = results.create_color_manager() + manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') + manager.add_grouping_rule('Gas', 'fossil', 'reds', match_type='prefix') + manager.auto_group_components() + + # All plots automatically use the manager (colors='auto' is the default) results['ElectricityBus'].plot_node_balance() # Uses configured colors results['Battery'].plot_charge_state() # Also uses configured colors @@ -261,8 +262,8 @@ def __init__( self._sizes = None self._effects_per_component = None - # Color mapper for intelligent plot coloring - self.color_mapper: plotting.XarrayColorMapper | None = None + # Color manager for intelligent plot coloring + self.color_manager: plotting.ComponentColorManager | None = None def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults: if key in self.components: @@ -330,38 +331,41 @@ def flow_system(self) -> FlowSystem: logger.level = old_level return self._flow_system - def create_color_mapper(self) -> plotting.XarrayColorMapper: - """Create and assign a new XarrayColorMapper for this results instance. + def create_color_manager(self) -> plotting.ComponentColorManager: + """Create and assign a new ComponentColorManager for this results instance. - The color mapper is automatically used by all plotting methods when colors='auto' - (the default). Configure it with rules to define pattern-based color grouping. + The color manager is automatically used by all plotting methods when colors='auto' + (the default). Configure it with grouping rules to define pattern-based color families. - You can also assign an existing mapper directly via `results.color_mapper = mapper`. + You can also assign an existing manager directly via `results.color_manager = manager`. Returns: - The newly created XarrayColorMapper, ready to be configured with rules. + The newly created ComponentColorManager with all components registered, ready to be configured. Examples: - Create and configure a new mapper: + Create and configure a new manager: - >>> mapper = results.create_color_mapper() - >>> mapper.add_rule('Solar', 'oranges', 'prefix') - >>> mapper.add_rule('Wind', 'blues', 'prefix') - >>> mapper.add_rule('Gas', 'reds', 'prefix') - >>> results['ElectricityBus'].plot_node_balance() # Uses mapper automatically + >>> manager = results.create_color_manager() + >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + >>> manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + >>> manager.add_grouping_rule('Gas', 'fossil', 'reds', match_type='prefix') + >>> manager.auto_group_components() + >>> results['ElectricityBus'].plot_node_balance() # Uses manager automatically - Or assign an existing mapper: + Or assign an existing manager: - >>> my_mapper = plotting.XarrayColorMapper() - >>> my_mapper.add_rule('Renewable', 'greens', 'prefix') - >>> results.color_mapper = my_mapper + >>> my_manager = plotting.ComponentColorManager(list(results.components.keys())) + >>> my_manager.add_grouping_rule('Renewable', 'renewables', 'greens', match_type='prefix') + >>> my_manager.auto_group_components() + >>> results.color_manager = my_manager Override with explicit colors if needed: - >>> results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores mapper + >>> results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores manager """ - self.color_mapper = plotting.XarrayColorMapper() - return self.color_mapper + component_names = list(self.components.keys()) + self.color_manager = plotting.ComponentColorManager(component_names) + return self.color_manager def filter_solution( self, @@ -1073,11 +1077,11 @@ def plot_node_balance( 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. Options: - - 'auto' (default): Use `self.color_mapper` if configured, else fall back to 'viridis' + - 'auto' (default): Use `self.color_manager` if configured, else fall back to 'viridis' - Colormap name string (e.g., 'viridis', 'plasma') - List of color strings - Dict mapping variable names to colors - Set `results.color_mapper` to an `XarrayColorMapper` for automatic pattern-based grouping. + Set `results.color_manager` to a `ComponentColorManager` for automatic component-based grouping. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. select: Optional data selection dict. Supports: - Single values: {'scenario': 'base', 'period': 2024} @@ -1198,8 +1202,8 @@ def plot_node_balance( # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( - self._calculation_results.color_mapper - if colors == 'auto' and self._calculation_results.color_mapper is not None + self._calculation_results.color_manager + if colors == 'auto' and self._calculation_results.color_manager is not None else 'viridis' if colors == 'auto' else colors @@ -1391,8 +1395,8 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) colors_to_use = ( - self._calculation_results.color_mapper - if colors == 'auto' and self._calculation_results.color_mapper is not None + self._calculation_results.color_manager + if colors == 'auto' and self._calculation_results.color_manager is not None else 'viridis' if colors == 'auto' else colors @@ -1646,8 +1650,8 @@ def plot_charge_state( # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( - self._calculation_results.color_mapper - if colors == 'auto' and self._calculation_results.color_mapper is not None + self._calculation_results.color_manager + if colors == 'auto' and self._calculation_results.color_manager is not None else 'viridis' if colors == 'auto' else colors @@ -1862,10 +1866,10 @@ class SegmentedCalculationResults: name: Identifier for this segmented calculation folder: Directory path for result storage and loading hours_per_timestep: Duration of each timestep - color_mapper: Optional XarrayColorMapper for automatic pattern-based coloring in plots. + color_manager: Optional ComponentColorManager for automatic component-based coloring in plots. When set, it is automatically propagated to all segment results, ensuring - consistent coloring across segments. Use `create_color_mapper()` to create - and configure one, or assign an existing mapper directly. + consistent coloring across segments. Use `create_color_manager()` to create + and configure one, or assign an existing manager directly. Examples: Load and analyze segmented results: @@ -1922,14 +1926,15 @@ class SegmentedCalculationResults: storage_continuity = results.check_storage_continuity('Battery') ``` - Configure color mapping for consistent plotting across segments: + Configure color management for consistent plotting across segments: ```python - # Create and configure a color mapper - mapper = results.create_color_mapper() - mapper.add_rule('Solar', 'oranges', 'prefix') - mapper.add_rule('Wind', 'blues', 'prefix') - mapper.add_rule('Battery', 'greens', 'prefix') + # Create and configure a color manager + manager = results.create_color_manager() + manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') + manager.auto_group_components() # Plot using any segment - colors are consistent across all segments results.segment_results[0]['ElectricityBus'].plot_node_balance() @@ -2010,8 +2015,8 @@ def __init__( self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps) - # Color mapper for intelligent plot coloring - self.color_mapper: plotting.XarrayColorMapper | None = None + # Color manager for intelligent plot coloring + self.color_manager: plotting.ComponentColorManager | None = None @property def meta_data(self) -> dict[str, int | list[str]]: @@ -2026,29 +2031,32 @@ def meta_data(self) -> dict[str, int | list[str]]: def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] - def create_color_mapper(self) -> plotting.XarrayColorMapper: - """Create and assign a new XarrayColorMapper for this segmented results instance. + def create_color_manager(self) -> plotting.ComponentColorManager: + """Create and assign a new ComponentColorManager for this segmented results instance. - The color mapper is automatically propagated to all segment results, + The color manager is automatically propagated to all segment results, ensuring consistent coloring across all segments when using plotting methods. Returns: - The newly created XarrayColorMapper, ready to be configured with rules. + The newly created ComponentColorManager with all components registered. Examples: - Create and configure a mapper for segmented results: + Create and configure a manager for segmented results: - >>> mapper = segmented_results.create_color_mapper() - >>> mapper.add_rule('Solar', 'oranges', 'prefix') - >>> mapper.add_rule('Wind', 'blues', 'prefix') - >>> # The mapper is now available on all segments + >>> manager = segmented_results.create_color_manager() + >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + >>> manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + >>> manager.auto_group_components() + >>> # The manager is now available on all segments >>> segmented_results.segment_results[0]['ElectricityBus'].plot_node_balance() """ - self.color_mapper = plotting.XarrayColorMapper() + # Get component names from first segment (all segments should have same components) + component_names = list(self.segment_results[0].components.keys()) if self.segment_results else [] + self.color_manager = plotting.ComponentColorManager(component_names) # Propagate to all segment results for consistent coloring for segment in self.segment_results: - segment.color_mapper = self.color_mapper - return self.color_mapper + segment.color_manager = self.color_manager + return self.color_manager def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Get variable solution removing segment overlaps. From a5832405b6e20682ba241b5cea5123c098b53a8c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:31:50 +0200 Subject: [PATCH 095/173] Add repr and str method --- flixopt/plotting.py | 48 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 5eabfddf3..897cfb624 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -406,7 +406,7 @@ class ComponentColorManager: 'darkmint': px.colors.sequential.Darkmint[1:8], } - def __init__(self, components: list[str], default_colormap: str = 'tab10') -> None: + def __init__(self, components: list[str], default_colormap: str = 'viridis') -> None: """Initialize component color manager. Args: @@ -429,6 +429,52 @@ def __init__(self, components: list[str], default_colormap: str = 'tab10') -> No # Auto-assign default colors self._assign_default_colors() + def __repr__(self) -> str: + """Return detailed representation of ComponentColorManager.""" + return ( + f'ComponentColorManager(components={len(self.components)}, ' + f'rules={len(self._grouping_rules)}, ' + f'overrides={len(self._overrides)}, ' + f"default_colormap='{self.default_colormap}')" + ) + + def __str__(self) -> str: + """Return human-readable summary of ComponentColorManager.""" + lines = [ + 'ComponentColorManager', + f' Components: {len(self.components)}', + ] + + # Show first few components as examples + if self.components: + sample = self.components[:5] + if len(self.components) > 5: + sample_str = ', '.join(sample) + f', ... ({len(self.components) - 5} more)' + else: + sample_str = ', '.join(sample) + lines.append(f' [{sample_str}]') + + lines.append(f' Grouping rules: {len(self._grouping_rules)}') + if self._grouping_rules: + for rule in self._grouping_rules[:3]: # Show first 3 rules + lines.append( + f" - {rule['match_type']}('{rule['pattern']}') → " + f"group '{rule['group_name']}' ({rule['colormap']})" + ) + if len(self._grouping_rules) > 3: + lines.append(f' ... and {len(self._grouping_rules) - 3} more') + + lines.append(f' Overrides: {len(self._overrides)}') + if self._overrides: + for comp, color in list(self._overrides.items())[:3]: + lines.append(f' - {comp}: {color}') + if len(self._overrides) > 3: + lines.append(f' ... and {len(self._overrides) - 3} more') + + lines.append(f' Default colormap: {self.default_colormap}') + + return '\n'.join(lines) + def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManager: """Add a custom color family. From f9c28e512aac40e23939a9ec2289c2ed0389d7ef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:31:58 +0200 Subject: [PATCH 096/173] Added tests --- tests/test_component_color_manager.py | 607 ++++++++++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 tests/test_component_color_manager.py diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py new file mode 100644 index 000000000..614ca30c8 --- /dev/null +++ b/tests/test_component_color_manager.py @@ -0,0 +1,607 @@ +"""Tests for ComponentColorManager functionality.""" + +import numpy as np +import pytest +import xarray as xr + +from flixopt.plotting import ComponentColorManager, resolve_colors + + +class TestBasicFunctionality: + """Test basic ComponentColorManager functionality.""" + + def test_initialization_default(self): + """Test default initialization.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] + manager = ComponentColorManager(components) + + assert len(manager.components) == 3 + assert manager.default_colormap == 'viridis' + assert 'Solar_PV' in manager.components + assert 'Wind_Onshore' in manager.components + assert 'Coal_Plant' in manager.components + + def test_initialization_custom_colormap(self): + """Test initialization with custom default colormap.""" + components = ['Comp1', 'Comp2'] + manager = ComponentColorManager(components, default_colormap='viridis') + + assert manager.default_colormap == 'viridis' + + def test_sorted_components(self): + """Test that components are sorted for stability.""" + components = ['C_Component', 'A_Component', 'B_Component'] + manager = ComponentColorManager(components) + + # Components should be sorted + assert manager.components == ['A_Component', 'B_Component', 'C_Component'] + + def test_duplicate_components_removed(self): + """Test that duplicate components are removed.""" + components = ['Comp1', 'Comp2', 'Comp1', 'Comp3', 'Comp2'] + manager = ComponentColorManager(components) + + assert len(manager.components) == 3 + assert manager.components == ['Comp1', 'Comp2', 'Comp3'] + + def test_default_color_assignment(self): + """Test that components get default colors on initialization.""" + components = ['Comp1', 'Comp2', 'Comp3'] + manager = ComponentColorManager(components) + + # Each component should have a color + for comp in components: + color = manager.get_color(comp) + assert color is not None + assert isinstance(color, str) + + +class TestColorFamilies: + """Test color family functionality.""" + + def test_default_families(self): + """Test that default families are available.""" + manager = ComponentColorManager([]) + + assert 'blues' in manager.color_families + assert 'oranges' in manager.color_families + assert 'greens' in manager.color_families + assert 'reds' in manager.color_families + + def test_add_custom_family(self): + """Test adding a custom color family.""" + manager = ComponentColorManager([]) + custom_colors = ['#FF0000', '#00FF00', '#0000FF'] + + result = manager.add_custom_family('ocean', custom_colors) + + assert 'ocean' in manager.color_families + assert manager.color_families['ocean'] == custom_colors + assert result is manager # Check method chaining + + def test_add_custom_family_replaces_existing(self): + """Test that adding a family with existing name replaces it.""" + manager = ComponentColorManager([]) + original = ['#FF0000'] + replacement = ['#00FF00', '#0000FF'] + + manager.add_custom_family('test', original) + manager.add_custom_family('test', replacement) + + assert manager.color_families['test'] == replacement + + +class TestGroupingRules: + """Test grouping rule functionality.""" + + def test_add_grouping_rule_prefix(self): + """Test adding a prefix grouping rule.""" + components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore'] + manager = ComponentColorManager(components) + + result = manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + + assert len(manager._grouping_rules) == 1 + assert result is manager # Check method chaining + + def test_add_grouping_rule_suffix(self): + """Test adding a suffix grouping rule.""" + components = ['PV_Solar', 'Thermal_Solar', 'Onshore_Wind'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('_Solar', 'solar_tech', 'oranges', match_type='suffix') + + assert manager._grouping_rules[0]['match_type'] == 'suffix' + + def test_add_grouping_rule_contains(self): + """Test adding a contains grouping rule.""" + components = ['BigSolarPV', 'SmallSolarThermal', 'WindTurbine'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('Solar', 'solar_tech', 'oranges', match_type='contains') + + assert manager._grouping_rules[0]['match_type'] == 'contains' + + def test_add_grouping_rule_glob(self): + """Test adding a glob grouping rule.""" + components = ['Solar_PV_1', 'Solar_PV_2', 'Wind_1'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('Solar_*', 'solar_tech', 'oranges', match_type='glob') + + assert manager._grouping_rules[0]['match_type'] == 'glob' + + def test_add_grouping_rule_regex(self): + """Test adding a regex grouping rule.""" + components = ['Solar_01', 'Solar_02', 'Wind_01'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule(r'Solar_\d+', 'solar_tech', 'oranges', match_type='regex') + + assert manager._grouping_rules[0]['match_type'] == 'regex' + + def test_auto_group_components(self): + """Test auto-grouping components based on rules.""" + components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore', 'Wind_Offshore', 'Coal_Plant'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('Solar', 'renewables_solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables_wind', 'blues', match_type='prefix') + manager.add_grouping_rule('Coal', 'fossil', 'greys', match_type='prefix') + manager.auto_group_components() + + # Check that components got colors from appropriate families + solar_color = manager.get_color('Solar_PV') + wind_color = manager.get_color('Wind_Onshore') + + assert solar_color is not None + assert wind_color is not None + # Colors should be different (from different families) + assert solar_color != wind_color + + def test_first_match_wins(self): + """Test that first matching rule wins.""" + components = ['Solar_Wind_Hybrid'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='contains') + manager.auto_group_components() + + # Should match 'Solar' rule first (prefix match) + color = manager.get_color('Solar_Wind_Hybrid') + # Should be from oranges family (first rule) + assert 'rgb' in color.lower() + + +class TestColorStability: + """Test color stability across different datasets.""" + + def test_same_component_same_color(self): + """Test that same component always gets same color.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant', 'Gas_Plant'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('Solar', 'renewables_solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'renewables_wind', 'blues', match_type='prefix') + manager.auto_group_components() + + # Get colors multiple times + color1 = manager.get_color('Solar_PV') + color2 = manager.get_color('Solar_PV') + color3 = manager.get_color('Solar_PV') + + assert color1 == color2 == color3 + + def test_color_stability_with_different_datasets(self): + """Test that colors remain stable across different variable subsets.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant', 'Gas_Plant'] + manager = ComponentColorManager(components) + + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') + manager.add_grouping_rule('Coal', 'fossil_coal', 'greys', match_type='prefix') + manager.add_grouping_rule('Gas', 'fossil_gas', 'reds', match_type='prefix') + manager.auto_group_components() + + # Dataset 1: Only Solar and Wind + dataset1 = xr.Dataset( + { + 'Solar_PV(Bus)|flow_rate': (['time'], np.random.rand(10)), + 'Wind_Onshore(Bus)|flow_rate': (['time'], np.random.rand(10)), + }, + coords={'time': np.arange(10)}, + ) + + # Dataset 2: All components + dataset2 = xr.Dataset( + { + 'Solar_PV(Bus)|flow_rate': (['time'], np.random.rand(10)), + 'Wind_Onshore(Bus)|flow_rate': (['time'], np.random.rand(10)), + 'Coal_Plant(Bus)|flow_rate': (['time'], np.random.rand(10)), + 'Gas_Plant(Bus)|flow_rate': (['time'], np.random.rand(10)), + }, + coords={'time': np.arange(10)}, + ) + + colors1 = resolve_colors(dataset1, manager, engine='plotly') + colors2 = resolve_colors(dataset2, manager, engine='plotly') + + # Solar_PV and Wind_Onshore should have same colors in both datasets + assert colors1['Solar_PV(Bus)|flow_rate'] == colors2['Solar_PV(Bus)|flow_rate'] + assert colors1['Wind_Onshore(Bus)|flow_rate'] == colors2['Wind_Onshore(Bus)|flow_rate'] + + +class TestVariableExtraction: + """Test variable to component extraction.""" + + def test_extract_component_with_parentheses(self): + """Test extracting component from variable with parentheses.""" + manager = ComponentColorManager([]) + + variable = 'Solar_PV(ElectricityBus)|flow_rate' + component = manager.extract_component(variable) + + assert component == 'Solar_PV' + + def test_extract_component_with_pipe(self): + """Test extracting component from variable with pipe.""" + manager = ComponentColorManager([]) + + variable = 'Wind_Turbine|investment_size' + component = manager.extract_component(variable) + + assert component == 'Wind_Turbine' + + def test_extract_component_with_both(self): + """Test extracting component from variable with both separators.""" + manager = ComponentColorManager([]) + + variable = 'Gas_Plant(HeatBus)|flow_rate' + component = manager.extract_component(variable) + + assert component == 'Gas_Plant' + + def test_extract_component_no_separators(self): + """Test extracting component from variable without separators.""" + manager = ComponentColorManager([]) + + variable = 'SimpleComponent' + component = manager.extract_component(variable) + + assert component == 'SimpleComponent' + + +class TestVariableColorResolution: + """Test getting colors for variables.""" + + def test_get_variable_color(self): + """Test getting color for a single variable.""" + components = ['Solar_PV', 'Wind_Onshore'] + manager = ComponentColorManager(components) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.auto_group_components() + + variable = 'Solar_PV(Bus)|flow_rate' + color = manager.get_variable_color(variable) + + assert color is not None + assert isinstance(color, str) + + def test_get_variable_colors_multiple(self): + """Test getting colors for multiple variables.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] + manager = ComponentColorManager(components) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') + manager.auto_group_components() + + variables = ['Solar_PV(Bus)|flow_rate', 'Wind_Onshore(Bus)|flow_rate', 'Coal_Plant(Bus)|flow_rate'] + + colors = manager.get_variable_colors(variables) + + assert len(colors) == 3 + assert all(var in colors for var in variables) + assert all(isinstance(color, str) for color in colors.values()) + + def test_get_variable_color_unknown_component(self): + """Test getting color for variable with unknown component.""" + components = ['Solar_PV'] + manager = ComponentColorManager(components) + + variable = 'Unknown_Component(Bus)|flow_rate' + color = manager.get_variable_color(variable) + + # Should still return a color (from default colormap or fallback) + assert color is not None + + +class TestOverrides: + """Test override functionality.""" + + def test_simple_override(self): + """Test simple color override.""" + components = ['Solar_PV', 'Wind_Onshore'] + manager = ComponentColorManager(components) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.auto_group_components() + + # Override Solar_PV color + manager.override({'Solar_PV': '#FF0000'}) + + color = manager.get_color('Solar_PV') + assert color == '#FF0000' + + def test_multiple_overrides(self): + """Test multiple overrides.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] + manager = ComponentColorManager(components) + manager.auto_group_components() + + manager.override({'Solar_PV': '#FF0000', 'Wind_Onshore': '#00FF00'}) + + assert manager.get_color('Solar_PV') == '#FF0000' + assert manager.get_color('Wind_Onshore') == '#00FF00' + + def test_override_precedence(self): + """Test that overrides take precedence over grouping rules.""" + components = ['Solar_PV'] + manager = ComponentColorManager(components) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.auto_group_components() + + original_color = manager.get_color('Solar_PV') + + manager.override({'Solar_PV': '#FFD700'}) + + new_color = manager.get_color('Solar_PV') + assert new_color == '#FFD700' + assert new_color != original_color + + +class TestIntegrationWithResolveColors: + """Test integration with resolve_colors function.""" + + def test_resolve_colors_with_manager(self): + """Test resolve_colors with ComponentColorManager.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] + manager = ComponentColorManager(components) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') + manager.auto_group_components() + + dataset = xr.Dataset( + { + 'Solar_PV(Bus)|flow_rate': (['time'], np.random.rand(10)), + 'Wind_Onshore(Bus)|flow_rate': (['time'], np.random.rand(10)), + }, + coords={'time': np.arange(10)}, + ) + + colors = resolve_colors(dataset, manager, engine='plotly') + + assert len(colors) == 2 + assert 'Solar_PV(Bus)|flow_rate' in colors + assert 'Wind_Onshore(Bus)|flow_rate' in colors + + def test_resolve_colors_with_dict(self): + """Test that resolve_colors still works with dict.""" + dataset = xr.Dataset( + {'var1': (['time'], np.random.rand(10)), 'var2': (['time'], np.random.rand(10))}, + coords={'time': np.arange(10)}, + ) + + color_dict = {'var1': '#FF0000', 'var2': '#00FF00'} + colors = resolve_colors(dataset, color_dict, engine='plotly') + + assert colors == color_dict + + def test_resolve_colors_with_colormap_name(self): + """Test that resolve_colors still works with colormap name.""" + dataset = xr.Dataset( + {'var1': (['time'], np.random.rand(10)), 'var2': (['time'], np.random.rand(10))}, + coords={'time': np.arange(10)}, + ) + + colors = resolve_colors(dataset, 'viridis', engine='plotly') + + assert len(colors) == 2 + assert 'var1' in colors + assert 'var2' in colors + + +class TestToDictMethod: + """Test to_dict method.""" + + def test_to_dict_returns_all_colors(self): + """Test that to_dict returns colors for all components.""" + components = ['Comp1', 'Comp2', 'Comp3'] + manager = ComponentColorManager(components) + + color_dict = manager.to_dict() + + assert len(color_dict) == 3 + assert all(comp in color_dict for comp in components) + + def test_to_dict_with_grouping(self): + """Test to_dict with grouping applied.""" + components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore'] + manager = ComponentColorManager(components) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.auto_group_components() + + color_dict = manager.to_dict() + + assert len(color_dict) == 3 + assert 'Solar_PV' in color_dict + assert 'Solar_Thermal' in color_dict + assert 'Wind_Onshore' in color_dict + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_components_list(self): + """Test with empty components list.""" + manager = ComponentColorManager([]) + + assert manager.components == [] + assert manager.to_dict() == {} + + def test_get_color_for_missing_component(self): + """Test getting color for component not in list.""" + components = ['Comp1'] + manager = ComponentColorManager(components) + + # Should return a color (fallback behavior) + color = manager.get_color('MissingComponent') + assert color is not None + + def test_invalid_match_type(self): + """Test that invalid match type raises error.""" + manager = ComponentColorManager([]) + + with pytest.raises(ValueError, match='match_type must be one of'): + manager.add_grouping_rule('test', 'group', 'blues', match_type='invalid') + + +class TestStringRepresentation: + """Test __repr__ and __str__ methods.""" + + def test_repr_simple(self): + """Test __repr__ with simple manager.""" + components = ['Comp1', 'Comp2', 'Comp3'] + manager = ComponentColorManager(components) + + repr_str = repr(manager) + + assert 'ComponentColorManager' in repr_str + assert 'components=3' in repr_str + assert 'rules=0' in repr_str + assert 'overrides=0' in repr_str + assert "default_colormap='viridis'" in repr_str + + def test_repr_with_rules_and_overrides(self): + """Test __repr__ with rules and overrides.""" + manager = ComponentColorManager(['Solar_PV', 'Wind_Onshore']) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') + manager.override({'Solar_PV': '#FF0000'}) + + repr_str = repr(manager) + + assert 'components=2' in repr_str + assert 'rules=2' in repr_str + assert 'overrides=1' in repr_str + + def test_str_simple(self): + """Test __str__ with simple manager.""" + components = ['Comp1', 'Comp2', 'Comp3'] + manager = ComponentColorManager(components) + + str_output = str(manager) + + assert 'ComponentColorManager' in str_output + assert 'Components: 3' in str_output + assert 'Comp1' in str_output + assert 'Comp2' in str_output + assert 'Comp3' in str_output + assert 'Grouping rules: 0' in str_output + assert 'Overrides: 0' in str_output + assert 'Default colormap: viridis' in str_output + + def test_str_with_many_components(self): + """Test __str__ with many components (truncation).""" + components = [f'Comp{i}' for i in range(10)] + manager = ComponentColorManager(components) + + str_output = str(manager) + + assert 'Components: 10' in str_output + assert '... (5 more)' in str_output + + def test_str_with_grouping_rules(self): + """Test __str__ with grouping rules.""" + manager = ComponentColorManager(['Solar_PV', 'Wind_Onshore']) + manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') + manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='suffix') + + str_output = str(manager) + + assert 'Grouping rules: 2' in str_output + assert "prefix('Solar')" in str_output + assert "suffix('Wind')" in str_output + assert 'oranges' in str_output + assert 'blues' in str_output + + def test_str_with_many_rules(self): + """Test __str__ with many rules (truncation).""" + manager = ComponentColorManager([]) + for i in range(5): + manager.add_grouping_rule(f'Pattern{i}', f'group{i}', 'blues', match_type='prefix') + + str_output = str(manager) + + assert 'Grouping rules: 5' in str_output + assert '... and 2 more' in str_output + + def test_str_with_overrides(self): + """Test __str__ with overrides.""" + manager = ComponentColorManager(['Comp1', 'Comp2']) + manager.override({'Comp1': '#FF0000', 'Comp2': '#00FF00'}) + + str_output = str(manager) + + assert 'Overrides: 2' in str_output + assert 'Comp1: #FF0000' in str_output + assert 'Comp2: #00FF00' in str_output + + def test_str_with_many_overrides(self): + """Test __str__ with many overrides (truncation).""" + manager = ComponentColorManager([]) + overrides = {f'Comp{i}': f'#FF{i:04X}' for i in range(5)} + manager.override(overrides) + + str_output = str(manager) + + assert 'Overrides: 5' in str_output + assert '... and 2 more' in str_output + + def test_str_empty_manager(self): + """Test __str__ with empty manager.""" + manager = ComponentColorManager([]) + + str_output = str(manager) + + assert 'Components: 0' in str_output + assert 'Grouping rules: 0' in str_output + assert 'Overrides: 0' in str_output + + +class TestMethodChaining: + """Test method chaining.""" + + def test_chaining_add_custom_family(self): + """Test that add_custom_family returns self for chaining.""" + manager = ComponentColorManager([]) + result = manager.add_custom_family('ocean', ['#003f5c']) + assert result is manager + + def test_chaining_add_grouping_rule(self): + """Test that add_grouping_rule returns self for chaining.""" + manager = ComponentColorManager([]) + result = manager.add_grouping_rule('Solar', 'solar', 'oranges') + assert result is manager + + def test_full_chaining(self): + """Test full method chaining.""" + components = ['Solar_PV', 'Wind_Onshore', 'Gas_Plant'] + manager = ( + ComponentColorManager(components) + .add_custom_family('ocean', ['#003f5c', '#2f4b7c']) + .add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + .add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') + ) + + assert 'ocean' in manager.color_families + assert len(manager._grouping_rules) == 2 From 7f6d875aff9684744e7b2f2a904b95ddb18deccc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:39:22 +0200 Subject: [PATCH 097/173] Add caching to ColorManager --- flixopt/plotting.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 897cfb624..b4df326d0 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -426,6 +426,9 @@ def __init__(self, components: list[str], default_colormap: str = 'viridis') -> # Manual overrides (highest priority) self._overrides: dict[str, str] = {} + # Variable color cache for performance: {variable_name: color} + self._variable_cache: dict[str, str] = {} + # Auto-assign default colors self._assign_default_colors() @@ -567,6 +570,9 @@ def auto_group_components(self) -> None: # Apply overrides (highest priority) self._component_colors.update(self._overrides) + # Clear variable cache since colors have changed + self._variable_cache.clear() + def override(self, component_colors: dict[str, str]) -> None: """Override colors for specific components. @@ -583,6 +589,9 @@ def override(self, component_colors: dict[str, str]) -> None: self._overrides.update(component_colors) self._component_colors.update(component_colors) + # Clear variable cache since colors have changed + self._variable_cache.clear() + def get_color(self, component: str) -> str: """Get color for a component. @@ -630,8 +639,15 @@ def get_variable_color(self, variable: str) -> str: Returns: Hex color string """ + # Check cache first + if variable in self._variable_cache: + return self._variable_cache[variable] + + # Compute and cache component = self.extract_component(variable) - return self.get_color(component) + color = self.get_color(component) + self._variable_cache[variable] = color + return color def get_variable_colors(self, variables: list[str]) -> dict[str, str]: """Get colors for multiple variables. From 6e6997f4dbd4b677f375f470e4e6a2df726bfb27 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:39:45 +0200 Subject: [PATCH 098/173] Test caching --- tests/test_component_color_manager.py | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index 614ca30c8..03c1eaa02 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -578,6 +578,112 @@ def test_str_empty_manager(self): assert 'Overrides: 0' in str_output +class TestCaching: + """Test caching functionality.""" + + def test_cache_stores_variable_colors(self): + """Test that cache stores variable-to-color mappings.""" + components = ['Solar_PV', 'Wind_Onshore'] + manager = ComponentColorManager(components) + + # Cache should be empty initially + assert len(manager._variable_cache) == 0 + + # Get color for variable + variable = 'Solar_PV(Bus)|flow_rate' + color = manager.get_variable_color(variable) + + # Cache should now contain the variable + assert len(manager._variable_cache) == 1 + assert variable in manager._variable_cache + assert manager._variable_cache[variable] == color + + def test_cache_reuses_stored_colors(self): + """Test that subsequent calls use cached colors.""" + components = ['Solar_PV'] + manager = ComponentColorManager(components) + + variable = 'Solar_PV(Bus)|flow_rate' + + # First call + color1 = manager.get_variable_color(variable) + + # Second call should return same color from cache + color2 = manager.get_variable_color(variable) + + assert color1 == color2 + assert len(manager._variable_cache) == 1 + + def test_cache_cleared_on_auto_group_components(self): + """Test that cache is cleared when auto_group_components is called.""" + components = ['Solar_PV', 'Wind_Onshore'] + manager = ComponentColorManager(components) + + # Populate cache + manager.get_variable_color('Solar_PV(Bus)|flow_rate') + manager.get_variable_color('Wind_Onshore(Bus)|flow_rate') + assert len(manager._variable_cache) == 2 + + # Call auto_group_components + manager.auto_group_components() + + # Cache should be cleared + assert len(manager._variable_cache) == 0 + + def test_cache_cleared_on_override(self): + """Test that cache is cleared when override is called.""" + components = ['Solar_PV', 'Wind_Onshore'] + manager = ComponentColorManager(components) + + # Populate cache + manager.get_variable_color('Solar_PV(Bus)|flow_rate') + manager.get_variable_color('Wind_Onshore(Bus)|flow_rate') + assert len(manager._variable_cache) == 2 + + # Call override + manager.override({'Solar_PV': '#FF0000'}) + + # Cache should be cleared + assert len(manager._variable_cache) == 0 + + def test_cache_returns_updated_colors_after_override(self): + """Test that cache returns new colors after override.""" + components = ['Solar_PV'] + manager = ComponentColorManager(components) + + variable = 'Solar_PV(Bus)|flow_rate' + + # Get original color + color_before = manager.get_variable_color(variable) + + # Override color + manager.override({'Solar_PV': '#FF0000'}) + + # Get new color (cache was cleared, so this will recompute) + color_after = manager.get_variable_color(variable) + + assert color_before != color_after + assert color_after == '#FF0000' + + def test_get_variable_colors_populates_cache(self): + """Test that get_variable_colors populates cache.""" + components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] + manager = ComponentColorManager(components) + + variables = ['Solar_PV(Bus)|flow_rate', 'Wind_Onshore(Bus)|flow_rate', 'Coal_Plant(Bus)|flow_rate'] + + # Cache should be empty + assert len(manager._variable_cache) == 0 + + # Get colors for all variables + manager.get_variable_colors(variables) + + # Cache should now contain all variables + assert len(manager._variable_cache) == 3 + for var in variables: + assert var in manager._variable_cache + + class TestMethodChaining: """Test method chaining.""" From 9f8fa1c3319c94fcd24049c6dedd41a724aa012d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:41:26 +0200 Subject: [PATCH 099/173] Change default colormap and improve colormap settings --- flixopt/plotting.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index b4df326d0..368825f81 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -406,7 +406,7 @@ class ComponentColorManager: 'darkmint': px.colors.sequential.Darkmint[1:8], } - def __init__(self, components: list[str], default_colormap: str = 'viridis') -> None: + def __init__(self, components: list[str], default_colormap: str = 'Plotly') -> None: """Initialize component color manager. Args: @@ -706,27 +706,28 @@ def _match_pattern(self, value: str, pattern: str, match_type: str) -> bool: return False def _get_colormap_colors(self, colormap_name: str) -> list[str]: - """Get list of colors from colormap name. + """Get list of colors from colormap name.""" - Args: - colormap_name: Name of colormap ('reds', 'blues', 'tab10', 'viridis', etc.) - - Returns: - List of hex color strings - """ - # Check if it's a known family + # Check custom families first if colormap_name in self.color_families: return self.color_families[colormap_name] - # Otherwise use ColorProcessor to generate from matplotlib/plotly colormaps + # Try qualitative palettes (best for discrete components) + if hasattr(px.colors.qualitative, colormap_name.title()): + return getattr(px.colors.qualitative, colormap_name.title()) + + # Try sequential palettes + if hasattr(px.colors.sequential, colormap_name.title()): + return getattr(px.colors.sequential, colormap_name.title()) + + # Fall back to ColorProcessor for matplotlib colormaps processor = ColorProcessor(engine='plotly') try: colors = processor._generate_colors_from_colormap(colormap_name, 10) return colors except Exception: - # Fallback to greys if colormap not found - logger.warning(f"Colormap '{colormap_name}' not found, using 'greys' instead") - return self.color_families['greys'] + logger.warning(f"Colormap '{colormap_name}' not found, using 'Plotly' instead") + return px.colors.qualitative.Plotly def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: From 7b436f1ce7063a829a3a9eb2e4e390fc3cfc62f7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:44:01 +0200 Subject: [PATCH 100/173] Automatically initiallize the ColorManager --- flixopt/results.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/results.py b/flixopt/results.py index 6d52b56de..e091a04a8 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -365,6 +365,7 @@ def create_color_manager(self) -> plotting.ComponentColorManager: """ component_names = list(self.components.keys()) self.color_manager = plotting.ComponentColorManager(component_names) + self.color_manager.auto_group_components() return self.color_manager def filter_solution( From 7a8933e8936c8daf6e2ce1586c1bbe4574aee909 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:57:07 +0200 Subject: [PATCH 101/173] Use Dark24 as the default colormap --- flixopt/plotting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 368825f81..ac0d6fae9 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -406,7 +406,7 @@ class ComponentColorManager: 'darkmint': px.colors.sequential.Darkmint[1:8], } - def __init__(self, components: list[str], default_colormap: str = 'Plotly') -> None: + def __init__(self, components: list[str], default_colormap: str = 'Dark24') -> None: """Initialize component color manager. Args: @@ -726,8 +726,8 @@ def _get_colormap_colors(self, colormap_name: str) -> list[str]: colors = processor._generate_colors_from_colormap(colormap_name, 10) return colors except Exception: - logger.warning(f"Colormap '{colormap_name}' not found, using 'Plotly' instead") - return px.colors.qualitative.Plotly + logger.warning(f"Colormap '{colormap_name}' not found, using 'Dark24' instead") + return px.colors.qualitative.Dark24 def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: From 1861e696cef76f4b1d8cd0b016cd7fc1e7aa2fa2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:57:29 +0200 Subject: [PATCH 102/173] Rename auto_group_components() to apply_colors() --- flixopt/plotting.py | 16 ++++++++-------- flixopt/results.py | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index ac0d6fae9..5a9f20eeb 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -368,7 +368,7 @@ class ComponentColorManager: manager.add_grouping_rule('Boiler', 'Heat_Producers', 'reds', 'prefix') manager.add_grouping_rule('CHP', 'Heat_Producers', 'reds', 'prefix') manager.add_grouping_rule('Storage', 'Storage', 'blues', 'contains') - manager.auto_group_components() + manager.apply_colors() # Boiler1, Boiler2, CHP1 get different shades of red # Storage1 gets blue @@ -529,7 +529,7 @@ def add_grouping_rule( ) return self - def auto_group_components(self) -> None: + def apply_colors(self) -> None: """Apply grouping rules and assign colors to all components. This recomputes colors for all components based on current grouping rules. @@ -788,9 +788,9 @@ def _validate_plotting_data(data: xr.Dataset, allow_empty: bool = False) -> None # Warn about NaN/Inf values for var in data.data_vars: if data[var].isnull().any(): - logger.warning(f"Variable '{var}' contains NaN values which may affect visualization.") + logger.debug(f"Variable '{var}' contains NaN values which may affect visualization.") if np.isinf(data[var].values).any(): - logger.warning(f"Variable '{var}' contains Inf values which may affect visualization.") + logger.debug(f"Variable '{var}' contains Inf values which may affect visualization.") def resolve_colors( @@ -933,7 +933,7 @@ def with_plotly( manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='contains') - manager.auto_group_components() + manager.apply_colors() fig = with_plotly(dataset, colors=manager, mode='area') ``` """ @@ -1218,7 +1218,7 @@ def with_matplotlib( manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() fig, ax = with_matplotlib(dataset, colors=manager, mode='line') ``` """ @@ -1643,7 +1643,7 @@ def pie_with_plotly( manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() fig = pie_with_plotly(dataset, colors=manager, title='Renewable Energy') ``` """ @@ -1755,7 +1755,7 @@ def pie_with_matplotlib( manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() fig, ax = pie_with_matplotlib(dataset, colors=manager, title='Renewable Energy') ``` """ diff --git a/flixopt/results.py b/flixopt/results.py index e091a04a8..53b70edd7 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -120,7 +120,7 @@ class CalculationResults: manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') manager.add_grouping_rule('Gas', 'fossil', 'reds', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() # All plots automatically use the manager (colors='auto' is the default) results['ElectricityBus'].plot_node_balance() # Uses configured colors @@ -349,14 +349,14 @@ def create_color_manager(self) -> plotting.ComponentColorManager: >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') >>> manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') >>> manager.add_grouping_rule('Gas', 'fossil', 'reds', match_type='prefix') - >>> manager.auto_group_components() + >>> manager.apply_colors() >>> results['ElectricityBus'].plot_node_balance() # Uses manager automatically Or assign an existing manager: >>> my_manager = plotting.ComponentColorManager(list(results.components.keys())) >>> my_manager.add_grouping_rule('Renewable', 'renewables', 'greens', match_type='prefix') - >>> my_manager.auto_group_components() + >>> my_manager.apply_colors() >>> results.color_manager = my_manager Override with explicit colors if needed: @@ -365,7 +365,7 @@ def create_color_manager(self) -> plotting.ComponentColorManager: """ component_names = list(self.components.keys()) self.color_manager = plotting.ComponentColorManager(component_names) - self.color_manager.auto_group_components() + self.color_manager.apply_colors() return self.color_manager def filter_solution( @@ -1935,7 +1935,7 @@ class SegmentedCalculationResults: manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() # Plot using any segment - colors are consistent across all segments results.segment_results[0]['ElectricityBus'].plot_node_balance() @@ -2047,7 +2047,7 @@ def create_color_manager(self) -> plotting.ComponentColorManager: >>> manager = segmented_results.create_color_manager() >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') >>> manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - >>> manager.auto_group_components() + >>> manager.apply_colors() >>> # The manager is now available on all segments >>> segmented_results.segment_results[0]['ElectricityBus'].plot_node_balance() """ From 695228cd532914e77dba4e8aabea84128b6a8484 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:58:35 +0200 Subject: [PATCH 103/173] Use ColorManager in examples --- examples/01_Simple/simple_example.py | 1 + examples/02_Complex/complex_example.py | 8 +---- .../02_Complex/complex_example_results.py | 13 +++----- tests/test_component_color_manager.py | 32 +++++++++---------- 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 906c24622..8ebe56c67 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -110,6 +110,7 @@ # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + calculation.results.create_color_manager().apply_colors() # --- Analyze Results --- calculation.results['Fernwärme'].plot_node_balance_pie() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 36437b4d0..74ecc1439 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -206,13 +206,7 @@ calculation.results.to_file() # Configure color mapping for consistent plot colors - mapper = calculation.results.create_color_mapper() - mapper.add_rule('BHKW', '#FF8C00', 'prefix') # All CHP units: dark orange - mapper.add_rule('Kessel', '#DC143C', 'prefix') # All boilers: crimson - mapper.add_rule('Speicher', '#32CD32', 'prefix') # All storage: lime green - mapper.add_rule('last', 'skyblue', 'contains') # All loads/demands: skyblue - mapper.add_rule('tarif', 'greys', 'contains') # Tariffs cycle through grey shades - mapper.add_rule('Einspeisung', '#9370DB', 'prefix') # Feed-in: medium purple + calculation.results.create_color_manager() # Plot results with automatic color mapping calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorMapper) diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index aa7012fd5..eac799213 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -21,13 +21,7 @@ # --- Configure Color Mapping for Consistent Plot Colors --- # Create a color mapper to automatically assign consistent colors to components # based on naming patterns. This ensures visual grouping in all plots. - mapper = results.create_color_mapper() - mapper.add_rule('BHKW', '#FF8C00', 'prefix') # All CHP units: dark orange - mapper.add_rule('Kessel', '#DC143C', 'prefix') # All boilers: crimson - mapper.add_rule('Speicher', '#32CD32', 'prefix') # All storage: lime green - mapper.add_rule('last', 'skyblue', 'contains') # All loads/demands: skyblue - mapper.add_rule('tarif', 'greys', 'contains') # Tariffs cycle through grey shades - mapper.add_rule('Einspeisung', '#9370DB', 'prefix') # Feed-in: medium purple + mapper = results.create_color_manager() # --- Basic overview --- results.plot_network(show=True) @@ -37,8 +31,9 @@ # --- Detailed Plots --- # In depth plot for individual flow rates ('__' is used as the delimiter between Component and Flow results.plot_heatmap('Wärmelast(Q_th_Last)|flow_rate') - for flow_rate in results['BHKW2'].inputs + results['BHKW2'].outputs: - results.plot_heatmap(flow_rate) + for bus in results.buses.values(): + bus.plot_node_balance_pie() + bus.plot_node_balance() # --- Plotting internal variables manually --- results.plot_heatmap('BHKW2(Q_th)|on') diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index 03c1eaa02..3bb6924bf 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -140,7 +140,7 @@ def test_add_grouping_rule_regex(self): assert manager._grouping_rules[0]['match_type'] == 'regex' - def test_auto_group_components(self): + def test_apply_colors(self): """Test auto-grouping components based on rules.""" components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore', 'Wind_Offshore', 'Coal_Plant'] manager = ComponentColorManager(components) @@ -148,7 +148,7 @@ def test_auto_group_components(self): manager.add_grouping_rule('Solar', 'renewables_solar', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables_wind', 'blues', match_type='prefix') manager.add_grouping_rule('Coal', 'fossil', 'greys', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() # Check that components got colors from appropriate families solar_color = manager.get_color('Solar_PV') @@ -166,7 +166,7 @@ def test_first_match_wins(self): manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='contains') - manager.auto_group_components() + manager.apply_colors() # Should match 'Solar' rule first (prefix match) color = manager.get_color('Solar_Wind_Hybrid') @@ -184,7 +184,7 @@ def test_same_component_same_color(self): manager.add_grouping_rule('Solar', 'renewables_solar', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables_wind', 'blues', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() # Get colors multiple times color1 = manager.get_color('Solar_PV') @@ -202,7 +202,7 @@ def test_color_stability_with_different_datasets(self): manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') manager.add_grouping_rule('Coal', 'fossil_coal', 'greys', match_type='prefix') manager.add_grouping_rule('Gas', 'fossil_gas', 'reds', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() # Dataset 1: Only Solar and Wind dataset1 = xr.Dataset( @@ -280,7 +280,7 @@ def test_get_variable_color(self): components = ['Solar_PV', 'Wind_Onshore'] manager = ComponentColorManager(components) manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() variable = 'Solar_PV(Bus)|flow_rate' color = manager.get_variable_color(variable) @@ -294,7 +294,7 @@ def test_get_variable_colors_multiple(self): manager = ComponentColorManager(components) manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() variables = ['Solar_PV(Bus)|flow_rate', 'Wind_Onshore(Bus)|flow_rate', 'Coal_Plant(Bus)|flow_rate'] @@ -324,7 +324,7 @@ def test_simple_override(self): components = ['Solar_PV', 'Wind_Onshore'] manager = ComponentColorManager(components) manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() # Override Solar_PV color manager.override({'Solar_PV': '#FF0000'}) @@ -336,7 +336,7 @@ def test_multiple_overrides(self): """Test multiple overrides.""" components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] manager = ComponentColorManager(components) - manager.auto_group_components() + manager.apply_colors() manager.override({'Solar_PV': '#FF0000', 'Wind_Onshore': '#00FF00'}) @@ -348,7 +348,7 @@ def test_override_precedence(self): components = ['Solar_PV'] manager = ComponentColorManager(components) manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() original_color = manager.get_color('Solar_PV') @@ -368,7 +368,7 @@ def test_resolve_colors_with_manager(self): manager = ComponentColorManager(components) manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() dataset = xr.Dataset( { @@ -428,7 +428,7 @@ def test_to_dict_with_grouping(self): components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore'] manager = ComponentColorManager(components) manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.auto_group_components() + manager.apply_colors() color_dict = manager.to_dict() @@ -614,8 +614,8 @@ def test_cache_reuses_stored_colors(self): assert color1 == color2 assert len(manager._variable_cache) == 1 - def test_cache_cleared_on_auto_group_components(self): - """Test that cache is cleared when auto_group_components is called.""" + def test_cache_cleared_on_apply_colors(self): + """Test that cache is cleared when apply_colors is called.""" components = ['Solar_PV', 'Wind_Onshore'] manager = ComponentColorManager(components) @@ -624,8 +624,8 @@ def test_cache_cleared_on_auto_group_components(self): manager.get_variable_color('Wind_Onshore(Bus)|flow_rate') assert len(manager._variable_cache) == 2 - # Call auto_group_components - manager.auto_group_components() + # Call apply_colors + manager.apply_colors() # Cache should be cleared assert len(manager._variable_cache) == 0 From 66185d36a8cf1d5ee946fed2600104ec98a89ac6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:02:20 +0200 Subject: [PATCH 104/173] Fix tests --- tests/test_component_color_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index 3bb6924bf..6c10107e2 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -16,7 +16,7 @@ def test_initialization_default(self): manager = ComponentColorManager(components) assert len(manager.components) == 3 - assert manager.default_colormap == 'viridis' + assert manager.default_colormap == 'Dark24' assert 'Solar_PV' in manager.components assert 'Wind_Onshore' in manager.components assert 'Coal_Plant' in manager.components @@ -479,7 +479,7 @@ def test_repr_simple(self): assert 'components=3' in repr_str assert 'rules=0' in repr_str assert 'overrides=0' in repr_str - assert "default_colormap='viridis'" in repr_str + assert "default_colormap='Dark24'" in repr_str def test_repr_with_rules_and_overrides(self): """Test __repr__ with rules and overrides.""" @@ -508,7 +508,7 @@ def test_str_simple(self): assert 'Comp3' in str_output assert 'Grouping rules: 0' in str_output assert 'Overrides: 0' in str_output - assert 'Default colormap: viridis' in str_output + assert 'Default colormap: Dark24' in str_output def test_str_with_many_components(self): """Test __str__ with many components (truncation).""" From 97c1b85aefd024932db6e6c095cc653c0953b5c4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:16:14 +0200 Subject: [PATCH 105/173] Extend config to cover plotting settings --- flixopt/config.py | 68 +++++++++++++++++++++++++++++++++++++++++++++- flixopt/results.py | 37 +++++++++++++++++++++---- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index a7549a3ec..4e82c00dd 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -54,6 +54,14 @@ 'big_binary_bound': 100_000, } ), + 'plotting': MappingProxyType( + { + 'renderer': 'browser', + 'default_show': False, + 'default_save_path': None, + 'default_engine': 'plotly', + } + ), } ) @@ -185,6 +193,33 @@ class Modeling: epsilon: float = _DEFAULTS['modeling']['epsilon'] big_binary_bound: int = _DEFAULTS['modeling']['big_binary_bound'] + class Plotting: + """Plotting configuration. + + Attributes: + renderer: Plotly renderer to use. Options: 'browser' (opens in browser), 'notebook' (Jupyter), + 'svg', 'png', 'pdf' (static images), or None (auto-detect). + default_show: Default value for the `show` parameter in plot methods. + default_save_path: Default directory for saving plots (None for no default). + default_engine: Default plotting engine ('plotly' or 'matplotlib'). + + Examples: + ```python + # Force browser rendering for all Plotly plots + CONFIG.Plotting.renderer = 'browser' + CONFIG.apply() + + # Don't show plots by default + CONFIG.Plotting.default_show = False + CONFIG.apply() + ``` + """ + + renderer: str | None = _DEFAULTS['plotting']['renderer'] + default_show: bool = _DEFAULTS['plotting']['default_show'] + default_save_path: str | None = _DEFAULTS['plotting']['default_save_path'] + default_engine: Literal['plotly', 'matplotlib'] = _DEFAULTS['plotting']['default_engine'] + config_name: str = _DEFAULTS['config_name'] @classmethod @@ -201,12 +236,15 @@ def reset(cls): for key, value in _DEFAULTS['modeling'].items(): setattr(cls.Modeling, key, value) + for key, value in _DEFAULTS['plotting'].items(): + setattr(cls.Plotting, key, value) + cls.config_name = _DEFAULTS['config_name'] cls.apply() @classmethod def apply(cls): - """Apply current configuration to logging system.""" + """Apply current configuration to logging and plotting systems.""" # Convert Colors class attributes to dict colors_dict = { 'DEBUG': cls.Logging.Colors.DEBUG, @@ -243,6 +281,9 @@ def apply(cls): colors=colors_dict, ) + # Apply plotting configuration + _apply_plotting_config(renderer=cls.Plotting.renderer) + @classmethod def load_from_file(cls, config_file: str | Path): """Load configuration from YAML file and apply it. @@ -282,6 +323,9 @@ def _apply_config_dict(cls, config_dict: dict): elif key == 'modeling' and isinstance(value, dict): for nested_key, nested_value in value.items(): setattr(cls.Modeling, nested_key, nested_value) + elif key == 'plotting' and isinstance(value, dict): + for nested_key, nested_value in value.items(): + setattr(cls.Plotting, nested_key, nested_value) elif hasattr(cls, key): setattr(cls, key, value) @@ -319,6 +363,12 @@ def to_dict(cls) -> dict: 'epsilon': cls.Modeling.epsilon, 'big_binary_bound': cls.Modeling.big_binary_bound, }, + 'plotting': { + 'renderer': cls.Plotting.renderer, + 'default_show': cls.Plotting.default_show, + 'default_save_path': cls.Plotting.default_save_path, + 'default_engine': cls.Plotting.default_engine, + }, } @@ -588,6 +638,22 @@ def _setup_logging( logger.addHandler(logging.NullHandler()) +def _apply_plotting_config(renderer: str | None = 'browser') -> None: + """Apply plotting configuration to plotly. + + Args: + renderer: Plotly renderer to use. Options: 'browser', 'notebook', 'svg', 'png', 'pdf', or None. + """ + try: + import plotly.io as pio + + if renderer is not None: + pio.renderers.default = renderer + except ImportError: + # Plotly not installed, skip configuration + pass + + def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): """Change the logging level for the flixopt logger and all its handlers. diff --git a/flixopt/results.py b/flixopt/results.py index 53b70edd7..d4a0c0a30 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -15,6 +15,7 @@ from . import io as fx_io from . import plotting +from .config import CONFIG from .flow_system import FlowSystem if TYPE_CHECKING: @@ -752,7 +753,7 @@ def plot_heatmap( self, variable_name: str | list[str], save: bool | pathlib.Path = False, - show: bool = True, + show: bool | None = None, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, @@ -869,6 +870,10 @@ def plot_heatmap( ... imshow_kwargs={'interpolation': 'bilinear', 'aspect': 'auto'}, ... ) """ + # Use CONFIG default if show is not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + # Delegate to module-level plot_heatmap function return plot_heatmap( data=self.solution[variable_name], @@ -1057,7 +1062,7 @@ def __init__( def plot_node_balance( self, save: bool | pathlib.Path = False, - show: bool = True, + show: bool | None = None, colors: plotting.ColorType | Literal['auto'] = 'auto', engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, @@ -1173,6 +1178,10 @@ def plot_node_balance( >>> results['Boiler'].plot_node_balance(engine='matplotlib', plot_kwargs={'linewidth': 3, 'alpha': 0.7}) """ + # Use CONFIG default if show is not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + # Handle deprecated indexer parameter if indexer is not None: # Check for conflict with new parameter @@ -1264,7 +1273,7 @@ def plot_node_balance_pie( colors: plotting.ColorType | Literal['auto'] = 'auto', text_info: str = 'percent+label+value', save: bool | pathlib.Path = False, - show: bool = True, + show: bool | None = None, engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, # Deprecated parameter (kept for backwards compatibility) @@ -1318,6 +1327,10 @@ def plot_node_balance_pie( >>> results['Bus'].plot_node_balance_pie(save='figure.png', dpi=600) """ + # Use CONFIG default if show is not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + # Handle deprecated indexer parameter if indexer is not None: # Check for conflict with new parameter @@ -1537,7 +1550,7 @@ def charge_state(self) -> xr.DataArray: def plot_charge_state( self, save: bool | pathlib.Path = False, - show: bool = True, + show: bool | None = None, colors: plotting.ColorType | Literal['auto'] = 'auto', engine: plotting.PlottingEngine = 'plotly', mode: Literal['area', 'stacked_bar', 'line'] = 'area', @@ -1611,6 +1624,10 @@ def plot_charge_state( >>> results['Storage'].plot_charge_state(save='storage.png', dpi=600) """ + # Use CONFIG default if show is not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + # Handle deprecated indexer parameter if indexer is not None: # Check for conflict with new parameter @@ -2082,7 +2099,7 @@ def plot_heatmap( | None = 'auto', colors: str = 'portland', save: bool | pathlib.Path = False, - show: bool = True, + show: bool | None = None, engine: plotting.PlottingEngine = 'plotly', facet_by: str | list[str] | None = None, animate_by: str | None = None, @@ -2128,6 +2145,10 @@ def plot_heatmap( Returns: Figure object. """ + # Use CONFIG default if show is not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + # Handle deprecated parameters if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: # Check for conflict with new parameter @@ -2213,7 +2234,7 @@ def plot_heatmap( folder: pathlib.Path | None = None, colors: plotting.ColorType = 'viridis', save: bool | pathlib.Path = False, - show: bool = True, + show: bool | None = None, engine: plotting.PlottingEngine = 'plotly', select: dict[str, Any] | None = None, facet_by: str | list[str] | None = None, @@ -2276,6 +2297,10 @@ def plot_heatmap( >>> plot_heatmap(dataset, animate_by='variable', reshape_time=('D', 'h')) """ + # Use CONFIG default if show is not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + # 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 From d9606be69ec76130055c1cd7dbe2f10a8e20767e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:20:29 +0200 Subject: [PATCH 106/173] Centralize behaviour --- flixopt/plotting.py | 10 ++++++++-- flixopt/results.py | 24 ------------------------ 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 5a9f20eeb..521538c08 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -45,6 +45,8 @@ import xarray as xr from plotly.exceptions import PlotlyError +from .config import CONFIG + if TYPE_CHECKING: import pyvis @@ -2535,7 +2537,7 @@ def export_figure( default_path: pathlib.Path, default_filetype: str | None = None, user_path: pathlib.Path | None = None, - show: bool = True, + show: bool | None = None, save: bool = False, dpi: int = 300, ) -> go.Figure | tuple[plt.Figure, plt.Axes]: @@ -2547,7 +2549,7 @@ def export_figure( default_path: The default file path if no user filename is provided. default_filetype: The default filetype if the path doesnt end with a filetype. user_path: An optional user-specified file path. - show: Whether to display the figure (default: True). + show: Whether to display the figure. If None, uses CONFIG.Plotting.default_show (default: None). save: Whether to save the figure (default: False). dpi: DPI (dots per inch) for saving Matplotlib figures (default: 300). Only applies to matplotlib figures. @@ -2555,6 +2557,10 @@ def export_figure( ValueError: If no default filetype is provided and the path doesn't specify a filetype. TypeError: If the figure type is not supported. """ + # Apply CONFIG defaults if not explicitly set + if show is None: + show = CONFIG.Plotting.default_show + filename = user_path or default_path filename = filename.with_name(filename.name.replace('|', '__')) if filename.suffix == '': diff --git a/flixopt/results.py b/flixopt/results.py index d4a0c0a30..3b23f699f 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -870,10 +870,6 @@ def plot_heatmap( ... imshow_kwargs={'interpolation': 'bilinear', 'aspect': 'auto'}, ... ) """ - # Use CONFIG default if show is not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - # Delegate to module-level plot_heatmap function return plot_heatmap( data=self.solution[variable_name], @@ -1178,10 +1174,6 @@ def plot_node_balance( >>> results['Boiler'].plot_node_balance(engine='matplotlib', plot_kwargs={'linewidth': 3, 'alpha': 0.7}) """ - # Use CONFIG default if show is not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - # Handle deprecated indexer parameter if indexer is not None: # Check for conflict with new parameter @@ -1327,10 +1319,6 @@ def plot_node_balance_pie( >>> results['Bus'].plot_node_balance_pie(save='figure.png', dpi=600) """ - # Use CONFIG default if show is not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - # Handle deprecated indexer parameter if indexer is not None: # Check for conflict with new parameter @@ -1624,10 +1612,6 @@ def plot_charge_state( >>> results['Storage'].plot_charge_state(save='storage.png', dpi=600) """ - # Use CONFIG default if show is not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - # Handle deprecated indexer parameter if indexer is not None: # Check for conflict with new parameter @@ -2145,10 +2129,6 @@ def plot_heatmap( Returns: Figure object. """ - # Use CONFIG default if show is not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - # Handle deprecated parameters if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: # Check for conflict with new parameter @@ -2297,10 +2277,6 @@ def plot_heatmap( >>> plot_heatmap(dataset, animate_by='variable', reshape_time=('D', 'h')) """ - # Use CONFIG default if show is not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - # 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 From 606e81b0507ecfa607babf98a2d7a9181fe2ccbc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:25:03 +0200 Subject: [PATCH 107/173] More config options --- flixopt/config.py | 75 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 4e82c00dd..6fcbe52c5 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -56,7 +56,8 @@ ), 'plotting': MappingProxyType( { - 'renderer': 'browser', + 'plotly_renderer': 'browser', + 'matplotlib_backend': None, 'default_show': False, 'default_save_path': None, 'default_engine': 'plotly', @@ -197,16 +198,17 @@ class Plotting: """Plotting configuration. Attributes: - renderer: Plotly renderer to use. Options: 'browser' (opens in browser), 'notebook' (Jupyter), - 'svg', 'png', 'pdf' (static images), or None (auto-detect). + plotly_renderer: Plotly renderer to use. + matplotlib_backend: Matplotlib backend to use. default_show: Default value for the `show` parameter in plot methods. - default_save_path: Default directory for saving plots (None for no default). - default_engine: Default plotting engine ('plotly' or 'matplotlib'). + default_save_path: Default directory for saving plots. + default_engine: Default plotting engine. Examples: ```python - # Force browser rendering for all Plotly plots - CONFIG.Plotting.renderer = 'browser' + # Force browser rendering for Plotly and set matplotlib backend + CONFIG.Plotting.plotly_renderer = 'browser' + CONFIG.Plotting.matplotlib_backend = 'TkAgg' CONFIG.apply() # Don't show plots by default @@ -215,7 +217,25 @@ class Plotting: ``` """ - renderer: str | None = _DEFAULTS['plotting']['renderer'] + plotly_renderer: ( + Literal['browser', 'notebook', 'svg', 'png', 'pdf', 'jpeg', 'json', 'plotly_mimetype'] | None + ) = _DEFAULTS['plotting']['plotly_renderer'] + matplotlib_backend: ( + Literal[ + 'TkAgg', + 'Qt5Agg', + 'QtAgg', + 'WXAgg', + 'Agg', + 'Cairo', + 'PDF', + 'PS', + 'SVG', + 'WebAgg', + 'module://backend_interagg', + ] + | None + ) = _DEFAULTS['plotting']['matplotlib_backend'] default_show: bool = _DEFAULTS['plotting']['default_show'] default_save_path: str | None = _DEFAULTS['plotting']['default_save_path'] default_engine: Literal['plotly', 'matplotlib'] = _DEFAULTS['plotting']['default_engine'] @@ -282,7 +302,9 @@ def apply(cls): ) # Apply plotting configuration - _apply_plotting_config(renderer=cls.Plotting.renderer) + _apply_plotting_config( + plotly_renderer=cls.Plotting.plotly_renderer, matplotlib_backend=cls.Plotting.matplotlib_backend + ) @classmethod def load_from_file(cls, config_file: str | Path): @@ -364,7 +386,8 @@ def to_dict(cls) -> dict: 'big_binary_bound': cls.Modeling.big_binary_bound, }, 'plotting': { - 'renderer': cls.Plotting.renderer, + 'plotly_renderer': cls.Plotting.plotly_renderer, + 'matplotlib_backend': cls.Plotting.matplotlib_backend, 'default_show': cls.Plotting.default_show, 'default_save_path': cls.Plotting.default_save_path, 'default_engine': cls.Plotting.default_engine, @@ -638,21 +661,43 @@ def _setup_logging( logger.addHandler(logging.NullHandler()) -def _apply_plotting_config(renderer: str | None = 'browser') -> None: - """Apply plotting configuration to plotly. +def _apply_plotting_config( + plotly_renderer: Literal['browser', 'notebook', 'svg', 'png', 'pdf', 'jpeg', 'json', 'plotly_mimetype'] + | None = 'browser', + matplotlib_backend: Literal[ + 'TkAgg', 'Qt5Agg', 'QtAgg', 'WXAgg', 'Agg', 'Cairo', 'PDF', 'PS', 'SVG', 'WebAgg', 'module://backend_interagg' + ] + | None = None, +) -> None: + """Apply plotting configuration to plotly and matplotlib. Args: - renderer: Plotly renderer to use. Options: 'browser', 'notebook', 'svg', 'png', 'pdf', or None. + plotly_renderer: Plotly renderer to use. + matplotlib_backend: Matplotlib backend to use. If None, the existing backend is not changed. """ + # Configure Plotly renderer try: import plotly.io as pio - if renderer is not None: - pio.renderers.default = renderer + if plotly_renderer is not None: + pio.renderers.default = plotly_renderer except ImportError: # Plotly not installed, skip configuration pass + # Configure Matplotlib backend + if matplotlib_backend is not None: + try: + import matplotlib + + # Only set backend if it's different from current + current_backend = matplotlib.get_backend() + if current_backend != matplotlib_backend: + matplotlib.use(matplotlib_backend, force=True) + except ImportError: + # Matplotlib not installed, skip configuration + pass + def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): """Change the logging level for the flixopt logger and all its handlers. From 54f2d1b8cba5262f2bfb955add3394fcb74f83ad Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:37:03 +0200 Subject: [PATCH 108/173] More config options --- flixopt/config.py | 68 +++++++++++++++++++++++++++++++++++++++++---- flixopt/plotting.py | 29 ++++++++++++++++--- flixopt/results.py | 14 +++++----- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 6fcbe52c5..384c6fe7a 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -57,10 +57,15 @@ 'plotting': MappingProxyType( { 'plotly_renderer': 'browser', + 'plotly_template': 'plotly', 'matplotlib_backend': None, 'default_show': False, 'default_save_path': None, 'default_engine': 'plotly', + 'default_dpi': 300, + 'default_figure_width': None, + 'default_figure_height': None, + 'default_facet_cols': 3, } ), } @@ -199,20 +204,28 @@ class Plotting: Attributes: plotly_renderer: Plotly renderer to use. + plotly_template: Plotly theme/template applied to all plots. matplotlib_backend: Matplotlib backend to use. default_show: Default value for the `show` parameter in plot methods. default_save_path: Default directory for saving plots. default_engine: Default plotting engine. + default_dpi: Default DPI for saved plots. + default_figure_width: Default plot width in pixels. + default_figure_height: Default plot height in pixels. + default_facet_cols: Default number of columns for faceted plots. Examples: ```python - # Force browser rendering for Plotly and set matplotlib backend + # Set consistent theming and rendering CONFIG.Plotting.plotly_renderer = 'browser' + CONFIG.Plotting.plotly_template = 'plotly_dark' CONFIG.Plotting.matplotlib_backend = 'TkAgg' CONFIG.apply() - # Don't show plots by default - CONFIG.Plotting.default_show = False + # Configure default export settings + CONFIG.Plotting.default_dpi = 600 + CONFIG.Plotting.default_figure_width = 1200 + CONFIG.Plotting.default_figure_height = 800 CONFIG.apply() ``` """ @@ -220,6 +233,22 @@ class Plotting: plotly_renderer: ( Literal['browser', 'notebook', 'svg', 'png', 'pdf', 'jpeg', 'json', 'plotly_mimetype'] | None ) = _DEFAULTS['plotting']['plotly_renderer'] + plotly_template: ( + Literal[ + 'plotly', + 'plotly_white', + 'plotly_dark', + 'ggplot2', + 'seaborn', + 'simple_white', + 'none', + 'gridon', + 'presentation', + 'xgridoff', + 'ygridoff', + ] + | None + ) = _DEFAULTS['plotting']['plotly_template'] matplotlib_backend: ( Literal[ 'TkAgg', @@ -239,6 +268,10 @@ class Plotting: default_show: bool = _DEFAULTS['plotting']['default_show'] default_save_path: str | None = _DEFAULTS['plotting']['default_save_path'] default_engine: Literal['plotly', 'matplotlib'] = _DEFAULTS['plotting']['default_engine'] + default_dpi: int = _DEFAULTS['plotting']['default_dpi'] + default_figure_width: int | None = _DEFAULTS['plotting']['default_figure_width'] + default_figure_height: int | None = _DEFAULTS['plotting']['default_figure_height'] + default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols'] config_name: str = _DEFAULTS['config_name'] @@ -303,7 +336,9 @@ def apply(cls): # Apply plotting configuration _apply_plotting_config( - plotly_renderer=cls.Plotting.plotly_renderer, matplotlib_backend=cls.Plotting.matplotlib_backend + plotly_renderer=cls.Plotting.plotly_renderer, + plotly_template=cls.Plotting.plotly_template, + matplotlib_backend=cls.Plotting.matplotlib_backend, ) @classmethod @@ -387,10 +422,15 @@ def to_dict(cls) -> dict: }, 'plotting': { 'plotly_renderer': cls.Plotting.plotly_renderer, + 'plotly_template': cls.Plotting.plotly_template, 'matplotlib_backend': cls.Plotting.matplotlib_backend, 'default_show': cls.Plotting.default_show, 'default_save_path': cls.Plotting.default_save_path, 'default_engine': cls.Plotting.default_engine, + 'default_dpi': cls.Plotting.default_dpi, + 'default_figure_width': cls.Plotting.default_figure_width, + 'default_figure_height': cls.Plotting.default_figure_height, + 'default_facet_cols': cls.Plotting.default_facet_cols, }, } @@ -664,6 +704,20 @@ def _setup_logging( def _apply_plotting_config( plotly_renderer: Literal['browser', 'notebook', 'svg', 'png', 'pdf', 'jpeg', 'json', 'plotly_mimetype'] | None = 'browser', + plotly_template: Literal[ + 'plotly', + 'plotly_white', + 'plotly_dark', + 'ggplot2', + 'seaborn', + 'simple_white', + 'none', + 'gridon', + 'presentation', + 'xgridoff', + 'ygridoff', + ] + | None = 'plotly', matplotlib_backend: Literal[ 'TkAgg', 'Qt5Agg', 'QtAgg', 'WXAgg', 'Agg', 'Cairo', 'PDF', 'PS', 'SVG', 'WebAgg', 'module://backend_interagg' ] @@ -673,14 +727,18 @@ def _apply_plotting_config( Args: plotly_renderer: Plotly renderer to use. + plotly_template: Plotly template/theme to apply to all plots. matplotlib_backend: Matplotlib backend to use. If None, the existing backend is not changed. """ - # Configure Plotly renderer + # Configure Plotly renderer and template try: import plotly.io as pio if plotly_renderer is not None: pio.renderers.default = plotly_renderer + + if plotly_template is not None: + pio.templates.default = plotly_template except ImportError: # Plotly not installed, skip configuration pass diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 521538c08..7798921bc 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -858,7 +858,7 @@ def with_plotly( fig: go.Figure | None = None, facet_by: str | list[str] | None = None, animate_by: str | None = None, - facet_cols: int = 3, + facet_cols: int | None = None, shared_yaxes: bool = True, shared_xaxes: bool = True, trace_kwargs: dict[str, Any] | None = None, @@ -942,6 +942,10 @@ def with_plotly( 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}") + # Apply CONFIG defaults if not explicitly set + if facet_cols is None: + facet_cols = CONFIG.Plotting.default_facet_cols + # Ensure data is a Dataset and validate it data = _ensure_dataset(data) _validate_plotting_data(data, allow_empty=True) @@ -2175,7 +2179,7 @@ def heatmap_with_plotly( title: str = '', facet_by: str | list[str] | None = None, animate_by: str | None = None, - facet_cols: int = 3, + facet_cols: int | None = None, reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', @@ -2255,6 +2259,10 @@ def heatmap_with_plotly( fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period', reshape_time=('W', 'D')) ``` """ + # Apply CONFIG defaults if not explicitly set + if facet_cols is None: + facet_cols = CONFIG.Plotting.default_facet_cols + # Handle empty data if data.size == 0: return go.Figure() @@ -2539,7 +2547,7 @@ def export_figure( user_path: pathlib.Path | None = None, show: bool | None = None, save: bool = False, - dpi: int = 300, + dpi: int | None = None, ) -> go.Figure | tuple[plt.Figure, plt.Axes]: """ Export a figure to a file and or show it. @@ -2551,7 +2559,7 @@ def export_figure( user_path: An optional user-specified file path. show: Whether to display the figure. If None, uses CONFIG.Plotting.default_show (default: None). save: Whether to save the figure (default: False). - dpi: DPI (dots per inch) for saving Matplotlib figures (default: 300). Only applies to matplotlib figures. + dpi: DPI (dots per inch) for saving Matplotlib figures. If None, uses CONFIG.Plotting.default_dpi. Raises: ValueError: If no default filetype is provided and the path doesn't specify a filetype. @@ -2561,6 +2569,9 @@ def export_figure( if show is None: show = CONFIG.Plotting.default_show + if dpi is None: + dpi = CONFIG.Plotting.default_dpi + filename = user_path or default_path filename = filename.with_name(filename.name.replace('|', '__')) if filename.suffix == '': @@ -2570,6 +2581,16 @@ def export_figure( if isinstance(figure_like, plotly.graph_objs.Figure): fig = figure_like + + # Apply default dimensions if configured + layout_updates = {} + if CONFIG.Plotting.default_figure_width is not None: + layout_updates['width'] = CONFIG.Plotting.default_figure_width + if CONFIG.Plotting.default_figure_height is not None: + layout_updates['height'] = CONFIG.Plotting.default_figure_height + if layout_updates: + fig.update_layout(**layout_updates) + if filename.suffix != '.html': logger.warning(f'To save a Plotly figure, using .html. Adjusting suffix for {filename}') filename = filename.with_suffix('.html') diff --git a/flixopt/results.py b/flixopt/results.py index 3b23f699f..49fc9d8fb 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -759,7 +759,7 @@ def plot_heatmap( select: dict[FlowSystemDimensions, Any] | None = None, facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', - facet_cols: int = 3, + facet_cols: int | None = None, reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', @@ -1067,7 +1067,7 @@ def plot_node_balance( drop_suffix: bool = True, facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', - facet_cols: int = 3, + facet_cols: int | None = None, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, **plot_kwargs: Any, @@ -1195,7 +1195,7 @@ def plot_node_balance( raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]') # Extract dpi for export_figure - dpi = plot_kwargs.pop('dpi', 300) + dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi # Don't pass select/indexer to node_balance - we'll apply it afterwards ds = self.node_balance(with_last_timestep=False, unit_type=unit_type, drop_suffix=drop_suffix) @@ -1337,7 +1337,7 @@ def plot_node_balance_pie( select = indexer # Extract dpi for export_figure - dpi = plot_kwargs.pop('dpi', 300) + dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi inputs = sanitize_dataset( ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, @@ -1545,7 +1545,7 @@ def plot_charge_state( select: dict[FlowSystemDimensions, Any] | None = None, facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', - facet_cols: int = 3, + facet_cols: int | None = None, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, **plot_kwargs: Any, @@ -1630,7 +1630,7 @@ def plot_charge_state( select = indexer # Extract dpi for export_figure - dpi = plot_kwargs.pop('dpi', 300) + dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') @@ -2087,7 +2087,7 @@ def plot_heatmap( engine: plotting.PlottingEngine = 'plotly', facet_by: str | list[str] | None = None, animate_by: str | None = None, - facet_cols: int = 3, + facet_cols: int | None = None, fill: Literal['ffill', 'bfill'] | None = 'ffill', # Deprecated parameters (kept for backwards compatibility) heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, From fbe491a517718cd8d2fb73febfc0817a31f9bc63 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:51:05 +0200 Subject: [PATCH 109/173] More config options --- flixopt/config.py | 12 ++++++++++- flixopt/results.py | 50 +++++++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 384c6fe7a..6d85fe5c1 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -66,6 +66,8 @@ 'default_figure_width': None, 'default_figure_height': None, 'default_facet_cols': 3, + 'default_continuous_colorscale': 'viridis', + 'default_categorical_colormap': 'auto', } ), } @@ -213,6 +215,8 @@ class Plotting: default_figure_width: Default plot width in pixels. default_figure_height: Default plot height in pixels. default_facet_cols: Default number of columns for faceted plots. + default_continuous_colorscale: Default colorscale for heatmaps and continuous data. + default_categorical_colormap: Default colormap for categorical plots (bar/line/area charts). Examples: ```python @@ -222,10 +226,12 @@ class Plotting: CONFIG.Plotting.matplotlib_backend = 'TkAgg' CONFIG.apply() - # Configure default export settings + # Configure default export and color settings CONFIG.Plotting.default_dpi = 600 CONFIG.Plotting.default_figure_width = 1200 CONFIG.Plotting.default_figure_height = 800 + CONFIG.Plotting.default_continuous_colorscale = 'plasma' + CONFIG.Plotting.default_categorical_colormap = 'Dark24' CONFIG.apply() ``` """ @@ -272,6 +278,8 @@ class Plotting: default_figure_width: int | None = _DEFAULTS['plotting']['default_figure_width'] default_figure_height: int | None = _DEFAULTS['plotting']['default_figure_height'] default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols'] + default_continuous_colorscale: str = _DEFAULTS['plotting']['default_continuous_colorscale'] + default_categorical_colormap: str | Literal['auto'] = _DEFAULTS['plotting']['default_categorical_colormap'] config_name: str = _DEFAULTS['config_name'] @@ -431,6 +439,8 @@ def to_dict(cls) -> dict: 'default_figure_width': cls.Plotting.default_figure_width, 'default_figure_height': cls.Plotting.default_figure_height, 'default_facet_cols': cls.Plotting.default_facet_cols, + 'default_continuous_colorscale': cls.Plotting.default_continuous_colorscale, + 'default_categorical_colormap': cls.Plotting.default_categorical_colormap, }, } diff --git a/flixopt/results.py b/flixopt/results.py index 49fc9d8fb..fe0bc3365 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -754,7 +754,7 @@ def plot_heatmap( variable_name: str | list[str], save: bool | pathlib.Path = False, show: bool | None = None, - colors: plotting.ColorType = 'viridis', + colors: plotting.ColorType | None = None, engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, facet_by: str | list[str] | None = 'scenario', @@ -786,7 +786,8 @@ def plot_heatmap( 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. + colors: Color scheme for the heatmap (default: None uses CONFIG.Plotting.default_continuous_colorscale). + 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. Applied BEFORE faceting/animation/reshaping. @@ -1059,7 +1060,7 @@ def plot_node_balance( self, save: bool | pathlib.Path = False, show: bool | None = None, - colors: plotting.ColorType | Literal['auto'] = 'auto', + colors: plotting.ColorType | Literal['auto'] | None = 'auto', engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', @@ -1079,7 +1080,8 @@ def plot_node_balance( 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. Options: - - 'auto' (default): Use `self.color_manager` if configured, else fall back to 'viridis' + - 'auto' (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_categorical_colormap + - None: Uses CONFIG.Plotting.default_categorical_colormap - Colormap name string (e.g., 'viridis', 'plasma') - List of color strings - Dict mapping variable names to colors @@ -1202,11 +1204,15 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) + # Apply CONFIG default if colors is None + if colors is None: + colors = CONFIG.Plotting.default_categorical_colormap + # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( self._calculation_results.color_manager if colors == 'auto' and self._calculation_results.color_manager is not None - else 'viridis' + else CONFIG.Plotting.default_categorical_colormap if colors == 'auto' else colors ) @@ -1262,7 +1268,7 @@ def plot_node_balance( def plot_node_balance_pie( self, lower_percentage_group: float = 5, - colors: plotting.ColorType | Literal['auto'] = 'auto', + colors: plotting.ColorType | Literal['auto'] | None = 'auto', text_info: str = 'percent+label+value', save: bool | pathlib.Path = False, show: bool | None = None, @@ -1283,7 +1289,8 @@ def plot_node_balance_pie( Args: lower_percentage_group: Percentage threshold for "Others" grouping. - colors: Color scheme. Also see plotly. + colors: Color scheme (default: 'auto' uses color_manager if configured, + else falls back to CONFIG.Plotting.default_categorical_colormap). text_info: Information to display on pie slices. save: Whether to save plot. show: Whether to display plot. @@ -1399,7 +1406,7 @@ def plot_node_balance_pie( colors_to_use = ( self._calculation_results.color_manager if colors == 'auto' and self._calculation_results.color_manager is not None - else 'viridis' + else CONFIG.Plotting.default_categorical_colormap if colors == 'auto' else colors ) @@ -1539,7 +1546,7 @@ def plot_charge_state( self, save: bool | pathlib.Path = False, show: bool | None = None, - colors: plotting.ColorType | Literal['auto'] = 'auto', + colors: plotting.ColorType | Literal['auto'] | None = 'auto', engine: plotting.PlottingEngine = 'plotly', mode: Literal['area', 'stacked_bar', 'line'] = 'area', select: dict[FlowSystemDimensions, Any] | None = None, @@ -1555,7 +1562,8 @@ def plot_charge_state( 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: Color scheme. Also see plotly. + colors: Color scheme (default: 'auto' uses color_manager if configured, + else falls back to CONFIG.Plotting.default_categorical_colormap). 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. @@ -1654,7 +1662,7 @@ def plot_charge_state( colors_to_use = ( self._calculation_results.color_manager if colors == 'auto' and self._calculation_results.color_manager is not None - else 'viridis' + else CONFIG.Plotting.default_categorical_colormap if colors == 'auto' else colors ) @@ -2081,7 +2089,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', - colors: str = 'portland', + colors: str | None = None, save: bool | pathlib.Path = False, show: bool | None = None, engine: plotting.PlottingEngine = 'plotly', @@ -2103,7 +2111,8 @@ def plot_heatmap( - '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. + colors: Color scheme (default: None uses CONFIG.Plotting.default_continuous_colorscale). + See plotting.ColorType for options. save: Whether to save plot. show: Whether to display plot. engine: Plotting engine. @@ -2152,7 +2161,7 @@ def plot_heatmap( if color_map is not None: # Check for conflict with new parameter - if colors != 'portland': # Check if user explicitly set colors + if colors is not None: # Check if user explicitly set colors raise ValueError( "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." ) @@ -2212,7 +2221,7 @@ def plot_heatmap( data: xr.DataArray | xr.Dataset, name: str | None = None, folder: pathlib.Path | None = None, - colors: plotting.ColorType = 'viridis', + colors: plotting.ColorType | None = None, save: bool | pathlib.Path = False, show: bool | None = None, engine: plotting.PlottingEngine = 'plotly', @@ -2242,7 +2251,8 @@ def plot_heatmap( 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. + colors: Color scheme for the heatmap (default: None uses CONFIG.Plotting.default_continuous_colorscale). + 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'. @@ -2301,7 +2311,7 @@ def plot_heatmap( # Handle deprecated color_map parameter if color_map is not None: # Check for conflict with new parameter - if colors != 'viridis': # User explicitly set colors + if colors is not None: # User explicitly set colors raise ValueError( "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." ) @@ -2384,8 +2394,12 @@ def plot_heatmap( timeframes, timesteps_per_frame = reshape_time title += f' ({timeframes} vs {timesteps_per_frame})' + # Apply CONFIG default if colors is None + if colors is None: + colors = CONFIG.Plotting.default_continuous_colorscale + # Extract dpi before passing to plotting functions - dpi = plot_kwargs.pop('dpi', 300) + dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi # Plot with appropriate engine if engine == 'plotly': From 46ed8cae041cbe55a2b5c634b4c5bb27d9dc864d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:57:59 +0200 Subject: [PATCH 110/173] Rename config parameter --- flixopt/aggregation.py | 17 +++++++++--- flixopt/config.py | 60 +++++++++++++++++++++--------------------- flixopt/results.py | 24 ++++++++--------- 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index cd22c2ad7..18cac8013 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -21,6 +21,7 @@ TSAM_AVAILABLE = False from .components import Storage +from .config import CONFIG from .structure import ( FlowSystemModel, Submodel, @@ -141,7 +142,7 @@ def describe_clusters(self) -> str: def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks - def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path | None = None) -> go.Figure: + def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Path | None = None) -> go.Figure: from . import plotting df_org = self.original_data.copy().rename( @@ -150,10 +151,20 @@ def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path df_agg = self.aggregated_data.copy().rename( columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns} ) - fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap, xlabel='Time in h') + fig = plotting.with_plotly( + df_org.to_xarray(), + 'line', + colors=colormap or CONFIG.Plotting.default_qualitative_colorscale, + xlabel='Time in h', + ) for trace in fig.data: trace.update(dict(line=dict(dash='dash'))) - fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap, xlabel='Time in h') + fig2 = plotting.with_plotly( + df_agg.to_xarray(), + 'line', + colors=colormap or CONFIG.Plotting.default_qualitative_colorscale, + xlabel='Time in h', + ) for trace in fig2.data: fig.add_trace(trace) diff --git a/flixopt/config.py b/flixopt/config.py index 6d85fe5c1..fce0cbbf7 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -66,8 +66,8 @@ 'default_figure_width': None, 'default_figure_height': None, 'default_facet_cols': 3, - 'default_continuous_colorscale': 'viridis', - 'default_categorical_colormap': 'auto', + 'default_sequential_colorscale': 'viridis', + 'default_qualitative_colorscale': 'dark24', } ), } @@ -77,28 +77,28 @@ class CONFIG: """Configuration for flixopt library. - Always call ``CONFIG.apply()`` after changes. + Always call ``CONFIG.apply()`` after changes. + c + Attributes: + Logging: Logging configuration. + Modeling: Optimization modeling parameters. + config_name: Configuration name. + + Examples: + ```python + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + ``` - Attributes: - Logging: Logging configuration. - Modeling: Optimization modeling parameters. - config_name: Configuration name. + Load from YAML file: - Examples: - ```python - CONFIG.Logging.console = True - CONFIG.Logging.level = 'DEBUG' - CONFIG.apply() - ``` - - Load from YAML file: - - ```yaml - logging: - level: DEBUG - console: true - file: app.log - ``` + ```yaml + logging: + level: DEBUG + console: true + file: app.log + ``` """ class Logging: @@ -215,8 +215,8 @@ class Plotting: default_figure_width: Default plot width in pixels. default_figure_height: Default plot height in pixels. default_facet_cols: Default number of columns for faceted plots. - default_continuous_colorscale: Default colorscale for heatmaps and continuous data. - default_categorical_colormap: Default colormap for categorical plots (bar/line/area charts). + default_sequential_colorscale: Default colorscale for heatmaps and continuous data. + default_qualitative_colorscale: Default colormap for categorical plots (bar/line/area charts). Examples: ```python @@ -230,8 +230,8 @@ class Plotting: CONFIG.Plotting.default_dpi = 600 CONFIG.Plotting.default_figure_width = 1200 CONFIG.Plotting.default_figure_height = 800 - CONFIG.Plotting.default_continuous_colorscale = 'plasma' - CONFIG.Plotting.default_categorical_colormap = 'Dark24' + CONFIG.Plotting.default_sequential_colorscale = 'plasma' + CONFIG.Plotting.default_qualitative_colorscale = 'Dark24' CONFIG.apply() ``` """ @@ -278,8 +278,8 @@ class Plotting: default_figure_width: int | None = _DEFAULTS['plotting']['default_figure_width'] default_figure_height: int | None = _DEFAULTS['plotting']['default_figure_height'] default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols'] - default_continuous_colorscale: str = _DEFAULTS['plotting']['default_continuous_colorscale'] - default_categorical_colormap: str | Literal['auto'] = _DEFAULTS['plotting']['default_categorical_colormap'] + default_sequential_colorscale: str = _DEFAULTS['plotting']['default_sequential_colorscale'] + default_qualitative_colorscale: str | Literal['auto'] = _DEFAULTS['plotting']['default_qualitative_colorscale'] config_name: str = _DEFAULTS['config_name'] @@ -439,8 +439,8 @@ def to_dict(cls) -> dict: 'default_figure_width': cls.Plotting.default_figure_width, 'default_figure_height': cls.Plotting.default_figure_height, 'default_facet_cols': cls.Plotting.default_facet_cols, - 'default_continuous_colorscale': cls.Plotting.default_continuous_colorscale, - 'default_categorical_colormap': cls.Plotting.default_categorical_colormap, + 'default_sequential_colorscale': cls.Plotting.default_sequential_colorscale, + 'default_qualitative_colorscale': cls.Plotting.default_qualitative_colorscale, }, } diff --git a/flixopt/results.py b/flixopt/results.py index fe0bc3365..9b5834f9f 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -786,7 +786,7 @@ def plot_heatmap( 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 (default: None uses CONFIG.Plotting.default_continuous_colorscale). + colors: Color scheme for the heatmap (default: None uses CONFIG.Plotting.default_sequential_colorscale). 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. @@ -1080,8 +1080,8 @@ def plot_node_balance( 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. Options: - - 'auto' (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_categorical_colormap - - None: Uses CONFIG.Plotting.default_categorical_colormap + - 'auto' (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale + - None: Uses CONFIG.Plotting.default_qualitative_colorscale - Colormap name string (e.g., 'viridis', 'plasma') - List of color strings - Dict mapping variable names to colors @@ -1206,13 +1206,13 @@ def plot_node_balance( # Apply CONFIG default if colors is None if colors is None: - colors = CONFIG.Plotting.default_categorical_colormap + colors = CONFIG.Plotting.default_qualitative_colorscale # Resolve colors to a dict (handles auto, mapper, etc.) colors_to_use = ( self._calculation_results.color_manager if colors == 'auto' and self._calculation_results.color_manager is not None - else CONFIG.Plotting.default_categorical_colormap + else CONFIG.Plotting.default_qualitative_colorscale if colors == 'auto' else colors ) @@ -1290,7 +1290,7 @@ def plot_node_balance_pie( Args: lower_percentage_group: Percentage threshold for "Others" grouping. colors: Color scheme (default: 'auto' uses color_manager if configured, - else falls back to CONFIG.Plotting.default_categorical_colormap). + else falls back to CONFIG.Plotting.default_qualitative_colorscale). text_info: Information to display on pie slices. save: Whether to save plot. show: Whether to display plot. @@ -1406,7 +1406,7 @@ def plot_node_balance_pie( colors_to_use = ( self._calculation_results.color_manager if colors == 'auto' and self._calculation_results.color_manager is not None - else CONFIG.Plotting.default_categorical_colormap + else CONFIG.Plotting.default_qualitative_colorscale if colors == 'auto' else colors ) @@ -1563,7 +1563,7 @@ def plot_charge_state( 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 (default: 'auto' uses color_manager if configured, - else falls back to CONFIG.Plotting.default_categorical_colormap). + else falls back to CONFIG.Plotting.default_qualitative_colorscale). 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. @@ -1662,7 +1662,7 @@ def plot_charge_state( colors_to_use = ( self._calculation_results.color_manager if colors == 'auto' and self._calculation_results.color_manager is not None - else CONFIG.Plotting.default_categorical_colormap + else CONFIG.Plotting.default_qualitative_colorscale if colors == 'auto' else colors ) @@ -2111,7 +2111,7 @@ def plot_heatmap( - '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 (default: None uses CONFIG.Plotting.default_continuous_colorscale). + colors: Color scheme (default: None uses CONFIG.Plotting.default_sequential_colorscale). See plotting.ColorType for options. save: Whether to save plot. show: Whether to display plot. @@ -2251,7 +2251,7 @@ def plot_heatmap( 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 (default: None uses CONFIG.Plotting.default_continuous_colorscale). + colors: Color scheme for the heatmap (default: None uses CONFIG.Plotting.default_sequential_colorscale). 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. @@ -2396,7 +2396,7 @@ def plot_heatmap( # Apply CONFIG default if colors is None if colors is None: - colors = CONFIG.Plotting.default_continuous_colorscale + colors = CONFIG.Plotting.default_sequential_colorscale # Extract dpi before passing to plotting functions dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi From 0211ef90274c49c6565b688977a636d0fa153312 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:06:35 +0200 Subject: [PATCH 111/173] Improve color defaults --- flixopt/plotting.py | 54 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 7798921bc..07f04d6b8 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -186,12 +186,14 @@ class ColorProcessor: """ - def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'): + def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str | None = None): """Initialize the color processor with specified backend and defaults.""" if engine not in ['plotly', 'matplotlib']: raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}') self.engine = engine - self.default_colormap = default_colormap + self.default_colormap = ( + default_colormap if default_colormap is not None else CONFIG.Plotting.default_qualitative_colorscale + ) def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list[Any]: """ @@ -851,7 +853,7 @@ def resolve_colors( def with_plotly( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', - colors: ColorType | ComponentColorManager = 'viridis', + colors: ColorType | ComponentColorManager | None = None, title: str = '', ylabel: str = '', xlabel: str = '', @@ -939,6 +941,9 @@ def with_plotly( fig = with_plotly(dataset, colors=manager, mode='area') ``` """ + if colors is None: + colors = CONFIG.Plotting.default_qualitative_colorscale + 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}") @@ -1183,7 +1188,7 @@ def with_plotly( def with_matplotlib( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', - colors: ColorType | ComponentColorManager = 'viridis', + colors: ColorType | ComponentColorManager | None = None, title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -1228,6 +1233,9 @@ def with_matplotlib( fig, ax = with_matplotlib(dataset, colors=manager, mode='line') ``` """ + if colors is None: + colors = CONFIG.Plotting.default_qualitative_colorscale + if mode not in ('stacked_bar', 'line'): raise ValueError(f"'mode' must be one of {{'stacked_bar','line'}} for matplotlib, got {mode!r}") @@ -1599,7 +1607,7 @@ def plot_network( def pie_with_plotly( data: xr.Dataset | pd.DataFrame, - colors: ColorType | ComponentColorManager = 'viridis', + colors: ColorType | ComponentColorManager | None = None, title: str = '', legend_title: str = '', hole: float = 0.0, @@ -1653,6 +1661,9 @@ def pie_with_plotly( fig = pie_with_plotly(dataset, colors=manager, title='Renewable Energy') ``` """ + if colors is None: + colors = CONFIG.Plotting.default_qualitative_colorscale + # Ensure data is a Dataset and validate it data = _ensure_dataset(data) _validate_plotting_data(data, allow_empty=True) @@ -1718,7 +1729,7 @@ def pie_with_plotly( def pie_with_matplotlib( data: xr.Dataset | pd.DataFrame, - colors: ColorType | ComponentColorManager = 'viridis', + colors: ColorType | ComponentColorManager | None = None, title: str = '', legend_title: str = 'Categories', hole: float = 0.0, @@ -1765,6 +1776,9 @@ def pie_with_matplotlib( fig, ax = pie_with_matplotlib(dataset, colors=manager, title='Renewable Energy') ``` """ + if colors is None: + colors = CONFIG.Plotting.default_qualitative_colorscale + # Ensure data is a Dataset and validate it data = _ensure_dataset(data) _validate_plotting_data(data, allow_empty=True) @@ -1851,7 +1865,7 @@ def pie_with_matplotlib( def dual_pie_with_plotly( data_left: xr.Dataset | pd.DataFrame, data_right: xr.Dataset | pd.DataFrame, - colors: ColorType | ComponentColorManager = 'viridis', + colors: ColorType | ComponentColorManager | None = None, title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1885,6 +1899,9 @@ def dual_pie_with_plotly( Returns: A Plotly figure object containing the generated dual pie chart. """ + if colors is None: + colors = CONFIG.Plotting.default_qualitative_colorscale + from plotly.subplots import make_subplots # Ensure data is a Dataset and validate it @@ -1992,7 +2009,7 @@ def create_pie_trace(labels, values, side): def dual_pie_with_matplotlib( data_left: pd.Series, data_right: pd.Series, - colors: ColorType = 'viridis', + colors: ColorType | None = None, title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -2020,6 +2037,9 @@ def dual_pie_with_matplotlib( Returns: A tuple containing the Matplotlib figure and list of axes objects used for the plot. """ + if colors is None: + colors = CONFIG.Plotting.default_qualitative_colorscale + # Create figure and axes fig, axes = plt.subplots(1, 2, figsize=figsize) @@ -2175,7 +2195,7 @@ def draw_pie_on_axis(ax, data_series, colors_list, subtitle, hole_size): def heatmap_with_plotly( data: xr.DataArray, - colors: ColorType = 'viridis', + colors: ColorType | None = None, title: str = '', facet_by: str | list[str] | None = None, animate_by: str | None = None, @@ -2259,6 +2279,9 @@ def heatmap_with_plotly( fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period', reshape_time=('W', 'D')) ``` """ + if colors is None: + colors = CONFIG.Plotting.default_sequential_colorscale + # Apply CONFIG defaults if not explicitly set if facet_cols is None: facet_cols = CONFIG.Plotting.default_facet_cols @@ -2356,7 +2379,7 @@ def heatmap_with_plotly( # 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', + 'color_continuous_scale': colors if isinstance(colors, str) else CONFIG.Plotting.default_sequential_colorscale, 'title': title, } @@ -2380,7 +2403,9 @@ def heatmap_with_plotly( # Fallback: create a simple heatmap without faceting fallback_args = { 'img': data.values, - 'color_continuous_scale': colors if isinstance(colors, str) else 'viridis', + 'color_continuous_scale': colors + if isinstance(colors, str) + else CONFIG.Plotting.default_sequential_colorscale, 'title': title, } fallback_args.update(imshow_kwargs) @@ -2398,7 +2423,7 @@ def heatmap_with_plotly( def heatmap_with_matplotlib( data: xr.DataArray, - colors: ColorType = 'viridis', + colors: ColorType | None = None, title: str = '', figsize: tuple[float, float] = (12, 6), reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] @@ -2461,6 +2486,9 @@ def heatmap_with_matplotlib( fig, ax = heatmap_with_matplotlib(data_array, reshape_time=('D', 'h')) ``` """ + if colors is None: + colors = CONFIG.Plotting.default_sequential_colorscale + # Initialize kwargs if not provided if imshow_kwargs is None: imshow_kwargs = {} @@ -2513,7 +2541,7 @@ def heatmap_with_matplotlib( y_labels = 'y' # Process colormap - cmap = colors if isinstance(colors, str) else 'viridis' + cmap = colors if isinstance(colors, str) else CONFIG.Plotting.default_sequential_colorscale # Create the heatmap using imshow with user customizations imshow_defaults = {'cmap': cmap, 'aspect': 'auto', 'origin': 'upper', 'vmin': vmin, 'vmax': vmax} From 1e721717e9aea0b9a9b989bcf65f3d5caa381f14 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:23:38 +0200 Subject: [PATCH 112/173] Removed 'auto' and Simplified Color Resolution --- flixopt/config.py | 2 +- flixopt/results.py | 35 ++++++++++++++++------------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index fce0cbbf7..cfb240ff8 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -279,7 +279,7 @@ class Plotting: default_figure_height: int | None = _DEFAULTS['plotting']['default_figure_height'] default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols'] default_sequential_colorscale: str = _DEFAULTS['plotting']['default_sequential_colorscale'] - default_qualitative_colorscale: str | Literal['auto'] = _DEFAULTS['plotting']['default_qualitative_colorscale'] + default_qualitative_colorscale: str = _DEFAULTS['plotting']['default_qualitative_colorscale'] config_name: str = _DEFAULTS['config_name'] diff --git a/flixopt/results.py b/flixopt/results.py index 9b5834f9f..36f0a386c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1060,7 +1060,7 @@ def plot_node_balance( self, save: bool | pathlib.Path = False, show: bool | None = None, - colors: plotting.ColorType | Literal['auto'] | None = 'auto', + colors: plotting.ColorType | None = None, engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', @@ -1080,8 +1080,7 @@ def plot_node_balance( 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. Options: - - 'auto' (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale - - None: Uses CONFIG.Plotting.default_qualitative_colorscale + - None (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale - Colormap name string (e.g., 'viridis', 'plasma') - List of color strings - Dict mapping variable names to colors @@ -1204,16 +1203,12 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - # Apply CONFIG default if colors is None - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - - # Resolve colors to a dict (handles auto, mapper, etc.) + # Resolve colors: None -> color_manager if set -> CONFIG default -> explicit value colors_to_use = ( self._calculation_results.color_manager - if colors == 'auto' and self._calculation_results.color_manager is not None + if colors is None and self._calculation_results.color_manager is not None else CONFIG.Plotting.default_qualitative_colorscale - if colors == 'auto' + if colors is None else colors ) resolved_colors = plotting.resolve_colors(ds, colors_to_use, engine=engine) @@ -1268,7 +1263,7 @@ def plot_node_balance( def plot_node_balance_pie( self, lower_percentage_group: float = 5, - colors: plotting.ColorType | Literal['auto'] | None = 'auto', + colors: plotting.ColorType | None = None, text_info: str = 'percent+label+value', save: bool | pathlib.Path = False, show: bool | None = None, @@ -1289,7 +1284,7 @@ def plot_node_balance_pie( Args: lower_percentage_group: Percentage threshold for "Others" grouping. - colors: Color scheme (default: 'auto' uses color_manager if configured, + colors: Color scheme (default: None uses color_manager if configured, else falls back to CONFIG.Plotting.default_qualitative_colorscale). text_info: Information to display on pie slices. save: Whether to save plot. @@ -1403,11 +1398,13 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) + + # Resolve colors: None -> color_manager if set -> CONFIG default -> explicit value colors_to_use = ( self._calculation_results.color_manager - if colors == 'auto' and self._calculation_results.color_manager is not None + if colors is None and self._calculation_results.color_manager is not None else CONFIG.Plotting.default_qualitative_colorscale - if colors == 'auto' + if colors is None else colors ) resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) @@ -1546,7 +1543,7 @@ def plot_charge_state( self, save: bool | pathlib.Path = False, show: bool | None = None, - colors: plotting.ColorType | Literal['auto'] | None = 'auto', + colors: plotting.ColorType | None = None, engine: plotting.PlottingEngine = 'plotly', mode: Literal['area', 'stacked_bar', 'line'] = 'area', select: dict[FlowSystemDimensions, Any] | None = None, @@ -1562,7 +1559,7 @@ def plot_charge_state( 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: Color scheme (default: 'auto' uses color_manager if configured, + colors: Color scheme (default: None uses color_manager if configured, else falls back to CONFIG.Plotting.default_qualitative_colorscale). 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. @@ -1658,12 +1655,12 @@ def plot_charge_state( # We need to include both in the color map for consistency combined_ds = ds.assign({self._charge_state: charge_state_da}) - # Resolve colors to a dict (handles auto, mapper, etc.) + # Resolve colors: None -> color_manager if set -> CONFIG default -> explicit value colors_to_use = ( self._calculation_results.color_manager - if colors == 'auto' and self._calculation_results.color_manager is not None + if colors is None and self._calculation_results.color_manager is not None else CONFIG.Plotting.default_qualitative_colorscale - if colors == 'auto' + if colors is None else colors ) resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) From c0c792fcfdd38a332f9a217b9265525ad27c07d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:36:38 +0200 Subject: [PATCH 113/173] Fix ColorProcessor to accept qualitative colorscales --- flixopt/plotting.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 07f04d6b8..af8830f4c 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -207,10 +207,22 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list of colors in the format appropriate for the engine """ if self.engine == 'plotly': + # First try qualitative color sequences (Dark24, Plotly, Set1, etc.) + if hasattr(px.colors.qualitative, colormap_name): + color_list = getattr(px.colors.qualitative, colormap_name) + # Cycle through colors if we need more than available + return [color_list[i % len(color_list)] for i in range(num_colors)] + + # Then try sequential/continuous colorscales (viridis, plasma, etc.) try: colorscale = px.colors.get_colorscale(colormap_name) except PlotlyError as e: logger.error(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}") + # Try default as qualitative first + if hasattr(px.colors.qualitative, self.default_colormap): + color_list = getattr(px.colors.qualitative, self.default_colormap) + return [color_list[i % len(color_list)] for i in range(num_colors)] + # Otherwise use default as sequential colorscale = px.colors.get_colorscale(self.default_colormap) # Generate evenly spaced points From 66121570e7534af1ccbb5bfbddf4124e13625f71 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:37:56 +0200 Subject: [PATCH 114/173] Fix ColorProcessor to accept qualitative colorscales --- flixopt/plotting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index af8830f4c..0317d75c6 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -208,6 +208,7 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> """ if self.engine == 'plotly': # First try qualitative color sequences (Dark24, Plotly, Set1, etc.) + colormap_name = colormap_name.title() if hasattr(px.colors.qualitative, colormap_name): color_list = getattr(px.colors.qualitative, colormap_name) # Cycle through colors if we need more than available From 6bcee9c6181610d5c5d115ae7a49007579d3405b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:46:58 +0200 Subject: [PATCH 115/173] Remove old test --- examples/00_Minmal/minimal_example.py | 1 + tests/test_color_mapper.py | 464 -------------------------- 2 files changed, 1 insertion(+), 464 deletions(-) delete mode 100644 tests/test_color_mapper.py diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 81b7c2dba..aab2797be 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -11,6 +11,7 @@ if __name__ == '__main__': # Enable console logging fx.CONFIG.Logging.console = True + fx.CONFIG.Plotting.default_show = True fx.CONFIG.apply() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') diff --git a/tests/test_color_mapper.py b/tests/test_color_mapper.py deleted file mode 100644 index 803a5e16d..000000000 --- a/tests/test_color_mapper.py +++ /dev/null @@ -1,464 +0,0 @@ -"""Tests for XarrayColorMapper functionality.""" - -import numpy as np -import pytest -import xarray as xr - -from flixopt.plotting import XarrayColorMapper - - -class TestBasicFunctionality: - """Test basic XarrayColorMapper functionality.""" - - def test_initialization_default(self): - """Test default initialization.""" - mapper = XarrayColorMapper() - assert len(mapper.get_families()) == 14 # Default families - assert 'blues' in mapper.get_families() - assert mapper.sort_within_groups is True - - def test_initialization_custom_families(self): - """Test initialization with custom color families.""" - custom_families = { - 'custom1': ['#FF0000', '#00FF00', '#0000FF'], - 'custom2': ['#FFFF00', '#FF00FF', '#00FFFF'], - } - mapper = XarrayColorMapper(color_families=custom_families, sort_within_groups=False) - assert len(mapper.get_families()) == 2 - assert 'custom1' in mapper.get_families() - assert mapper.sort_within_groups is False - - def test_add_custom_family(self): - """Test adding a custom color family.""" - mapper = XarrayColorMapper() - mapper.add_custom_family('ocean', ['#003f5c', '#2f4b7c', '#665191']) - assert 'ocean' in mapper.get_families() - assert len(mapper.get_families()['ocean']) == 3 - - -class TestPatternMatching: - """Test pattern matching functionality.""" - - def test_prefix_matching(self): - """Test prefix pattern matching.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('Product_B', 'greens', 'prefix') - - categories = ['Product_A1', 'Product_A2', 'Product_B1', 'Product_B2', 'Other'] - groups = mapper._group_categories(categories) - - assert 'blues' in groups - assert 'greens' in groups - assert '_unmatched' in groups - assert 'Product_A1' in groups['blues'] - assert 'Product_B1' in groups['greens'] - assert 'Other' in groups['_unmatched'] - - def test_suffix_matching(self): - """Test suffix pattern matching.""" - mapper = XarrayColorMapper() - mapper.add_rule('_test', 'blues', 'suffix') - mapper.add_rule('_prod', 'greens', 'suffix') - - categories = ['system_test', 'system_prod', 'development'] - groups = mapper._group_categories(categories) - - assert 'system_test' in groups['blues'] - assert 'system_prod' in groups['greens'] - assert 'development' in groups['_unmatched'] - - def test_contains_matching(self): - """Test contains pattern matching.""" - mapper = XarrayColorMapper() - mapper.add_rule('renewable', 'greens', 'contains') - mapper.add_rule('fossil', 'reds', 'contains') - - categories = ['renewable_wind', 'fossil_gas', 'renewable_solar', 'battery'] - groups = mapper._group_categories(categories) - - assert 'renewable_wind' in groups['greens'] - assert 'fossil_gas' in groups['reds'] - assert 'battery' in groups['_unmatched'] - - def test_glob_matching(self): - """Test glob pattern matching.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product_A*', 'blues', 'glob') - mapper.add_rule('*_test', 'greens', 'glob') - - categories = ['Product_A1', 'Product_A2', 'system_test', 'Other'] - groups = mapper._group_categories(categories) - - assert 'Product_A1' in groups['blues'] - assert 'system_test' in groups['greens'] - assert 'Other' in groups['_unmatched'] - - def test_regex_matching(self): - """Test regex pattern matching.""" - mapper = XarrayColorMapper() - mapper.add_rule(r'^exp_[AB]\d+$', 'blues', 'regex') - - categories = ['exp_A1', 'exp_B2', 'exp_C1', 'test'] - groups = mapper._group_categories(categories) - - assert 'exp_A1' in groups['blues'] - assert 'exp_B2' in groups['blues'] - assert 'exp_C1' in groups['_unmatched'] - - def test_invalid_regex(self): - """Test that invalid regex raises error.""" - mapper = XarrayColorMapper() - mapper.add_rule('[invalid', 'blues', 'regex') - - with pytest.raises(ValueError, match='Invalid regex pattern'): - mapper._match_rule('test', mapper.rules[0]) - - -class TestColorMapping: - """Test color mapping creation.""" - - def test_create_color_map_with_list(self): - """Test creating color map from a list.""" - mapper = XarrayColorMapper() - mapper.add_rule('A', 'blues', 'prefix') - mapper.add_rule('B', 'greens', 'prefix') - - categories = ['A1', 'A2', 'B1', 'B2'] - color_map = mapper.create_color_map(categories) - - assert len(color_map) == 4 - assert all(key in color_map for key in categories) - # A items should have blue colors, B items should have green colors - # (We can't assert exact colors as they come from plotly, but we can check they exist) - - def test_create_color_map_with_numpy_array(self): - """Test creating color map from numpy array.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product', 'blues', 'prefix') - - categories = np.array(['Product_A', 'Product_B', 'Product_C']) - color_map = mapper.create_color_map(categories) - - assert len(color_map) == 3 - assert 'Product_A' in color_map - - def test_create_color_map_with_xarray(self): - """Test creating color map from xarray DataArray.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product', 'blues', 'prefix') - - da = xr.DataArray([1, 2, 3], coords={'product': ['Product_A', 'Product_B', 'Product_C']}, dims=['product']) - color_map = mapper.create_color_map(da.coords['product']) - - assert len(color_map) == 3 - assert 'Product_A' in color_map - - def test_sorting_within_groups(self): - """Test that sorting within groups works correctly.""" - mapper = XarrayColorMapper(sort_within_groups=True) - mapper.add_rule('Product', 'blues', 'prefix') - - categories = ['Product_C', 'Product_A', 'Product_B'] - color_map = mapper.create_color_map(categories) - - # With sorting, the order should be alphabetical - keys = list(color_map.keys()) - assert keys == ['Product_A', 'Product_B', 'Product_C'] - - def test_no_sorting_within_groups(self): - """Test that disabling sorting preserves order.""" - mapper = XarrayColorMapper(sort_within_groups=False) - mapper.add_rule('Product', 'blues', 'prefix') - - categories = ['Product_C', 'Product_A', 'Product_B'] - color_map = mapper.create_color_map(categories, sort_within_groups=False) - - # Without sorting, order should match rules order, then input order within group - keys = list(color_map.keys()) - assert keys == ['Product_C', 'Product_A', 'Product_B'] - - -class TestOverrides: - """Test override functionality.""" - - def test_override_simple(self): - """Test simple override.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product', 'blues', 'prefix') - mapper.add_override({'Product_A': '#FF0000'}) - - categories = ['Product_A', 'Product_B'] - color_map = mapper.create_color_map(categories) - - assert color_map['Product_A'] == '#FF0000' - assert color_map['Product_B'] != '#FF0000' # Should use blues - - def test_override_multiple(self): - """Test multiple overrides.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product', 'blues', 'prefix') - mapper.add_override({'Product_A': '#FF0000', 'Product_B': '#00FF00'}) - - categories = ['Product_A', 'Product_B', 'Product_C'] - color_map = mapper.create_color_map(categories) - - assert color_map['Product_A'] == '#FF0000' - assert color_map['Product_B'] == '#00FF00' - # Product_C should use the rule - - def test_override_precedence(self): - """Test that overrides take precedence over rules.""" - mapper = XarrayColorMapper() - mapper.add_rule('Special', 'blues', 'prefix') - mapper.add_override({'Special_Case': '#FFD700'}) - - categories = ['Special_Case', 'Special_Normal'] - color_map = mapper.create_color_map(categories) - - # Override should take precedence - assert color_map['Special_Case'] == '#FFD700' - - -class TestXarrayIntegration: - """Test integration with xarray DataArrays.""" - - def test_reorder_dataset(self): - """Test reordering Dataset variables.""" - mapper = XarrayColorMapper() - mapper.add_rule('A', 'blues', 'prefix') - mapper.add_rule('B', 'greens', 'prefix') - - ds = xr.Dataset( - { - 'B2': xr.DataArray([1, 2, 3], dims=['time']), - 'A1': xr.DataArray([4, 5, 6], dims=['time']), - 'B1': xr.DataArray([7, 8, 9], dims=['time']), - 'A2': xr.DataArray([10, 11, 12], dims=['time']), - } - ) - - ds_reordered = mapper.reorder_dataset(ds) - - # With sorting, variables are grouped by family (order of first occurrence in input), - # then sorted within each group - # B variables are encountered first, so greens group comes first - expected_order = ['B1', 'B2', 'A1', 'A2'] - assert list(ds_reordered.data_vars.keys()) == expected_order - - def test_reorder_dataset_preserves_data(self): - """Test that reordering preserves data values.""" - mapper = XarrayColorMapper() - mapper.add_rule('A', 'blues', 'prefix') - - ds = xr.Dataset( - { - 'A4': xr.DataArray([10, 20], dims=['time']), - 'A1': xr.DataArray([30, 40], dims=['time']), - 'A3': xr.DataArray([50, 60], dims=['time']), - 'A2': xr.DataArray([70, 80], dims=['time']), - } - ) - - ds_reordered = mapper.reorder_dataset(ds) - - # Check that the data is correctly preserved - assert (ds_reordered['A1'].values == np.array([30, 40])).all() - assert (ds_reordered['A2'].values == np.array([70, 80])).all() - assert (ds_reordered['A3'].values == np.array([50, 60])).all() - assert (ds_reordered['A4'].values == np.array([10, 20])).all() - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_empty_categories(self): - """Test with empty categories list.""" - mapper = XarrayColorMapper() - color_map = mapper.create_color_map([]) - assert color_map == {} - - def test_duplicate_categories(self): - """Test that duplicates are handled correctly.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product', 'blues', 'prefix') - - # Duplicates should be removed - categories = ['Product_A', 'Product_B', 'Product_A', 'Product_B'] - color_map = mapper.create_color_map(categories) - - assert len(color_map) == 2 - assert 'Product_A' in color_map - assert 'Product_B' in color_map - - def test_invalid_match_type(self): - """Test that invalid match type raises error.""" - mapper = XarrayColorMapper() - with pytest.raises(ValueError, match='match_type must be one of'): - mapper.add_rule('Product', 'blues', 'invalid_type') - - def test_first_match_wins(self): - """Test that first matching rule wins.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product', 'blues', 'prefix') - mapper.add_rule('Product_A', 'greens', 'prefix') # More specific rule added second - - categories = ['Product_A1', 'Product_B1'] - groups = mapper._group_categories(categories) - - # Both should match the first rule (Product) since it's added first - assert 'Product_A1' in groups['blues'] - assert 'Product_B1' in groups['blues'] - - def test_more_items_than_colors(self): - """Test behavior when there are more items than colors in a family.""" - mapper = XarrayColorMapper() - mapper.add_rule('Item', 'blues', 'prefix') - - # Create many items (more than the 5 colors in blues family) - categories = [f'Item_{i}' for i in range(10)] - color_map = mapper.create_color_map(categories) - - # Should cycle through colors - assert len(color_map) == 10 - # First and 6th item should have the same color (cycling) - assert color_map['Item_0'] == color_map['Item_7'] - - -class TestInspectionMethods: - """Test inspection methods.""" - - def test_get_rules(self): - """Test getting rules.""" - mapper = XarrayColorMapper() - mapper.add_rule('Product_A', 'blues', 'prefix') - mapper.add_rule('Product_B', 'greens', 'suffix') - - rules = mapper.get_rules() - assert len(rules) == 2 - assert rules[0]['pattern'] == 'Product_A' - assert rules[0]['family'] == 'blues' - assert rules[0]['match_type'] == 'prefix' - - def test_get_overrides(self): - """Test getting overrides.""" - mapper = XarrayColorMapper() - mapper.add_override({'Special': '#FFD700', 'Other': '#FF0000'}) - - overrides = mapper.get_overrides() - assert len(overrides) == 2 - assert overrides['Special'] == '#FFD700' - - def test_get_families(self): - """Test getting color families.""" - mapper = XarrayColorMapper() - families = mapper.get_families() - - assert 'blues' in families - assert 'greens' in families - assert len(families['blues']) == 7 # Blues[1:8] has 5 colors - - -class TestMethodChaining: - """Test method chaining.""" - - def test_chaining_add_rule(self): - """Test that add_rule returns self for chaining.""" - mapper = XarrayColorMapper() - result = mapper.add_rule('Product', 'blues', 'prefix') - assert result is mapper - - def test_chaining_add_override(self): - """Test that add_override returns self for chaining.""" - mapper = XarrayColorMapper() - result = mapper.add_override({'Special': '#FFD700'}) - assert result is mapper - - def test_chaining_add_custom_family(self): - """Test that add_custom_family returns self for chaining.""" - mapper = XarrayColorMapper() - result = mapper.add_custom_family('custom', ['#FF0000']) - assert result is mapper - - def test_full_chaining(self): - """Test full method chaining.""" - mapper = ( - XarrayColorMapper() - .add_custom_family('ocean', ['#003f5c', '#2f4b7c']) - .add_rule('Product_A', 'blues', 'prefix') - .add_rule('Product_B', 'greens', 'prefix') - .add_override({'Special': '#FFD700'}) - ) - - assert len(mapper.get_rules()) == 2 - assert len(mapper.get_overrides()) == 1 - assert 'ocean' in mapper.get_families() - - -class TestRealWorldScenarios: - """Test real-world usage scenarios.""" - - def test_energy_system_components(self): - """Test color mapping for energy system components.""" - mapper = ( - XarrayColorMapper() - .add_rule('Solar', 'oranges', 'prefix') - .add_rule('Wind', 'blues', 'prefix') - .add_rule('Gas', 'reds', 'prefix') - .add_rule('Battery', 'greens', 'prefix') - .add_override({'Grid_Import': '#808080'}) - ) - - components = ['Solar_PV', 'Wind_Turbine', 'Gas_Turbine', 'Battery_Storage', 'Grid_Import'] - - color_map = mapper.create_color_map(components) - - assert color_map['Grid_Import'] == '#808080' # Override - assert all(comp in color_map for comp in components) - - def test_scenario_analysis(self): - """Test color mapping for scenario analysis.""" - mapper = ( - XarrayColorMapper() - .add_rule('baseline*', 'greys', 'glob') - .add_rule('renewable_high*', 'greens', 'glob') - .add_rule('renewable_low*', 'teals', 'glob') - .add_rule('fossil*', 'reds', 'glob') - ) - - scenarios = [ - 'baseline_2030', - 'baseline_2050', - 'renewable_high_2030', - 'renewable_low_2050', - 'fossil_phase_out_2040', - ] - - color_map = mapper.create_color_map(scenarios) - assert len(color_map) == 5 - - def test_product_tiers(self): - """Test color mapping for product tiers.""" - mapper = ( - XarrayColorMapper() - .add_rule('Premium_', 'purples', 'prefix') - .add_rule('Standard_', 'blues', 'prefix') - .add_rule('Budget_', 'greens', 'prefix') - ) - - products = ['Premium_A', 'Premium_B', 'Standard_A', 'Standard_B', 'Budget_A', 'Budget_B'] - ds = xr.Dataset({p: xr.DataArray(np.random.rand(10), dims=['time']) for p in products}) - - ds_reordered = mapper.reorder_dataset(ds) - mapper.create_color_map(list(ds_reordered.data_vars.keys())) - - # Check grouping: all Premium together, then Standard, then Budget - reordered_products = list(ds_reordered.data_vars.keys()) - premium_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Premium_')] - standard_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Standard_')] - budget_indices = [i for i, p in enumerate(reordered_products) if p.startswith('Budget_')] - - # Check that groups are contiguous - assert premium_indices == list(range(min(premium_indices), max(premium_indices) + 1)) - assert standard_indices == list(range(min(standard_indices), max(standard_indices) + 1)) - assert budget_indices == list(range(min(budget_indices), max(budget_indices) + 1)) From cd650a0c487c4961cabdc2e6b7ac78c468bc0728 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:49:06 +0200 Subject: [PATCH 116/173] Simplified tests --- tests/test_component_color_manager.py | 417 -------------------------- tests/test_plotting_api.py | 116 +------ 2 files changed, 10 insertions(+), 523 deletions(-) diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index 6c10107e2..c14501be5 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -18,15 +18,6 @@ def test_initialization_default(self): assert len(manager.components) == 3 assert manager.default_colormap == 'Dark24' assert 'Solar_PV' in manager.components - assert 'Wind_Onshore' in manager.components - assert 'Coal_Plant' in manager.components - - def test_initialization_custom_colormap(self): - """Test initialization with custom default colormap.""" - components = ['Comp1', 'Comp2'] - manager = ComponentColorManager(components, default_colormap='viridis') - - assert manager.default_colormap == 'viridis' def test_sorted_components(self): """Test that components are sorted for stability.""" @@ -36,14 +27,6 @@ def test_sorted_components(self): # Components should be sorted assert manager.components == ['A_Component', 'B_Component', 'C_Component'] - def test_duplicate_components_removed(self): - """Test that duplicate components are removed.""" - components = ['Comp1', 'Comp2', 'Comp1', 'Comp3', 'Comp2'] - manager = ComponentColorManager(components) - - assert len(manager.components) == 3 - assert manager.components == ['Comp1', 'Comp2', 'Comp3'] - def test_default_color_assignment(self): """Test that components get default colors on initialization.""" components = ['Comp1', 'Comp2', 'Comp3'] @@ -79,17 +62,6 @@ def test_add_custom_family(self): assert manager.color_families['ocean'] == custom_colors assert result is manager # Check method chaining - def test_add_custom_family_replaces_existing(self): - """Test that adding a family with existing name replaces it.""" - manager = ComponentColorManager([]) - original = ['#FF0000'] - replacement = ['#00FF00', '#0000FF'] - - manager.add_custom_family('test', original) - manager.add_custom_family('test', replacement) - - assert manager.color_families['test'] == replacement - class TestGroupingRules: """Test grouping rule functionality.""" @@ -104,42 +76,6 @@ def test_add_grouping_rule_prefix(self): assert len(manager._grouping_rules) == 1 assert result is manager # Check method chaining - def test_add_grouping_rule_suffix(self): - """Test adding a suffix grouping rule.""" - components = ['PV_Solar', 'Thermal_Solar', 'Onshore_Wind'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule('_Solar', 'solar_tech', 'oranges', match_type='suffix') - - assert manager._grouping_rules[0]['match_type'] == 'suffix' - - def test_add_grouping_rule_contains(self): - """Test adding a contains grouping rule.""" - components = ['BigSolarPV', 'SmallSolarThermal', 'WindTurbine'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule('Solar', 'solar_tech', 'oranges', match_type='contains') - - assert manager._grouping_rules[0]['match_type'] == 'contains' - - def test_add_grouping_rule_glob(self): - """Test adding a glob grouping rule.""" - components = ['Solar_PV_1', 'Solar_PV_2', 'Wind_1'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule('Solar_*', 'solar_tech', 'oranges', match_type='glob') - - assert manager._grouping_rules[0]['match_type'] == 'glob' - - def test_add_grouping_rule_regex(self): - """Test adding a regex grouping rule.""" - components = ['Solar_01', 'Solar_02', 'Wind_01'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule(r'Solar_\d+', 'solar_tech', 'oranges', match_type='regex') - - assert manager._grouping_rules[0]['match_type'] == 'regex' - def test_apply_colors(self): """Test auto-grouping components based on rules.""" components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore', 'Wind_Offshore', 'Coal_Plant'] @@ -159,20 +95,6 @@ def test_apply_colors(self): # Colors should be different (from different families) assert solar_color != wind_color - def test_first_match_wins(self): - """Test that first matching rule wins.""" - components = ['Solar_Wind_Hybrid'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='contains') - manager.apply_colors() - - # Should match 'Solar' rule first (prefix match) - color = manager.get_color('Solar_Wind_Hybrid') - # Should be from oranges family (first rule) - assert 'rgb' in color.lower() - class TestColorStability: """Test color stability across different datasets.""" @@ -244,24 +166,6 @@ def test_extract_component_with_parentheses(self): assert component == 'Solar_PV' - def test_extract_component_with_pipe(self): - """Test extracting component from variable with pipe.""" - manager = ComponentColorManager([]) - - variable = 'Wind_Turbine|investment_size' - component = manager.extract_component(variable) - - assert component == 'Wind_Turbine' - - def test_extract_component_with_both(self): - """Test extracting component from variable with both separators.""" - manager = ComponentColorManager([]) - - variable = 'Gas_Plant(HeatBus)|flow_rate' - component = manager.extract_component(variable) - - assert component == 'Gas_Plant' - def test_extract_component_no_separators(self): """Test extracting component from variable without separators.""" manager = ComponentColorManager([]) @@ -304,17 +208,6 @@ def test_get_variable_colors_multiple(self): assert all(var in colors for var in variables) assert all(isinstance(color, str) for color in colors.values()) - def test_get_variable_color_unknown_component(self): - """Test getting color for variable with unknown component.""" - components = ['Solar_PV'] - manager = ComponentColorManager(components) - - variable = 'Unknown_Component(Bus)|flow_rate' - color = manager.get_variable_color(variable) - - # Should still return a color (from default colormap or fallback) - assert color is not None - class TestOverrides: """Test override functionality.""" @@ -332,17 +225,6 @@ def test_simple_override(self): color = manager.get_color('Solar_PV') assert color == '#FF0000' - def test_multiple_overrides(self): - """Test multiple overrides.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] - manager = ComponentColorManager(components) - manager.apply_colors() - - manager.override({'Solar_PV': '#FF0000', 'Wind_Onshore': '#00FF00'}) - - assert manager.get_color('Solar_PV') == '#FF0000' - assert manager.get_color('Wind_Onshore') == '#00FF00' - def test_override_precedence(self): """Test that overrides take precedence over grouping rules.""" components = ['Solar_PV'] @@ -396,309 +278,10 @@ def test_resolve_colors_with_dict(self): assert colors == color_dict - def test_resolve_colors_with_colormap_name(self): - """Test that resolve_colors still works with colormap name.""" - dataset = xr.Dataset( - {'var1': (['time'], np.random.rand(10)), 'var2': (['time'], np.random.rand(10))}, - coords={'time': np.arange(10)}, - ) - - colors = resolve_colors(dataset, 'viridis', engine='plotly') - - assert len(colors) == 2 - assert 'var1' in colors - assert 'var2' in colors - - -class TestToDictMethod: - """Test to_dict method.""" - - def test_to_dict_returns_all_colors(self): - """Test that to_dict returns colors for all components.""" - components = ['Comp1', 'Comp2', 'Comp3'] - manager = ComponentColorManager(components) - - color_dict = manager.to_dict() - - assert len(color_dict) == 3 - assert all(comp in color_dict for comp in components) - - def test_to_dict_with_grouping(self): - """Test to_dict with grouping applied.""" - components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore'] - manager = ComponentColorManager(components) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.apply_colors() - - color_dict = manager.to_dict() - - assert len(color_dict) == 3 - assert 'Solar_PV' in color_dict - assert 'Solar_Thermal' in color_dict - assert 'Wind_Onshore' in color_dict - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_empty_components_list(self): - """Test with empty components list.""" - manager = ComponentColorManager([]) - - assert manager.components == [] - assert manager.to_dict() == {} - - def test_get_color_for_missing_component(self): - """Test getting color for component not in list.""" - components = ['Comp1'] - manager = ComponentColorManager(components) - - # Should return a color (fallback behavior) - color = manager.get_color('MissingComponent') - assert color is not None - - def test_invalid_match_type(self): - """Test that invalid match type raises error.""" - manager = ComponentColorManager([]) - - with pytest.raises(ValueError, match='match_type must be one of'): - manager.add_grouping_rule('test', 'group', 'blues', match_type='invalid') - - -class TestStringRepresentation: - """Test __repr__ and __str__ methods.""" - - def test_repr_simple(self): - """Test __repr__ with simple manager.""" - components = ['Comp1', 'Comp2', 'Comp3'] - manager = ComponentColorManager(components) - - repr_str = repr(manager) - - assert 'ComponentColorManager' in repr_str - assert 'components=3' in repr_str - assert 'rules=0' in repr_str - assert 'overrides=0' in repr_str - assert "default_colormap='Dark24'" in repr_str - - def test_repr_with_rules_and_overrides(self): - """Test __repr__ with rules and overrides.""" - manager = ComponentColorManager(['Solar_PV', 'Wind_Onshore']) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') - manager.override({'Solar_PV': '#FF0000'}) - - repr_str = repr(manager) - - assert 'components=2' in repr_str - assert 'rules=2' in repr_str - assert 'overrides=1' in repr_str - - def test_str_simple(self): - """Test __str__ with simple manager.""" - components = ['Comp1', 'Comp2', 'Comp3'] - manager = ComponentColorManager(components) - - str_output = str(manager) - - assert 'ComponentColorManager' in str_output - assert 'Components: 3' in str_output - assert 'Comp1' in str_output - assert 'Comp2' in str_output - assert 'Comp3' in str_output - assert 'Grouping rules: 0' in str_output - assert 'Overrides: 0' in str_output - assert 'Default colormap: Dark24' in str_output - - def test_str_with_many_components(self): - """Test __str__ with many components (truncation).""" - components = [f'Comp{i}' for i in range(10)] - manager = ComponentColorManager(components) - - str_output = str(manager) - - assert 'Components: 10' in str_output - assert '... (5 more)' in str_output - - def test_str_with_grouping_rules(self): - """Test __str__ with grouping rules.""" - manager = ComponentColorManager(['Solar_PV', 'Wind_Onshore']) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='suffix') - - str_output = str(manager) - - assert 'Grouping rules: 2' in str_output - assert "prefix('Solar')" in str_output - assert "suffix('Wind')" in str_output - assert 'oranges' in str_output - assert 'blues' in str_output - - def test_str_with_many_rules(self): - """Test __str__ with many rules (truncation).""" - manager = ComponentColorManager([]) - for i in range(5): - manager.add_grouping_rule(f'Pattern{i}', f'group{i}', 'blues', match_type='prefix') - - str_output = str(manager) - - assert 'Grouping rules: 5' in str_output - assert '... and 2 more' in str_output - - def test_str_with_overrides(self): - """Test __str__ with overrides.""" - manager = ComponentColorManager(['Comp1', 'Comp2']) - manager.override({'Comp1': '#FF0000', 'Comp2': '#00FF00'}) - - str_output = str(manager) - - assert 'Overrides: 2' in str_output - assert 'Comp1: #FF0000' in str_output - assert 'Comp2: #00FF00' in str_output - - def test_str_with_many_overrides(self): - """Test __str__ with many overrides (truncation).""" - manager = ComponentColorManager([]) - overrides = {f'Comp{i}': f'#FF{i:04X}' for i in range(5)} - manager.override(overrides) - - str_output = str(manager) - - assert 'Overrides: 5' in str_output - assert '... and 2 more' in str_output - - def test_str_empty_manager(self): - """Test __str__ with empty manager.""" - manager = ComponentColorManager([]) - - str_output = str(manager) - - assert 'Components: 0' in str_output - assert 'Grouping rules: 0' in str_output - assert 'Overrides: 0' in str_output - - -class TestCaching: - """Test caching functionality.""" - - def test_cache_stores_variable_colors(self): - """Test that cache stores variable-to-color mappings.""" - components = ['Solar_PV', 'Wind_Onshore'] - manager = ComponentColorManager(components) - - # Cache should be empty initially - assert len(manager._variable_cache) == 0 - - # Get color for variable - variable = 'Solar_PV(Bus)|flow_rate' - color = manager.get_variable_color(variable) - - # Cache should now contain the variable - assert len(manager._variable_cache) == 1 - assert variable in manager._variable_cache - assert manager._variable_cache[variable] == color - - def test_cache_reuses_stored_colors(self): - """Test that subsequent calls use cached colors.""" - components = ['Solar_PV'] - manager = ComponentColorManager(components) - - variable = 'Solar_PV(Bus)|flow_rate' - - # First call - color1 = manager.get_variable_color(variable) - - # Second call should return same color from cache - color2 = manager.get_variable_color(variable) - - assert color1 == color2 - assert len(manager._variable_cache) == 1 - - def test_cache_cleared_on_apply_colors(self): - """Test that cache is cleared when apply_colors is called.""" - components = ['Solar_PV', 'Wind_Onshore'] - manager = ComponentColorManager(components) - - # Populate cache - manager.get_variable_color('Solar_PV(Bus)|flow_rate') - manager.get_variable_color('Wind_Onshore(Bus)|flow_rate') - assert len(manager._variable_cache) == 2 - - # Call apply_colors - manager.apply_colors() - - # Cache should be cleared - assert len(manager._variable_cache) == 0 - - def test_cache_cleared_on_override(self): - """Test that cache is cleared when override is called.""" - components = ['Solar_PV', 'Wind_Onshore'] - manager = ComponentColorManager(components) - - # Populate cache - manager.get_variable_color('Solar_PV(Bus)|flow_rate') - manager.get_variable_color('Wind_Onshore(Bus)|flow_rate') - assert len(manager._variable_cache) == 2 - - # Call override - manager.override({'Solar_PV': '#FF0000'}) - - # Cache should be cleared - assert len(manager._variable_cache) == 0 - - def test_cache_returns_updated_colors_after_override(self): - """Test that cache returns new colors after override.""" - components = ['Solar_PV'] - manager = ComponentColorManager(components) - - variable = 'Solar_PV(Bus)|flow_rate' - - # Get original color - color_before = manager.get_variable_color(variable) - - # Override color - manager.override({'Solar_PV': '#FF0000'}) - - # Get new color (cache was cleared, so this will recompute) - color_after = manager.get_variable_color(variable) - - assert color_before != color_after - assert color_after == '#FF0000' - - def test_get_variable_colors_populates_cache(self): - """Test that get_variable_colors populates cache.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] - manager = ComponentColorManager(components) - - variables = ['Solar_PV(Bus)|flow_rate', 'Wind_Onshore(Bus)|flow_rate', 'Coal_Plant(Bus)|flow_rate'] - - # Cache should be empty - assert len(manager._variable_cache) == 0 - - # Get colors for all variables - manager.get_variable_colors(variables) - - # Cache should now contain all variables - assert len(manager._variable_cache) == 3 - for var in variables: - assert var in manager._variable_cache - class TestMethodChaining: """Test method chaining.""" - def test_chaining_add_custom_family(self): - """Test that add_custom_family returns self for chaining.""" - manager = ComponentColorManager([]) - result = manager.add_custom_family('ocean', ['#003f5c']) - assert result is manager - - def test_chaining_add_grouping_rule(self): - """Test that add_grouping_rule returns self for chaining.""" - manager = ComponentColorManager([]) - result = manager.add_grouping_rule('Solar', 'solar', 'oranges') - assert result is manager - def test_full_chaining(self): """Test full method chaining.""" components = ['Solar_PV', 'Wind_Onshore', 'Gas_Plant'] diff --git a/tests/test_plotting_api.py b/tests/test_plotting_api.py index a86c8131d..f59601dca 100644 --- a/tests/test_plotting_api.py +++ b/tests/test_plotting_api.py @@ -32,129 +32,33 @@ def sample_dataframe(): return df -@pytest.mark.parametrize('engine', ['plotly', 'matplotlib']) -def test_kwargs_passthrough(sample_dataset, engine): +def test_kwargs_passthrough_plotly(sample_dataset): """Test that backend-specific kwargs are passed through correctly.""" - if engine == 'plotly': - # Test with_plotly kwargs - fig = plotting.with_plotly( - sample_dataset, - mode='line', - trace_kwargs={'line': {'width': 5}}, - layout_kwargs={'width': 1200, 'height': 600}, - ) - assert fig.layout.width == 1200 - assert fig.layout.height == 600 - - elif engine == 'matplotlib': - # Test with_matplotlib kwargs - fig, ax = plotting.with_matplotlib(sample_dataset, mode='line', plot_kwargs={'linewidth': 3, 'alpha': 0.7}) - # Verify that the plot was created (basic smoke test) - assert fig is not None - assert ax is not None + fig = plotting.with_plotly( + sample_dataset, + mode='line', + trace_kwargs={'line': {'width': 5}}, + layout_kwargs={'width': 1200, 'height': 600}, + ) + assert fig.layout.width == 1200 + assert fig.layout.height == 600 def test_dataframe_support_plotly(sample_dataframe): """Test that DataFrames are accepted by plotting functions.""" - # Should not raise an error fig = plotting.with_plotly(sample_dataframe, mode='line') assert fig is not None -def test_dataframe_support_matplotlib(sample_dataframe): - """Test that DataFrames are accepted by matplotlib plotting functions.""" - # Should not raise an error - fig, ax = plotting.with_matplotlib(sample_dataframe, mode='line') - assert fig is not None - assert ax is not None - - -def test_heatmap_vmin_vmax(): - """Test that vmin/vmax parameters work for heatmaps.""" - data = xr.DataArray(np.random.rand(10, 10), dims=['x', 'y']) - - fig, ax = plotting.heatmap_with_matplotlib(data, vmin=0.2, vmax=0.8) - assert fig is not None - assert ax is not None - - # Check that the image has the correct vmin/vmax - images = [child for child in ax.get_children() if hasattr(child, 'get_clim')] - if images: - vmin, vmax = images[0].get_clim() - assert vmin == 0.2 - assert vmax == 0.8 - - -def test_heatmap_imshow_kwargs(): - """Test that imshow_kwargs are passed to imshow.""" - data = xr.DataArray(np.random.rand(10, 10), dims=['x', 'y']) - - fig, ax = plotting.heatmap_with_matplotlib(data, imshow_kwargs={'interpolation': 'nearest', 'aspect': 'equal'}) - assert fig is not None - assert ax is not None - - -def test_pie_text_customization(): - """Test that pie chart text customization parameters work.""" - data = xr.Dataset({'var1': 10, 'var2': 20, 'var3': 30}) - - fig = plotting.pie_with_plotly( - data, text_info='percent', text_position='outside', hover_template='Custom: %{label} = %{value}' - ) - assert fig is not None - - # Check that the trace has the correct parameters - assert fig.data[0].textinfo == 'percent' - assert fig.data[0].textposition == 'outside' - assert fig.data[0].hovertemplate == 'Custom: %{label} = %{value}' - - def test_data_validation_non_numeric(): """Test that validation catches non-numeric data.""" - # Create dataset with non-numeric data data = xr.Dataset({'var1': (['time'], ['a', 'b', 'c'])}, coords={'time': [0, 1, 2]}) with pytest.raises(TypeError, match='non-numeric dtype'): plotting.with_plotly(data) -def test_data_validation_nan_handling(sample_dataset): - """Test that validation handles NaN values without raising an error.""" - # Add NaN to the dataset - data = sample_dataset.copy() - data['var1'].values[0] = np.nan - - # Should not raise an error (warning is logged but we can't easily test that) - fig = plotting.with_plotly(data) - assert fig is not None - - -def test_export_figure_dpi(sample_dataset, tmp_path): - """Test that DPI parameter works for export_figure.""" - import matplotlib.pyplot as plt - - fig, ax = plotting.with_matplotlib(sample_dataset, mode='line') - - output_path = tmp_path / 'test_plot.png' - plotting.export_figure((fig, ax), default_path=output_path, save=True, show=False, dpi=150) - - assert output_path.exists() - plt.close(fig) - - def test_ensure_dataset_invalid_type(): """Test that _ensure_dataset raises error for invalid types.""" with pytest.raises(TypeError, match='must be xr.Dataset or pd.DataFrame'): - plotting._ensure_dataset([1, 2, 3]) # List is not valid - - -def test_validate_plotting_data_empty(): - """Test that validation handles empty datasets appropriately.""" - empty_data = xr.Dataset() - - # Should raise ValueError when allow_empty=False - with pytest.raises(ValueError, match='Empty Dataset'): - plotting._validate_plotting_data(empty_data, allow_empty=False) - - # Should not raise when allow_empty=True - plotting._validate_plotting_data(empty_data, allow_empty=True) + plotting._ensure_dataset([1, 2, 3]) From 009e740ccad3e489207a935a48d70b3666134102 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:01:00 +0200 Subject: [PATCH 117/173] Update examples and CHANGELOG.md --- CHANGELOG.md | 15 +++++++-------- examples/02_Complex/complex_example.py | 8 ++++---- examples/02_Complex/complex_example_results.py | 4 ++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 956e571de..d413ca4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,15 +53,14 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). ### ✨ Added -- **Pattern-based color mapping with `XarrayColorMapper`**: New color mapping system for automatic, semantically meaningful plot colors based on component naming patterns - - `XarrayColorMapper` class provides pattern-based color assignment using prefix, suffix, contains, glob, and regex matching +- **Pattern-based color mapping with `ComponentColorManager`**: New color mapping system for automatic, semantically meaningful plot colors based on component naming patterns + - `ComponentColorManager` class provides pattern-based color assignment using prefix, suffix, contains, glob, and regex matching - **Discrete color support**: Directly assign single colors (hex, rgb, named) to patterns for consistent coloring across all matching items (e.g., all Solar components get exact same orange) - Color families from Plotly sequential palettes: 14 single-hue families (blues, greens, reds, purples, oranges, teals, greys, pinks, peach, burg, sunsetdark, mint, emrld, darkmint) - Support for custom color families and explicit color overrides for special cases - - Coordinate reordering for visual grouping in plots - - `CalculationResults.create_color_mapper()` factory method for easy setup - - `CalculationResults.color_mapper` attribute automatically applies colors to all plots when `colors='auto'` (the default) - - **`SegmentedCalculationResults.create_color_mapper()`**: ColorMapper support for segmented results, automatically propagates to all segments for consistent coloring + - `CalculationResults.create_color_manager()` factory method for easy setup + - `CalculationResults.color_manager` attribute automatically applies colors to all plots when `colors='auto'` (the default) + - **`SegmentedCalculationResults.create_color_manager()`**: ColorManager support for segmented results, automatically propagates to all segments for consistent coloring - `resolve_colors()` utility function in `plotting` module for standalone color resolution - **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 @@ -71,7 +70,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### 💥 Breaking Changes ### ♻️ Changed -- **Plotting color defaults**: All plotting methods now default to `colors='auto'`, which uses `CalculationResults.color_mapper` if configured, otherwise falls back to 'viridis'. Explicit colors (dict, string, list) still work as before +- **Plotting color defaults**: All plotting methods now default to using `CalculationResults.color_manager` if configured, otherwise falls back to defaults. Explicit colors (dict, string, list) still work as before - **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` @@ -89,7 +88,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### 📦 Dependencies ### 📝 Docs -- Updated `complex_example.py` and `complex_example_results.py` to demonstrate ColorMapper usage with discrete colors +- Updated `complex_example.py` and `complex_example_results.py` to demonstrate ColorManager usage with discrete colors ### 👷 Development - Renamed `_apply_indexer_to_data()` to `_apply_selection_to_data()` for consistency with new API diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 74ecc1439..1238dace0 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -209,7 +209,7 @@ calculation.results.create_color_manager() # Plot results with automatic color mapping - calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorMapper) - calculation.results['BHKW2'].plot_node_balance() # Uses ColorMapper - calculation.results['Speicher'].plot_charge_state() # Uses ColorMapper - calculation.results['Fernwärme'].plot_node_balance_pie() # Uses ColorMapper + calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorManager) + calculation.results['BHKW2'].plot_node_balance() # Uses ColorManager + calculation.results['Speicher'].plot_charge_state() # Uses ColorManager + calculation.results['Fernwärme'].plot_node_balance_pie() # Uses ColorManager diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index eac799213..562e140e5 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -19,13 +19,13 @@ ) from e # --- Configure Color Mapping for Consistent Plot Colors --- - # Create a color mapper to automatically assign consistent colors to components + # Create a color manager to automatically assign consistent colors to components # based on naming patterns. This ensures visual grouping in all plots. mapper = results.create_color_manager() # --- Basic overview --- results.plot_network(show=True) - # All plots below automatically use the color mapper (colors='auto' is the default) + # All plots below automatically use the color manager results['Fernwärme'].plot_node_balance() # --- Detailed Plots --- From 07fbad884e47c632ee83ab7ece1fde840fca36eb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:16:56 +0200 Subject: [PATCH 118/173] Update method name --- flixopt/plotting.py | 2 + flixopt/results.py | 123 ++++++++++++++++++++++++++------------------ 2 files changed, 76 insertions(+), 49 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 0317d75c6..3a3248c78 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -544,6 +544,8 @@ def add_grouping_rule( self._grouping_rules.append( {'pattern': pattern, 'group_name': group_name, 'colormap': colormap, 'match_type': match_type} ) + # Auto-apply colors after adding rule for immediate effect + self.apply_colors() return self def apply_colors(self) -> None: diff --git a/flixopt/results.py b/flixopt/results.py index 36f0a386c..d2c369127 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -72,7 +72,7 @@ class CalculationResults: hours_per_timestep: Duration of each timestep for proper energy calculations color_manager: Optional ComponentColorManager for automatic component-based coloring in plots. When set, all plotting methods automatically use this manager when colors='auto' - (the default). Use `create_color_manager()` to create and configure one, or assign + (the default). Use `setup_colors()` to create and configure one, or assign an existing manager directly. Set to None to disable automatic coloring. Examples: @@ -116,7 +116,7 @@ class CalculationResults: ```python # Create and configure a color manager for pattern-based coloring - manager = results.create_color_manager() + manager = results.setup_colors() manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') @@ -263,7 +263,7 @@ def __init__( self._sizes = None self._effects_per_component = None - # Color manager for intelligent plot coloring + # Color manager for intelligent plot coloring - None by default, user configures explicitly self.color_manager: plotting.ComponentColorManager | None = None def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults: @@ -332,41 +332,50 @@ def flow_system(self) -> FlowSystem: logger.level = old_level return self._flow_system - def create_color_manager(self) -> plotting.ComponentColorManager: - """Create and assign a new ComponentColorManager for this results instance. + def setup_colors(self) -> plotting.ComponentColorManager: + """Initialize and return a ColorManager for configuring plot colors. - The color manager is automatically used by all plotting methods when colors='auto' - (the default). Configure it with grouping rules to define pattern-based color families. - - You can also assign an existing manager directly via `results.color_manager = manager`. + Convenience method that creates a ComponentColorManager with all components + registered and assigns it to `self.color_manager`. Colors are automatically + applied when adding grouping rules via `add_grouping_rule()`. Returns: - The newly created ComponentColorManager with all components registered, ready to be configured. + ComponentColorManager instance ready for configuration. Examples: - Create and configure a new manager: + Simple chained configuration: + + ```python + results.setup_colors()\ + .add_grouping_rule('Solar', 'renewables', 'oranges')\ + .add_grouping_rule('Wind', 'renewables', 'blues') + results['ElectricityBus'].plot_node_balance() # Uses configured colors + ``` - >>> manager = results.create_color_manager() - >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - >>> manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - >>> manager.add_grouping_rule('Gas', 'fossil', 'reds', match_type='prefix') - >>> manager.apply_colors() - >>> results['ElectricityBus'].plot_node_balance() # Uses manager automatically + Or step-by-step: - Or assign an existing manager: + ```python + mgr = results.setup_colors() + mgr.add_grouping_rule('Solar', 'renewables', 'oranges') + mgr.add_grouping_rule('Battery', 'storage', 'greens') + ``` - >>> my_manager = plotting.ComponentColorManager(list(results.components.keys())) - >>> my_manager.add_grouping_rule('Renewable', 'renewables', 'greens', match_type='prefix') - >>> my_manager.apply_colors() - >>> results.color_manager = my_manager + Manual creation (alternative): - Override with explicit colors if needed: + ```python + results.color_manager = ComponentColorManager(list(results.components.keys())) + results.color_manager.add_grouping_rule('Storage', 'storage', 'greens') + ``` - >>> results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores manager + Disable automatic coloring: + + ```python + results.color_manager = None # Plots use default colorscales + ``` """ - component_names = list(self.components.keys()) - self.color_manager = plotting.ComponentColorManager(component_names) - self.color_manager.apply_colors() + if self.color_manager is None: + component_names = list(self.components.keys()) + self.color_manager = plotting.ComponentColorManager(component_names) return self.color_manager def filter_solution( @@ -1875,7 +1884,7 @@ class SegmentedCalculationResults: hours_per_timestep: Duration of each timestep color_manager: Optional ComponentColorManager for automatic component-based coloring in plots. When set, it is automatically propagated to all segment results, ensuring - consistent coloring across segments. Use `create_color_manager()` to create + consistent coloring across segments. Use `setup_colors()` to create and configure one, or assign an existing manager directly. Examples: @@ -1937,7 +1946,7 @@ class SegmentedCalculationResults: ```python # Create and configure a color manager - manager = results.create_color_manager() + manager = results.setup_colors() manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') @@ -2022,7 +2031,7 @@ def __init__( self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps) - # Color manager for intelligent plot coloring + # Color manager for intelligent plot coloring - None by default, user configures explicitly self.color_manager: plotting.ComponentColorManager | None = None @property @@ -2038,31 +2047,47 @@ def meta_data(self) -> dict[str, int | list[str]]: def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] - def create_color_manager(self) -> plotting.ComponentColorManager: - """Create and assign a new ComponentColorManager for this segmented results instance. + def setup_colors(self) -> plotting.ComponentColorManager: + """Initialize and return a ColorManager that propagates to all segments. - The color manager is automatically propagated to all segment results, - ensuring consistent coloring across all segments when using plotting methods. + Convenience method that creates a ComponentColorManager with all components + registered and assigns it to `self.color_manager` and all segment results. + Colors are automatically applied when adding grouping rules. Returns: - The newly created ComponentColorManager with all components registered. + ComponentColorManager instance ready for configuration. Examples: - Create and configure a manager for segmented results: - - >>> manager = segmented_results.create_color_manager() - >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - >>> manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - >>> manager.apply_colors() - >>> # The manager is now available on all segments - >>> segmented_results.segment_results[0]['ElectricityBus'].plot_node_balance() + Simple chained configuration: + + ```python + results.setup_colors()\ + .add_grouping_rule('Solar', 'renewables', 'oranges')\ + .add_grouping_rule('Wind', 'renewables', 'blues') + + # All segments use the same colors + results.segment_results[0]['ElectricityBus'].plot_node_balance() + results.segment_results[1]['ElectricityBus'].plot_node_balance() + ``` + + Manual assignment (you must propagate yourself): + + ```python + mgr = ComponentColorManager(list(results.segment_results[0].components.keys())) + mgr.add_grouping_rule('Storage', 'storage', 'greens') + results.color_manager = mgr + # Propagate to all segments + for segment in results.segment_results: + segment.color_manager = mgr + ``` """ - # Get component names from first segment (all segments should have same components) - component_names = list(self.segment_results[0].components.keys()) if self.segment_results else [] - self.color_manager = plotting.ComponentColorManager(component_names) - # Propagate to all segment results for consistent coloring - for segment in self.segment_results: - segment.color_manager = self.color_manager + if self.color_manager is None: + # Get component names from first segment (all segments should have same components) + component_names = list(self.segment_results[0].components.keys()) if self.segment_results else [] + self.color_manager = plotting.ComponentColorManager(component_names) + # Propagate to all segment results for consistent coloring + for segment in self.segment_results: + segment.color_manager = self.color_manager return self.color_manager def solution_without_overlap(self, variable_name: str) -> xr.DataArray: From d95056e8b064e21c7c5c4f4ad3fdc3db714cef11 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:18:07 +0200 Subject: [PATCH 119/173] Update examples --- examples/01_Simple/simple_example.py | 2 +- examples/02_Complex/complex_example.py | 5 ++--- examples/02_Complex/complex_example_results.py | 8 +++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 8ebe56c67..26c24b2ed 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -110,9 +110,9 @@ # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - calculation.results.create_color_manager().apply_colors() # --- Analyze Results --- + calculation.results.setup_colors() calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_charge_state() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 1238dace0..7677131cc 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -205,10 +205,9 @@ # You can analyze results directly or save them to file and reload them later. calculation.results.to_file() - # Configure color mapping for consistent plot colors - calculation.results.create_color_manager() + calculation.results.setup_colors() - # Plot results with automatic color mapping + # Plot results (colors are automatically assigned to components) calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorManager) calculation.results['BHKW2'].plot_node_balance() # Uses ColorManager calculation.results['Speicher'].plot_charge_state() # Uses ColorManager diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 562e140e5..fba8309d2 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -18,14 +18,12 @@ f'Original error: {e}' ) from e - # --- Configure Color Mapping for Consistent Plot Colors --- - # Create a color manager to automatically assign consistent colors to components - # based on naming patterns. This ensures visual grouping in all plots. - mapper = results.create_color_manager() + # --- Configure Color Mapping for Consistent Plot Colors (Optional) --- + results.setup_colors() # --- Basic overview --- results.plot_network(show=True) - # All plots below automatically use the color manager + # All plots automatically use default colors (or configured color manager if rules were added) results['Fernwärme'].plot_node_balance() # --- Detailed Plots --- From eb6b9967997225a361728ec0a61630a06e872faf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:33:44 +0200 Subject: [PATCH 120/173] extended ComponentColorManager to support flow-level color distinctio --- flixopt/plotting.py | 281 ++++++++++++++++++++++++-- tests/test_component_color_manager.py | 114 ++++++++++- 2 files changed, 378 insertions(+), 17 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 3a3248c78..e6c050e9f 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -350,6 +350,105 @@ def process_colors( MatchType = Literal['prefix', 'suffix', 'contains', 'glob', 'regex'] +def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]: + """Convert hex or rgb color to RGB (0-1 range). + + Args: + hex_color: Hex color string (e.g., '#FF0000' or 'FF0000') or 'rgb(255, 0, 0)' + + Returns: + Tuple of (r, g, b) values in range [0, 1] + """ + # Handle rgb(r, g, b) format from Plotly + if hex_color.startswith('rgb('): + rgb_values = hex_color[4:-1].split(',') + return tuple(float(v.strip()) / 255.0 for v in rgb_values) + + # Handle hex format + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) + + +def _rgb_to_hsl(r: float, g: float, b: float) -> tuple[float, float, float]: + """Convert RGB to HSL color space. + + Args: + r, g, b: RGB values in range [0, 1] + + Returns: + Tuple of (h, s, lightness) where h in [0, 360], s and lightness in [0, 1] + """ + max_c = max(r, g, b) + min_c = min(r, g, b) + lightness = (max_c + min_c) / 2.0 + + if max_c == min_c: + h = s = 0.0 # achromatic + else: + d = max_c - min_c + s = d / (2.0 - max_c - min_c) if lightness > 0.5 else d / (max_c + min_c) + + if max_c == r: + h = (g - b) / d + (6.0 if g < b else 0.0) + elif max_c == g: + h = (b - r) / d + 2.0 + else: + h = (r - g) / d + 4.0 + h /= 6.0 + + return h * 360.0, s, lightness + + +def _hsl_to_rgb(h: float, s: float, lightness: float) -> tuple[float, float, float]: + """Convert HSL to RGB color space. + + Args: + h: Hue in range [0, 360] + s: Saturation in range [0, 1] + lightness: Lightness in range [0, 1] + + Returns: + Tuple of (r, g, b) values in range [0, 1] + """ + h = h / 360.0 # Normalize to [0, 1] + + def hue_to_rgb(p, q, t): + if t < 0: + t += 1 + if t > 1: + t -= 1 + if t < 1 / 6: + return p + (q - p) * 6 * t + if t < 1 / 2: + return q + if t < 2 / 3: + return p + (q - p) * (2 / 3 - t) * 6 + return p + + if s == 0: + r = g = b = lightness # achromatic + else: + q = lightness * (1 + s) if lightness < 0.5 else lightness + s - lightness * s + p = 2 * lightness - q + r = hue_to_rgb(p, q, h + 1 / 3) + g = hue_to_rgb(p, q, h) + b = hue_to_rgb(p, q, h - 1 / 3) + + return r, g, b + + +def _rgb_to_hex(r: float, g: float, b: float) -> str: + """Convert RGB to hex color string. + + Args: + r, g, b: RGB values in range [0, 1] + + Returns: + Hex color string (e.g., '#FF0000') + """ + return f'#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}' + + class ComponentColorManager: """Manage stable colors for flow system components with pattern-based grouping. @@ -423,17 +522,40 @@ class ComponentColorManager: 'darkmint': px.colors.sequential.Darkmint[1:8], } - def __init__(self, components: list[str], default_colormap: str = 'Dark24') -> None: + def __init__( + self, + components: list[str] | None = None, + flows: dict[str, list[str]] | None = None, + enable_flow_shading: bool = False, + flow_variation_strength: float = 0.08, + default_colormap: str = 'Dark24', + ) -> None: """Initialize component color manager. Args: - components: List of all component names in the system - default_colormap: Default colormap for ungrouped components (default: 'tab10') + components: List of all component names in the system (optional if flows provided) + flows: Dict mapping component names to their flow labels (e.g., {'Boiler': ['Q_th', 'Q_fu']}) + enable_flow_shading: If True, create subtle color variations for flows of same component + flow_variation_strength: Lightness variation per flow (0.05-0.15, default: 0.08 = 8%) + default_colormap: Default colormap for ungrouped components (default: 'Dark24') """ - self.components = sorted(set(components)) # Stable sorted order, remove duplicates + # Extract components from flows dict if provided + if flows is not None: + self.flows = {comp: sorted(set(flow_list)) for comp, flow_list in flows.items()} + self.components = sorted(self.flows.keys()) + elif components is not None: + self.components = sorted(set(components)) + self.flows = {} + else: + raise ValueError('Must provide either components or flows parameter') + self.default_colormap = default_colormap self.color_families = self.DEFAULT_FAMILIES.copy() + # Flow shading settings + self.enable_flow_shading = enable_flow_shading + self.flow_variation_strength = flow_variation_strength + # Pattern-based grouping rules self._grouping_rules: list[dict[str, str]] = [] @@ -451,11 +573,12 @@ def __init__(self, components: list[str], default_colormap: str = 'Dark24') -> N def __repr__(self) -> str: """Return detailed representation of ComponentColorManager.""" + flow_info = f', flow_shading={self.enable_flow_shading}' if self.enable_flow_shading else '' return ( f'ComponentColorManager(components={len(self.components)}, ' f'rules={len(self._grouping_rules)}, ' f'overrides={len(self._overrides)}, ' - f"default_colormap='{self.default_colormap}')" + f"default_colormap='{self.default_colormap}'{flow_info})" ) def __str__(self) -> str: @@ -495,6 +618,45 @@ def __str__(self) -> str: return '\n'.join(lines) + @classmethod + def from_flow_system(cls, flow_system, enable_flow_shading: bool = False, **kwargs): + """Create ComponentColorManager from a FlowSystem. + + Automatically extracts all components and their flows from the FlowSystem. + + Args: + flow_system: FlowSystem instance to extract components and flows from + enable_flow_shading: Enable subtle color variations for flows (default: False) + **kwargs: Additional arguments passed to ComponentColorManager.__init__ + + Returns: + ComponentColorManager instance + + Examples: + ```python + # Basic usage + manager = ComponentColorManager.from_flow_system(flow_system) + + # With flow shading + manager = ComponentColorManager.from_flow_system( + flow_system, enable_flow_shading=True, flow_variation_strength=0.10 + ) + ``` + """ + from .flow_system import FlowSystem + + if not isinstance(flow_system, FlowSystem): + raise TypeError(f'Expected FlowSystem, got {type(flow_system).__name__}') + + # Extract flows from all components + flows = {} + for component_label, component in flow_system.components.items(): + flow_labels = [flow.label for flow in component.inputs + component.outputs] + if flow_labels: # Only add if component has flows + flows[component_label] = flow_labels + + return cls(flows=flows, enable_flow_shading=enable_flow_shading, **kwargs) + def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManager: """Add a custom color family. @@ -640,18 +802,49 @@ def extract_component(self, variable: str) -> str: extract_component('Storage') # Returns: 'Storage' ``` """ - # Try "Component(Bus)|type" format - if '(' in variable: - return variable.split('(')[0] - # Try "Component|type" format - elif '|' in variable: - return variable.split('|')[0] - # Just use the variable name itself - return variable + component, _ = self._extract_component_and_flow(variable) + return component + + def _extract_component_and_flow(self, variable: str) -> tuple[str, str | None]: + """Extract both component and flow name from variable name. + + Parses variable formats: + - 'Component(Flow)|attribute' → ('Component', 'Flow') + - 'Component|attribute' → ('Component', None) + - 'Component' → ('Component', None) + + Args: + variable: Variable name + + Returns: + Tuple of (component_name, flow_name or None) + + Examples: + ```python + _extract_component_and_flow('Boiler(Q_th)|flow_rate') # ('Boiler', 'Q_th') + _extract_component_and_flow('CHP(P_el)|flow_rate') # ('CHP', 'P_el') + _extract_component_and_flow('Boiler|investment') # ('Boiler', None) + ``` + """ + # Try "Component(Flow)|attribute" format + if '(' in variable and ')' in variable: + component = variable.split('(')[0] + flow = variable.split('(')[1].split(')')[0] + return component, flow + + # Try "Component|attribute" format (no flow) + if '|' in variable: + return variable.split('|')[0], None + + # Just the component name itself + return variable, None def get_variable_color(self, variable: str) -> str: """Get color for a variable (extracts component automatically). + If flow_shading is enabled, generates subtle color variations for different + flows of the same component. + Args: variable: Variable name @@ -662,9 +855,32 @@ def get_variable_color(self, variable: str) -> str: if variable in self._variable_cache: return self._variable_cache[variable] - # Compute and cache - component = self.extract_component(variable) - color = self.get_color(component) + # Extract component and flow + component, flow = self._extract_component_and_flow(variable) + + # Get base color for component + base_color = self.get_color(component) + + # Apply flow shading if enabled and flow is present + if self.enable_flow_shading and flow is not None and component in self.flows: + # Get sorted flow list for this component + component_flows = self.flows[component] + + if flow in component_flows and len(component_flows) > 1: + # Generate shades for all flows + shades = self._create_flow_shades(base_color, len(component_flows)) + + # Assign shade based on flow's position in sorted list + flow_idx = component_flows.index(flow) + color = shades[flow_idx] + else: + # Flow not in predefined list or only one flow - use base color + color = base_color + else: + # No flow shading or no flow info - use base color + color = base_color + + # Cache and return self._variable_cache[variable] = color return color @@ -748,6 +964,39 @@ def _get_colormap_colors(self, colormap_name: str) -> list[str]: logger.warning(f"Colormap '{colormap_name}' not found, using 'Dark24' instead") return px.colors.qualitative.Dark24 + def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: + """Generate subtle color variations from a single base color using HSL. + + Args: + base_color: Hex color (e.g., '#D62728') + num_flows: Number of distinct shades needed + + Returns: + List of hex colors with subtle lightness variations + """ + if num_flows == 1: + return [base_color] + + # Convert to HSL + r, g, b = _hex_to_rgb(base_color) + h, s, lightness = _rgb_to_hsl(r, g, b) + + # Create symmetric variations around base lightness + # For 3 flows with strength 0.08: [-0.08, 0, +0.08] + # For 5 flows: [-0.16, -0.08, 0, +0.08, +0.16] + center_idx = (num_flows - 1) / 2 + shades = [] + + for idx in range(num_flows): + delta_lightness = (idx - center_idx) * self.flow_variation_strength + new_lightness = np.clip(lightness + delta_lightness, 0.1, 0.9) # Stay within valid range + + # Convert back to hex + r_new, g_new, b_new = _hsl_to_rgb(h, s, new_lightness) + shades.append(_rgb_to_hex(r_new, g_new, b_new)) + + return shades + def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: """ diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index c14501be5..30ee32871 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -286,7 +286,7 @@ def test_full_chaining(self): """Test full method chaining.""" components = ['Solar_PV', 'Wind_Onshore', 'Gas_Plant'] manager = ( - ComponentColorManager(components) + ComponentColorManager(components=components) .add_custom_family('ocean', ['#003f5c', '#2f4b7c']) .add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') .add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') @@ -294,3 +294,115 @@ def test_full_chaining(self): assert 'ocean' in manager.color_families assert len(manager._grouping_rules) == 2 + + +class TestFlowShading: + """Test flow-level color distinction.""" + + def test_initialization_with_flows_dict(self): + """Test initialization with flows parameter.""" + flows = { + 'Boiler': ['Q_th', 'Q_fu'], + 'CHP': ['P_el', 'Q_th', 'Q_fu'], + } + manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + + assert len(manager.components) == 2 + assert manager.flows == {'Boiler': ['Q_fu', 'Q_th'], 'CHP': ['P_el', 'Q_fu', 'Q_th']} # Sorted + assert manager.enable_flow_shading is True + + def test_flow_extraction(self): + """Test _extract_component_and_flow method.""" + manager = ComponentColorManager(components=['Boiler']) + + # Test Component(Flow)|attribute format + comp, flow = manager._extract_component_and_flow('Boiler(Q_th)|flow_rate') + assert comp == 'Boiler' + assert flow == 'Q_th' + + # Test Component|attribute format (no flow) + comp, flow = manager._extract_component_and_flow('Boiler|investment') + assert comp == 'Boiler' + assert flow is None + + # Test plain component name + comp, flow = manager._extract_component_and_flow('Boiler') + assert comp == 'Boiler' + assert flow is None + + def test_flow_shading_disabled(self): + """Test that flow shading is disabled by default.""" + flows = {'Boiler': ['Q_th', 'Q_fu']} + manager = ComponentColorManager(flows=flows, enable_flow_shading=False) + + # Both flows should get the same color + color1 = manager.get_variable_color('Boiler(Q_th)|flow_rate') + color2 = manager.get_variable_color('Boiler(Q_fu)|flow_rate') + + assert color1 == color2 + + def test_flow_shading_enabled(self): + """Test that flow shading creates distinct colors.""" + flows = {'Boiler': ['Q_th', 'Q_fu', 'Q_el']} + manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + + # Get colors for all three flows + color_th = manager.get_variable_color('Boiler(Q_th)|flow_rate') + color_fu = manager.get_variable_color('Boiler(Q_fu)|flow_rate') + color_el = manager.get_variable_color('Boiler(Q_el)|flow_rate') + + # All colors should be different + assert color_th != color_fu + assert color_fu != color_el + assert color_th != color_el + + # All colors should be valid hex + assert color_th.startswith('#') + assert color_fu.startswith('#') + assert color_el.startswith('#') + + def test_flow_shading_stability(self): + """Test that flow shading produces stable colors.""" + flows = {'Boiler': ['Q_th', 'Q_fu']} + manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + + # Get color multiple times + color1 = manager.get_variable_color('Boiler(Q_th)|flow_rate') + color2 = manager.get_variable_color('Boiler(Q_th)|flow_rate') + color3 = manager.get_variable_color('Boiler(Q_th)|flow_rate') + + assert color1 == color2 == color3 + + def test_single_flow_no_shading(self): + """Test that single flow gets base color (no shading needed).""" + flows = {'Storage': ['Q_th_load']} + manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + + # Single flow should get base color + color = manager.get_variable_color('Storage(Q_th_load)|flow_rate') + base_color = manager.get_color('Storage') + + assert color == base_color + + def test_flow_variation_strength(self): + """Test that variation strength parameter works.""" + flows = {'Boiler': ['Q_th', 'Q_fu']} + + # Low variation + manager_low = ComponentColorManager(flows=flows, enable_flow_shading=True, flow_variation_strength=0.02) + color_low_1 = manager_low.get_variable_color('Boiler(Q_th)|flow_rate') + color_low_2 = manager_low.get_variable_color('Boiler(Q_fu)|flow_rate') + + # High variation + manager_high = ComponentColorManager(flows=flows, enable_flow_shading=True, flow_variation_strength=0.15) + color_high_1 = manager_high.get_variable_color('Boiler(Q_th)|flow_rate') + color_high_2 = manager_high.get_variable_color('Boiler(Q_fu)|flow_rate') + + # Colors should be different + assert color_low_1 != color_low_2 + assert color_high_1 != color_high_2 + + # High variation should have larger difference (this is approximate test) + # We can't easily measure color distance, so just check they're all different + assert color_low_1 != color_high_1 + assert color_low_2 != color_high_2 From a56d2071ed2f24aece2d6869117a3225e364284f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:40:54 +0200 Subject: [PATCH 121/173] Change default flow shading --- flixopt/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index e6c050e9f..75394c51f 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -527,7 +527,7 @@ def __init__( components: list[str] | None = None, flows: dict[str, list[str]] | None = None, enable_flow_shading: bool = False, - flow_variation_strength: float = 0.08, + flow_variation_strength: float = 0.04, default_colormap: str = 'Dark24', ) -> None: """Initialize component color manager. From 043e408f1222925ffe340eebc1960cef44ad39cd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 07:40:06 +0200 Subject: [PATCH 122/173] Improve Setup_colors --- flixopt/results.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index d2c369127..f299d6a18 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -332,7 +332,7 @@ def flow_system(self) -> FlowSystem: logger.level = old_level return self._flow_system - def setup_colors(self) -> plotting.ComponentColorManager: + def setup_colors(self, enable_flow_shading=False) -> plotting.ComponentColorManager: """Initialize and return a ColorManager for configuring plot colors. Convenience method that creates a ComponentColorManager with all components @@ -374,8 +374,9 @@ def setup_colors(self) -> plotting.ComponentColorManager: ``` """ if self.color_manager is None: - component_names = list(self.components.keys()) - self.color_manager = plotting.ComponentColorManager(component_names) + self.color_manager = plotting.ComponentColorManager.from_flow_system( + self.flow_system, enable_flow_shading=enable_flow_shading + ) return self.color_manager def filter_solution( @@ -2047,7 +2048,7 @@ def meta_data(self) -> dict[str, int | list[str]]: def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] - def setup_colors(self) -> plotting.ComponentColorManager: + def setup_colors(self, enable_flow_shading: bool = False) -> plotting.ComponentColorManager: """Initialize and return a ColorManager that propagates to all segments. Convenience method that creates a ComponentColorManager with all components @@ -2082,9 +2083,9 @@ def setup_colors(self) -> plotting.ComponentColorManager: ``` """ if self.color_manager is None: - # Get component names from first segment (all segments should have same components) - component_names = list(self.segment_results[0].components.keys()) if self.segment_results else [] - self.color_manager = plotting.ComponentColorManager(component_names) + self.color_manager = plotting.ComponentColorManager.from_flow_system( + self.flow_system, enable_flow_shading=enable_flow_shading + ) # Propagate to all segment results for consistent coloring for segment in self.segment_results: segment.color_manager = self.color_manager From 97c5c19b623d7f381773e4e55f90ed8ad15985a7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:38:45 +0200 Subject: [PATCH 123/173] Use external dependency for color handling --- flixopt/plotting.py | 118 +++++--------------------------------------- pyproject.toml | 1 + 2 files changed, 12 insertions(+), 107 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 75394c51f..67096d42e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -43,6 +43,7 @@ import plotly.graph_objects as go import plotly.offline import xarray as xr +from colour import Color from plotly.exceptions import PlotlyError from .config import CONFIG @@ -350,105 +351,6 @@ def process_colors( MatchType = Literal['prefix', 'suffix', 'contains', 'glob', 'regex'] -def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]: - """Convert hex or rgb color to RGB (0-1 range). - - Args: - hex_color: Hex color string (e.g., '#FF0000' or 'FF0000') or 'rgb(255, 0, 0)' - - Returns: - Tuple of (r, g, b) values in range [0, 1] - """ - # Handle rgb(r, g, b) format from Plotly - if hex_color.startswith('rgb('): - rgb_values = hex_color[4:-1].split(',') - return tuple(float(v.strip()) / 255.0 for v in rgb_values) - - # Handle hex format - hex_color = hex_color.lstrip('#') - return tuple(int(hex_color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) - - -def _rgb_to_hsl(r: float, g: float, b: float) -> tuple[float, float, float]: - """Convert RGB to HSL color space. - - Args: - r, g, b: RGB values in range [0, 1] - - Returns: - Tuple of (h, s, lightness) where h in [0, 360], s and lightness in [0, 1] - """ - max_c = max(r, g, b) - min_c = min(r, g, b) - lightness = (max_c + min_c) / 2.0 - - if max_c == min_c: - h = s = 0.0 # achromatic - else: - d = max_c - min_c - s = d / (2.0 - max_c - min_c) if lightness > 0.5 else d / (max_c + min_c) - - if max_c == r: - h = (g - b) / d + (6.0 if g < b else 0.0) - elif max_c == g: - h = (b - r) / d + 2.0 - else: - h = (r - g) / d + 4.0 - h /= 6.0 - - return h * 360.0, s, lightness - - -def _hsl_to_rgb(h: float, s: float, lightness: float) -> tuple[float, float, float]: - """Convert HSL to RGB color space. - - Args: - h: Hue in range [0, 360] - s: Saturation in range [0, 1] - lightness: Lightness in range [0, 1] - - Returns: - Tuple of (r, g, b) values in range [0, 1] - """ - h = h / 360.0 # Normalize to [0, 1] - - def hue_to_rgb(p, q, t): - if t < 0: - t += 1 - if t > 1: - t -= 1 - if t < 1 / 6: - return p + (q - p) * 6 * t - if t < 1 / 2: - return q - if t < 2 / 3: - return p + (q - p) * (2 / 3 - t) * 6 - return p - - if s == 0: - r = g = b = lightness # achromatic - else: - q = lightness * (1 + s) if lightness < 0.5 else lightness + s - lightness * s - p = 2 * lightness - q - r = hue_to_rgb(p, q, h + 1 / 3) - g = hue_to_rgb(p, q, h) - b = hue_to_rgb(p, q, h - 1 / 3) - - return r, g, b - - -def _rgb_to_hex(r: float, g: float, b: float) -> str: - """Convert RGB to hex color string. - - Args: - r, g, b: RGB values in range [0, 1] - - Returns: - Hex color string (e.g., '#FF0000') - """ - return f'#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}' - - class ComponentColorManager: """Manage stable colors for flow system components with pattern-based grouping. @@ -967,8 +869,10 @@ def _get_colormap_colors(self, colormap_name: str) -> list[str]: def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: """Generate subtle color variations from a single base color using HSL. + Uses the `colour` library for robust color manipulation. + Args: - base_color: Hex color (e.g., '#D62728') + base_color: Color string (hex like '#D62728' or rgb like 'rgb(255, 0, 0)') num_flows: Number of distinct shades needed Returns: @@ -977,9 +881,9 @@ def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: if num_flows == 1: return [base_color] - # Convert to HSL - r, g, b = _hex_to_rgb(base_color) - h, s, lightness = _rgb_to_hsl(r, g, b) + # Parse color using colour library (handles hex, rgb(), etc.) + color = Color(base_color) + h, s, lightness = color.hsl # Create symmetric variations around base lightness # For 3 flows with strength 0.08: [-0.08, 0, +0.08] @@ -989,11 +893,11 @@ def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: for idx in range(num_flows): delta_lightness = (idx - center_idx) * self.flow_variation_strength - new_lightness = np.clip(lightness + delta_lightness, 0.1, 0.9) # Stay within valid range + new_lightness = np.clip(lightness + delta_lightness, 0.1, 0.9) - # Convert back to hex - r_new, g_new, b_new = _hsl_to_rgb(h, s, new_lightness) - shades.append(_rgb_to_hex(r_new, g_new, b_new)) + # Create new color with adjusted lightness + new_color = Color(hsl=(h, s, new_lightness)) + shades.append(new_color.hex_l) return shades diff --git a/pyproject.toml b/pyproject.toml index 227eca49e..cb1683609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ # Visualization "matplotlib >= 3.5.2, < 4", "plotly >= 5.15.0, < 7", + "colour >= 0.1.5, < 0.2", # Color manipulation for flow shading # Fix for numexpr compatibility issue with numpy 1.26.4 on Python 3.10 "numexpr >= 2.8.4, < 2.14; python_version < '3.11'", # Avoid 2.14.0 on older Python ] From f20652fff43a39bdbc96e8555c92c98643007695 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:48:09 +0200 Subject: [PATCH 124/173] Make colour dependency optional --- flixopt/plotting.py | 28 ++++++++++++++++++++++++---- pyproject.toml | 7 ++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 67096d42e..ad5c6f797 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -43,11 +43,18 @@ import plotly.graph_objects as go import plotly.offline import xarray as xr -from colour import Color from plotly.exceptions import PlotlyError from .config import CONFIG +# Optional dependency for flow-level color shading +try: + from colour import Color + + HAS_COLOUR = True +except ImportError: + HAS_COLOUR = False + if TYPE_CHECKING: import pyvis @@ -454,8 +461,16 @@ def __init__( self.default_colormap = default_colormap self.color_families = self.DEFAULT_FAMILIES.copy() - # Flow shading settings - self.enable_flow_shading = enable_flow_shading + # Flow shading settings (requires optional 'colour' library) + if enable_flow_shading and not HAS_COLOUR: + logger.error( + 'Flow shading requested but optional dependency "colour" is not installed. ' + 'Install it with: pip install flixopt[flow_colors]\n' + 'Flow shading will be disabled.' + ) + self.enable_flow_shading = False + else: + self.enable_flow_shading = enable_flow_shading self.flow_variation_strength = flow_variation_strength # Pattern-based grouping rules @@ -869,7 +884,8 @@ def _get_colormap_colors(self, colormap_name: str) -> list[str]: def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: """Generate subtle color variations from a single base color using HSL. - Uses the `colour` library for robust color manipulation. + Uses the `colour` library for robust color manipulation. If `colour` is not + available, returns the base color for all flows. Args: base_color: Color string (hex like '#D62728' or rgb like 'rgb(255, 0, 0)') @@ -881,6 +897,10 @@ def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: if num_flows == 1: return [base_color] + # Fallback if colour library not available (defensive check) + if not HAS_COLOUR: + return [base_color] * num_flows + # Parse color using colour library (handles hex, rgb(), etc.) color = Color(base_color) h, s, lightness = color.hsl diff --git a/pyproject.toml b/pyproject.toml index cb1683609..f6d27f294 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,12 +47,16 @@ dependencies = [ # Visualization "matplotlib >= 3.5.2, < 4", "plotly >= 5.15.0, < 7", - "colour >= 0.1.5, < 0.2", # Color manipulation for flow shading # Fix for numexpr compatibility issue with numpy 1.26.4 on Python 3.10 "numexpr >= 2.8.4, < 2.14; python_version < '3.11'", # Avoid 2.14.0 on older Python ] [project.optional-dependencies] +# Flow-level color shading (enables subtle color variations for flows) +flow_colors = [ + "colour >= 0.1.5, < 0.2", +] + # Interactive network visualization with enhanced color picker network_viz = [ "dash >= 3.0.0, < 4", @@ -69,6 +73,7 @@ full = [ "tsam >= 2.3.1, < 3", # Time series aggregation "scipy >= 1.15.1, < 2", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0, < 13", + "colour >= 0.1.5, < 0.2", # Flow-level color shading "dash >= 3.0.0, < 4", # Visualizing FlowSystem Network as app "dash-cytoscape >= 1.0.0, < 2", # Visualizing FlowSystem Network as app "dash-daq >= 0.6.0, < 1", # Visualizing FlowSystem Network as app From 4092a735077fa527ab50a1dfb15a3a68c0d99739 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:16:09 +0200 Subject: [PATCH 125/173] streamlined the ColorManager configuration API --- flixopt/plotting.py | 188 +++++++++++++++++++++++++++++++++++++++++++- flixopt/results.py | 107 ++++++++++++++++++------- 2 files changed, 265 insertions(+), 30 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index ad5c6f797..f8d8c387b 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -587,18 +587,80 @@ def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManag self.color_families[name] = colors return self + def add_rule(self, pattern: str, colormap: str) -> ComponentColorManager: + """Add color rule with auto-detected match type (simplified API). + + Automatically detects the matching strategy from pattern syntax: + - 'Solar' → prefix matching + - 'Solar*' → glob matching + - '~Storage' → contains matching (strip ~) + - 'Solar$' → suffix matching (strip $) + - '.*Solar.*' → regex matching + + Colors are automatically applied after adding the rule. + + Args: + pattern: Pattern to match components (auto-detects match type) + colormap: Colormap name ('reds', 'blues', 'greens', etc.) + + Returns: + Self for method chaining + + Examples: + Simple patterns: + + ```python + manager.add_rule('Solar', 'oranges') # Matches Solar, Solar1, Solar2, ... + manager.add_rule('Wind*', 'blues') # Glob: Wind1, WindPark, ... + manager.add_rule('~Storage', 'greens') # Contains: Storage, BatteryStorage, ... + manager.add_rule('Gas$', 'reds') # Suffix: NaturalGas, BiogasGas + manager.add_rule('.*Battery.*', 'teals') # Regex: Any with 'Battery' + ``` + + Chained configuration: + + ```python + manager.add_rule('Solar*', 'oranges')\ + .add_rule('Wind*', 'blues')\ + .add_rule('Battery', 'greens') + ``` + + Note: + This is the simplified API. For explicit control over match type and group names, + use `add_grouping_rule()` instead. + """ + # Auto-detect match type + match_type = self._detect_match_type(pattern) + + # Clean pattern based on detected type + clean_pattern = pattern + if match_type == 'contains' and pattern.startswith('~'): + clean_pattern = pattern[1:] # Strip ~ prefix + elif match_type == 'suffix' and pattern.endswith('$'): + clean_pattern = pattern[:-1] # Strip $ suffix + + # Generate group name from pattern (for internal organization) + group_name = clean_pattern.replace('*', '').replace('?', '').replace('.', '')[:20] + + # Delegate to add_grouping_rule + return self.add_grouping_rule(clean_pattern, group_name, colormap, match_type) + def add_grouping_rule( self, pattern: str, group_name: str, colormap: str, match_type: MatchType = 'prefix' ) -> ComponentColorManager: """Add pattern rule for grouping components. + .. note:: + This is the explicit API with full control. For simpler usage, consider `add_rule()` + which auto-detects match_type and doesn't require group_name. + Components matching the pattern are assigned to the specified group, and colors are drawn from the group's colormap. Args: pattern: Pattern to match component names against group_name: Name of the group (used for organization) - colormap: Colormap name for this group ('reds', 'blues', etc.) + colormap: Colormap name for this group ('reds', 'blues', etc.') match_type: Type of pattern matching (default: 'prefix') - 'prefix': Match if component starts with pattern - 'suffix': Match if component ends with pattern @@ -610,11 +672,20 @@ def add_grouping_rule( Self for method chaining Examples: + Explicit control (this method): + ```python manager.add_grouping_rule('Boiler', 'Heat_Production', 'reds', 'prefix') manager.add_grouping_rule('CHP', 'Heat_Production', 'oranges', 'prefix') manager.add_grouping_rule('.*Storage.*', 'Storage', 'blues', 'regex') ``` + + Simpler alternative (recommended): + + ```python + manager.add_rule('Boiler', 'reds') # Auto-detects prefix + manager.add_rule('.*Storage.*', 'blues') # Auto-detects regex + ``` """ valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') if match_type not in valid_types: @@ -831,6 +902,121 @@ def _assign_default_colors(self) -> None: for idx, component in enumerate(self.components): self._component_colors[component] = colormap[idx % len(colormap)] + def _detect_match_type(self, pattern: str) -> MatchType: + """Auto-detect match type from pattern syntax. + + Detection logic: + - Contains '~' prefix → 'contains' (strip ~ from pattern) + - Ends with '$' → 'suffix' + - Contains '*' or '?' → 'glob' + - Contains regex special chars (^[]().|+\\) → 'regex' + - Otherwise → 'prefix' (default) + + Args: + pattern: Pattern string to analyze + + Returns: + Detected match type + + Examples: + >>> _detect_match_type('Solar') # 'prefix' + >>> _detect_match_type('Solar*') # 'glob' + >>> _detect_match_type('~Storage') # 'contains' + >>> _detect_match_type('.*Solar.*') # 'regex' + >>> _detect_match_type('Solar$') # 'suffix' + """ + # Check for explicit contains marker + if pattern.startswith('~'): + return 'contains' + + # Check for suffix marker (but only if not a regex pattern) + if pattern.endswith('$') and len(pattern) > 1 and not any(c in pattern for c in r'.[]()^|+\\'): + return 'suffix' + + # Check for regex special characters (before glob, since .* is regex not glob) + # Exclude * and ? which are also glob chars + regex_only_chars = r'.[]()^|+\\' + if any(char in pattern for char in regex_only_chars): + return 'regex' + + # Check for simple glob wildcards + if '*' in pattern or '?' in pattern: + return 'glob' + + # Default to prefix matching + return 'prefix' + + @staticmethod + def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str]: + """Load color configuration from YAML or JSON file. + + Args: + file_path: Path to YAML or JSON configuration file + + Returns: + Dictionary mapping patterns to colormaps + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If file format is unsupported or invalid + + Examples: + YAML file (colors.yaml): + ```yaml + Solar*: oranges + Wind*: blues + Battery: greens + ~Storage: teals + ``` + + JSON file (colors.json): + ```json + { + "Solar*": "oranges", + "Wind*": "blues", + "Battery": "greens" + } + ``` + """ + import json + + file_path = pathlib.Path(file_path) + + if not file_path.exists(): + raise FileNotFoundError(f'Color configuration file not found: {file_path}') + + # Determine file type from extension + suffix = file_path.suffix.lower() + + if suffix in ['.yaml', '.yml']: + try: + import yaml + except ImportError as e: + raise ImportError( + 'PyYAML is required to load YAML config files. Install it with: pip install pyyaml' + ) from e + + with open(file_path, encoding='utf-8') as f: + config = yaml.safe_load(f) + + elif suffix == '.json': + with open(file_path, encoding='utf-8') as f: + config = json.load(f) + + else: + raise ValueError(f'Unsupported file format: {suffix}. Supported formats: .yaml, .yml, .json') + + # Validate config structure + if not isinstance(config, dict): + raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') + + # Ensure all values are strings + for key, value in config.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError(f'Invalid config entry: {key}: {value}. Both keys and values must be strings.') + + return config + def _match_pattern(self, value: str, pattern: str, match_type: str) -> bool: """Check if value matches pattern. diff --git a/flixopt/results.py b/flixopt/results.py index f299d6a18..0a5a9b4d8 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -332,39 +332,53 @@ def flow_system(self) -> FlowSystem: logger.level = old_level return self._flow_system - def setup_colors(self, enable_flow_shading=False) -> plotting.ComponentColorManager: + def setup_colors( + self, config: dict[str, str] | str | pathlib.Path | None = None, enable_flow_shading: bool = False + ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager for configuring plot colors. Convenience method that creates a ComponentColorManager with all components - registered and assigns it to `self.color_manager`. Colors are automatically - applied when adding grouping rules via `add_grouping_rule()`. + registered and assigns it to `self.color_manager`. Optionally load configuration + from a dict or file. Colors are automatically applied when adding rules. + + Args: + config: Optional color configuration: + - dict: {pattern: colormap} mapping + - str/Path: Path to YAML or JSON file + - None: Create empty manager for manual config (default) + enable_flow_shading: Enable subtle color variations for different flows (default: False) Returns: ComponentColorManager instance ready for configuration. Examples: - Simple chained configuration: + Dict-based configuration (simplest): ```python - results.setup_colors()\ - .add_grouping_rule('Solar', 'renewables', 'oranges')\ - .add_grouping_rule('Wind', 'renewables', 'blues') - results['ElectricityBus'].plot_node_balance() # Uses configured colors + results.setup_colors({ + 'Solar*': 'oranges', + 'Wind*': 'blues', + 'Battery': 'greens', + '~Storage': 'teals' + }) + results['ElectricityBus'].plot_node_balance() ``` - Or step-by-step: + Load from YAML file: ```python - mgr = results.setup_colors() - mgr.add_grouping_rule('Solar', 'renewables', 'oranges') - mgr.add_grouping_rule('Battery', 'storage', 'greens') + # colors.yaml contains: + # Solar*: oranges + # Wind*: blues + results.setup_colors('colors.yaml') ``` - Manual creation (alternative): + Programmatic configuration: ```python - results.color_manager = ComponentColorManager(list(results.components.keys())) - results.color_manager.add_grouping_rule('Storage', 'storage', 'greens') + results.setup_colors()\ + .add_rule('Solar*', 'oranges')\ + .add_rule('Wind*', 'blues') ``` Disable automatic coloring: @@ -373,10 +387,24 @@ def setup_colors(self, enable_flow_shading=False) -> plotting.ComponentColorMana results.color_manager = None # Plots use default colorscales ``` """ + import pathlib + if self.color_manager is None: self.color_manager = plotting.ComponentColorManager.from_flow_system( self.flow_system, enable_flow_shading=enable_flow_shading ) + + # Apply configuration if provided + if config is not None: + # Load from file if string/Path + if isinstance(config, (str, pathlib.Path)): + config = plotting.ComponentColorManager._load_config_from_file(config) + + # Apply dict configuration + if isinstance(config, dict): + for pattern, colormap in config.items(): + self.color_manager.add_rule(pattern, colormap) + return self.color_manager def filter_solution( @@ -2048,38 +2076,47 @@ def meta_data(self) -> dict[str, int | list[str]]: def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] - def setup_colors(self, enable_flow_shading: bool = False) -> plotting.ComponentColorManager: + def setup_colors( + self, config: dict[str, str] | str | pathlib.Path | None = None, enable_flow_shading: bool = False + ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager that propagates to all segments. Convenience method that creates a ComponentColorManager with all components registered and assigns it to `self.color_manager` and all segment results. - Colors are automatically applied when adding grouping rules. + Optionally load configuration from a dict or file. Colors are automatically + applied when adding rules. + + Args: + config: Optional color configuration: + - dict: {pattern: colormap} mapping + - str/Path: Path to YAML or JSON file + - None: Create empty manager for manual config (default) + enable_flow_shading: Enable subtle color variations for different flows (default: False) Returns: - ComponentColorManager instance ready for configuration. + ComponentColorManager instance ready for configuration (propagated to all segments). Examples: - Simple chained configuration: + Dict-based configuration: ```python - results.setup_colors()\ - .add_grouping_rule('Solar', 'renewables', 'oranges')\ - .add_grouping_rule('Wind', 'renewables', 'blues') + results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'}) # All segments use the same colors results.segment_results[0]['ElectricityBus'].plot_node_balance() results.segment_results[1]['ElectricityBus'].plot_node_balance() ``` - Manual assignment (you must propagate yourself): + Load from file: ```python - mgr = ComponentColorManager(list(results.segment_results[0].components.keys())) - mgr.add_grouping_rule('Storage', 'storage', 'greens') - results.color_manager = mgr - # Propagate to all segments - for segment in results.segment_results: - segment.color_manager = mgr + results.setup_colors('colors.yaml') + ``` + + Programmatic configuration: + + ```python + results.setup_colors().add_rule('Solar*', 'oranges') ``` """ if self.color_manager is None: @@ -2089,6 +2126,18 @@ def setup_colors(self, enable_flow_shading: bool = False) -> plotting.ComponentC # Propagate to all segment results for consistent coloring for segment in self.segment_results: segment.color_manager = self.color_manager + + # Apply configuration if provided + if config is not None: + # Load from file if string/Path + if isinstance(config, (str, pathlib.Path)): + config = plotting.ComponentColorManager._load_config_from_file(config) + + # Apply dict configuration + if isinstance(config, dict): + for pattern, colormap in config.items(): + self.color_manager.add_rule(pattern, colormap) + return self.color_manager def solution_without_overlap(self, variable_name: str) -> xr.DataArray: From 1ea66e42d78e682a5d366f1eadc564a2f38c0f23 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:26:25 +0200 Subject: [PATCH 126/173] streamlined the ColorManager configuration API --- flixopt/plotting.py | 100 +++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 53 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index f8d8c387b..af83c6666 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -587,10 +587,15 @@ def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManag self.color_families[name] = colors return self - def add_rule(self, pattern: str, colormap: str) -> ComponentColorManager: - """Add color rule with auto-detected match type (simplified API). + def add_rule( + self, pattern: str, colormap: str, match_type: MatchType | None = None, group_name: str | None = None + ) -> ComponentColorManager: + """Add color rule with optional auto-detection (flexible API). + + By default, automatically detects the matching strategy from pattern syntax. + Optionally override with explicit match_type and group_name for full control. - Automatically detects the matching strategy from pattern syntax: + Auto-detection rules: - 'Solar' → prefix matching - 'Solar*' → glob matching - '~Storage' → contains matching (strip ~) @@ -600,21 +605,38 @@ def add_rule(self, pattern: str, colormap: str) -> ComponentColorManager: Colors are automatically applied after adding the rule. Args: - pattern: Pattern to match components (auto-detects match type) - colormap: Colormap name ('reds', 'blues', 'greens', etc.) + pattern: Pattern to match components + colormap: Colormap name ('reds', 'blues', 'greens', etc.') + match_type: Optional explicit match type. If None (default), auto-detects from pattern. + Options: 'prefix', 'suffix', 'contains', 'glob', 'regex' + group_name: Optional group name for organization. If None, auto-generates from pattern. Returns: Self for method chaining Examples: - Simple patterns: + Simple auto-detection (most common): ```python - manager.add_rule('Solar', 'oranges') # Matches Solar, Solar1, Solar2, ... - manager.add_rule('Wind*', 'blues') # Glob: Wind1, WindPark, ... - manager.add_rule('~Storage', 'greens') # Contains: Storage, BatteryStorage, ... - manager.add_rule('Gas$', 'reds') # Suffix: NaturalGas, BiogasGas - manager.add_rule('.*Battery.*', 'teals') # Regex: Any with 'Battery' + manager.add_rule('Solar', 'oranges') # Auto: prefix + manager.add_rule('Wind*', 'blues') # Auto: glob + manager.add_rule('~Storage', 'greens') # Auto: contains + ``` + + Override auto-detection when needed: + + ```python + # Force prefix matching even though it has special chars + manager.add_rule('Solar*', 'oranges', match_type='prefix') + + # Explicit regex when pattern is ambiguous + manager.add_rule('Solar.+', 'oranges', match_type='regex') + ``` + + Full explicit control: + + ```python + manager.add_rule('Solar', 'oranges', 'prefix', 'renewables') ``` Chained configuration: @@ -622,40 +644,31 @@ def add_rule(self, pattern: str, colormap: str) -> ComponentColorManager: ```python manager.add_rule('Solar*', 'oranges')\ .add_rule('Wind*', 'blues')\ - .add_rule('Battery', 'greens') + .add_rule('Battery', 'greens', 'prefix', 'storage') ``` - - Note: - This is the simplified API. For explicit control over match type and group names, - use `add_grouping_rule()` instead. """ - # Auto-detect match type - match_type = self._detect_match_type(pattern) + # Auto-detect match type if not provided + if match_type is None: + match_type = self._detect_match_type(pattern) - # Clean pattern based on detected type + # Clean pattern based on match type (strip special markers) clean_pattern = pattern if match_type == 'contains' and pattern.startswith('~'): clean_pattern = pattern[1:] # Strip ~ prefix - elif match_type == 'suffix' and pattern.endswith('$'): - clean_pattern = pattern[:-1] # Strip $ suffix + elif match_type == 'suffix' and pattern.endswith('$') and not any(c in pattern for c in r'.[]()^|+\\'): + clean_pattern = pattern[:-1] # Strip $ suffix (only if not part of regex) - # Generate group name from pattern (for internal organization) - group_name = clean_pattern.replace('*', '').replace('?', '').replace('.', '')[:20] + # Auto-generate group name if not provided + if group_name is None: + group_name = clean_pattern.replace('*', '').replace('?', '').replace('.', '')[:20] - # Delegate to add_grouping_rule - return self.add_grouping_rule(clean_pattern, group_name, colormap, match_type) + # Delegate to _add_grouping_rule + return self._add_grouping_rule(clean_pattern, group_name, colormap, match_type) - def add_grouping_rule( + def _add_grouping_rule( self, pattern: str, group_name: str, colormap: str, match_type: MatchType = 'prefix' ) -> ComponentColorManager: - """Add pattern rule for grouping components. - - .. note:: - This is the explicit API with full control. For simpler usage, consider `add_rule()` - which auto-detects match_type and doesn't require group_name. - - Components matching the pattern are assigned to the specified group, - and colors are drawn from the group's colormap. + """Add pattern rule for grouping components (low-level API). Args: pattern: Pattern to match component names against @@ -667,25 +680,6 @@ def add_grouping_rule( - 'contains': Match if pattern appears in component name - 'glob': Unix wildcards (* and ?) - 'regex': Regular expression matching - - Returns: - Self for method chaining - - Examples: - Explicit control (this method): - - ```python - manager.add_grouping_rule('Boiler', 'Heat_Production', 'reds', 'prefix') - manager.add_grouping_rule('CHP', 'Heat_Production', 'oranges', 'prefix') - manager.add_grouping_rule('.*Storage.*', 'Storage', 'blues', 'regex') - ``` - - Simpler alternative (recommended): - - ```python - manager.add_rule('Boiler', 'reds') # Auto-detects prefix - manager.add_rule('.*Storage.*', 'blues') # Auto-detects regex - ``` """ valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') if match_type not in valid_types: From a5f43a519380a4ae2be9344e7051212302909d4e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:36:52 +0200 Subject: [PATCH 127/173] Update usages of new api --- flixopt/plotting.py | 2 +- flixopt/results.py | 34 ++++++++++++++-------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index af83c6666..5bdbc4451 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1192,7 +1192,7 @@ def resolve_colors( Standalone usage: >>> manager = plotting.ComponentColorManager(['Solar', 'Wind', 'Coal']) - >>> manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + >>> manager.add_rule('Solar', 'oranges') >>> resolved_colors = resolve_colors(data, manager) Without manager: diff --git a/flixopt/results.py b/flixopt/results.py index 0a5a9b4d8..e282257e2 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -115,17 +115,15 @@ class CalculationResults: Configure automatic color management for plots: ```python - # Create and configure a color manager for pattern-based coloring - manager = results.setup_colors() - manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') - manager.add_grouping_rule('Gas', 'fossil', 'reds', match_type='prefix') - manager.apply_colors() - - # All plots automatically use the manager (colors='auto' is the default) - results['ElectricityBus'].plot_node_balance() # Uses configured colors - results['Battery'].plot_charge_state() # Also uses configured colors + # Dict-based configuration (simplest): + results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues', 'Battery': 'greens'}) + + # Or programmatically: + results.setup_colors().add_rule('Solar*', 'oranges').add_rule('Wind*', 'blues') + + # All plots automatically use configured colors (colors='auto' is the default) + results['ElectricityBus'].plot_node_balance() + results['Battery'].plot_charge_state() # Override when needed results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores mapper @@ -1974,16 +1972,12 @@ class SegmentedCalculationResults: Configure color management for consistent plotting across segments: ```python - # Create and configure a color manager - manager = results.setup_colors() - manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='prefix') - manager.apply_colors() - - # Plot using any segment - colors are consistent across all segments + # Dict-based configuration (simplest): + results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues', 'Battery': 'greens'}) + + # Colors automatically propagate to all segments results.segment_results[0]['ElectricityBus'].plot_node_balance() - results.segment_results[1]['ElectricityBus'].plot_node_balance() + results.segment_results[1]['ElectricityBus'].plot_node_balance() # Same colors ``` Design Considerations: From 5e6e6281ff126105a7cb747eb91697de0392accc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:38:39 +0200 Subject: [PATCH 128/173] Update CHANGELOG.md --- CHANGELOG.md | 44 ++++++++------------------------------------ 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8264718..b8cbdb54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,48 +53,20 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). ### ✨ Added -- **Pattern-based color mapping with `ComponentColorManager`**: New color mapping system for automatic, semantically meaningful plot colors based on component naming patterns - - `ComponentColorManager` class provides pattern-based color assignment using prefix, suffix, contains, glob, and regex matching - - **Discrete color support**: Directly assign single colors (hex, rgb, named) to patterns for consistent coloring across all matching items (e.g., all Solar components get exact same orange) - - Color families from Plotly sequential palettes: 14 single-hue families (blues, greens, reds, purples, oranges, teals, greys, pinks, peach, burg, sunsetdark, mint, emrld, darkmint) - - Support for custom color families and explicit color overrides for special cases - - `CalculationResults.create_color_manager()` factory method for easy setup - - `CalculationResults.color_manager` attribute automatically applies colors to all plots when `colors='auto'` (the default) - - **`SegmentedCalculationResults.create_color_manager()`**: ColorManager support for segmented results, automatically propagates to all segments for consistent coloring - - `resolve_colors()` utility function in `plotting` module for standalone color resolution -- **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 +- **Smart color management**: Configure consistent plot colors by pattern matching component names + - Dict: `results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'})` + - File: `results.setup_colors('colors.yaml')` (supports YAML/JSON) + - Programmatic: `results.setup_colors().add_rule('Solar*', 'oranges')` +- **Heatmap fill control**: Control missing value handling with `fill='ffill'` or `fill='bfill'` ### ♻️ Changed -- **Plotting color defaults**: All plotting methods now default to using `CalculationResults.color_manager` if configured, otherwise falls back to defaults. Explicit colors (dict, string, list) still work as before -- **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` +- Plotting methods now use `color_manager` by default if configured ### 🗑️ 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 - -### 📦 Dependencies - -### 📝 Docs -- Updated `complex_example.py` and `complex_example_results.py` to demonstrate ColorManager usage with discrete colors - -### 👷 Development -- Fixed concurrency issue in CI -- Renamed `_apply_indexer_to_data()` to `_apply_selection_to_data()` for consistency with new API - -### 🚧 Known Issues +- Improved error messages for matplotlib with multidimensional data +- Better dimension validation in `plot_heatmap()` --- From 8592e07b1042879d4ab79d07815a1ee89478e1b2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:54:51 +0200 Subject: [PATCH 129/173] Update examples --- examples/01_Simple/simple_example.py | 11 ++++++----- examples/02_Complex/complex_example.py | 3 ++- examples/02_Complex/complex_example_results.py | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 26c24b2ed..bdbbfda07 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -10,6 +10,7 @@ if __name__ == '__main__': # Enable console logging fx.CONFIG.Logging.console = True + fx.CONFIG.Plotting.default_show = True fx.CONFIG.apply() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices @@ -45,11 +46,9 @@ # --- Define Flow System Components --- # Boiler: Converts fuel (gas) into thermal energy (heat) - boiler = fx.linear_converters.Boiler( + boiler = fx.Source( label='Boiler', - eta=0.5, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), - Q_fu=fx.Flow(label='Q_fu', bus='Gas'), + outputs=[fx.Flow(label=str(i), bus='Fernwärme', size=5) for i in range(10)], ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel @@ -112,7 +111,9 @@ calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- - calculation.results.setup_colors() + # Colors are automatically assigned using default colormap + # Optional: Configure custom colors with + calculation.results.setup_colors({'Boiler': 'oranges', 'Storage': 'greens'}) calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_charge_state() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 7677131cc..cf331b497 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -205,7 +205,8 @@ # You can analyze results directly or save them to file and reload them later. calculation.results.to_file() - calculation.results.setup_colors() + # Optional: Configure custom colors (dict is simplest): + calculation.results.setup_colors({'BHKW*': 'oranges', 'Speicher': 'greens'}) # Plot results (colors are automatically assigned to components) calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorManager) diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index fba8309d2..8eba4de50 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -19,11 +19,12 @@ ) from e # --- Configure Color Mapping for Consistent Plot Colors (Optional) --- - results.setup_colors() + results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'}) # Dict (simplest) + # results.setup_colors('colors.yaml') # Or from file + # results.setup_colors().add_rule('Solar*', 'oranges') # Or programmatic # --- Basic overview --- results.plot_network(show=True) - # All plots automatically use default colors (or configured color manager if rules were added) results['Fernwärme'].plot_node_balance() # --- Detailed Plots --- From 5ea9bf42515c55ab1c7612acaa3dc6c1a8bc2e0a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:15:36 +0200 Subject: [PATCH 130/173] use turbo as the new default sequential colormap --- flixopt/config.py | 2 +- flixopt/plotting.py | 34 +++++++++++++++++----------------- flixopt/results.py | 4 ++-- tests/test_results_plots.py | 4 ++-- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index cfb240ff8..7f5d932c7 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -66,7 +66,7 @@ 'default_figure_width': None, 'default_figure_height': None, 'default_facet_cols': 3, - 'default_sequential_colorscale': 'viridis', + 'default_sequential_colorscale': 'turbo', 'default_qualitative_colorscale': 'dark24', } ), diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 5bdbc4451..d00e23516 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -85,7 +85,7 @@ Color specifications can take several forms to accommodate different use cases: **Named Colormaps** (str): - - Standard colormaps: 'viridis', 'plasma', 'cividis', 'tab10', 'Set1' + - Standard colormaps: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1' - Energy-focused: 'portland' (custom flixopt colormap for energy systems) - Backend-specific maps available in Plotly and Matplotlib @@ -102,7 +102,7 @@ Examples: ```python # Named colormap - colors = 'viridis' # Automatic color generation + colors = 'turbo' # Automatic color generation # Explicit color list colors = ['red', 'blue', 'green', '#FFD700'] @@ -149,7 +149,7 @@ class ColorProcessor: **Energy System Colors**: Built-in palettes optimized for energy system visualization Color Input Types: - - **Named Colormaps**: 'viridis', 'plasma', 'portland', 'tab10', etc. + - **Named Colormaps**: 'turbo', 'plasma', 'portland', etc. - **Color Lists**: ['red', 'blue', 'green'] or ['#FF0000', '#0000FF', '#00FF00'] - **Label Dictionaries**: {'Generator': 'red', 'Storage': 'blue', 'Load': 'green'} @@ -158,7 +158,7 @@ class ColorProcessor: ```python # Initialize for Plotly backend - processor = ColorProcessor(engine='plotly', default_colormap='viridis') + processor = ColorProcessor(engine='plotly', default_colormap='turbo') # Process different color specifications colors = processor.process_colors('plasma', ['Gen1', 'Gen2', 'Storage']) @@ -190,7 +190,7 @@ class ColorProcessor: Args: engine: Plotting backend ('plotly' or 'matplotlib'). Determines output color format. default_colormap: Fallback colormap when requested palettes are unavailable. - Common options: 'viridis', 'plasma', 'tab10', 'portland'. + Common options: 'turbo', 'plasma', 'portland'. """ @@ -222,7 +222,7 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> # Cycle through colors if we need more than available return [color_list[i % len(color_list)] for i in range(num_colors)] - # Then try sequential/continuous colorscales (viridis, plasma, etc.) + # Then try sequential/continuous colorscales (turbo, plasma, etc.) try: colorscale = px.colors.get_colorscale(colormap_name) except PlotlyError as e: @@ -1197,7 +1197,7 @@ def resolve_colors( Without manager: - >>> resolved_colors = resolve_colors(data, 'viridis') + >>> resolved_colors = resolve_colors(data, 'turbo') """ # Get variable names from Dataset (always strings and unique) labels = list(data.data_vars.keys()) @@ -1246,7 +1246,7 @@ def with_plotly( 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. Can be: - - A colormap name (e.g., 'viridis', 'plasma') + - A colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dict mapping labels to colors (e.g., {'Solar': '#FFD700'}) - A ComponentColorManager instance for pattern-based color rules with component grouping @@ -1571,7 +1571,7 @@ def with_matplotlib( the index represents time and each column represents a separate data series (variables). mode: Plotting mode. Use 'stacked_bar' for stacked bar charts or 'line' for stepped lines. colors: Color specification. Can be: - - A colormap name (e.g., 'viridis', 'plasma') + - A colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dict mapping column names to colors (e.g., {'Column1': '#ff0000'}) - A ComponentColorManager instance for pattern-based color rules with grouping and sorting @@ -1991,7 +1991,7 @@ def pie_with_plotly( data: An xarray Dataset containing the data to plot. All dimensions will be summed to get the total for each variable. colors: Color specification, can be: - - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A string with a colorscale name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - A ComponentColorManager instance for pattern-based color rules @@ -2016,7 +2016,7 @@ def pie_with_plotly( Simple pie chart: ```python - fig = pie_with_plotly(dataset, colors='viridis', title='Energy Mix') + fig = pie_with_plotly(dataset, colors='turbo', title='Energy Mix') ``` With ComponentColorManager: @@ -2110,7 +2110,7 @@ def pie_with_matplotlib( data: An xarray Dataset containing the data to plot. All dimensions will be summed to get the total for each variable. colors: Color specification, can be: - - A string with a colormap name (e.g., 'viridis', 'plasma') + - A string with a colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - A ComponentColorManager instance for pattern-based color rules @@ -2131,7 +2131,7 @@ def pie_with_matplotlib( Simple pie chart: ```python - fig, ax = pie_with_matplotlib(dataset, colors='viridis', title='Energy Mix') + fig, ax = pie_with_matplotlib(dataset, colors='turbo', title='Energy Mix') ``` With ComponentColorManager: @@ -2250,7 +2250,7 @@ def dual_pie_with_plotly( data_left: Dataset for the left pie chart. Variables are summed across all dimensions. data_right: Dataset for the right pie chart. Variables are summed across all dimensions. colors: Color specification, can be: - - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A string with a colorscale name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - A ComponentColorManager instance for pattern-based color rules @@ -2392,7 +2392,7 @@ def dual_pie_with_matplotlib( data_left: Series for the left pie chart. data_right: Series for the right pie chart. colors: Color specification, can be: - - A string with a colormap name (e.g., 'viridis', 'plasma') + - A string with a colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'}) title: The main title of the plot. @@ -2591,7 +2591,7 @@ def heatmap_with_plotly( 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'. + 'turbo', '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). @@ -2814,7 +2814,7 @@ def heatmap_with_matplotlib( 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'). + colors: Color specification. Should be a colormap name (e.g., 'turbo', 'RdBu'). title: The title of the heatmap. figsize: The size of the figure (width, height) in inches. reshape_time: Time reshaping configuration: diff --git a/flixopt/results.py b/flixopt/results.py index 9b12492c4..0db8d0393 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -126,7 +126,7 @@ class CalculationResults: results['Battery'].plot_charge_state() # Override when needed - results['ElectricityBus'].plot_node_balance(colors='viridis') # Ignores mapper + results['ElectricityBus'].plot_node_balance(colors='turbo') # Ignores mapper ``` Design Patterns: @@ -1133,7 +1133,7 @@ def plot_node_balance( show: Whether to show the plot or not. colors: The colors to use for the plot. Options: - None (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale - - Colormap name string (e.g., 'viridis', 'plasma') + - Colormap name string (e.g., 'turbo', 'plasma') - List of color strings - Dict mapping variable names to colors Set `results.color_manager` to a `ComponentColorManager` for automatic component-based grouping. diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 1fd6cf7f5..a656f7c44 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -28,7 +28,7 @@ def plotting_engine(request): @pytest.fixture( params=[ - 'viridis', # Test string colormap + 'turbo', # Test string colormap ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'], # Test color list { 'Boiler(Q_th)|flow_rate': '#ff0000', @@ -51,7 +51,7 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): # 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 + 'colors': 'turbo', # Note: heatmap only accepts string colormap 'save': save, 'show': show, 'engine': plotting_engine, From 93bd4fb97b33d277c1be0756392607d7cec1f146 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:23:22 +0200 Subject: [PATCH 131/173] Add support for direct mappings of components --- flixopt/results.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 0db8d0393..dd2e65fdc 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -401,7 +401,26 @@ def setup_colors( # Apply dict configuration if isinstance(config, dict): - for pattern, colormap in config.items(): + import matplotlib.colors as mcolors + + # Separate explicit colors from colormap patterns + explicit_colors = {} + pattern_rules = {} + + for key, value in config.items(): + if mcolors.is_color_like(value): + # Value is an explicit color (e.g., 'red', '#FF0000', 'rgb(255,0,0)') + explicit_colors[key] = value + else: + # Value is a colormap name (e.g., 'reds', 'blues', 'oranges') + pattern_rules[key] = value + + # Apply explicit color overrides first + if explicit_colors: + self.color_manager.override(explicit_colors) + + # Then apply pattern-based rules + for pattern, colormap in pattern_rules.items(): self.color_manager.add_rule(pattern, colormap) return self.color_manager @@ -2145,7 +2164,26 @@ def setup_colors( # Apply dict configuration if isinstance(config, dict): - for pattern, colormap in config.items(): + import matplotlib.colors as mcolors + + # Separate explicit colors from colormap patterns + explicit_colors = {} + pattern_rules = {} + + for key, value in config.items(): + if mcolors.is_color_like(value): + # Value is an explicit color (e.g., 'red', '#FF0000', 'rgb(255,0,0)') + explicit_colors[key] = value + else: + # Value is a colormap name (e.g., 'reds', 'blues', 'oranges') + pattern_rules[key] = value + + # Apply explicit color overrides first + if explicit_colors: + self.color_manager.override(explicit_colors) + + # Then apply pattern-based rules + for pattern, colormap in pattern_rules.items(): self.color_manager.add_rule(pattern, colormap) return self.color_manager From 684e0c8525a9c9106c461e03f1803448f8b1421c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:28:20 +0200 Subject: [PATCH 132/173] Add support for direct mappings of components --- flixopt/plotting.py | 54 ++++++++++++++++++++++++++++++++++----------- flixopt/results.py | 46 ++++---------------------------------- 2 files changed, 45 insertions(+), 55 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index d00e23516..64ecb96a5 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -595,6 +595,10 @@ def add_rule( By default, automatically detects the matching strategy from pattern syntax. Optionally override with explicit match_type and group_name for full control. + **Smart Color Detection:** If `colormap` is actually an explicit color (e.g., 'red', + '#FF0000'), and `pattern` is an exact component name (no wildcards), this method + automatically uses `override()` instead of pattern matching. + Auto-detection rules: - 'Solar' → prefix matching - 'Solar*' → glob matching @@ -605,8 +609,9 @@ def add_rule( Colors are automatically applied after adding the rule. Args: - pattern: Pattern to match components - colormap: Colormap name ('reds', 'blues', 'greens', etc.') + pattern: Pattern to match components, or exact component name for explicit colors + colormap: Colormap name ('reds', 'blues', 'greens', etc.) OR explicit color + ('red', 'orange', '#FF5733', 'rgb(255,0,0)', etc.) match_type: Optional explicit match type. If None (default), auto-detects from pattern. Options: 'prefix', 'suffix', 'contains', 'glob', 'regex' group_name: Optional group name for organization. If None, auto-generates from pattern. @@ -615,7 +620,15 @@ def add_rule( Self for method chaining Examples: - Simple auto-detection (most common): + Explicit color mapping (exact component names): + + ```python + manager.add_rule('Boiler', 'orange') # Exact component → explicit color + manager.add_rule('Storage', '#00FF00') # Hex color + manager.add_rule('CHP', 'rgb(255,0,0)') # RGB color + ``` + + Pattern-based colormap rules: ```python manager.add_rule('Solar', 'oranges') # Auto: prefix @@ -633,20 +646,35 @@ def add_rule( manager.add_rule('Solar.+', 'oranges', match_type='regex') ``` - Full explicit control: + Chained configuration (mixed): ```python - manager.add_rule('Solar', 'oranges', 'prefix', 'renewables') - ``` - - Chained configuration: - - ```python - manager.add_rule('Solar*', 'oranges')\ - .add_rule('Wind*', 'blues')\ - .add_rule('Battery', 'greens', 'prefix', 'storage') + manager.add_rule('Boiler', 'orange')\ + .add_rule('Solar*', 'oranges')\ + .add_rule('Wind*', 'blues') ``` """ + # Check if colormap is actually an explicit color + if mcolors.is_color_like(colormap): + # It's an explicit color, not a colormap name + # Check if pattern looks like an exact component name (no wildcards/special chars) + has_wildcards = any(char in pattern for char in '*?~$[]()^|+\\.') + + if not has_wildcards: + # Exact component name → use override for direct color assignment + self.override({pattern: colormap}) + return self + else: + # Pattern with wildcards but explicit color - this is ambiguous + logger.warning( + f"Pattern '{pattern}' has wildcards but '{colormap}' is an explicit color, not a colormap. " + f'Explicit colors should be used with exact component names. ' + f"Applying as override to component '{pattern}' (treating as literal name)." + ) + self.override({pattern: colormap}) + return self + + # colormap is a colormap name - proceed with pattern-based rule logic # Auto-detect match type if not provided if match_type is None: match_type = self._detect_match_type(pattern) diff --git a/flixopt/results.py b/flixopt/results.py index dd2e65fdc..a22691bf6 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -399,28 +399,9 @@ def setup_colors( if isinstance(config, (str, pathlib.Path)): config = plotting.ComponentColorManager._load_config_from_file(config) - # Apply dict configuration + # Apply dict configuration - add_rule() now handles both colors and colormaps if isinstance(config, dict): - import matplotlib.colors as mcolors - - # Separate explicit colors from colormap patterns - explicit_colors = {} - pattern_rules = {} - - for key, value in config.items(): - if mcolors.is_color_like(value): - # Value is an explicit color (e.g., 'red', '#FF0000', 'rgb(255,0,0)') - explicit_colors[key] = value - else: - # Value is a colormap name (e.g., 'reds', 'blues', 'oranges') - pattern_rules[key] = value - - # Apply explicit color overrides first - if explicit_colors: - self.color_manager.override(explicit_colors) - - # Then apply pattern-based rules - for pattern, colormap in pattern_rules.items(): + for pattern, colormap in config.items(): self.color_manager.add_rule(pattern, colormap) return self.color_manager @@ -2162,28 +2143,9 @@ def setup_colors( if isinstance(config, (str, pathlib.Path)): config = plotting.ComponentColorManager._load_config_from_file(config) - # Apply dict configuration + # Apply dict configuration - add_rule() now handles both colors and colormaps if isinstance(config, dict): - import matplotlib.colors as mcolors - - # Separate explicit colors from colormap patterns - explicit_colors = {} - pattern_rules = {} - - for key, value in config.items(): - if mcolors.is_color_like(value): - # Value is an explicit color (e.g., 'red', '#FF0000', 'rgb(255,0,0)') - explicit_colors[key] = value - else: - # Value is a colormap name (e.g., 'reds', 'blues', 'oranges') - pattern_rules[key] = value - - # Apply explicit color overrides first - if explicit_colors: - self.color_manager.override(explicit_colors) - - # Then apply pattern-based rules - for pattern, colormap in pattern_rules.items(): + for pattern, colormap in config.items(): self.color_manager.add_rule(pattern, colormap) return self.color_manager From 43a91962c2702e24608a2ada42692ef523f01ce8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:31:56 +0200 Subject: [PATCH 133/173] Add configurable flow_variation --- flixopt/plotting.py | 34 ++++++++++++++++------------------ flixopt/results.py | 14 ++++++++------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 64ecb96a5..291613f88 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -435,8 +435,7 @@ def __init__( self, components: list[str] | None = None, flows: dict[str, list[str]] | None = None, - enable_flow_shading: bool = False, - flow_variation_strength: float = 0.04, + flow_variation: float | None = None, default_colormap: str = 'Dark24', ) -> None: """Initialize component color manager. @@ -444,8 +443,8 @@ def __init__( Args: components: List of all component names in the system (optional if flows provided) flows: Dict mapping component names to their flow labels (e.g., {'Boiler': ['Q_th', 'Q_fu']}) - enable_flow_shading: If True, create subtle color variations for flows of same component - flow_variation_strength: Lightness variation per flow (0.05-0.15, default: 0.08 = 8%) + flow_variation: Lightness variation strength per flow (0.02-0.15). + None or 0 disables flow shading (default: None) default_colormap: Default colormap for ungrouped components (default: 'Dark24') """ # Extract components from flows dict if provided @@ -462,16 +461,16 @@ def __init__( self.color_families = self.DEFAULT_FAMILIES.copy() # Flow shading settings (requires optional 'colour' library) - if enable_flow_shading and not HAS_COLOUR: + # flow_variation serves as both the enable flag and the variation strength + if flow_variation and not HAS_COLOUR: logger.error( 'Flow shading requested but optional dependency "colour" is not installed. ' 'Install it with: pip install flixopt[flow_colors]\n' 'Flow shading will be disabled.' ) - self.enable_flow_shading = False + self.flow_variation = None else: - self.enable_flow_shading = enable_flow_shading - self.flow_variation_strength = flow_variation_strength + self.flow_variation = flow_variation # Pattern-based grouping rules self._grouping_rules: list[dict[str, str]] = [] @@ -490,7 +489,7 @@ def __init__( def __repr__(self) -> str: """Return detailed representation of ComponentColorManager.""" - flow_info = f', flow_shading={self.enable_flow_shading}' if self.enable_flow_shading else '' + flow_info = f', flow_variation={self.flow_variation}' if self.flow_variation else '' return ( f'ComponentColorManager(components={len(self.components)}, ' f'rules={len(self._grouping_rules)}, ' @@ -536,14 +535,15 @@ def __str__(self) -> str: return '\n'.join(lines) @classmethod - def from_flow_system(cls, flow_system, enable_flow_shading: bool = False, **kwargs): + def from_flow_system(cls, flow_system, flow_variation: float | None = None, **kwargs): """Create ComponentColorManager from a FlowSystem. Automatically extracts all components and their flows from the FlowSystem. Args: flow_system: FlowSystem instance to extract components and flows from - enable_flow_shading: Enable subtle color variations for flows (default: False) + flow_variation: Lightness variation strength per flow (0.02-0.15). + None or 0 disables flow shading (default: None) **kwargs: Additional arguments passed to ComponentColorManager.__init__ Returns: @@ -551,13 +551,11 @@ def from_flow_system(cls, flow_system, enable_flow_shading: bool = False, **kwar Examples: ```python - # Basic usage + # Basic usage (no flow shading) manager = ComponentColorManager.from_flow_system(flow_system) # With flow shading - manager = ComponentColorManager.from_flow_system( - flow_system, enable_flow_shading=True, flow_variation_strength=0.10 - ) + manager = ComponentColorManager.from_flow_system(flow_system, flow_variation=0.10) ``` """ from .flow_system import FlowSystem @@ -572,7 +570,7 @@ def from_flow_system(cls, flow_system, enable_flow_shading: bool = False, **kwar if flow_labels: # Only add if component has flows flows[component_label] = flow_labels - return cls(flows=flows, enable_flow_shading=enable_flow_shading, **kwargs) + return cls(flows=flows, flow_variation=flow_variation, **kwargs) def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManager: """Add a custom color family. @@ -872,7 +870,7 @@ def get_variable_color(self, variable: str) -> str: base_color = self.get_color(component) # Apply flow shading if enabled and flow is present - if self.enable_flow_shading and flow is not None and component in self.flows: + if self.flow_variation and flow is not None and component in self.flows: # Get sorted flow list for this component component_flows = self.flows[component] @@ -1120,7 +1118,7 @@ def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: shades = [] for idx in range(num_flows): - delta_lightness = (idx - center_idx) * self.flow_variation_strength + delta_lightness = (idx - center_idx) * self.flow_variation new_lightness = np.clip(lightness + delta_lightness, 0.1, 0.9) # Create new color with adjusted lightness diff --git a/flixopt/results.py b/flixopt/results.py index a22691bf6..6f418de08 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -332,7 +332,7 @@ def flow_system(self) -> FlowSystem: return self._flow_system def setup_colors( - self, config: dict[str, str] | str | pathlib.Path | None = None, enable_flow_shading: bool = False + self, config: dict[str, str] | str | pathlib.Path | None = None, flow_variation: float | None = None ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager for configuring plot colors. @@ -345,7 +345,8 @@ def setup_colors( - dict: {pattern: colormap} mapping - str/Path: Path to YAML or JSON file - None: Create empty manager for manual config (default) - enable_flow_shading: Enable subtle color variations for different flows (default: False) + flow_variation: Lightness variation strength per flow (0.02-0.15). + None or 0 disables flow shading (default: None) Returns: ComponentColorManager instance ready for configuration. @@ -390,7 +391,7 @@ def setup_colors( if self.color_manager is None: self.color_manager = plotting.ComponentColorManager.from_flow_system( - self.flow_system, enable_flow_shading=enable_flow_shading + self.flow_system, flow_variation=flow_variation ) # Apply configuration if provided @@ -2087,7 +2088,7 @@ def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] def setup_colors( - self, config: dict[str, str] | str | pathlib.Path | None = None, enable_flow_shading: bool = False + self, config: dict[str, str] | str | pathlib.Path | None = None, flow_variation: float | None = None ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager that propagates to all segments. @@ -2101,7 +2102,8 @@ def setup_colors( - dict: {pattern: colormap} mapping - str/Path: Path to YAML or JSON file - None: Create empty manager for manual config (default) - enable_flow_shading: Enable subtle color variations for different flows (default: False) + flow_variation: Lightness variation strength per flow (0.02-0.15). + None or 0 disables flow shading (default: None) Returns: ComponentColorManager instance ready for configuration (propagated to all segments). @@ -2131,7 +2133,7 @@ def setup_colors( """ if self.color_manager is None: self.color_manager = plotting.ComponentColorManager.from_flow_system( - self.flow_system, enable_flow_shading=enable_flow_shading + self.flow_system, flow_variation=flow_variation ) # Propagate to all segment results for consistent coloring for segment in self.segment_results: From 837f70a45753bbc8a1862bd28af0585412783a19 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:02:05 +0200 Subject: [PATCH 134/173] Update tests --- tests/test_component_color_manager.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index 30ee32871..355b85066 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -305,11 +305,11 @@ def test_initialization_with_flows_dict(self): 'Boiler': ['Q_th', 'Q_fu'], 'CHP': ['P_el', 'Q_th', 'Q_fu'], } - manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + manager = ComponentColorManager(flows=flows, flow_variation=0.04) assert len(manager.components) == 2 assert manager.flows == {'Boiler': ['Q_fu', 'Q_th'], 'CHP': ['P_el', 'Q_fu', 'Q_th']} # Sorted - assert manager.enable_flow_shading is True + assert manager.flow_variation == 0.04 def test_flow_extraction(self): """Test _extract_component_and_flow method.""" @@ -333,7 +333,7 @@ def test_flow_extraction(self): def test_flow_shading_disabled(self): """Test that flow shading is disabled by default.""" flows = {'Boiler': ['Q_th', 'Q_fu']} - manager = ComponentColorManager(flows=flows, enable_flow_shading=False) + manager = ComponentColorManager(flows=flows, flow_variation=None) # Both flows should get the same color color1 = manager.get_variable_color('Boiler(Q_th)|flow_rate') @@ -344,7 +344,7 @@ def test_flow_shading_disabled(self): def test_flow_shading_enabled(self): """Test that flow shading creates distinct colors.""" flows = {'Boiler': ['Q_th', 'Q_fu', 'Q_el']} - manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + manager = ComponentColorManager(flows=flows, flow_variation=0.04) # Get colors for all three flows color_th = manager.get_variable_color('Boiler(Q_th)|flow_rate') @@ -364,7 +364,7 @@ def test_flow_shading_enabled(self): def test_flow_shading_stability(self): """Test that flow shading produces stable colors.""" flows = {'Boiler': ['Q_th', 'Q_fu']} - manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + manager = ComponentColorManager(flows=flows, flow_variation=0.04) # Get color multiple times color1 = manager.get_variable_color('Boiler(Q_th)|flow_rate') @@ -376,7 +376,7 @@ def test_flow_shading_stability(self): def test_single_flow_no_shading(self): """Test that single flow gets base color (no shading needed).""" flows = {'Storage': ['Q_th_load']} - manager = ComponentColorManager(flows=flows, enable_flow_shading=True) + manager = ComponentColorManager(flows=flows, flow_variation=0.04) # Single flow should get base color color = manager.get_variable_color('Storage(Q_th_load)|flow_rate') @@ -389,12 +389,12 @@ def test_flow_variation_strength(self): flows = {'Boiler': ['Q_th', 'Q_fu']} # Low variation - manager_low = ComponentColorManager(flows=flows, enable_flow_shading=True, flow_variation_strength=0.02) + manager_low = ComponentColorManager(flows=flows, flow_variation=0.02) color_low_1 = manager_low.get_variable_color('Boiler(Q_th)|flow_rate') color_low_2 = manager_low.get_variable_color('Boiler(Q_fu)|flow_rate') # High variation - manager_high = ComponentColorManager(flows=flows, enable_flow_shading=True, flow_variation_strength=0.15) + manager_high = ComponentColorManager(flows=flows, flow_variation=0.15) color_high_1 = manager_high.get_variable_color('Boiler(Q_th)|flow_rate') color_high_2 = manager_high.get_variable_color('Boiler(Q_fu)|flow_rate') From 4c58cbb21661425dd74aa78060680a329932c718 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:45:29 +0200 Subject: [PATCH 135/173] Make color getter mroe robust --- flixopt/plotting.py | 88 ++++++++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 291613f88..1d4b76fd8 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -203,6 +203,58 @@ def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str | No default_colormap if default_colormap is not None else CONFIG.Plotting.default_qualitative_colorscale ) + def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> list[str]: + """ + Robustly get colors from Plotly colormaps with multiple fallback levels. + + Args: + colormap_name: Name of the colormap to try + num_colors: Number of colors needed + + Returns: + List of color strings (hex format) + """ + # First try qualitative color sequences (Dark24, Plotly, Set1, etc.) + colormap_title = colormap_name.title() + if hasattr(px.colors.qualitative, colormap_title): + color_list = getattr(px.colors.qualitative, colormap_title) + # Cycle through colors if we need more than available + return [color_list[i % len(color_list)] for i in range(num_colors)] + + # Then try sequential/continuous colorscales (turbo, plasma, etc.) + try: + colorscale = px.colors.get_colorscale(colormap_name) + # Generate evenly spaced points + color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] + return px.colors.sample_colorscale(colorscale, color_points) + except PlotlyError: + pass # Will try fallbacks below + + # Fallback to default_colormap + logger.warning(f"Colormap '{colormap_name}' not found in Plotly. Trying default '{self.default_colormap}'") + + # Try default as qualitative + default_title = self.default_colormap.title() + if hasattr(px.colors.qualitative, default_title): + color_list = getattr(px.colors.qualitative, default_title) + return [color_list[i % len(color_list)] for i in range(num_colors)] + + # Try default as sequential + try: + colorscale = px.colors.get_colorscale(self.default_colormap) + color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] + return px.colors.sample_colorscale(colorscale, color_points) + except PlotlyError: + pass + + # Ultimate fallback: use built-in Plotly qualitative colormap + logger.warning( + f"Both '{colormap_name}' and default '{self.default_colormap}' not found. " + f"Using hardcoded fallback 'Plotly' colormap" + ) + color_list = px.colors.qualitative.Plotly + return [color_list[i % len(color_list)] for i in range(num_colors)] + def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list[Any]: """ Generate colors from a named colormap. @@ -215,35 +267,23 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list of colors in the format appropriate for the engine """ if self.engine == 'plotly': - # First try qualitative color sequences (Dark24, Plotly, Set1, etc.) - colormap_name = colormap_name.title() - if hasattr(px.colors.qualitative, colormap_name): - color_list = getattr(px.colors.qualitative, colormap_name) - # Cycle through colors if we need more than available - return [color_list[i % len(color_list)] for i in range(num_colors)] - - # Then try sequential/continuous colorscales (turbo, plasma, etc.) - try: - colorscale = px.colors.get_colorscale(colormap_name) - except PlotlyError as e: - logger.error(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}") - # Try default as qualitative first - if hasattr(px.colors.qualitative, self.default_colormap): - color_list = getattr(px.colors.qualitative, self.default_colormap) - return [color_list[i % len(color_list)] for i in range(num_colors)] - # Otherwise use default as sequential - colorscale = px.colors.get_colorscale(self.default_colormap) - - # Generate evenly spaced points - color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] - return px.colors.sample_colorscale(colorscale, color_points) + return self._get_plotly_colormap_robust(colormap_name, num_colors) else: # matplotlib try: cmap = plt.get_cmap(colormap_name, num_colors) except ValueError as e: - logger.error(f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}") - cmap = plt.get_cmap(self.default_colormap, num_colors) + logger.warning( + f"Colormap '{colormap_name}' not found in Matplotlib. Trying default '{self.default_colormap}': {e}" + ) + try: + cmap = plt.get_cmap(self.default_colormap, num_colors) + except ValueError: + logger.warning( + f"Default colormap '{self.default_colormap}' also not found in Matplotlib. " + f"Using hardcoded fallback 'tab10'" + ) + cmap = plt.get_cmap('tab10', num_colors) return [cmap(i) for i in range(num_colors)] From cf9788106710dd0eca2a7b983e7650dbdc761e52 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:51:07 +0200 Subject: [PATCH 136/173] Make color getter mroe robust --- flixopt/plotting.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 1d4b76fd8..332e6ca3e 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -203,6 +203,25 @@ def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str | No default_colormap if default_colormap is not None else CONFIG.Plotting.default_qualitative_colorscale ) + def _get_sequential_colorscale(self, colormap_name: str, num_colors: int) -> list[str] | None: + """ + Get colors from a sequential/continuous Plotly colorscale. + + Args: + colormap_name: Name of the colorscale to sample from + num_colors: Number of colors to sample + + Returns: + List of color strings (hex format), or None if colorscale not found + """ + try: + colorscale = px.colors.get_colorscale(colormap_name) + # Generate evenly spaced points + color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] + return px.colors.sample_colorscale(colorscale, color_points) + except PlotlyError: + return None + def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> list[str]: """ Robustly get colors from Plotly colormaps with multiple fallback levels. @@ -222,13 +241,9 @@ def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> li return [color_list[i % len(color_list)] for i in range(num_colors)] # Then try sequential/continuous colorscales (turbo, plasma, etc.) - try: - colorscale = px.colors.get_colorscale(colormap_name) - # Generate evenly spaced points - color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] - return px.colors.sample_colorscale(colorscale, color_points) - except PlotlyError: - pass # Will try fallbacks below + colors = self._get_sequential_colorscale(colormap_name, num_colors) + if colors is not None: + return colors # Fallback to default_colormap logger.warning(f"Colormap '{colormap_name}' not found in Plotly. Trying default '{self.default_colormap}'") @@ -240,12 +255,9 @@ def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> li return [color_list[i % len(color_list)] for i in range(num_colors)] # Try default as sequential - try: - colorscale = px.colors.get_colorscale(self.default_colormap) - color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] - return px.colors.sample_colorscale(colorscale, color_points) - except PlotlyError: - pass + colors = self._get_sequential_colorscale(self.default_colormap, num_colors) + if colors is not None: + return colors # Ultimate fallback: use built-in Plotly qualitative colormap logger.warning( From 2836aa2924b2e4e445ba79e375594bfa17819d5e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:17:53 +0200 Subject: [PATCH 137/173] Temp --- CHANGELOG.md | 10 +- flixopt/plotting.py | 693 ++++++++++++-------------------------------- flixopt/results.py | 98 +++---- pyproject.toml | 6 - 4 files changed, 235 insertions(+), 572 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cbdb54d..0c641eaa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,14 +53,16 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). ### ✨ Added -- **Smart color management**: Configure consistent plot colors by pattern matching component names - - Dict: `results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'})` - - File: `results.setup_colors('colors.yaml')` (supports YAML/JSON) - - Programmatic: `results.setup_colors().add_rule('Solar*', 'oranges')` +- **Simplified color management**: Configure consistent plot colors with explicit component grouping + - Direct colors: `results.setup_colors({'Boiler1': '#FF0000', 'CHP': 'darkred'})` + - Grouped colors: `results.setup_colors({'oranges': ['Solar1', 'Solar2'], 'blues': ['Wind1', 'Wind2']})` + - Mixed approach: Combine direct and grouped colors in a single call + - File-based: `results.setup_colors('colors.yaml')` (YAML only) - **Heatmap fill control**: Control missing value handling with `fill='ffill'` or `fill='bfill'` ### ♻️ Changed - Plotting methods now use `color_manager` by default if configured +- Color management implementation simplified: Uses explicit component grouping instead of pattern matching for better clarity and maintainability (65% code reduction) ### 🗑️ Deprecated diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 332e6ca3e..2c146a030 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -26,12 +26,10 @@ from __future__ import annotations -import fnmatch import itertools import logging import os import pathlib -import re from typing import TYPE_CHECKING, Any, Literal import matplotlib @@ -47,14 +45,6 @@ from .config import CONFIG -# Optional dependency for flow-level color shading -try: - from colour import Color - - HAS_COLOUR = True -except ImportError: - HAS_COLOUR = False - if TYPE_CHECKING: import pyvis @@ -406,55 +396,46 @@ def process_colors( return color_list -# Type aliases for ComponentColorManager -MatchType = Literal['prefix', 'suffix', 'contains', 'glob', 'regex'] - - class ComponentColorManager: - """Manage stable colors for flow system components with pattern-based grouping. + """Manage consistent colors for flow system components with explicit grouping. - This class provides component-centric color management where each component gets - a stable color assigned once, ensuring consistent coloring across all plots. - Components can be grouped using pattern matching, and each group uses a different colormap. + This class provides simple, explicit color management where components are either + assigned direct colors or grouped together to get shades from a colorscale. Key Features: - - **Stable colors**: Components assigned colors once based on sorted order - - **Pattern-based grouping**: Auto-group components using patterns (prefix, contains, regex, etc.) + - **Explicit grouping**: Map colorscales to component lists for clear control + - **Direct colors**: Assign specific colors to individual components + - **Stable colors**: Components assigned colors once, ensuring consistency across all plots - **Variable extraction**: Auto-extract component names from variable names - - **Flexible colormaps**: Use Plotly sequential palettes or custom colors - - **Override support**: Manually override specific component colors - **Zero configuration**: Works automatically with sensible defaults - Available Color Families (14 single-hue palettes): + Available Colorscale Families (14 single-hue sequential palettes): Cool: blues, greens, teals, purples, mint, emrld, darkmint Warm: reds, oranges, peach, pinks, burg, sunsetdark Neutral: greys Example Usage: - Basic usage (automatic, each component gets distinct color): + Setup with mixed direct and grouped colors: ```python - manager = ComponentColorManager(components=['Boiler1', 'Boiler2', 'CHP1']) - color = manager.get_color('Boiler1') # Always same color - ``` - - Grouped coloring (components in same group get shades of same color): - - ```python - manager = ComponentColorManager(components=['Boiler1', 'Boiler2', 'CHP1', 'Storage1']) - manager.add_grouping_rule('Boiler', 'Heat_Producers', 'reds', 'prefix') - manager.add_grouping_rule('CHP', 'Heat_Producers', 'reds', 'prefix') - manager.add_grouping_rule('Storage', 'Storage', 'blues', 'contains') - manager.apply_colors() - - # Boiler1, Boiler2, CHP1 get different shades of red - # Storage1 gets blue + manager = ComponentColorManager() + manager.configure( + { + # Direct colors (component → color) + 'Boiler1': '#FF0000', + 'CHP': 'darkred', + # Grouped colors (colorscale → list of components) + 'oranges': ['Solar1', 'Solar2', 'SolarPV'], + 'blues': ['Wind1', 'Wind2'], + 'greens': ['Battery1', 'Battery2', 'Battery3'], + } + ) ``` - Override specific components: + Or from YAML file: ```python - manager.override({'Boiler1': '#FF0000'}) # Force Boiler1 to red + manager.configure('colors.yaml') ``` Get colors for variables (extracts component automatically): @@ -465,7 +446,7 @@ class ComponentColorManager: ``` """ - # Class-level color family defaults + # Class-level colorscale family defaults (Plotly sequential palettes) DEFAULT_FAMILIES = { 'blues': px.colors.sequential.Blues[1:8], 'greens': px.colors.sequential.Greens[1:8], @@ -486,67 +467,36 @@ class ComponentColorManager: def __init__( self, components: list[str] | None = None, - flows: dict[str, list[str]] | None = None, - flow_variation: float | None = None, - default_colormap: str = 'Dark24', + default_colormap: str | None = None, ) -> None: """Initialize component color manager. Args: - components: List of all component names in the system (optional if flows provided) - flows: Dict mapping component names to their flow labels (e.g., {'Boiler': ['Q_th', 'Q_fu']}) - flow_variation: Lightness variation strength per flow (0.02-0.15). - None or 0 disables flow shading (default: None) - default_colormap: Default colormap for ungrouped components (default: 'Dark24') + components: Optional list of all component names. If not provided, + components will be discovered from configure() calls. + default_colormap: Default colormap for ungrouped components. + If None, uses CONFIG.Plotting.default_qualitative_colorscale. """ - # Extract components from flows dict if provided - if flows is not None: - self.flows = {comp: sorted(set(flow_list)) for comp, flow_list in flows.items()} - self.components = sorted(self.flows.keys()) - elif components is not None: - self.components = sorted(set(components)) - self.flows = {} - else: - raise ValueError('Must provide either components or flows parameter') - - self.default_colormap = default_colormap + self.components = sorted(set(components)) if components else [] + self.default_colormap = default_colormap or CONFIG.Plotting.default_qualitative_colorscale self.color_families = self.DEFAULT_FAMILIES.copy() - # Flow shading settings (requires optional 'colour' library) - # flow_variation serves as both the enable flag and the variation strength - if flow_variation and not HAS_COLOUR: - logger.error( - 'Flow shading requested but optional dependency "colour" is not installed. ' - 'Install it with: pip install flixopt[flow_colors]\n' - 'Flow shading will be disabled.' - ) - self.flow_variation = None - else: - self.flow_variation = flow_variation - - # Pattern-based grouping rules - self._grouping_rules: list[dict[str, str]] = [] - # Computed colors: {component_name: color} self._component_colors: dict[str, str] = {} - # Manual overrides (highest priority) - self._overrides: dict[str, str] = {} - # Variable color cache for performance: {variable_name: color} self._variable_cache: dict[str, str] = {} - # Auto-assign default colors - self._assign_default_colors() + # Auto-assign default colors if components provided + if self.components: + self._assign_default_colors() def __repr__(self) -> str: """Return detailed representation of ComponentColorManager.""" - flow_info = f', flow_variation={self.flow_variation}' if self.flow_variation else '' return ( f'ComponentColorManager(components={len(self.components)}, ' - f'rules={len(self._grouping_rules)}, ' - f'overrides={len(self._overrides)}, ' - f"default_colormap='{self.default_colormap}'{flow_info})" + f'colors_configured={len(self._component_colors)}, ' + f"default_colormap='{self.default_colormap}')" ) def __str__(self) -> str: @@ -565,37 +515,25 @@ def __str__(self) -> str: sample_str = ', '.join(sample) lines.append(f' [{sample_str}]') - lines.append(f' Grouping rules: {len(self._grouping_rules)}') - if self._grouping_rules: - for rule in self._grouping_rules[:3]: # Show first 3 rules - lines.append( - f" - {rule['match_type']}('{rule['pattern']}') → " - f"group '{rule['group_name']}' ({rule['colormap']})" - ) - if len(self._grouping_rules) > 3: - lines.append(f' ... and {len(self._grouping_rules) - 3} more') - - lines.append(f' Overrides: {len(self._overrides)}') - if self._overrides: - for comp, color in list(self._overrides.items())[:3]: + lines.append(f' Colors configured: {len(self._component_colors)}') + if self._component_colors: + for comp, color in list(self._component_colors.items())[:3]: lines.append(f' - {comp}: {color}') - if len(self._overrides) > 3: - lines.append(f' ... and {len(self._overrides) - 3} more') + if len(self._component_colors) > 3: + lines.append(f' ... and {len(self._component_colors) - 3} more') lines.append(f' Default colormap: {self.default_colormap}') return '\n'.join(lines) @classmethod - def from_flow_system(cls, flow_system, flow_variation: float | None = None, **kwargs): + def from_flow_system(cls, flow_system, **kwargs): """Create ComponentColorManager from a FlowSystem. - Automatically extracts all components and their flows from the FlowSystem. + Automatically extracts all component names from the FlowSystem. Args: - flow_system: FlowSystem instance to extract components and flows from - flow_variation: Lightness variation strength per flow (0.02-0.15). - None or 0 disables flow shading (default: None) + flow_system: FlowSystem instance to extract components from **kwargs: Additional arguments passed to ComponentColorManager.__init__ Returns: @@ -603,11 +541,8 @@ def from_flow_system(cls, flow_system, flow_variation: float | None = None, **kw Examples: ```python - # Basic usage (no flow shading) manager = ComponentColorManager.from_flow_system(flow_system) - - # With flow shading - manager = ComponentColorManager.from_flow_system(flow_system, flow_variation=0.10) + manager.configure({'oranges': ['Solar1', 'Solar2']}) ``` """ from .flow_system import FlowSystem @@ -615,223 +550,109 @@ def from_flow_system(cls, flow_system, flow_variation: float | None = None, **kw if not isinstance(flow_system, FlowSystem): raise TypeError(f'Expected FlowSystem, got {type(flow_system).__name__}') - # Extract flows from all components - flows = {} - for component_label, component in flow_system.components.items(): - flow_labels = [flow.label for flow in component.inputs + component.outputs] - if flow_labels: # Only add if component has flows - flows[component_label] = flow_labels - - return cls(flows=flows, flow_variation=flow_variation, **kwargs) - - def add_custom_family(self, name: str, colors: list[str]) -> ComponentColorManager: - """Add a custom color family. - - Args: - name: Name for the color family. - colors: List of hex color codes. - - Returns: - Self for method chaining. - """ - self.color_families[name] = colors - return self - - def add_rule( - self, pattern: str, colormap: str, match_type: MatchType | None = None, group_name: str | None = None - ) -> ComponentColorManager: - """Add color rule with optional auto-detection (flexible API). + # Extract component names + components = list(flow_system.components.keys()) - By default, automatically detects the matching strategy from pattern syntax. - Optionally override with explicit match_type and group_name for full control. + return cls(components=components, **kwargs) - **Smart Color Detection:** If `colormap` is actually an explicit color (e.g., 'red', - '#FF0000'), and `pattern` is an exact component name (no wildcards), this method - automatically uses `override()` instead of pattern matching. + def configure(self, config: dict[str, str | list[str]] | str | pathlib.Path) -> ComponentColorManager: + """Configure component colors with explicit grouping or from file. - Auto-detection rules: - - 'Solar' → prefix matching - - 'Solar*' → glob matching - - '~Storage' → contains matching (strip ~) - - 'Solar$' → suffix matching (strip $) - - '.*Solar.*' → regex matching - - Colors are automatically applied after adding the rule. + Supports two mapping types in the config dict: + 1. Component → color (str → str): Direct color assignment + 2. Colorscale → components (str → list[str]): Group gets shades from colorscale Args: - pattern: Pattern to match components, or exact component name for explicit colors - colormap: Colormap name ('reds', 'blues', 'greens', etc.) OR explicit color - ('red', 'orange', '#FF5733', 'rgb(255,0,0)', etc.) - match_type: Optional explicit match type. If None (default), auto-detects from pattern. - Options: 'prefix', 'suffix', 'contains', 'glob', 'regex' - group_name: Optional group name for organization. If None, auto-generates from pattern. + config: Color configuration as dict or path to YAML file. + Dict format: {key: value} where: + - str → str: Direct color assignment (e.g., 'Boiler1': '#FF0000') + - str → list[str]: Colorscale mapping (e.g., 'oranges': ['Solar1', 'Solar2']) Returns: Self for method chaining Examples: - Explicit color mapping (exact component names): - - ```python - manager.add_rule('Boiler', 'orange') # Exact component → explicit color - manager.add_rule('Storage', '#00FF00') # Hex color - manager.add_rule('CHP', 'rgb(255,0,0)') # RGB color - ``` - - Pattern-based colormap rules: + Dict-based configuration (mixed direct + grouped): ```python - manager.add_rule('Solar', 'oranges') # Auto: prefix - manager.add_rule('Wind*', 'blues') # Auto: glob - manager.add_rule('~Storage', 'greens') # Auto: contains + manager.configure( + { + # Direct colors + 'Boiler1': '#FF0000', + 'CHP': 'darkred', + # Grouped colors (shades from colorscale) + 'oranges': ['Solar1', 'Solar2', 'SolarPV'], + 'blues': ['Wind1', 'Wind2'], + 'greens': ['Battery1', 'Battery2', 'Battery3'], + } + ) ``` - Override auto-detection when needed: + From YAML file: ```python - # Force prefix matching even though it has special chars - manager.add_rule('Solar*', 'oranges', match_type='prefix') - - # Explicit regex when pattern is ambiguous - manager.add_rule('Solar.+', 'oranges', match_type='regex') + manager.configure('colors.yaml') ``` - Chained configuration (mixed): - - ```python - manager.add_rule('Boiler', 'orange')\ - .add_rule('Solar*', 'oranges')\ - .add_rule('Wind*', 'blues') + YAML file example (colors.yaml): + ```yaml + # Direct colors + Boiler1: '#FF0000' + CHP: darkred + + # Grouped colors + oranges: + - Solar1 + - Solar2 + - SolarPV + blues: + - Wind1 + - Wind2 ``` """ - # Check if colormap is actually an explicit color - if mcolors.is_color_like(colormap): - # It's an explicit color, not a colormap name - # Check if pattern looks like an exact component name (no wildcards/special chars) - has_wildcards = any(char in pattern for char in '*?~$[]()^|+\\.') - - if not has_wildcards: - # Exact component name → use override for direct color assignment - self.override({pattern: colormap}) - return self - else: - # Pattern with wildcards but explicit color - this is ambiguous - logger.warning( - f"Pattern '{pattern}' has wildcards but '{colormap}' is an explicit color, not a colormap. " - f'Explicit colors should be used with exact component names. ' - f"Applying as override to component '{pattern}' (treating as literal name)." - ) - self.override({pattern: colormap}) - return self - - # colormap is a colormap name - proceed with pattern-based rule logic - # Auto-detect match type if not provided - if match_type is None: - match_type = self._detect_match_type(pattern) - - # Clean pattern based on match type (strip special markers) - clean_pattern = pattern - if match_type == 'contains' and pattern.startswith('~'): - clean_pattern = pattern[1:] # Strip ~ prefix - elif match_type == 'suffix' and pattern.endswith('$') and not any(c in pattern for c in r'.[]()^|+\\'): - clean_pattern = pattern[:-1] # Strip $ suffix (only if not part of regex) - - # Auto-generate group name if not provided - if group_name is None: - group_name = clean_pattern.replace('*', '').replace('?', '').replace('.', '')[:20] - - # Delegate to _add_grouping_rule - return self._add_grouping_rule(clean_pattern, group_name, colormap, match_type) - - def _add_grouping_rule( - self, pattern: str, group_name: str, colormap: str, match_type: MatchType = 'prefix' - ) -> ComponentColorManager: - """Add pattern rule for grouping components (low-level API). - - Args: - pattern: Pattern to match component names against - group_name: Name of the group (used for organization) - colormap: Colormap name for this group ('reds', 'blues', etc.') - match_type: Type of pattern matching (default: 'prefix') - - 'prefix': Match if component starts with pattern - - 'suffix': Match if component ends with pattern - - 'contains': Match if pattern appears in component name - - 'glob': Unix wildcards (* and ?) - - 'regex': Regular expression matching - """ - valid_types = ('prefix', 'suffix', 'contains', 'glob', 'regex') - if match_type not in valid_types: - raise ValueError(f"match_type must be one of {valid_types}, got '{match_type}'") + # Load from file if path provided + if isinstance(config, (str, pathlib.Path)): + config = self._load_config_from_file(config) - self._grouping_rules.append( - {'pattern': pattern, 'group_name': group_name, 'colormap': colormap, 'match_type': match_type} - ) - # Auto-apply colors after adding rule for immediate effect - self.apply_colors() - return self + if not isinstance(config, dict): + raise TypeError(f'Config must be dict or file path, got {type(config).__name__}') - def apply_colors(self) -> None: - """Apply grouping rules and assign colors to all components. + # Process config: distinguish between direct colors and grouped colors + for key, value in config.items(): + if isinstance(value, str): + # Direct assignment: component → color + self._component_colors[key] = value + # Add to components list if not already there + if key not in self.components: + self.components.append(key) + self.components.sort() + + elif isinstance(value, list): + # Group assignment: colorscale → [components] + colorscale_name = key + components = value + + # Sample N colors from the colorscale + colors = self._sample_colors_from_colorscale(colorscale_name, len(components)) + + # Assign each component a color + for component, color in zip(components, colors, strict=False): + self._component_colors[component] = color + # Add to components list if not already there + if component not in self.components: + self.components.append(component) + self.components.sort() - This recomputes colors for all components based on current grouping rules. - Components are grouped, then within each group they get sequential colors - from the group's colormap (based on sorted order for stability). + else: + raise TypeError( + f'Invalid config value type for key "{key}". ' + f'Expected str (color) or list[str] (components), got {type(value).__name__}' + ) - Call this after adding/changing grouping rules to update colors. - """ - # Group components by matching rules - groups: dict[str, dict] = {} - - for component in self.components: - matched = False - for rule in self._grouping_rules: - if self._match_pattern(component, rule['pattern'], rule['match_type']): - group_name = rule['group_name'] - if group_name not in groups: - groups[group_name] = {'components': [], 'colormap': rule['colormap']} - groups[group_name]['components'].append(component) - matched = True - break # First match wins - - if not matched: - # Unmatched components go to default group - if '_ungrouped' not in groups: - groups['_ungrouped'] = {'components': [], 'colormap': self.default_colormap} - groups['_ungrouped']['components'].append(component) - - # Assign colors within each group (stable sorted order) - self._component_colors = {} - for group_data in groups.values(): - colormap = self._get_colormap_colors(group_data['colormap']) - sorted_components = sorted(group_data['components']) # Stable! - - for idx, component in enumerate(sorted_components): - self._component_colors[component] = colormap[idx % len(colormap)] - - # Apply overrides (highest priority) - self._component_colors.update(self._overrides) - - # Clear variable cache since colors have changed + # Clear cache since colors changed self._variable_cache.clear() - def override(self, component_colors: dict[str, str]) -> None: - """Override colors for specific components. - - These overrides have highest priority and persist even after regrouping. - - Args: - component_colors: Dict mapping component names to colors - - Examples: - ```python - manager.override({'Boiler1': '#FF0000', 'CHP1': '#00FF00'}) - ``` - """ - self._overrides.update(component_colors) - self._component_colors.update(component_colors) - - # Clear variable cache since colors have changed - self._variable_cache.clear() + return self def get_color(self, component: str) -> str: """Get color for a component. @@ -902,43 +723,27 @@ def _extract_component_and_flow(self, variable: str) -> tuple[str, str | None]: def get_variable_color(self, variable: str) -> str: """Get color for a variable (extracts component automatically). - If flow_shading is enabled, generates subtle color variations for different - flows of the same component. - Args: - variable: Variable name + variable: Variable name (e.g., 'Boiler1(Bus_A)|flow_rate') Returns: Hex color string + + Examples: + ```python + color = manager.get_variable_color('Boiler1(Q_th)|flow_rate') + # Returns the color assigned to 'Boiler1' + ``` """ # Check cache first if variable in self._variable_cache: return self._variable_cache[variable] - # Extract component and flow - component, flow = self._extract_component_and_flow(variable) - - # Get base color for component - base_color = self.get_color(component) + # Extract component name from variable + component = self.extract_component(variable) - # Apply flow shading if enabled and flow is present - if self.flow_variation and flow is not None and component in self.flows: - # Get sorted flow list for this component - component_flows = self.flows[component] - - if flow in component_flows and len(component_flows) > 1: - # Generate shades for all flows - shades = self._create_flow_shades(base_color, len(component_flows)) - - # Assign shade based on flow's position in sorted list - flow_idx = component_flows.index(flow) - color = shades[flow_idx] - else: - # Flow not in predefined list or only one flow - use base color - color = base_color - else: - # No flow shading or no flow info - use base color - color = base_color + # Get color for component + color = self.get_color(component) # Cache and return self._variable_cache[variable] = color @@ -968,216 +773,104 @@ def to_dict(self) -> dict[str, str]: # ==================== INTERNAL METHODS ==================== def _assign_default_colors(self) -> None: - """Assign default colors to all components (no grouping).""" - colormap = self._get_colormap_colors(self.default_colormap) + """Assign default colors to all components using default colormap.""" + colors = self._sample_colors_from_colorscale(self.default_colormap, len(self.components)) - for idx, component in enumerate(self.components): - self._component_colors[component] = colormap[idx % len(colormap)] - - def _detect_match_type(self, pattern: str) -> MatchType: - """Auto-detect match type from pattern syntax. - - Detection logic: - - Contains '~' prefix → 'contains' (strip ~ from pattern) - - Ends with '$' → 'suffix' - - Contains '*' or '?' → 'glob' - - Contains regex special chars (^[]().|+\\) → 'regex' - - Otherwise → 'prefix' (default) - - Args: - pattern: Pattern string to analyze - - Returns: - Detected match type - - Examples: - >>> _detect_match_type('Solar') # 'prefix' - >>> _detect_match_type('Solar*') # 'glob' - >>> _detect_match_type('~Storage') # 'contains' - >>> _detect_match_type('.*Solar.*') # 'regex' - >>> _detect_match_type('Solar$') # 'suffix' - """ - # Check for explicit contains marker - if pattern.startswith('~'): - return 'contains' - - # Check for suffix marker (but only if not a regex pattern) - if pattern.endswith('$') and len(pattern) > 1 and not any(c in pattern for c in r'.[]()^|+\\'): - return 'suffix' - - # Check for regex special characters (before glob, since .* is regex not glob) - # Exclude * and ? which are also glob chars - regex_only_chars = r'.[]()^|+\\' - if any(char in pattern for char in regex_only_chars): - return 'regex' - - # Check for simple glob wildcards - if '*' in pattern or '?' in pattern: - return 'glob' - - # Default to prefix matching - return 'prefix' + for component, color in zip(self.components, colors, strict=False): + self._component_colors[component] = color @staticmethod - def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str]: - """Load color configuration from YAML or JSON file. + def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str | list[str]]: + """Load color configuration from YAML file. Args: - file_path: Path to YAML or JSON configuration file + file_path: Path to YAML configuration file Returns: - Dictionary mapping patterns to colormaps + Dictionary with color configuration (supports both str and list values) Raises: FileNotFoundError: If file doesn't exist ValueError: If file format is unsupported or invalid + ImportError: If PyYAML is not installed Examples: YAML file (colors.yaml): ```yaml - Solar*: oranges - Wind*: blues - Battery: greens - ~Storage: teals - ``` - - JSON file (colors.json): - ```json - { - "Solar*": "oranges", - "Wind*": "blues", - "Battery": "greens" - } + # Direct colors + Boiler1: '#FF0000' + CHP: darkred + + # Grouped colors + oranges: + - Solar1 + - Solar2 + blues: + - Wind1 + - Wind2 ``` """ - import json - file_path = pathlib.Path(file_path) if not file_path.exists(): raise FileNotFoundError(f'Color configuration file not found: {file_path}') - # Determine file type from extension + # Only support YAML suffix = file_path.suffix.lower() + if suffix not in ['.yaml', '.yml']: + raise ValueError(f'Unsupported file format: {suffix}. Only YAML (.yaml, .yml) is supported.') - if suffix in ['.yaml', '.yml']: - try: - import yaml - except ImportError as e: - raise ImportError( - 'PyYAML is required to load YAML config files. Install it with: pip install pyyaml' - ) from e - - with open(file_path, encoding='utf-8') as f: - config = yaml.safe_load(f) + try: + import yaml + except ImportError as e: + raise ImportError( + 'PyYAML is required to load YAML config files. Install it with: pip install pyyaml' + ) from e - elif suffix == '.json': - with open(file_path, encoding='utf-8') as f: - config = json.load(f) - - else: - raise ValueError(f'Unsupported file format: {suffix}. Supported formats: .yaml, .yml, .json') + with open(file_path, encoding='utf-8') as f: + config = yaml.safe_load(f) # Validate config structure if not isinstance(config, dict): raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') - # Ensure all values are strings - for key, value in config.items(): - if not isinstance(key, str) or not isinstance(value, str): - raise ValueError(f'Invalid config entry: {key}: {value}. Both keys and values must be strings.') - return config - def _match_pattern(self, value: str, pattern: str, match_type: str) -> bool: - """Check if value matches pattern. - - Args: - value: String to test - pattern: Pattern to match against - match_type: Type of matching - - Returns: - True if matches - """ - if match_type == 'prefix': - return value.startswith(pattern) - elif match_type == 'suffix': - return value.endswith(pattern) - elif match_type == 'contains': - return pattern in value - elif match_type == 'glob': - return fnmatch.fnmatch(value, pattern) - elif match_type == 'regex': - try: - return bool(re.search(pattern, value)) - except re.error as e: - raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e - return False - - def _get_colormap_colors(self, colormap_name: str) -> list[str]: - """Get list of colors from colormap name.""" - - # Check custom families first - if colormap_name in self.color_families: - return self.color_families[colormap_name] - - # Try qualitative palettes (best for discrete components) - if hasattr(px.colors.qualitative, colormap_name.title()): - return getattr(px.colors.qualitative, colormap_name.title()) + def _sample_colors_from_colorscale(self, colorscale_name: str, num_colors: int) -> list[str]: + """Sample N colors from a colorscale (cycling if needed).""" - # Try sequential palettes - if hasattr(px.colors.sequential, colormap_name.title()): - return getattr(px.colors.sequential, colormap_name.title()) + # Get the base colorscale + color_list = None - # Fall back to ColorProcessor for matplotlib colormaps - processor = ColorProcessor(engine='plotly') - try: - colors = processor._generate_colors_from_colormap(colormap_name, 10) - return colors - except Exception: - logger.warning(f"Colormap '{colormap_name}' not found, using 'Dark24' instead") - return px.colors.qualitative.Dark24 - - def _create_flow_shades(self, base_color: str, num_flows: int) -> list[str]: - """Generate subtle color variations from a single base color using HSL. - - Uses the `colour` library for robust color manipulation. If `colour` is not - available, returns the base color for all flows. - - Args: - base_color: Color string (hex like '#D62728' or rgb like 'rgb(255, 0, 0)') - num_flows: Number of distinct shades needed - - Returns: - List of hex colors with subtle lightness variations - """ - if num_flows == 1: - return [base_color] + # 1. Check custom families first + if colorscale_name in self.color_families: + color_list = self.color_families[colorscale_name] - # Fallback if colour library not available (defensive check) - if not HAS_COLOUR: - return [base_color] * num_flows + # 2. Try qualitative palettes (best for discrete components) + elif hasattr(px.colors.qualitative, colorscale_name.title()): + color_list = getattr(px.colors.qualitative, colorscale_name.title()) - # Parse color using colour library (handles hex, rgb(), etc.) - color = Color(base_color) - h, s, lightness = color.hsl + # 3. Try sequential palettes + elif hasattr(px.colors.sequential, colorscale_name.title()): + color_list = getattr(px.colors.sequential, colorscale_name.title()) - # Create symmetric variations around base lightness - # For 3 flows with strength 0.08: [-0.08, 0, +0.08] - # For 5 flows: [-0.16, -0.08, 0, +0.08, +0.16] - center_idx = (num_flows - 1) / 2 - shades = [] - - for idx in range(num_flows): - delta_lightness = (idx - center_idx) * self.flow_variation - new_lightness = np.clip(lightness + delta_lightness, 0.1, 0.9) - - # Create new color with adjusted lightness - new_color = Color(hsl=(h, s, new_lightness)) - shades.append(new_color.hex_l) - - return shades + # 4. Fall back to ColorProcessor for other colormaps + else: + processor = ColorProcessor(engine='plotly') + try: + color_list = processor._generate_colors_from_colormap(colorscale_name, max(num_colors, 10)) + except Exception: + logger.warning(f"Colormap '{colorscale_name}' not found, using default instead") + default_title = CONFIG.Plotting.default_qualitative_colorscale.title() + color_list = getattr(px.colors.qualitative, default_title) + + # Sample/cycle to get exactly num_colors + if len(color_list) >= num_colors: + # Take first N colors if we have enough + return color_list[:num_colors] + else: + # Cycle through colors if we need more than available + return [color_list[i % len(color_list)] for i in range(num_colors)] def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: diff --git a/flixopt/results.py b/flixopt/results.py index 6f418de08..9bcd8ab4c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -332,35 +332,38 @@ def flow_system(self) -> FlowSystem: return self._flow_system def setup_colors( - self, config: dict[str, str] | str | pathlib.Path | None = None, flow_variation: float | None = None + self, config: dict[str, str | list[str]] | str | pathlib.Path | None = None ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager for configuring plot colors. Convenience method that creates a ComponentColorManager with all components registered and assigns it to `self.color_manager`. Optionally load configuration - from a dict or file. Colors are automatically applied when adding rules. + from a dict or file. Args: config: Optional color configuration: - - dict: {pattern: colormap} mapping - - str/Path: Path to YAML or JSON file + - dict: Mixed {component: color} or {colorscale: [components]} mapping + - str/Path: Path to YAML file - None: Create empty manager for manual config (default) - flow_variation: Lightness variation strength per flow (0.02-0.15). - None or 0 disables flow shading (default: None) Returns: ComponentColorManager instance ready for configuration. Examples: - Dict-based configuration (simplest): + Dict-based configuration (mixed direct + grouped): ```python - results.setup_colors({ - 'Solar*': 'oranges', - 'Wind*': 'blues', - 'Battery': 'greens', - '~Storage': 'teals' - }) + results.setup_colors( + { + # Direct colors + 'Boiler1': '#FF0000', + 'CHP': 'darkred', + # Grouped colors + 'oranges': ['Solar1', 'Solar2'], + 'blues': ['Wind1', 'Wind2'], + 'greens': ['Battery1', 'Battery2', 'Battery3'], + } + ) results['ElectricityBus'].plot_node_balance() ``` @@ -368,42 +371,25 @@ def setup_colors( ```python # colors.yaml contains: - # Solar*: oranges - # Wind*: blues + # Boiler1: '#FF0000' + # oranges: + # - Solar1 + # - Solar2 results.setup_colors('colors.yaml') ``` - Programmatic configuration: - - ```python - results.setup_colors()\ - .add_rule('Solar*', 'oranges')\ - .add_rule('Wind*', 'blues') - ``` - Disable automatic coloring: ```python results.color_manager = None # Plots use default colorscales ``` """ - import pathlib - if self.color_manager is None: - self.color_manager = plotting.ComponentColorManager.from_flow_system( - self.flow_system, flow_variation=flow_variation - ) + self.color_manager = plotting.ComponentColorManager.from_flow_system(self.flow_system) # Apply configuration if provided if config is not None: - # Load from file if string/Path - if isinstance(config, (str, pathlib.Path)): - config = plotting.ComponentColorManager._load_config_from_file(config) - - # Apply dict configuration - add_rule() now handles both colors and colormaps - if isinstance(config, dict): - for pattern, colormap in config.items(): - self.color_manager.add_rule(pattern, colormap) + self.color_manager.configure(config) return self.color_manager @@ -2088,31 +2074,34 @@ def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] def setup_colors( - self, config: dict[str, str] | str | pathlib.Path | None = None, flow_variation: float | None = None + self, config: dict[str, str | list[str]] | str | pathlib.Path | None = None ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager that propagates to all segments. Convenience method that creates a ComponentColorManager with all components registered and assigns it to `self.color_manager` and all segment results. - Optionally load configuration from a dict or file. Colors are automatically - applied when adding rules. + Optionally load configuration from a dict or file. Args: config: Optional color configuration: - - dict: {pattern: colormap} mapping - - str/Path: Path to YAML or JSON file + - dict: Mixed {component: color} or {colorscale: [components]} mapping + - str/Path: Path to YAML file - None: Create empty manager for manual config (default) - flow_variation: Lightness variation strength per flow (0.02-0.15). - None or 0 disables flow shading (default: None) Returns: ComponentColorManager instance ready for configuration (propagated to all segments). Examples: - Dict-based configuration: + Dict-based configuration (mixed direct + grouped): ```python - results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'}) + results.setup_colors( + { + 'Boiler1': '#FF0000', + 'oranges': ['Solar1', 'Solar2'], + 'blues': ['Wind1', 'Wind2'], + } + ) # All segments use the same colors results.segment_results[0]['ElectricityBus'].plot_node_balance() @@ -2124,31 +2113,16 @@ def setup_colors( ```python results.setup_colors('colors.yaml') ``` - - Programmatic configuration: - - ```python - results.setup_colors().add_rule('Solar*', 'oranges') - ``` """ if self.color_manager is None: - self.color_manager = plotting.ComponentColorManager.from_flow_system( - self.flow_system, flow_variation=flow_variation - ) + self.color_manager = plotting.ComponentColorManager.from_flow_system(self.flow_system) # Propagate to all segment results for consistent coloring for segment in self.segment_results: segment.color_manager = self.color_manager # Apply configuration if provided if config is not None: - # Load from file if string/Path - if isinstance(config, (str, pathlib.Path)): - config = plotting.ComponentColorManager._load_config_from_file(config) - - # Apply dict configuration - add_rule() now handles both colors and colormaps - if isinstance(config, dict): - for pattern, colormap in config.items(): - self.color_manager.add_rule(pattern, colormap) + self.color_manager.configure(config) return self.color_manager diff --git a/pyproject.toml b/pyproject.toml index f6d27f294..227eca49e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,11 +52,6 @@ dependencies = [ ] [project.optional-dependencies] -# Flow-level color shading (enables subtle color variations for flows) -flow_colors = [ - "colour >= 0.1.5, < 0.2", -] - # Interactive network visualization with enhanced color picker network_viz = [ "dash >= 3.0.0, < 4", @@ -73,7 +68,6 @@ full = [ "tsam >= 2.3.1, < 3", # Time series aggregation "scipy >= 1.15.1, < 2", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0, < 13", - "colour >= 0.1.5, < 0.2", # Flow-level color shading "dash >= 3.0.0, < 4", # Visualizing FlowSystem Network as app "dash-cytoscape >= 1.0.0, < 2", # Visualizing FlowSystem Network as app "dash-daq >= 0.6.0, < 1", # Visualizing FlowSystem Network as app From c634da7d788ad1e333aeec8af815fceeac9d7f29 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:11:47 +0200 Subject: [PATCH 138/173] Update default colormap --- flixopt/config.py | 2 +- flixopt/results.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 7f5d932c7..7531465e9 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -67,7 +67,7 @@ 'default_figure_height': None, 'default_facet_cols': 3, 'default_sequential_colorscale': 'turbo', - 'default_qualitative_colorscale': 'dark24', + 'default_qualitative_colorscale': 'plotly', } ), } diff --git a/flixopt/results.py b/flixopt/results.py index 9bcd8ab4c..53beed686 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -384,8 +384,7 @@ def setup_colors( results.color_manager = None # Plots use default colorscales ``` """ - if self.color_manager is None: - self.color_manager = plotting.ComponentColorManager.from_flow_system(self.flow_system) + self.color_manager = plotting.ComponentColorManager.from_flow_system(self.flow_system) # Apply configuration if provided if config is not None: From d109bf8dfe8abb225907f167691195b14eecea7d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:14:23 +0200 Subject: [PATCH 139/173] Update default colormap to default colorscale --- flixopt/plotting.py | 54 +++++++++++++-------------- flixopt/results.py | 9 ++++- tests/test_component_color_manager.py | 2 +- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 2c146a030..df765b541 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -148,7 +148,7 @@ class ColorProcessor: ```python # Initialize for Plotly backend - processor = ColorProcessor(engine='plotly', default_colormap='turbo') + processor = ColorProcessor(engine='plotly', default_colorscale='turbo') # Process different color specifications colors = processor.process_colors('plasma', ['Gen1', 'Gen2', 'Storage']) @@ -179,18 +179,18 @@ class ColorProcessor: Args: engine: Plotting backend ('plotly' or 'matplotlib'). Determines output color format. - default_colormap: Fallback colormap when requested palettes are unavailable. + default_colorscale: Fallback colormap when requested palettes are unavailable. Common options: 'turbo', 'plasma', 'portland'. """ - def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str | None = None): + def __init__(self, engine: PlottingEngine = 'plotly', default_colorscale: str | None = None): """Initialize the color processor with specified backend and defaults.""" if engine not in ['plotly', 'matplotlib']: raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}') self.engine = engine - self.default_colormap = ( - default_colormap if default_colormap is not None else CONFIG.Plotting.default_qualitative_colorscale + self.default_colorscale = ( + default_colorscale if default_colorscale is not None else CONFIG.Plotting.default_qualitative_colorscale ) def _get_sequential_colorscale(self, colormap_name: str, num_colors: int) -> list[str] | None: @@ -235,23 +235,23 @@ def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> li if colors is not None: return colors - # Fallback to default_colormap - logger.warning(f"Colormap '{colormap_name}' not found in Plotly. Trying default '{self.default_colormap}'") + # Fallback to default_colorscale + logger.warning(f"Colormap '{colormap_name}' not found in Plotly. Trying default '{self.default_colorscale}'") # Try default as qualitative - default_title = self.default_colormap.title() + default_title = self.default_colorscale.title() if hasattr(px.colors.qualitative, default_title): color_list = getattr(px.colors.qualitative, default_title) return [color_list[i % len(color_list)] for i in range(num_colors)] # Try default as sequential - colors = self._get_sequential_colorscale(self.default_colormap, num_colors) + colors = self._get_sequential_colorscale(self.default_colorscale, num_colors) if colors is not None: return colors # Ultimate fallback: use built-in Plotly qualitative colormap logger.warning( - f"Both '{colormap_name}' and default '{self.default_colormap}' not found. " + f"Both '{colormap_name}' and default '{self.default_colorscale}' not found. " f"Using hardcoded fallback 'Plotly' colormap" ) color_list = px.colors.qualitative.Plotly @@ -276,13 +276,13 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> cmap = plt.get_cmap(colormap_name, num_colors) except ValueError as e: logger.warning( - f"Colormap '{colormap_name}' not found in Matplotlib. Trying default '{self.default_colormap}': {e}" + f"Colormap '{colormap_name}' not found in Matplotlib. Trying default '{self.default_colorscale}': {e}" ) try: - cmap = plt.get_cmap(self.default_colormap, num_colors) + cmap = plt.get_cmap(self.default_colorscale, num_colors) except ValueError: logger.warning( - f"Default colormap '{self.default_colormap}' also not found in Matplotlib. " + f"Default colormap '{self.default_colorscale}' also not found in Matplotlib. " f"Using hardcoded fallback 'tab10'" ) cmap = plt.get_cmap('tab10', num_colors) @@ -301,8 +301,8 @@ def _handle_color_list(self, colors: list[str], num_labels: int) -> list[str]: list of colors matching the number of labels """ if len(colors) == 0: - logger.error(f'Empty color list provided. Using {self.default_colormap} instead.') - return self._generate_colors_from_colormap(self.default_colormap, num_labels) + logger.error(f'Empty color list provided. Using {self.default_colorscale} instead.') + return self._generate_colors_from_colormap(self.default_colorscale, num_labels) if len(colors) < num_labels: logger.warning( @@ -331,18 +331,18 @@ def _handle_color_dict(self, colors: dict[str, str], labels: list[str]) -> list[ list of colors in the same order as labels """ if len(colors) == 0: - logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.') - return self._generate_colors_from_colormap(self.default_colormap, len(labels)) + logger.warning(f'Empty color dictionary provided. Using {self.default_colorscale} instead.') + return self._generate_colors_from_colormap(self.default_colorscale, len(labels)) # Find missing labels missing_labels = sorted(set(labels) - set(colors.keys())) if missing_labels: logger.warning( - f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.' + f'Some labels have no color specified: {missing_labels}. Using {self.default_colorscale} for these.' ) # Generate colors for missing labels - missing_colors = self._generate_colors_from_colormap(self.default_colormap, len(missing_labels)) + missing_colors = self._generate_colors_from_colormap(self.default_colorscale, len(missing_labels)) # Create a copy to avoid modifying the original colors_copy = colors.copy() @@ -385,9 +385,9 @@ def process_colors( color_list = self._handle_color_dict(colors, labels) else: logger.error( - f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.' + f'Unsupported color specification type: {type(colors)}. Using {self.default_colorscale} instead.' ) - color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels)) + color_list = self._generate_colors_from_colormap(self.default_colorscale, len(labels)) # Return either a list or a mapping if return_mapping: @@ -467,18 +467,18 @@ class ComponentColorManager: def __init__( self, components: list[str] | None = None, - default_colormap: str | None = None, + default_colorscale: str | None = None, ) -> None: """Initialize component color manager. Args: components: Optional list of all component names. If not provided, components will be discovered from configure() calls. - default_colormap: Default colormap for ungrouped components. + default_colorscale: Default colormap for ungrouped components. If None, uses CONFIG.Plotting.default_qualitative_colorscale. """ self.components = sorted(set(components)) if components else [] - self.default_colormap = default_colormap or CONFIG.Plotting.default_qualitative_colorscale + self.default_colorscale = default_colorscale or CONFIG.Plotting.default_qualitative_colorscale self.color_families = self.DEFAULT_FAMILIES.copy() # Computed colors: {component_name: color} @@ -496,7 +496,7 @@ def __repr__(self) -> str: return ( f'ComponentColorManager(components={len(self.components)}, ' f'colors_configured={len(self._component_colors)}, ' - f"default_colormap='{self.default_colormap}')" + f"default_colorscale='{self.default_colorscale}')" ) def __str__(self) -> str: @@ -522,7 +522,7 @@ def __str__(self) -> str: if len(self._component_colors) > 3: lines.append(f' ... and {len(self._component_colors) - 3} more') - lines.append(f' Default colormap: {self.default_colormap}') + lines.append(f' Default colormap: {self.default_colorscale}') return '\n'.join(lines) @@ -774,7 +774,7 @@ def to_dict(self) -> dict[str, str]: def _assign_default_colors(self) -> None: """Assign default colors to all components using default colormap.""" - colors = self._sample_colors_from_colorscale(self.default_colormap, len(self.components)) + colors = self._sample_colors_from_colorscale(self.default_colorscale, len(self.components)) for component, color in zip(self.components, colors, strict=False): self._component_colors[component] = color diff --git a/flixopt/results.py b/flixopt/results.py index 53beed686..d61ea6778 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -332,7 +332,9 @@ def flow_system(self) -> FlowSystem: return self._flow_system def setup_colors( - self, config: dict[str, str | list[str]] | str | pathlib.Path | None = None + self, + config: dict[str, str | list[str]] | str | pathlib.Path | None = None, + default_colorscale: str | None = None, ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager for configuring plot colors. @@ -345,6 +347,7 @@ def setup_colors( - dict: Mixed {component: color} or {colorscale: [components]} mapping - str/Path: Path to YAML file - None: Create empty manager for manual config (default) + default_colorscale: Optional default colorscale to use. Defaults to CONFIG.Plotting.default_default_qualitative_colorscale Returns: ComponentColorManager instance ready for configuration. @@ -384,7 +387,9 @@ def setup_colors( results.color_manager = None # Plots use default colorscales ``` """ - self.color_manager = plotting.ComponentColorManager.from_flow_system(self.flow_system) + self.color_manager = plotting.ComponentColorManager.from_flow_system( + self.flow_system, default_colorscale=default_colorscale + ) # Apply configuration if provided if config is not None: diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index 355b85066..bf59c7e40 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -16,7 +16,7 @@ def test_initialization_default(self): manager = ComponentColorManager(components) assert len(manager.components) == 3 - assert manager.default_colormap == 'Dark24' + assert manager.default_colorscale == 'Plotly' assert 'Solar_PV' in manager.components def test_sorted_components(self): From 1bbee1bc38bb571743d2d35273859ff3375cecba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:20:38 +0200 Subject: [PATCH 140/173] Update default Improve colorscale handling --- CHANGELOG.md | 1 + examples/04_Scenarios/scenario_example.py | 4 +++ flixopt/plotting.py | 42 +++++++---------------- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c641eaa4..7b1647e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### ♻️ Changed - Plotting methods now use `color_manager` by default if configured - Color management implementation simplified: Uses explicit component grouping instead of pattern matching for better clarity and maintainability (65% code reduction) +- Consolidated colormap lookup logic: `ComponentColorManager` now delegates to `ColorProcessor` for consistent colormap handling across the codebase ### 🗑️ Deprecated diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 834e55782..c366f5084 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -8,6 +8,8 @@ import flixopt as fx if __name__ == '__main__': + fx.CONFIG.Plotting.default_show = True + fx.CONFIG.apply() # 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']) @@ -196,6 +198,8 @@ # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + calculation.results.setup_colors() + calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # --- Analyze Results --- diff --git a/flixopt/plotting.py b/flixopt/plotting.py index df765b541..facc63c80 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -837,40 +837,22 @@ def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str | lis return config def _sample_colors_from_colorscale(self, colorscale_name: str, num_colors: int) -> list[str]: - """Sample N colors from a colorscale (cycling if needed).""" + """Sample N colors from a colorscale (cycling if needed). - # Get the base colorscale - color_list = None - - # 1. Check custom families first + Delegates to ColorProcessor for consistent colormap handling across the codebase. + """ + # Check custom families first (ComponentColorManager-specific feature) if colorscale_name in self.color_families: color_list = self.color_families[colorscale_name] + # Cycle through colors if needed + if len(color_list) >= num_colors: + return color_list[:num_colors] + else: + return [color_list[i % len(color_list)] for i in range(num_colors)] - # 2. Try qualitative palettes (best for discrete components) - elif hasattr(px.colors.qualitative, colorscale_name.title()): - color_list = getattr(px.colors.qualitative, colorscale_name.title()) - - # 3. Try sequential palettes - elif hasattr(px.colors.sequential, colorscale_name.title()): - color_list = getattr(px.colors.sequential, colorscale_name.title()) - - # 4. Fall back to ColorProcessor for other colormaps - else: - processor = ColorProcessor(engine='plotly') - try: - color_list = processor._generate_colors_from_colormap(colorscale_name, max(num_colors, 10)) - except Exception: - logger.warning(f"Colormap '{colorscale_name}' not found, using default instead") - default_title = CONFIG.Plotting.default_qualitative_colorscale.title() - color_list = getattr(px.colors.qualitative, default_title) - - # Sample/cycle to get exactly num_colors - if len(color_list) >= num_colors: - # Take first N colors if we have enough - return color_list[:num_colors] - else: - # Cycle through colors if we need more than available - return [color_list[i % len(color_list)] for i in range(num_colors)] + # Delegate everything else to ColorProcessor (handles qualitative, sequential, fallbacks, cycling) + processor = ColorProcessor(engine='plotly', default_colorscale=self.default_colorscale) + return processor._generate_colors_from_colormap(colorscale_name, num_colors) def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: From c921859272fe80c420f44b34c470565508bae3ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:22:45 +0200 Subject: [PATCH 141/173] Update default color families --- flixopt/plotting.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index facc63c80..4f462530c 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -448,20 +448,20 @@ class ComponentColorManager: # Class-level colorscale family defaults (Plotly sequential palettes) DEFAULT_FAMILIES = { - 'blues': px.colors.sequential.Blues[1:8], - 'greens': px.colors.sequential.Greens[1:8], - 'reds': px.colors.sequential.Reds[1:8], - 'purples': px.colors.sequential.Purples[1:8], - 'oranges': px.colors.sequential.Oranges[1:8], - 'teals': px.colors.sequential.Teal[1:8], - 'greys': px.colors.sequential.Greys[1:8], - 'pinks': px.colors.sequential.Pinkyl[1:8], - 'peach': px.colors.sequential.Peach[1:8], - 'burg': px.colors.sequential.Burg[1:8], - 'sunsetdark': px.colors.sequential.Sunsetdark[1:8], - 'mint': px.colors.sequential.Mint[1:8], - 'emrld': px.colors.sequential.Emrld[1:8], - 'darkmint': px.colors.sequential.Darkmint[1:8], + 'blues': px.colors.sequential.Blues[7:2], + 'greens': px.colors.sequential.Greens[7:2], + 'reds': px.colors.sequential.Reds[7:2], + 'purples': px.colors.sequential.Purples[7:2], + 'oranges': px.colors.sequential.Oranges[7:2], + 'teals': px.colors.sequential.Teal[7:2], + 'greys': px.colors.sequential.Greys[7:2], + 'pinks': px.colors.sequential.Pinkyl[7:2], + 'peach': px.colors.sequential.Peach[7:2], + 'burg': px.colors.sequential.Burg[7:2], + 'sunsetdark': px.colors.sequential.Sunsetdark[7:2], + 'mint': px.colors.sequential.Mint[7:2], + 'emrld': px.colors.sequential.Emrld[7:2], + 'darkmint': px.colors.sequential.Darkmint[7:2], } def __init__( From e455c6f14c84a8aa0f7aa816c32564a67da81333 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:24:36 +0200 Subject: [PATCH 142/173] Update plotly template --- flixopt/config.py | 2 +- flixopt/plotting.py | 24 ++---------------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 7531465e9..937217241 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -57,7 +57,7 @@ 'plotting': MappingProxyType( { 'plotly_renderer': 'browser', - 'plotly_template': 'plotly', + 'plotly_template': 'plotly_white', 'matplotlib_backend': None, 'default_show': False, 'default_save_path': None, diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 4f462530c..2ed866c05 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1284,13 +1284,6 @@ def with_plotly( if hasattr(trace, 'fill'): trace.fill = None - # Update layout with basic styling (Plotly Express handles sizing automatically) - fig.update_layout( - 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) @@ -1836,13 +1829,10 @@ def pie_with_plotly( ) ) - # Update layout for better aesthetics + # Update layout with plot-specific properties fig.update_layout( title=title, legend_title=legend_title, - 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 ) return fig @@ -2114,13 +2104,10 @@ def create_pie_trace(labels, values, side): right_trace.domain = dict(x=[0.52, 1]) fig.add_trace(right_trace, row=1, col=2) - # Update layout + # Update layout with plot-specific properties fig.update_layout( title=title, legend_title=legend_title, - plot_bgcolor='rgba(0,0,0,0)', # Transparent background - paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background - font=dict(size=14), margin=dict(t=80, b=50, l=30, r=30), ) @@ -2532,13 +2519,6 @@ def heatmap_with_plotly( fallback_args.update(imshow_kwargs) fig = px.imshow(**fallback_args) - # 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 From 1192caa757678e500d382d5d272f844bd3da9057 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:42:13 +0200 Subject: [PATCH 143/173] Update example --- examples/01_Simple/simple_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index bdbbfda07..36cbd9d7c 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -113,7 +113,7 @@ # --- Analyze Results --- # Colors are automatically assigned using default colormap # Optional: Configure custom colors with - calculation.results.setup_colors({'Boiler': 'oranges', 'Storage': 'greens'}) + calculation.results.setup_colors({'CHP': 'red'}) calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_charge_state() From 617b77f696880cf91c27aab6b3ecdebf2e0064c3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:20:38 +0200 Subject: [PATCH 144/173] Simplify test --- tests/test_component_color_manager.py | 371 ++++++++++++-------------- 1 file changed, 170 insertions(+), 201 deletions(-) diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py index bf59c7e40..4d58cdc3c 100644 --- a/tests/test_component_color_manager.py +++ b/tests/test_component_color_manager.py @@ -16,7 +16,7 @@ def test_initialization_default(self): manager = ComponentColorManager(components) assert len(manager.components) == 3 - assert manager.default_colorscale == 'Plotly' + assert manager.default_colorscale == 'plotly' assert 'Solar_PV' in manager.components def test_sorted_components(self): @@ -38,62 +38,87 @@ def test_default_color_assignment(self): assert color is not None assert isinstance(color, str) + def test_empty_initialization(self): + """Test initialization without components.""" + manager = ComponentColorManager() + assert len(manager.components) == 0 -class TestColorFamilies: - """Test color family functionality.""" - def test_default_families(self): - """Test that default families are available.""" - manager = ComponentColorManager([]) +class TestConfigureAPI: + """Test the configure() method with various inputs.""" - assert 'blues' in manager.color_families - assert 'oranges' in manager.color_families - assert 'greens' in manager.color_families - assert 'reds' in manager.color_families + def test_configure_direct_colors(self): + """Test direct color assignment (component → color).""" + manager = ComponentColorManager() + manager.configure({'Boiler1': '#FF0000', 'CHP': 'darkred', 'Storage': 'green'}) - def test_add_custom_family(self): - """Test adding a custom color family.""" - manager = ComponentColorManager([]) - custom_colors = ['#FF0000', '#00FF00', '#0000FF'] + assert manager.get_color('Boiler1') == '#FF0000' + assert manager.get_color('CHP') == 'darkred' + assert manager.get_color('Storage') == 'green' - result = manager.add_custom_family('ocean', custom_colors) + def test_configure_grouped_colors(self): + """Test grouped color assignment (colorscale → list of components).""" + manager = ComponentColorManager() + manager.configure( + { + 'oranges': ['Solar1', 'Solar2'], + 'blues': ['Wind1', 'Wind2'], + } + ) - assert 'ocean' in manager.color_families - assert manager.color_families['ocean'] == custom_colors - assert result is manager # Check method chaining + # All should have colors + assert manager.get_color('Solar1') is not None + assert manager.get_color('Solar2') is not None + assert manager.get_color('Wind1') is not None + assert manager.get_color('Wind2') is not None + # Solar components should have different shades + assert manager.get_color('Solar1') != manager.get_color('Solar2') -class TestGroupingRules: - """Test grouping rule functionality.""" + # Wind components should have different shades + assert manager.get_color('Wind1') != manager.get_color('Wind2') - def test_add_grouping_rule_prefix(self): - """Test adding a prefix grouping rule.""" - components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore'] - manager = ComponentColorManager(components) + def test_configure_mixed(self): + """Test mixed direct and grouped colors.""" + manager = ComponentColorManager() + manager.configure( + { + 'Boiler1': '#FF0000', + 'oranges': ['Solar1', 'Solar2'], + 'blues': ['Wind1', 'Wind2'], + } + ) - result = manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') + # Direct color + assert manager.get_color('Boiler1') == '#FF0000' - assert len(manager._grouping_rules) == 1 - assert result is manager # Check method chaining + # Grouped colors + assert manager.get_color('Solar1') is not None + assert manager.get_color('Wind1') is not None - def test_apply_colors(self): - """Test auto-grouping components based on rules.""" - components = ['Solar_PV', 'Solar_Thermal', 'Wind_Onshore', 'Wind_Offshore', 'Coal_Plant'] - manager = ComponentColorManager(components) + def test_configure_updates_components_list(self): + """Test that configure() adds components to the list.""" + manager = ComponentColorManager() + assert len(manager.components) == 0 - manager.add_grouping_rule('Solar', 'renewables_solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables_wind', 'blues', match_type='prefix') - manager.add_grouping_rule('Coal', 'fossil', 'greys', match_type='prefix') - manager.apply_colors() + manager.configure({'Boiler1': '#FF0000', 'CHP': 'red'}) - # Check that components got colors from appropriate families - solar_color = manager.get_color('Solar_PV') - wind_color = manager.get_color('Wind_Onshore') + assert len(manager.components) == 2 + assert 'Boiler1' in manager.components + assert 'CHP' in manager.components - assert solar_color is not None - assert wind_color is not None - # Colors should be different (from different families) - assert solar_color != wind_color + +class TestColorFamilies: + """Test color family functionality.""" + + def test_default_families(self): + """Test that default families are available.""" + manager = ComponentColorManager([]) + + assert 'blues' in manager.color_families + assert 'oranges' in manager.color_families + assert 'greens' in manager.color_families + assert 'reds' in manager.color_families class TestColorStability: @@ -101,12 +126,13 @@ class TestColorStability: def test_same_component_same_color(self): """Test that same component always gets same color.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant', 'Gas_Plant'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule('Solar', 'renewables_solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables_wind', 'blues', match_type='prefix') - manager.apply_colors() + manager = ComponentColorManager() + manager.configure( + { + 'oranges': ['Solar_PV'], + 'blues': ['Wind_Onshore'], + } + ) # Get colors multiple times color1 = manager.get_color('Solar_PV') @@ -117,14 +143,15 @@ def test_same_component_same_color(self): def test_color_stability_with_different_datasets(self): """Test that colors remain stable across different variable subsets.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant', 'Gas_Plant'] - manager = ComponentColorManager(components) - - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') - manager.add_grouping_rule('Coal', 'fossil_coal', 'greys', match_type='prefix') - manager.add_grouping_rule('Gas', 'fossil_gas', 'reds', match_type='prefix') - manager.apply_colors() + manager = ComponentColorManager() + manager.configure( + { + 'oranges': ['Solar_PV'], + 'blues': ['Wind_Onshore'], + 'greys': ['Coal_Plant'], + 'reds': ['Gas_Plant'], + } + ) # Dataset 1: Only Solar and Wind dataset1 = xr.Dataset( @@ -166,6 +193,15 @@ def test_extract_component_with_parentheses(self): assert component == 'Solar_PV' + def test_extract_component_with_pipe(self): + """Test extracting component from variable with pipe.""" + manager = ComponentColorManager([]) + + variable = 'Solar_PV|investment' + component = manager.extract_component(variable) + + assert component == 'Solar_PV' + def test_extract_component_no_separators(self): """Test extracting component from variable without separators.""" manager = ComponentColorManager([]) @@ -181,10 +217,8 @@ class TestVariableColorResolution: def test_get_variable_color(self): """Test getting color for a single variable.""" - components = ['Solar_PV', 'Wind_Onshore'] - manager = ComponentColorManager(components) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.apply_colors() + manager = ComponentColorManager() + manager.configure({'oranges': ['Solar_PV']}) variable = 'Solar_PV(Bus)|flow_rate' color = manager.get_variable_color(variable) @@ -194,11 +228,14 @@ def test_get_variable_color(self): def test_get_variable_colors_multiple(self): """Test getting colors for multiple variables.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] - manager = ComponentColorManager(components) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') - manager.apply_colors() + manager = ComponentColorManager() + manager.configure( + { + 'oranges': ['Solar_PV'], + 'blues': ['Wind_Onshore'], + 'greys': ['Coal_Plant'], + } + ) variables = ['Solar_PV(Bus)|flow_rate', 'Wind_Onshore(Bus)|flow_rate', 'Coal_Plant(Bus)|flow_rate'] @@ -208,37 +245,17 @@ def test_get_variable_colors_multiple(self): assert all(var in colors for var in variables) assert all(isinstance(color, str) for color in colors.values()) + def test_variable_extraction_in_color_resolution(self): + """Test that variable names are properly extracted to component names.""" + manager = ComponentColorManager() + manager.configure({'Solar_PV': '#FF0000'}) -class TestOverrides: - """Test override functionality.""" + # Variable format with flow + variable_color = manager.get_variable_color('Solar_PV(Bus)|flow_rate') + component_color = manager.get_color('Solar_PV') - def test_simple_override(self): - """Test simple color override.""" - components = ['Solar_PV', 'Wind_Onshore'] - manager = ComponentColorManager(components) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.apply_colors() - - # Override Solar_PV color - manager.override({'Solar_PV': '#FF0000'}) - - color = manager.get_color('Solar_PV') - assert color == '#FF0000' - - def test_override_precedence(self): - """Test that overrides take precedence over grouping rules.""" - components = ['Solar_PV'] - manager = ComponentColorManager(components) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.apply_colors() - - original_color = manager.get_color('Solar_PV') - - manager.override({'Solar_PV': '#FFD700'}) - - new_color = manager.get_color('Solar_PV') - assert new_color == '#FFD700' - assert new_color != original_color + # Should be the same color + assert variable_color == component_color class TestIntegrationWithResolveColors: @@ -246,11 +263,13 @@ class TestIntegrationWithResolveColors: def test_resolve_colors_with_manager(self): """Test resolve_colors with ComponentColorManager.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] - manager = ComponentColorManager(components) - manager.add_grouping_rule('Solar', 'solar', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'wind', 'blues', match_type='prefix') - manager.apply_colors() + manager = ComponentColorManager() + manager.configure( + { + 'oranges': ['Solar_PV'], + 'blues': ['Wind_Onshore'], + } + ) dataset = xr.Dataset( { @@ -282,127 +301,77 @@ def test_resolve_colors_with_dict(self): class TestMethodChaining: """Test method chaining.""" - def test_full_chaining(self): - """Test full method chaining.""" - components = ['Solar_PV', 'Wind_Onshore', 'Gas_Plant'] - manager = ( - ComponentColorManager(components=components) - .add_custom_family('ocean', ['#003f5c', '#2f4b7c']) - .add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - .add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - ) - - assert 'ocean' in manager.color_families - assert len(manager._grouping_rules) == 2 + def test_configure_returns_self(self): + """Test that configure() returns self for chaining.""" + manager = ComponentColorManager() + result = manager.configure({'Boiler': 'red'}) + assert result is manager -class TestFlowShading: - """Test flow-level color distinction.""" - - def test_initialization_with_flows_dict(self): - """Test initialization with flows parameter.""" - flows = { - 'Boiler': ['Q_th', 'Q_fu'], - 'CHP': ['P_el', 'Q_th', 'Q_fu'], - } - manager = ComponentColorManager(flows=flows, flow_variation=0.04) + def test_chaining_with_initialization(self): + """Test method chaining with initialization.""" + # Test chaining configure() after __init__ + manager = ComponentColorManager(components=['Solar_PV', 'Wind_Onshore']) + manager.configure({'oranges': ['Solar_PV']}) assert len(manager.components) == 2 - assert manager.flows == {'Boiler': ['Q_fu', 'Q_th'], 'CHP': ['P_el', 'Q_fu', 'Q_th']} # Sorted - assert manager.flow_variation == 0.04 - - def test_flow_extraction(self): - """Test _extract_component_and_flow method.""" - manager = ComponentColorManager(components=['Boiler']) - - # Test Component(Flow)|attribute format - comp, flow = manager._extract_component_and_flow('Boiler(Q_th)|flow_rate') - assert comp == 'Boiler' - assert flow == 'Q_th' - - # Test Component|attribute format (no flow) - comp, flow = manager._extract_component_and_flow('Boiler|investment') - assert comp == 'Boiler' - assert flow is None + assert manager.get_color('Solar_PV') is not None - # Test plain component name - comp, flow = manager._extract_component_and_flow('Boiler') - assert comp == 'Boiler' - assert flow is None - def test_flow_shading_disabled(self): - """Test that flow shading is disabled by default.""" - flows = {'Boiler': ['Q_th', 'Q_fu']} - manager = ComponentColorManager(flows=flows, flow_variation=None) +class TestUnknownComponents: + """Test behavior with unknown components.""" - # Both flows should get the same color - color1 = manager.get_variable_color('Boiler(Q_th)|flow_rate') - color2 = manager.get_variable_color('Boiler(Q_fu)|flow_rate') + def test_get_color_unknown_component(self): + """Test that unknown components get a default grey color.""" + manager = ComponentColorManager() + manager.configure({'Boiler': 'red'}) - assert color1 == color2 - - def test_flow_shading_enabled(self): - """Test that flow shading creates distinct colors.""" - flows = {'Boiler': ['Q_th', 'Q_fu', 'Q_el']} - manager = ComponentColorManager(flows=flows, flow_variation=0.04) + # Unknown component + color = manager.get_color('UnknownComponent') - # Get colors for all three flows - color_th = manager.get_variable_color('Boiler(Q_th)|flow_rate') - color_fu = manager.get_variable_color('Boiler(Q_fu)|flow_rate') - color_el = manager.get_variable_color('Boiler(Q_el)|flow_rate') + # Should return grey default + assert color == '#808080' - # All colors should be different - assert color_th != color_fu - assert color_fu != color_el - assert color_th != color_el + def test_get_variable_color_unknown_component(self): + """Test that unknown components in variables get default color.""" + manager = ComponentColorManager() + manager.configure({'Boiler': 'red'}) - # All colors should be valid hex - assert color_th.startswith('#') - assert color_fu.startswith('#') - assert color_el.startswith('#') + # Unknown component + color = manager.get_variable_color('UnknownComponent(Bus)|flow') - def test_flow_shading_stability(self): - """Test that flow shading produces stable colors.""" - flows = {'Boiler': ['Q_th', 'Q_fu']} - manager = ComponentColorManager(flows=flows, flow_variation=0.04) + # Should return grey default + assert color == '#808080' - # Get color multiple times - color1 = manager.get_variable_color('Boiler(Q_th)|flow_rate') - color2 = manager.get_variable_color('Boiler(Q_th)|flow_rate') - color3 = manager.get_variable_color('Boiler(Q_th)|flow_rate') - assert color1 == color2 == color3 +class TestColorCaching: + """Test that variable color caching works.""" - def test_single_flow_no_shading(self): - """Test that single flow gets base color (no shading needed).""" - flows = {'Storage': ['Q_th_load']} - manager = ComponentColorManager(flows=flows, flow_variation=0.04) + def test_cache_is_used(self): + """Test that cache is used for repeated variable lookups.""" + manager = ComponentColorManager() + manager.configure({'Solar_PV': '#FF0000'}) - # Single flow should get base color - color = manager.get_variable_color('Storage(Q_th_load)|flow_rate') - base_color = manager.get_color('Storage') + # First call populates cache + color1 = manager.get_variable_color('Solar_PV(Bus)|flow_rate') - assert color == base_color + # Second call should hit cache + color2 = manager.get_variable_color('Solar_PV(Bus)|flow_rate') - def test_flow_variation_strength(self): - """Test that variation strength parameter works.""" - flows = {'Boiler': ['Q_th', 'Q_fu']} + assert color1 == color2 + assert 'Solar_PV(Bus)|flow_rate' in manager._variable_cache - # Low variation - manager_low = ComponentColorManager(flows=flows, flow_variation=0.02) - color_low_1 = manager_low.get_variable_color('Boiler(Q_th)|flow_rate') - color_low_2 = manager_low.get_variable_color('Boiler(Q_fu)|flow_rate') + def test_cache_cleared_on_configure(self): + """Test that cache is cleared when colors are reconfigured.""" + manager = ComponentColorManager() + manager.configure({'Solar_PV': '#FF0000'}) - # High variation - manager_high = ComponentColorManager(flows=flows, flow_variation=0.15) - color_high_1 = manager_high.get_variable_color('Boiler(Q_th)|flow_rate') - color_high_2 = manager_high.get_variable_color('Boiler(Q_fu)|flow_rate') + # Populate cache + manager.get_variable_color('Solar_PV(Bus)|flow_rate') + assert len(manager._variable_cache) > 0 - # Colors should be different - assert color_low_1 != color_low_2 - assert color_high_1 != color_high_2 + # Reconfigure + manager.configure({'Solar_PV': '#00FF00'}) - # High variation should have larger difference (this is approximate test) - # We can't easily measure color distance, so just check they're all different - assert color_low_1 != color_high_1 - assert color_low_2 != color_high_2 + # Cache should be cleared + assert len(manager._variable_cache) == 0 From e1ac8a0a41f513bac9e46d1229a4d562d6a63307 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:27:11 +0200 Subject: [PATCH 145/173] Update color family defaults --- flixopt/plotting.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 2ed866c05..225f395d4 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -446,22 +446,23 @@ class ComponentColorManager: ``` """ - # Class-level colorscale family defaults (Plotly sequential palettes) + # Class-level colorscale family defaults (Plotly sequential palettes, reversed) + # Reversed so darker colors come first when assigning to components DEFAULT_FAMILIES = { - 'blues': px.colors.sequential.Blues[7:2], - 'greens': px.colors.sequential.Greens[7:2], - 'reds': px.colors.sequential.Reds[7:2], - 'purples': px.colors.sequential.Purples[7:2], - 'oranges': px.colors.sequential.Oranges[7:2], - 'teals': px.colors.sequential.Teal[7:2], - 'greys': px.colors.sequential.Greys[7:2], - 'pinks': px.colors.sequential.Pinkyl[7:2], - 'peach': px.colors.sequential.Peach[7:2], - 'burg': px.colors.sequential.Burg[7:2], - 'sunsetdark': px.colors.sequential.Sunsetdark[7:2], - 'mint': px.colors.sequential.Mint[7:2], - 'emrld': px.colors.sequential.Emrld[7:2], - 'darkmint': px.colors.sequential.Darkmint[7:2], + 'blues': px.colors.sequential.Blues[7:0:-1], + 'greens': px.colors.sequential.Greens[7:0:-1], + 'reds': px.colors.sequential.Reds[7:0:-1], + 'purples': px.colors.sequential.Purples[7:0:-1], + 'oranges': px.colors.sequential.Oranges[7:0:-1], + 'teals': px.colors.sequential.Teal[7:0:-1], + 'greys': px.colors.sequential.Greys[7:0:-1], + 'pinks': px.colors.sequential.Pinkyl[7:0:-1], + 'peach': px.colors.sequential.Peach[7:0:-1], + 'burg': px.colors.sequential.Burg[7:0:-1], + 'sunsetdark': px.colors.sequential.Sunsetdark[7:0:-1], + 'mint': px.colors.sequential.Mint[7:0:-1], + 'emrld': px.colors.sequential.Emrld[7:0:-1], + 'darkmint': px.colors.sequential.Darkmint[7:0:-1], } def __init__( From 6cf88511a539017add61fb6d30f15b8e499f6f9b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:26:33 +0200 Subject: [PATCH 146/173] Simplify documentation --- flixopt/plotting.py | 338 +++----------------------------------------- 1 file changed, 21 insertions(+), 317 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 225f395d4..e9796beaf 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -143,38 +143,10 @@ class ColorProcessor: - **Color Lists**: ['red', 'blue', 'green'] or ['#FF0000', '#0000FF', '#00FF00'] - **Label Dictionaries**: {'Generator': 'red', 'Storage': 'blue', 'Load': 'green'} - Examples: - Basic color processing: - + Example: ```python - # Initialize for Plotly backend processor = ColorProcessor(engine='plotly', default_colorscale='turbo') - - # Process different color specifications colors = processor.process_colors('plasma', ['Gen1', 'Gen2', 'Storage']) - colors = processor.process_colors(['red', 'blue', 'green'], ['A', 'B', 'C']) - colors = processor.process_colors({'Wind': 'skyblue', 'Solar': 'gold'}, ['Wind', 'Solar', 'Gas']) - - # Switch to Matplotlib - processor = ColorProcessor(engine='matplotlib') - mpl_colors = processor.process_colors('tab10', component_labels) - ``` - - Energy system visualization: - - ```python - # Specialized energy system palette - energy_colors = { - 'Natural_Gas': '#8B4513', # Brown - 'Electricity': '#FFD700', # Gold - 'Heat': '#FF4500', # Red-orange - 'Cooling': '#87CEEB', # Sky blue - 'Hydrogen': '#E6E6FA', # Lavender - 'Battery': '#32CD32', # Lime green - } - - processor = ColorProcessor('plotly') - flow_colors = processor.process_colors(energy_colors, flow_labels) ``` Args: @@ -194,16 +166,6 @@ def __init__(self, engine: PlottingEngine = 'plotly', default_colorscale: str | ) def _get_sequential_colorscale(self, colormap_name: str, num_colors: int) -> list[str] | None: - """ - Get colors from a sequential/continuous Plotly colorscale. - - Args: - colormap_name: Name of the colorscale to sample from - num_colors: Number of colors to sample - - Returns: - List of color strings (hex format), or None if colorscale not found - """ try: colorscale = px.colors.get_colorscale(colormap_name) # Generate evenly spaced points @@ -213,16 +175,6 @@ def _get_sequential_colorscale(self, colormap_name: str, num_colors: int) -> lis return None def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> list[str]: - """ - Robustly get colors from Plotly colormaps with multiple fallback levels. - - Args: - colormap_name: Name of the colormap to try - num_colors: Number of colors needed - - Returns: - List of color strings (hex format) - """ # First try qualitative color sequences (Dark24, Plotly, Set1, etc.) colormap_title = colormap_name.title() if hasattr(px.colors.qualitative, colormap_title): @@ -397,52 +349,21 @@ def process_colors( class ComponentColorManager: - """Manage consistent colors for flow system components with explicit grouping. - - This class provides simple, explicit color management where components are either - assigned direct colors or grouped together to get shades from a colorscale. - - Key Features: - - **Explicit grouping**: Map colorscales to component lists for clear control - - **Direct colors**: Assign specific colors to individual components - - **Stable colors**: Components assigned colors once, ensuring consistency across all plots - - **Variable extraction**: Auto-extract component names from variable names - - **Zero configuration**: Works automatically with sensible defaults + """Manage consistent colors for flow system components. - Available Colorscale Families (14 single-hue sequential palettes): - Cool: blues, greens, teals, purples, mint, emrld, darkmint - Warm: reds, oranges, peach, pinks, burg, sunsetdark - Neutral: greys - - Example Usage: - Setup with mixed direct and grouped colors: + Assign direct colors or group components to get shades from colorscales. + Colorscale families: blues, greens, oranges, reds, purples, teals, greys, etc. + Example: ```python manager = ComponentColorManager() manager.configure( { - # Direct colors (component → color) - 'Boiler1': '#FF0000', - 'CHP': 'darkred', - # Grouped colors (colorscale → list of components) - 'oranges': ['Solar1', 'Solar2', 'SolarPV'], - 'blues': ['Wind1', 'Wind2'], - 'greens': ['Battery1', 'Battery2', 'Battery3'], + 'Boiler1': '#FF0000', # Direct color + 'oranges': ['Solar1', 'Solar2'], # Group gets orange shades } ) - ``` - - Or from YAML file: - - ```python - manager.configure('colors.yaml') - ``` - - Get colors for variables (extracts component automatically): - - ```python - colors = manager.get_variable_colors(['Boiler1(Bus_A)|flow', 'CHP1(Bus_B)|flow']) - # Returns: {'Boiler1(Bus_A)|flow': '#...', 'CHP1(Bus_B)|flow': '#...'} + colors = manager.get_variable_colors(['Boiler1(Bus_A)|flow']) ``` """ @@ -493,7 +414,6 @@ def __init__( self._assign_default_colors() def __repr__(self) -> str: - """Return detailed representation of ComponentColorManager.""" return ( f'ComponentColorManager(components={len(self.components)}, ' f'colors_configured={len(self._component_colors)}, ' @@ -501,7 +421,6 @@ def __repr__(self) -> str: ) def __str__(self) -> str: - """Return human-readable summary of ComponentColorManager.""" lines = [ 'ComponentColorManager', f' Components: {len(self.components)}', @@ -529,23 +448,7 @@ def __str__(self) -> str: @classmethod def from_flow_system(cls, flow_system, **kwargs): - """Create ComponentColorManager from a FlowSystem. - - Automatically extracts all component names from the FlowSystem. - - Args: - flow_system: FlowSystem instance to extract components from - **kwargs: Additional arguments passed to ComponentColorManager.__init__ - - Returns: - ComponentColorManager instance - - Examples: - ```python - manager = ComponentColorManager.from_flow_system(flow_system) - manager.configure({'oranges': ['Solar1', 'Solar2']}) - ``` - """ + """Create ComponentColorManager from a FlowSystem.""" from .flow_system import FlowSystem if not isinstance(flow_system, FlowSystem): @@ -557,59 +460,11 @@ def from_flow_system(cls, flow_system, **kwargs): return cls(components=components, **kwargs) def configure(self, config: dict[str, str | list[str]] | str | pathlib.Path) -> ComponentColorManager: - """Configure component colors with explicit grouping or from file. - - Supports two mapping types in the config dict: - 1. Component → color (str → str): Direct color assignment - 2. Colorscale → components (str → list[str]): Group gets shades from colorscale + """Configure component colors from dict or YAML file. Args: - config: Color configuration as dict or path to YAML file. - Dict format: {key: value} where: - - str → str: Direct color assignment (e.g., 'Boiler1': '#FF0000') - - str → list[str]: Colorscale mapping (e.g., 'oranges': ['Solar1', 'Solar2']) - - Returns: - Self for method chaining - - Examples: - Dict-based configuration (mixed direct + grouped): - - ```python - manager.configure( - { - # Direct colors - 'Boiler1': '#FF0000', - 'CHP': 'darkred', - # Grouped colors (shades from colorscale) - 'oranges': ['Solar1', 'Solar2', 'SolarPV'], - 'blues': ['Wind1', 'Wind2'], - 'greens': ['Battery1', 'Battery2', 'Battery3'], - } - ) - ``` - - From YAML file: - - ```python - manager.configure('colors.yaml') - ``` - - YAML file example (colors.yaml): - ```yaml - # Direct colors - Boiler1: '#FF0000' - CHP: darkred - - # Grouped colors - oranges: - - Solar1 - - Solar2 - - SolarPV - blues: - - Wind1 - - Wind2 - ``` + config: Dict with 'component': 'color' or 'colorscale': ['comp1', 'comp2'], + or path to YAML file with same format. """ # Load from file if path provided if isinstance(config, (str, pathlib.Path)): @@ -656,58 +511,15 @@ def configure(self, config: dict[str, str | list[str]] | str | pathlib.Path) -> return self def get_color(self, component: str) -> str: - """Get color for a component. - - Args: - component: Component name - - Returns: - Hex color string (defaults to grey if component unknown) - """ + """Get color for a component (defaults to grey if unknown).""" return self._component_colors.get(component, '#808080') def extract_component(self, variable: str) -> str: - """Extract component name from variable name. - - Uses default extraction logic: split on '(' or '|' to get component. - - Args: - variable: Variable name (e.g., 'Boiler1(Bus_A)|flow_rate') - - Returns: - Component name (e.g., 'Boiler1') - - Examples: - ```python - extract_component('Boiler1(Bus_A)|flow') # Returns: 'Boiler1' - extract_component('CHP1|power') # Returns: 'CHP1' - extract_component('Storage') # Returns: 'Storage' - ``` - """ + """Extract component name from variable name (e.g., 'Boiler1(Bus_A)|flow' → 'Boiler1').""" component, _ = self._extract_component_and_flow(variable) return component def _extract_component_and_flow(self, variable: str) -> tuple[str, str | None]: - """Extract both component and flow name from variable name. - - Parses variable formats: - - 'Component(Flow)|attribute' → ('Component', 'Flow') - - 'Component|attribute' → ('Component', None) - - 'Component' → ('Component', None) - - Args: - variable: Variable name - - Returns: - Tuple of (component_name, flow_name or None) - - Examples: - ```python - _extract_component_and_flow('Boiler(Q_th)|flow_rate') # ('Boiler', 'Q_th') - _extract_component_and_flow('CHP(P_el)|flow_rate') # ('CHP', 'P_el') - _extract_component_and_flow('Boiler|investment') # ('Boiler', None) - ``` - """ # Try "Component(Flow)|attribute" format if '(' in variable and ')' in variable: component = variable.split('(')[0] @@ -722,20 +534,7 @@ def _extract_component_and_flow(self, variable: str) -> tuple[str, str | None]: return variable, None def get_variable_color(self, variable: str) -> str: - """Get color for a variable (extracts component automatically). - - Args: - variable: Variable name (e.g., 'Boiler1(Bus_A)|flow_rate') - - Returns: - Hex color string - - Examples: - ```python - color = manager.get_variable_color('Boiler1(Q_th)|flow_rate') - # Returns the color assigned to 'Boiler1' - ``` - """ + """Get color for a variable (extracts component automatically).""" # Check cache first if variable in self._variable_cache: return self._variable_cache[variable] @@ -751,30 +550,16 @@ def get_variable_color(self, variable: str) -> str: return color def get_variable_colors(self, variables: list[str]) -> dict[str, str]: - """Get colors for multiple variables. - - This is the main API used by plotting functions. - - Args: - variables: List of variable names - - Returns: - Dict mapping variable names to colors - """ + """Get colors for multiple variables (main API for plotting functions).""" return {var: self.get_variable_color(var) for var in variables} def to_dict(self) -> dict[str, str]: - """Get complete component→color mapping. - - Returns: - Dict of all components and their assigned colors - """ + """Get complete component→color mapping.""" return self._component_colors.copy() # ==================== INTERNAL METHODS ==================== def _assign_default_colors(self) -> None: - """Assign default colors to all components using default colormap.""" colors = self._sample_colors_from_colorscale(self.default_colorscale, len(self.components)) for component, color in zip(self.components, colors, strict=False): @@ -782,35 +567,7 @@ def _assign_default_colors(self) -> None: @staticmethod def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str | list[str]]: - """Load color configuration from YAML file. - - Args: - file_path: Path to YAML configuration file - - Returns: - Dictionary with color configuration (supports both str and list values) - - Raises: - FileNotFoundError: If file doesn't exist - ValueError: If file format is unsupported or invalid - ImportError: If PyYAML is not installed - - Examples: - YAML file (colors.yaml): - ```yaml - # Direct colors - Boiler1: '#FF0000' - CHP: darkred - - # Grouped colors - oranges: - - Solar1 - - Solar2 - blues: - - Wind1 - - Wind2 - ``` - """ + """Load color configuration from YAML file.""" file_path = pathlib.Path(file_path) if not file_path.exists(): @@ -838,10 +595,6 @@ def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str | lis return config def _sample_colors_from_colorscale(self, colorscale_name: str, num_colors: int) -> list[str]: - """Sample N colors from a colorscale (cycling if needed). - - Delegates to ColorProcessor for consistent colormap handling across the codebase. - """ # Check custom families first (ComponentColorManager-specific feature) if colorscale_name in self.color_families: color_list = self.color_families[colorscale_name] @@ -857,18 +610,7 @@ def _sample_colors_from_colorscale(self, colorscale_name: str, num_colors: int) def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: - """ - Ensure the input data is an xarray Dataset, converting from DataFrame if needed. - - Args: - data: Input data, either xarray Dataset or pandas DataFrame. - - Returns: - xarray Dataset. - - Raises: - TypeError: If data is neither Dataset nor DataFrame. - """ + """Convert DataFrame to Dataset if needed.""" if isinstance(data, xr.Dataset): return data elif isinstance(data, pd.DataFrame): @@ -879,17 +621,7 @@ def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: def _validate_plotting_data(data: xr.Dataset, allow_empty: bool = False) -> None: - """ - Validate input data for plotting and raise clear errors for common issues. - - Args: - data: xarray Dataset to validate. - allow_empty: Whether to allow empty datasets (no variables). - - Raises: - ValueError: If data is invalid for plotting. - TypeError: If data contains non-numeric types. - """ + """Validate dataset for plotting (checks for empty data, non-numeric types, etc.).""" # Check for empty data if not allow_empty and len(data.data_vars) == 0: raise ValueError('Empty Dataset provided (no variables). Cannot create plot.') @@ -924,35 +656,7 @@ def resolve_colors( colors: ColorType | ComponentColorManager, engine: PlottingEngine = 'plotly', ) -> dict[str, str]: - """Resolve colors parameter to a color mapping dict. - - This public utility function handles all color parameter types and applies the - color manager intelligently based on the data structure. Can be used standalone - or as part of CalculationResults. - - Args: - data: Dataset to create colors for. Variable names from data_vars are used as labels. - colors: Color specification or a ComponentColorManager to use - engine: Plotting engine ('plotly' or 'matplotlib') - - Returns: - Dictionary mapping variable names to colors - - Examples: - With CalculationResults: - - >>> resolved_colors = resolve_colors(data, results.color_manager) - - Standalone usage: - - >>> manager = plotting.ComponentColorManager(['Solar', 'Wind', 'Coal']) - >>> manager.add_rule('Solar', 'oranges') - >>> resolved_colors = resolve_colors(data, manager) - - Without manager: - - >>> resolved_colors = resolve_colors(data, 'turbo') - """ + """Resolve colors parameter to a dict mapping variable names to colors.""" # Get variable names from Dataset (always strings and unique) labels = list(data.data_vars.keys()) From f8bcf60b517d9cd044de87107055e1b70e749a57 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:40:58 +0200 Subject: [PATCH 147/173] Typo --- flixopt/config.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 937217241..749f87237 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -77,28 +77,28 @@ class CONFIG: """Configuration for flixopt library. - Always call ``CONFIG.apply()`` after changes. - c - Attributes: - Logging: Logging configuration. - Modeling: Optimization modeling parameters. - config_name: Configuration name. - - Examples: - ```python - CONFIG.Logging.console = True - CONFIG.Logging.level = 'DEBUG' - CONFIG.apply() - ``` + Always call ``CONFIG.apply()`` after changes. - Load from YAML file: + Attributes: + Logging: Logging configuration. + Modeling: Optimization modeling parameters. + config_name: Configuration name. - ```yaml - logging: - level: DEBUG - console: true - file: app.log - ``` + Examples: + ```python + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + ``` + + Load from YAML file: + + ```yaml + logging: + level: DEBUG + console: true + file: app.log + ``` """ class Logging: From b4ef8c2f9dae7c7661adb3cbb5c0f2f311dbade7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:47:55 +0200 Subject: [PATCH 148/173] Make matplotlib backend switch more robst --- flixopt/config.py | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 749f87237..14adb176f 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import os import sys import warnings from logging.handlers import RotatingFileHandler @@ -758,10 +759,42 @@ def _apply_plotting_config( try: import matplotlib - # Only set backend if it's different from current - current_backend = matplotlib.get_backend() - if current_backend != matplotlib_backend: - matplotlib.use(matplotlib_backend, force=True) + # Check if pyplot has been imported yet + pyplot_imported = 'matplotlib.pyplot' in sys.modules + + if not pyplot_imported: + # Safe path: Set environment variable before pyplot import + # This is the preferred method as it avoids runtime backend switching + os.environ['MPLBACKEND'] = matplotlib_backend + logger.debug(f"Set MPLBACKEND environment variable to '{matplotlib_backend}'") + else: + # pyplot is already imported - check if we need to switch + current_backend = matplotlib.get_backend() + + if current_backend == matplotlib_backend: + logger.debug(f"matplotlib backend already set to '{matplotlib_backend}'") + else: + # Need to switch backend - check if it's safe + import matplotlib.pyplot as plt + + if len(plt.get_fignums()) > 0: + logger.warning( + f"Cannot switch matplotlib backend from '{current_backend}' to '{matplotlib_backend}': " + f'There are {len(plt.get_fignums())} open figures. Close all figures before changing backend, ' + f'or set CONFIG.Plotting.matplotlib_backend before importing matplotlib.pyplot.' + ) + else: + # No open figures - attempt safe backend switch + try: + plt.switch_backend(matplotlib_backend) + logger.info( + f"Switched matplotlib backend from '{current_backend}' to '{matplotlib_backend}'" + ) + except Exception as e: + logger.warning( + f"Failed to switch matplotlib backend from '{current_backend}' to '{matplotlib_backend}': {e}. " + f'Set CONFIG.Plotting.matplotlib_backend before importing matplotlib.pyplot to avoid this issue.' + ) except ImportError: # Matplotlib not installed, skip configuration pass From f52eae04cbb19ab23a122f23ce66bcdf8364138f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:49:16 +0200 Subject: [PATCH 149/173] Update setup_colors() in SegmentedCalculationResults --- flixopt/results.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index d61ea6778..abc71457c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -2078,7 +2078,9 @@ def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] def setup_colors( - self, config: dict[str, str | list[str]] | str | pathlib.Path | None = None + self, + config: dict[str, str | list[str]] | str | pathlib.Path | None = None, + default_colorscale: str | None = None, ) -> plotting.ComponentColorManager: """Initialize and return a ColorManager that propagates to all segments. @@ -2091,6 +2093,7 @@ def setup_colors( - dict: Mixed {component: color} or {colorscale: [components]} mapping - str/Path: Path to YAML file - None: Create empty manager for manual config (default) + default_colorscale: Optional default colorscale to use. Defaults to CONFIG.Plotting.default_default_qualitative_colorscale Returns: ComponentColorManager instance ready for configuration (propagated to all segments). @@ -2118,11 +2121,12 @@ def setup_colors( results.setup_colors('colors.yaml') ``` """ - if self.color_manager is None: - self.color_manager = plotting.ComponentColorManager.from_flow_system(self.flow_system) - # Propagate to all segment results for consistent coloring - for segment in self.segment_results: - segment.color_manager = self.color_manager + self.color_manager = plotting.ComponentColorManager.from_flow_system( + self.flow_system, default_colorscale=default_colorscale + ) + # Propagate to all segment results for consistent coloring + for segment in self.segment_results: + segment.color_manager = self.color_manager # Apply configuration if provided if config is not None: From ab2957e8ed692bcfa9c627d76ed2bfe66c1b72c0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:51:50 +0200 Subject: [PATCH 150/173] Fix example --- examples/02_Complex/complex_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index cf331b497..7a4742284 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -206,7 +206,7 @@ calculation.results.to_file() # Optional: Configure custom colors (dict is simplest): - calculation.results.setup_colors({'BHKW*': 'oranges', 'Speicher': 'greens'}) + calculation.results.setup_colors({'BHKW': 'orange', 'Speicher': 'green'}) # Plot results (colors are automatically assigned to components) calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorManager) From 5c0e3986a4fd0c4a56874773c71d83caf753f9c6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:53:46 +0200 Subject: [PATCH 151/173] Update CHANGELOG.md --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1647e49..5817377f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,6 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### ♻️ Changed - Plotting methods now use `color_manager` by default if configured -- Color management implementation simplified: Uses explicit component grouping instead of pattern matching for better clarity and maintainability (65% code reduction) -- Consolidated colormap lookup logic: `ComponentColorManager` now delegates to `ColorProcessor` for consistent colormap handling across the codebase ### 🗑️ Deprecated From c583c42da35d56b14d0267a439d349b8e274f299 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:02:26 +0200 Subject: [PATCH 152/173] Simplify export_figure() --- flixopt/plotting.py | 35 +++++++++++++---------------------- tests/conftest.py | 5 +++++ 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index e9796beaf..3c112d926 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -2430,25 +2430,17 @@ def export_figure( filename = filename.with_suffix('.html') try: - is_test_env = 'PYTEST_CURRENT_TEST' in os.environ - - if is_test_env: - # Test environment: never open browser, only save if requested - if save: - fig.write_html(str(filename)) - # Ignore show flag in tests - else: - # Production environment: respect show and save flags - if save and show: - # Save and auto-open in browser - plotly.offline.plot(fig, filename=str(filename)) - elif save and not show: - # Save without opening - fig.write_html(str(filename)) - elif show and not save: - # Show interactively without saving - fig.show() - # If neither save nor show: do nothing + # Respect show and save flags (tests should set CONFIG.Plotting.default_show=False) + if save and show: + # Save and auto-open in browser + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + # Save without opening + fig.write_html(str(filename)) + elif show and not save: + # Show interactively without saving + fig.show() + # If neither save nor show: do nothing finally: # Cleanup to prevent socket warnings if hasattr(fig, '_renderer'): @@ -2459,12 +2451,11 @@ def export_figure( elif isinstance(figure_like, tuple): fig, ax = figure_like if show: - # Only show if using interactive backend and not in test environment + # Only show if using interactive backend (tests should set CONFIG.Plotting.default_show=False) backend = matplotlib.get_backend().lower() is_interactive = backend not in {'agg', 'pdf', 'ps', 'svg', 'template'} - is_test_env = 'PYTEST_CURRENT_TEST' in os.environ - if is_interactive and not is_test_env: + if is_interactive: plt.show() if save: diff --git a/tests/conftest.py b/tests/conftest.py index ac5255562..98929e467 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -838,6 +838,7 @@ def set_test_environment(): This fixture runs once per test session to: - Set matplotlib to use non-interactive 'Agg' backend - Set plotly to use non-interactive 'json' renderer + - Configure flixopt to not show plots by default - Prevent GUI windows from opening during tests """ import matplotlib @@ -848,4 +849,8 @@ def set_test_environment(): pio.renderers.default = 'json' # Use non-interactive renderer + # Configure flixopt to not show plots in tests + fx.CONFIG.Plotting.default_show = False + fx.CONFIG.apply() + yield From 67cb5a11b5186e77fe1010186f929ea634d764f5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:10:07 +0200 Subject: [PATCH 153/173] Update Examples and CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ examples/00_Minmal/minimal_example.py | 1 - examples/01_Simple/simple_example.py | 1 - examples/04_Scenarios/scenario_example.py | 2 -- flixopt/config.py | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9462641e..cd1634140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,8 +59,17 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - Mixed approach: Combine direct and grouped colors in a single call - File-based: `results.setup_colors('colors.yaml')` (YAML only) - **Heatmap fill control**: Control missing value handling with `fill='ffill'` or `fill='bfill'` +- **New CONFIG options for plot styling** + - `CONFIG.Plotting.default_sequential_colorscale` - Falls back to template's sequential colorscale when `None` + - `CONFIG.Plotting.default_qualitative_colorscale` - Falls back to template's colorway when `None` + - `CONFIG.Plotting.default_show` defaults to `True` - set to None to prevent unwanted GUI windows ### ♻️ Changed +- **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides + - Removed hardcoded `plot_bgcolor`, `paper_bgcolor`, and `font` settings from plotting functions + - Change template via `CONFIG.Plotting.plotly_template = 'plotly_dark'; CONFIG.apply()` +- **backend switching**: + - Set plotly and matplotlib backend via CONFIG - Plotting methods now use `color_manager` by default if configured ### 🗑️ Deprecated diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index aab2797be..81b7c2dba 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -11,7 +11,6 @@ if __name__ == '__main__': # Enable console logging fx.CONFIG.Logging.console = True - fx.CONFIG.Plotting.default_show = True fx.CONFIG.apply() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 36cbd9d7c..acf98a838 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -10,7 +10,6 @@ if __name__ == '__main__': # Enable console logging fx.CONFIG.Logging.console = True - fx.CONFIG.Plotting.default_show = True fx.CONFIG.apply() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index c366f5084..993349421 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -8,8 +8,6 @@ import flixopt as fx if __name__ == '__main__': - fx.CONFIG.Plotting.default_show = True - fx.CONFIG.apply() # 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']) diff --git a/flixopt/config.py b/flixopt/config.py index 14adb176f..7eee0c2c7 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -60,7 +60,7 @@ 'plotly_renderer': 'browser', 'plotly_template': 'plotly_white', 'matplotlib_backend': None, - 'default_show': False, + 'default_show': True, 'default_save_path': None, 'default_engine': 'plotly', 'default_dpi': 300, From a01a7e23b1b2d17ac511cf56015fe295760fb686 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 07:27:22 +0200 Subject: [PATCH 154/173] Simplified Config --- CHANGELOG.md | 2 - flixopt/config.py | 99 ++++++----------------------------------------- 2 files changed, 12 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1634140..955dd6b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,6 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides - Removed hardcoded `plot_bgcolor`, `paper_bgcolor`, and `font` settings from plotting functions - Change template via `CONFIG.Plotting.plotly_template = 'plotly_dark'; CONFIG.apply()` -- **backend switching**: - - Set plotly and matplotlib backend via CONFIG - Plotting methods now use `color_manager` by default if configured ### 🗑️ Deprecated diff --git a/flixopt/config.py b/flixopt/config.py index 7eee0c2c7..8b0ae1cf8 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import os import sys import warnings from logging.handlers import RotatingFileHandler @@ -57,9 +56,7 @@ ), 'plotting': MappingProxyType( { - 'plotly_renderer': 'browser', 'plotly_template': 'plotly_white', - 'matplotlib_backend': None, 'default_show': True, 'default_save_path': None, 'default_engine': 'plotly', @@ -205,10 +202,12 @@ class Modeling: class Plotting: """Plotting configuration. + Configure backends via environment variables: + - Matplotlib: Set `MPLBACKEND` environment variable (e.g., 'Agg', 'TkAgg') + - Plotly: Set `PLOTLY_RENDERER` or use `plotly.io.renderers.default` + Attributes: - plotly_renderer: Plotly renderer to use. plotly_template: Plotly theme/template applied to all plots. - matplotlib_backend: Matplotlib backend to use. default_show: Default value for the `show` parameter in plot methods. default_save_path: Default directory for saving plots. default_engine: Default plotting engine. @@ -221,10 +220,8 @@ class Plotting: Examples: ```python - # Set consistent theming and rendering - CONFIG.Plotting.plotly_renderer = 'browser' + # Set consistent theming CONFIG.Plotting.plotly_template = 'plotly_dark' - CONFIG.Plotting.matplotlib_backend = 'TkAgg' CONFIG.apply() # Configure default export and color settings @@ -237,9 +234,6 @@ class Plotting: ``` """ - plotly_renderer: ( - Literal['browser', 'notebook', 'svg', 'png', 'pdf', 'jpeg', 'json', 'plotly_mimetype'] | None - ) = _DEFAULTS['plotting']['plotly_renderer'] plotly_template: ( Literal[ 'plotly', @@ -256,22 +250,6 @@ class Plotting: ] | None ) = _DEFAULTS['plotting']['plotly_template'] - matplotlib_backend: ( - Literal[ - 'TkAgg', - 'Qt5Agg', - 'QtAgg', - 'WXAgg', - 'Agg', - 'Cairo', - 'PDF', - 'PS', - 'SVG', - 'WebAgg', - 'module://backend_interagg', - ] - | None - ) = _DEFAULTS['plotting']['matplotlib_backend'] default_show: bool = _DEFAULTS['plotting']['default_show'] default_save_path: str | None = _DEFAULTS['plotting']['default_save_path'] default_engine: Literal['plotly', 'matplotlib'] = _DEFAULTS['plotting']['default_engine'] @@ -345,9 +323,7 @@ def apply(cls): # Apply plotting configuration _apply_plotting_config( - plotly_renderer=cls.Plotting.plotly_renderer, plotly_template=cls.Plotting.plotly_template, - matplotlib_backend=cls.Plotting.matplotlib_backend, ) @classmethod @@ -713,8 +689,6 @@ def _setup_logging( def _apply_plotting_config( - plotly_renderer: Literal['browser', 'notebook', 'svg', 'png', 'pdf', 'jpeg', 'json', 'plotly_mimetype'] - | None = 'browser', plotly_template: Literal[ 'plotly', 'plotly_white', @@ -729,76 +703,27 @@ def _apply_plotting_config( 'ygridoff', ] | None = 'plotly', - matplotlib_backend: Literal[ - 'TkAgg', 'Qt5Agg', 'QtAgg', 'WXAgg', 'Agg', 'Cairo', 'PDF', 'PS', 'SVG', 'WebAgg', 'module://backend_interagg' - ] - | None = None, ) -> None: - """Apply plotting configuration to plotly and matplotlib. + """Apply plotting configuration to plotly. Args: - plotly_renderer: Plotly renderer to use. plotly_template: Plotly template/theme to apply to all plots. - matplotlib_backend: Matplotlib backend to use. If None, the existing backend is not changed. + + Note: + Configure backends via environment variables: + - Matplotlib: Set MPLBACKEND environment variable before importing matplotlib + - Plotly: Set PLOTLY_RENDERER or use plotly.io.renderers.default directly """ - # Configure Plotly renderer and template + # Configure Plotly template try: import plotly.io as pio - if plotly_renderer is not None: - pio.renderers.default = plotly_renderer - if plotly_template is not None: pio.templates.default = plotly_template except ImportError: # Plotly not installed, skip configuration pass - # Configure Matplotlib backend - if matplotlib_backend is not None: - try: - import matplotlib - - # Check if pyplot has been imported yet - pyplot_imported = 'matplotlib.pyplot' in sys.modules - - if not pyplot_imported: - # Safe path: Set environment variable before pyplot import - # This is the preferred method as it avoids runtime backend switching - os.environ['MPLBACKEND'] = matplotlib_backend - logger.debug(f"Set MPLBACKEND environment variable to '{matplotlib_backend}'") - else: - # pyplot is already imported - check if we need to switch - current_backend = matplotlib.get_backend() - - if current_backend == matplotlib_backend: - logger.debug(f"matplotlib backend already set to '{matplotlib_backend}'") - else: - # Need to switch backend - check if it's safe - import matplotlib.pyplot as plt - - if len(plt.get_fignums()) > 0: - logger.warning( - f"Cannot switch matplotlib backend from '{current_backend}' to '{matplotlib_backend}': " - f'There are {len(plt.get_fignums())} open figures. Close all figures before changing backend, ' - f'or set CONFIG.Plotting.matplotlib_backend before importing matplotlib.pyplot.' - ) - else: - # No open figures - attempt safe backend switch - try: - plt.switch_backend(matplotlib_backend) - logger.info( - f"Switched matplotlib backend from '{current_backend}' to '{matplotlib_backend}'" - ) - except Exception as e: - logger.warning( - f"Failed to switch matplotlib backend from '{current_backend}' to '{matplotlib_backend}': {e}. " - f'Set CONFIG.Plotting.matplotlib_backend before importing matplotlib.pyplot to avoid this issue.' - ) - except ImportError: - # Matplotlib not installed, skip configuration - pass - def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): """Change the logging level for the flixopt logger and all its handlers. From c636bd9fa5686d58c6241cacd1e483d40dac8ff4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:22:00 +0200 Subject: [PATCH 155/173] Simplified Colormanagement --- flixopt/plotting.py | 322 ++-------------------- flixopt/results.py | 327 +++++++++++++++------- tests/test_component_color_manager.py | 377 -------------------------- 3 files changed, 250 insertions(+), 776 deletions(-) delete mode 100644 tests/test_component_color_manager.py diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 3c112d926..9c7563718 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -8,8 +8,7 @@ Key Features: **Dual Backend Support**: Seamless switching between Plotly and Matplotlib **Energy System Focus**: Specialized plots for power flows, storage states, emissions - **Color Management**: Intelligent color processing with ColorProcessor and component-based - ComponentColorManager for stable, pattern-matched coloring + **Color Management**: Intelligent color processing with ColorProcessor for flexible coloring **Export Capabilities**: High-quality export for reports and publications **Integration Ready**: Designed for use with CalculationResults and standalone analysis @@ -348,267 +347,6 @@ def process_colors( return color_list -class ComponentColorManager: - """Manage consistent colors for flow system components. - - Assign direct colors or group components to get shades from colorscales. - Colorscale families: blues, greens, oranges, reds, purples, teals, greys, etc. - - Example: - ```python - manager = ComponentColorManager() - manager.configure( - { - 'Boiler1': '#FF0000', # Direct color - 'oranges': ['Solar1', 'Solar2'], # Group gets orange shades - } - ) - colors = manager.get_variable_colors(['Boiler1(Bus_A)|flow']) - ``` - """ - - # Class-level colorscale family defaults (Plotly sequential palettes, reversed) - # Reversed so darker colors come first when assigning to components - DEFAULT_FAMILIES = { - 'blues': px.colors.sequential.Blues[7:0:-1], - 'greens': px.colors.sequential.Greens[7:0:-1], - 'reds': px.colors.sequential.Reds[7:0:-1], - 'purples': px.colors.sequential.Purples[7:0:-1], - 'oranges': px.colors.sequential.Oranges[7:0:-1], - 'teals': px.colors.sequential.Teal[7:0:-1], - 'greys': px.colors.sequential.Greys[7:0:-1], - 'pinks': px.colors.sequential.Pinkyl[7:0:-1], - 'peach': px.colors.sequential.Peach[7:0:-1], - 'burg': px.colors.sequential.Burg[7:0:-1], - 'sunsetdark': px.colors.sequential.Sunsetdark[7:0:-1], - 'mint': px.colors.sequential.Mint[7:0:-1], - 'emrld': px.colors.sequential.Emrld[7:0:-1], - 'darkmint': px.colors.sequential.Darkmint[7:0:-1], - } - - def __init__( - self, - components: list[str] | None = None, - default_colorscale: str | None = None, - ) -> None: - """Initialize component color manager. - - Args: - components: Optional list of all component names. If not provided, - components will be discovered from configure() calls. - default_colorscale: Default colormap for ungrouped components. - If None, uses CONFIG.Plotting.default_qualitative_colorscale. - """ - self.components = sorted(set(components)) if components else [] - self.default_colorscale = default_colorscale or CONFIG.Plotting.default_qualitative_colorscale - self.color_families = self.DEFAULT_FAMILIES.copy() - - # Computed colors: {component_name: color} - self._component_colors: dict[str, str] = {} - - # Variable color cache for performance: {variable_name: color} - self._variable_cache: dict[str, str] = {} - - # Auto-assign default colors if components provided - if self.components: - self._assign_default_colors() - - def __repr__(self) -> str: - return ( - f'ComponentColorManager(components={len(self.components)}, ' - f'colors_configured={len(self._component_colors)}, ' - f"default_colorscale='{self.default_colorscale}')" - ) - - def __str__(self) -> str: - lines = [ - 'ComponentColorManager', - f' Components: {len(self.components)}', - ] - - # Show first few components as examples - if self.components: - sample = self.components[:5] - if len(self.components) > 5: - sample_str = ', '.join(sample) + f', ... ({len(self.components) - 5} more)' - else: - sample_str = ', '.join(sample) - lines.append(f' [{sample_str}]') - - lines.append(f' Colors configured: {len(self._component_colors)}') - if self._component_colors: - for comp, color in list(self._component_colors.items())[:3]: - lines.append(f' - {comp}: {color}') - if len(self._component_colors) > 3: - lines.append(f' ... and {len(self._component_colors) - 3} more') - - lines.append(f' Default colormap: {self.default_colorscale}') - - return '\n'.join(lines) - - @classmethod - def from_flow_system(cls, flow_system, **kwargs): - """Create ComponentColorManager from a FlowSystem.""" - from .flow_system import FlowSystem - - if not isinstance(flow_system, FlowSystem): - raise TypeError(f'Expected FlowSystem, got {type(flow_system).__name__}') - - # Extract component names - components = list(flow_system.components.keys()) - - return cls(components=components, **kwargs) - - def configure(self, config: dict[str, str | list[str]] | str | pathlib.Path) -> ComponentColorManager: - """Configure component colors from dict or YAML file. - - Args: - config: Dict with 'component': 'color' or 'colorscale': ['comp1', 'comp2'], - or path to YAML file with same format. - """ - # Load from file if path provided - if isinstance(config, (str, pathlib.Path)): - config = self._load_config_from_file(config) - - if not isinstance(config, dict): - raise TypeError(f'Config must be dict or file path, got {type(config).__name__}') - - # Process config: distinguish between direct colors and grouped colors - for key, value in config.items(): - if isinstance(value, str): - # Direct assignment: component → color - self._component_colors[key] = value - # Add to components list if not already there - if key not in self.components: - self.components.append(key) - self.components.sort() - - elif isinstance(value, list): - # Group assignment: colorscale → [components] - colorscale_name = key - components = value - - # Sample N colors from the colorscale - colors = self._sample_colors_from_colorscale(colorscale_name, len(components)) - - # Assign each component a color - for component, color in zip(components, colors, strict=False): - self._component_colors[component] = color - # Add to components list if not already there - if component not in self.components: - self.components.append(component) - self.components.sort() - - else: - raise TypeError( - f'Invalid config value type for key "{key}". ' - f'Expected str (color) or list[str] (components), got {type(value).__name__}' - ) - - # Clear cache since colors changed - self._variable_cache.clear() - - return self - - def get_color(self, component: str) -> str: - """Get color for a component (defaults to grey if unknown).""" - return self._component_colors.get(component, '#808080') - - def extract_component(self, variable: str) -> str: - """Extract component name from variable name (e.g., 'Boiler1(Bus_A)|flow' → 'Boiler1').""" - component, _ = self._extract_component_and_flow(variable) - return component - - def _extract_component_and_flow(self, variable: str) -> tuple[str, str | None]: - # Try "Component(Flow)|attribute" format - if '(' in variable and ')' in variable: - component = variable.split('(')[0] - flow = variable.split('(')[1].split(')')[0] - return component, flow - - # Try "Component|attribute" format (no flow) - if '|' in variable: - return variable.split('|')[0], None - - # Just the component name itself - return variable, None - - def get_variable_color(self, variable: str) -> str: - """Get color for a variable (extracts component automatically).""" - # Check cache first - if variable in self._variable_cache: - return self._variable_cache[variable] - - # Extract component name from variable - component = self.extract_component(variable) - - # Get color for component - color = self.get_color(component) - - # Cache and return - self._variable_cache[variable] = color - return color - - def get_variable_colors(self, variables: list[str]) -> dict[str, str]: - """Get colors for multiple variables (main API for plotting functions).""" - return {var: self.get_variable_color(var) for var in variables} - - def to_dict(self) -> dict[str, str]: - """Get complete component→color mapping.""" - return self._component_colors.copy() - - # ==================== INTERNAL METHODS ==================== - - def _assign_default_colors(self) -> None: - colors = self._sample_colors_from_colorscale(self.default_colorscale, len(self.components)) - - for component, color in zip(self.components, colors, strict=False): - self._component_colors[component] = color - - @staticmethod - def _load_config_from_file(file_path: str | pathlib.Path) -> dict[str, str | list[str]]: - """Load color configuration from YAML file.""" - file_path = pathlib.Path(file_path) - - if not file_path.exists(): - raise FileNotFoundError(f'Color configuration file not found: {file_path}') - - # Only support YAML - suffix = file_path.suffix.lower() - if suffix not in ['.yaml', '.yml']: - raise ValueError(f'Unsupported file format: {suffix}. Only YAML (.yaml, .yml) is supported.') - - try: - import yaml - except ImportError as e: - raise ImportError( - 'PyYAML is required to load YAML config files. Install it with: pip install pyyaml' - ) from e - - with open(file_path, encoding='utf-8') as f: - config = yaml.safe_load(f) - - # Validate config structure - if not isinstance(config, dict): - raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') - - return config - - def _sample_colors_from_colorscale(self, colorscale_name: str, num_colors: int) -> list[str]: - # Check custom families first (ComponentColorManager-specific feature) - if colorscale_name in self.color_families: - color_list = self.color_families[colorscale_name] - # Cycle through colors if needed - if len(color_list) >= num_colors: - return color_list[:num_colors] - else: - return [color_list[i % len(color_list)] for i in range(num_colors)] - - # Delegate everything else to ColorProcessor (handles qualitative, sequential, fallbacks, cycling) - processor = ColorProcessor(engine='plotly', default_colorscale=self.default_colorscale) - return processor._generate_colors_from_colormap(colorscale_name, num_colors) - - def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: """Convert DataFrame to Dataset if needed.""" if isinstance(data, xr.Dataset): @@ -653,7 +391,7 @@ def _validate_plotting_data(data: xr.Dataset, allow_empty: bool = False) -> None def resolve_colors( data: xr.Dataset, - colors: ColorType | ComponentColorManager, + colors: ColorType, engine: PlottingEngine = 'plotly', ) -> dict[str, str]: """Resolve colors parameter to a dict mapping variable names to colors.""" @@ -669,17 +407,13 @@ def resolve_colors( processor = ColorProcessor(engine=engine) return processor.process_colors(colors, labels, return_mapping=True) - if isinstance(colors, ComponentColorManager): - # Use color manager to resolve colors for variables - return colors.get_variable_colors(labels) - raise TypeError(f'Wrong type passed to resolve_colors(): {type(colors)}') def with_plotly( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', - colors: ColorType | ComponentColorManager | None = None, + colors: ColorType | None = None, title: str = '', ylabel: str = '', xlabel: str = '', @@ -707,7 +441,6 @@ def with_plotly( - A colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dict mapping labels to colors (e.g., {'Solar': '#FFD700'}) - - A ComponentColorManager instance for pattern-based color rules with component grouping title: The main title of the plot. ylabel: The label for the y-axis. xlabel: The label for the x-axis. @@ -756,15 +489,11 @@ def with_plotly( fig = with_plotly(dataset, facet_by='scenario', animate_by='period') ``` - Pattern-based colors with ComponentColorManager: + Custom color mapping: ```python - manager = ComponentColorManager(['Solar', 'Wind', 'Battery', 'Gas']) - manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.add_grouping_rule('Battery', 'storage', 'greens', match_type='contains') - manager.apply_colors() - fig = with_plotly(dataset, colors=manager, mode='area') + colors = {'Solar': 'orange', 'Wind': 'blue', 'Battery': 'green', 'Gas': 'red'} + fig = with_plotly(dataset, colors=colors, mode='area') ``` """ if colors is None: @@ -1007,7 +736,7 @@ def with_plotly( def with_matplotlib( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', - colors: ColorType | ComponentColorManager | None = None, + colors: ColorType | None = None, title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -1025,7 +754,6 @@ def with_matplotlib( - A colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dict mapping column names to colors (e.g., {'Column1': '#ff0000'}) - - A ComponentColorManager instance for pattern-based color rules with grouping and sorting title: The title of the plot. ylabel: The ylabel of the plot. xlabel: The xlabel of the plot. @@ -1042,14 +770,11 @@ def with_matplotlib( - If `mode` is 'line', stepped lines are drawn for each data series. Examples: - With ComponentColorManager: + Custom color mapping: ```python - manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) - manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.apply_colors() - fig, ax = with_matplotlib(dataset, colors=manager, mode='line') + colors = {'Solar': 'orange', 'Wind': 'blue', 'Coal': 'red'} + fig, ax = with_matplotlib(dataset, colors=colors, mode='line') ``` """ if colors is None: @@ -1426,7 +1151,7 @@ def plot_network( def pie_with_plotly( data: xr.Dataset | pd.DataFrame, - colors: ColorType | ComponentColorManager | None = None, + colors: ColorType | None = None, title: str = '', legend_title: str = '', hole: float = 0.0, @@ -1445,7 +1170,6 @@ def pie_with_plotly( - A string with a colorscale name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - - A ComponentColorManager instance for pattern-based color rules title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -1470,14 +1194,11 @@ def pie_with_plotly( fig = pie_with_plotly(dataset, colors='turbo', title='Energy Mix') ``` - With ComponentColorManager: + Custom color mapping: ```python - manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) - manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.apply_colors() - fig = pie_with_plotly(dataset, colors=manager, title='Renewable Energy') + colors = {'Solar': 'orange', 'Wind': 'blue', 'Coal': 'red'} + fig = pie_with_plotly(dataset, colors=colors, title='Renewable Energy') ``` """ if colors is None: @@ -1545,7 +1266,7 @@ def pie_with_plotly( def pie_with_matplotlib( data: xr.Dataset | pd.DataFrame, - colors: ColorType | ComponentColorManager | None = None, + colors: ColorType | None = None, title: str = '', legend_title: str = 'Categories', hole: float = 0.0, @@ -1561,7 +1282,6 @@ def pie_with_matplotlib( - A string with a colormap name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - - A ComponentColorManager instance for pattern-based color rules title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -1582,14 +1302,11 @@ def pie_with_matplotlib( fig, ax = pie_with_matplotlib(dataset, colors='turbo', title='Energy Mix') ``` - With ComponentColorManager: + Custom color mapping: ```python - manager = ComponentColorManager(['Solar', 'Wind', 'Coal']) - manager.add_grouping_rule('Solar', 'renewables', 'oranges', match_type='prefix') - manager.add_grouping_rule('Wind', 'renewables', 'blues', match_type='prefix') - manager.apply_colors() - fig, ax = pie_with_matplotlib(dataset, colors=manager, title='Renewable Energy') + colors = {'Solar': 'orange', 'Wind': 'blue', 'Coal': 'red'} + fig, ax = pie_with_matplotlib(dataset, colors=colors, title='Renewable Energy') ``` """ if colors is None: @@ -1681,7 +1398,7 @@ def pie_with_matplotlib( def dual_pie_with_plotly( data_left: xr.Dataset | pd.DataFrame, data_right: xr.Dataset | pd.DataFrame, - colors: ColorType | ComponentColorManager | None = None, + colors: ColorType | None = None, title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1701,7 +1418,6 @@ def dual_pie_with_plotly( - A string with a colorscale name (e.g., 'turbo', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - - A ComponentColorManager instance for pattern-based color rules title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. diff --git a/flixopt/results.py b/flixopt/results.py index abc71457c..8a7249e21 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -70,10 +70,10 @@ class CalculationResults: effects: Dictionary mapping effect names to EffectResults objects timesteps_extra: Extended time index including boundary conditions hours_per_timestep: Duration of each timestep for proper energy calculations - color_manager: Optional ComponentColorManager for automatic component-based coloring in plots. - When set, all plotting methods automatically use this manager when colors='auto' - (the default). Use `setup_colors()` to create and configure one, or assign - an existing manager directly. Set to None to disable automatic coloring. + colors: Optional dict mapping variable names to colors for automatic coloring in plots. + When set, all plotting methods automatically use these colors when colors=None + (the default). Use `setup_colors()` to configure colors, which returns this dict. + Set to None to disable automatic coloring. Examples: Load and analyze saved results: @@ -115,18 +115,15 @@ class CalculationResults: Configure automatic color management for plots: ```python - # Dict-based configuration (simplest): - results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues', 'Battery': 'greens'}) + # Dict-based configuration: + results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues', 'Battery': 'green'}) - # Or programmatically: - results.setup_colors().add_rule('Solar*', 'oranges').add_rule('Wind*', 'blues') - - # All plots automatically use configured colors (colors='auto' is the default) + # All plots automatically use configured colors (colors=None is the default) results['ElectricityBus'].plot_node_balance() results['Battery'].plot_charge_state() # Override when needed - results['ElectricityBus'].plot_node_balance(colors='turbo') # Ignores mapper + results['ElectricityBus'].plot_node_balance(colors='turbo') # Ignores setup ``` Design Patterns: @@ -262,8 +259,8 @@ def __init__( self._sizes = None self._effects_per_component = None - # Color manager for intelligent plot coloring - None by default, user configures explicitly - self.color_manager: plotting.ComponentColorManager | None = None + # Color dict for intelligent plot coloring - None by default, user configures explicitly + self.colors: dict[str, str] | None = None def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults: if key in self.components: @@ -334,47 +331,66 @@ def flow_system(self) -> FlowSystem: def setup_colors( self, config: dict[str, str | list[str]] | str | pathlib.Path | None = None, + *, default_colorscale: str | None = None, - ) -> plotting.ComponentColorManager: - """Initialize and return a ColorManager for configuring plot colors. + reset: bool = True, + ) -> dict[str, str]: + """Configure colors for plotting. Returns variable→color dict. - Convenience method that creates a ComponentColorManager with all components - registered and assigns it to `self.color_manager`. Optionally load configuration - from a dict or file. + Supports multiple configuration styles: + - Direct assignment: {'Boiler1': 'red'} + - Pattern matching: {'Solar*': 'orange'} or {'Solar*': 'Oranges'} + - Family grouping: {'oranges': ['Solar1', 'Solar2']} Args: config: Optional color configuration: - - dict: Mixed {component: color} or {colorscale: [components]} mapping + - dict: Component/pattern to color/colorscale mapping - str/Path: Path to YAML file - - None: Create empty manager for manual config (default) - default_colorscale: Optional default colorscale to use. Defaults to CONFIG.Plotting.default_default_qualitative_colorscale + - None: Use default colorscale for all components + default_colorscale: Default colorscale for unmapped components. + Defaults to CONFIG.Plotting.default_qualitative_colorscale + reset: If True, reset all existing colors before applying config. + If False, only update/add specified components (default: True) Returns: - ComponentColorManager instance ready for configuration. + dict[str, str]: Complete variable→color mapping Examples: - Dict-based configuration (mixed direct + grouped): + Direct color assignment: + + ```python + results.setup_colors({'Boiler1': 'red', 'CHP': 'darkred'}) + ``` + + Pattern matching with color: + + ```python + results.setup_colors({'Solar*': 'orange', 'Wind*': 'blue'}) + ``` + + Pattern matching with colorscale (generates shades): + + ```python + results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues'}) + ``` + + Family grouping (colorscale samples): ```python results.setup_colors( { - # Direct colors - 'Boiler1': '#FF0000', - 'CHP': 'darkred', - # Grouped colors 'oranges': ['Solar1', 'Solar2'], 'blues': ['Wind1', 'Wind2'], - 'greens': ['Battery1', 'Battery2', 'Battery3'], } ) - results['ElectricityBus'].plot_node_balance() ``` Load from YAML file: ```python - # colors.yaml contains: - # Boiler1: '#FF0000' + # colors.yaml: + # Boiler1: red + # Solar*: Oranges # oranges: # - Solar1 # - Solar2 @@ -384,18 +400,152 @@ def setup_colors( Disable automatic coloring: ```python - results.color_manager = None # Plots use default colorscales + results.colors = None # Plots use default colorscales ``` """ - self.color_manager = plotting.ComponentColorManager.from_flow_system( - self.flow_system, default_colorscale=default_colorscale - ) + if default_colorscale is None: + default_colorscale = CONFIG.Plotting.default_qualitative_colorscale + + if isinstance(config, (str, pathlib.Path)): + config = self._load_yaml(config) + + component_colors = self._expand_component_colors(config, default_colorscale, reset) + self.colors = self._expand_to_variables(component_colors) + return self.colors + + def _expand_component_colors( + self, config: dict[str, str | list[str]] | None, default_colorscale: str, reset: bool + ) -> dict[str, str]: + """Expand pattern matching and colorscale sampling to component→color dict.""" + import fnmatch + + component_names = list(self.components.keys()) + component_colors = {} if reset else self.get_component_colors() + + # If no config, use default colorscale for all components + if config is None: + colors = self._sample_colorscale(default_colorscale, len(component_names)) + return dict(zip(component_names, colors, strict=False)) + + # Process config entries + for key, value in config.items(): + if isinstance(value, str): + # Check if key is a pattern or direct component name + if '*' in key or '?' in key: + # Pattern matching + matched = [c for c in component_names if fnmatch.fnmatch(c, key)] + if self._is_colorscale(value): + # Sample colorscale for matched components + colors = self._sample_colorscale(value, len(matched)) + component_colors.update(zip(matched, colors, strict=False)) + else: + # Apply same color to all matched components + for comp in matched: + component_colors[comp] = value + else: + # Direct component→color assignment + component_colors[key] = value + + elif isinstance(value, list): + # Family grouping: colorscale → [components] + colors = self._sample_colorscale(key, len(value)) + component_colors.update(zip(value, colors, strict=False)) - # Apply configuration if provided - if config is not None: - self.color_manager.configure(config) + # Fill in missing components with default colorscale + missing = [c for c in component_names if c not in component_colors] + if missing: + colors = self._sample_colorscale(default_colorscale, len(missing)) + component_colors.update(zip(missing, colors, strict=False)) + + return component_colors + + def _expand_to_variables(self, component_colors: dict[str, str]) -> dict[str, str]: + """Map component colors to all their variables.""" + variable_colors = {} + for component, color in component_colors.items(): + if component in self.components: + for var in self.components[component]._variable_names: + variable_colors[var] = color + return variable_colors + + def get_component_colors(self) -> dict[str, str]: + """Extract component→color from variable→color dict.""" + if not self.colors: + return {} + component_colors = {} + for comp in self.components: + var_names = self.components[comp]._variable_names + if var_names and var_names[0] in self.colors: + component_colors[comp] = self.colors[var_names[0]] + return component_colors + + def _is_colorscale(self, name: str) -> bool: + """Check if string is a colorscale vs direct color.""" + # Direct color patterns + if name.startswith('#') or name.startswith('rgb'): + return False + # Check if it's a known CSS color (lowercase, common colors) + common_colors = { + 'red', + 'blue', + 'green', + 'yellow', + 'orange', + 'purple', + 'pink', + 'brown', + 'black', + 'white', + 'gray', + 'grey', + 'cyan', + 'magenta', + 'lime', + 'navy', + 'teal', + 'aqua', + 'maroon', + 'olive', + 'silver', + 'gold', + 'indigo', + 'violet', + } + if name.lower() in common_colors: + return False + # Check Plotly colorscales (qualitative or sequential) + import plotly.express as px + + if hasattr(px.colors.qualitative, name.title()) or hasattr(px.colors.sequential, name.title()): + return True + # Check matplotlib colorscales + try: + import matplotlib.pyplot as plt + + return name in plt.colormaps() + except Exception: + return False + + def _sample_colorscale(self, name: str, n: int) -> list[str]: + """Sample n colors from a colorscale using ColorProcessor.""" + processor = plotting.ColorProcessor(engine='plotly', default_colorscale=name) + return processor._generate_colors_from_colormap(name, n) + + def _load_yaml(self, path: str | pathlib.Path) -> dict[str, str | list[str]]: + """Load YAML config file.""" + import yaml + + path = pathlib.Path(path) + if not path.exists(): + raise FileNotFoundError(f'Color configuration file not found: {path}') + + with open(path, encoding='utf-8') as f: + config = yaml.safe_load(f) - return self.color_manager + if not isinstance(config, dict): + raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') + + return config def filter_solution( self, @@ -1123,11 +1273,11 @@ def plot_node_balance( 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. Options: - - None (default): Use `self.color_manager` if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale + - None (default): Use `self.colors` dict if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale - Colormap name string (e.g., 'turbo', 'plasma') - List of color strings - Dict mapping variable names to colors - Set `results.color_manager` to a `ComponentColorManager` for automatic component-based grouping. + Use `results.setup_colors()` to configure automatic component-based coloring. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. select: Optional data selection dict. Supports: - Single values: {'scenario': 'base', 'period': 2024} @@ -1246,14 +1396,8 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - # Resolve colors: None -> color_manager if set -> CONFIG default -> explicit value - colors_to_use = ( - self._calculation_results.color_manager - if colors is None and self._calculation_results.color_manager is not None - else CONFIG.Plotting.default_qualitative_colorscale - if colors is None - else colors - ) + # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value + colors_to_use = colors or self._calculation_results.colors or CONFIG.Plotting.default_qualitative_colorscale resolved_colors = plotting.resolve_colors(ds, colors_to_use, engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection @@ -1327,7 +1471,7 @@ def plot_node_balance_pie( Args: lower_percentage_group: Percentage threshold for "Others" grouping. - colors: Color scheme (default: None uses color_manager if configured, + colors: Color scheme (default: None uses colors dict if configured, else falls back to CONFIG.Plotting.default_qualitative_colorscale). text_info: Information to display on pie slices. save: Whether to save plot. @@ -1442,14 +1586,8 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) - # Resolve colors: None -> color_manager if set -> CONFIG default -> explicit value - colors_to_use = ( - self._calculation_results.color_manager - if colors is None and self._calculation_results.color_manager is not None - else CONFIG.Plotting.default_qualitative_colorscale - if colors is None - else colors - ) + # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value + colors_to_use = colors or self._calculation_results.colors or CONFIG.Plotting.default_qualitative_colorscale resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) if engine == 'plotly': @@ -1602,7 +1740,7 @@ def plot_charge_state( 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: Color scheme (default: None uses color_manager if configured, + colors: Color scheme (default: None uses colors dict if configured, else falls back to CONFIG.Plotting.default_qualitative_colorscale). 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. @@ -1698,14 +1836,8 @@ def plot_charge_state( # We need to include both in the color map for consistency combined_ds = ds.assign({self._charge_state: charge_state_da}) - # Resolve colors: None -> color_manager if set -> CONFIG default -> explicit value - colors_to_use = ( - self._calculation_results.color_manager - if colors is None and self._calculation_results.color_manager is not None - else CONFIG.Plotting.default_qualitative_colorscale - if colors is None - else colors - ) + # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value + colors_to_use = colors or self._calculation_results.colors or CONFIG.Plotting.default_qualitative_colorscale resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) if engine == 'plotly': @@ -1916,10 +2048,10 @@ class SegmentedCalculationResults: name: Identifier for this segmented calculation folder: Directory path for result storage and loading hours_per_timestep: Duration of each timestep - color_manager: Optional ComponentColorManager for automatic component-based coloring in plots. + colors: Optional dict mapping variable names to colors for automatic coloring in plots. When set, it is automatically propagated to all segment results, ensuring - consistent coloring across segments. Use `setup_colors()` to create - and configure one, or assign an existing manager directly. + consistent coloring across segments. Use `setup_colors()` to configure + colors across all segments. Examples: Load and analyze segmented results: @@ -1979,8 +2111,8 @@ class SegmentedCalculationResults: Configure color management for consistent plotting across segments: ```python - # Dict-based configuration (simplest): - results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues', 'Battery': 'greens'}) + # Dict-based configuration: + results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues', 'Battery': 'green'}) # Colors automatically propagate to all segments results.segment_results[0]['ElectricityBus'].plot_node_balance() @@ -2061,8 +2193,8 @@ def __init__( self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps) - # Color manager for intelligent plot coloring - None by default, user configures explicitly - self.color_manager: plotting.ComponentColorManager | None = None + # Color dict for intelligent plot coloring - None by default, user configures explicitly + self.colors: dict[str, str] | None = None @property def meta_data(self) -> dict[str, int | list[str]]: @@ -2080,33 +2212,37 @@ def segment_names(self) -> list[str]: def setup_colors( self, config: dict[str, str | list[str]] | str | pathlib.Path | None = None, + *, default_colorscale: str | None = None, - ) -> plotting.ComponentColorManager: - """Initialize and return a ColorManager that propagates to all segments. + reset: bool = True, + ) -> dict[str, str]: + """Configure colors for all segments. Returns variable→color dict. - Convenience method that creates a ComponentColorManager with all components - registered and assigns it to `self.color_manager` and all segment results. - Optionally load configuration from a dict or file. + Colors are set on the first segment and then propagated to all other + segments for consistent coloring across the entire segmented calculation. Args: config: Optional color configuration: - - dict: Mixed {component: color} or {colorscale: [components]} mapping + - dict: Component/pattern to color/colorscale mapping - str/Path: Path to YAML file - - None: Create empty manager for manual config (default) - default_colorscale: Optional default colorscale to use. Defaults to CONFIG.Plotting.default_default_qualitative_colorscale + - None: Use default colorscale for all components + default_colorscale: Default colorscale for unmapped components. + Defaults to CONFIG.Plotting.default_qualitative_colorscale + reset: If True, reset all existing colors before applying config. + If False, only update/add specified components (default: True) Returns: - ComponentColorManager instance ready for configuration (propagated to all segments). + dict[str, str]: Complete variable→color mapping Examples: - Dict-based configuration (mixed direct + grouped): + Dict-based configuration: ```python results.setup_colors( { - 'Boiler1': '#FF0000', + 'Boiler1': 'red', + 'Solar*': 'Oranges', 'oranges': ['Solar1', 'Solar2'], - 'blues': ['Wind1', 'Wind2'], } ) @@ -2121,18 +2257,17 @@ def setup_colors( results.setup_colors('colors.yaml') ``` """ - self.color_manager = plotting.ComponentColorManager.from_flow_system( - self.flow_system, default_colorscale=default_colorscale - ) - # Propagate to all segment results for consistent coloring - for segment in self.segment_results: - segment.color_manager = self.color_manager + # Setup colors on first segment + self.segment_results[0].setup_colors(config, default_colorscale=default_colorscale, reset=reset) + + # Propagate to all other segments + for segment in self.segment_results[1:]: + segment.colors = self.segment_results[0].colors - # Apply configuration if provided - if config is not None: - self.color_manager.configure(config) + # Store reference + self.colors = self.segment_results[0].colors - return self.color_manager + return self.colors def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Get variable solution removing segment overlaps. diff --git a/tests/test_component_color_manager.py b/tests/test_component_color_manager.py deleted file mode 100644 index 4d58cdc3c..000000000 --- a/tests/test_component_color_manager.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Tests for ComponentColorManager functionality.""" - -import numpy as np -import pytest -import xarray as xr - -from flixopt.plotting import ComponentColorManager, resolve_colors - - -class TestBasicFunctionality: - """Test basic ComponentColorManager functionality.""" - - def test_initialization_default(self): - """Test default initialization.""" - components = ['Solar_PV', 'Wind_Onshore', 'Coal_Plant'] - manager = ComponentColorManager(components) - - assert len(manager.components) == 3 - assert manager.default_colorscale == 'plotly' - assert 'Solar_PV' in manager.components - - def test_sorted_components(self): - """Test that components are sorted for stability.""" - components = ['C_Component', 'A_Component', 'B_Component'] - manager = ComponentColorManager(components) - - # Components should be sorted - assert manager.components == ['A_Component', 'B_Component', 'C_Component'] - - def test_default_color_assignment(self): - """Test that components get default colors on initialization.""" - components = ['Comp1', 'Comp2', 'Comp3'] - manager = ComponentColorManager(components) - - # Each component should have a color - for comp in components: - color = manager.get_color(comp) - assert color is not None - assert isinstance(color, str) - - def test_empty_initialization(self): - """Test initialization without components.""" - manager = ComponentColorManager() - assert len(manager.components) == 0 - - -class TestConfigureAPI: - """Test the configure() method with various inputs.""" - - def test_configure_direct_colors(self): - """Test direct color assignment (component → color).""" - manager = ComponentColorManager() - manager.configure({'Boiler1': '#FF0000', 'CHP': 'darkred', 'Storage': 'green'}) - - assert manager.get_color('Boiler1') == '#FF0000' - assert manager.get_color('CHP') == 'darkred' - assert manager.get_color('Storage') == 'green' - - def test_configure_grouped_colors(self): - """Test grouped color assignment (colorscale → list of components).""" - manager = ComponentColorManager() - manager.configure( - { - 'oranges': ['Solar1', 'Solar2'], - 'blues': ['Wind1', 'Wind2'], - } - ) - - # All should have colors - assert manager.get_color('Solar1') is not None - assert manager.get_color('Solar2') is not None - assert manager.get_color('Wind1') is not None - assert manager.get_color('Wind2') is not None - - # Solar components should have different shades - assert manager.get_color('Solar1') != manager.get_color('Solar2') - - # Wind components should have different shades - assert manager.get_color('Wind1') != manager.get_color('Wind2') - - def test_configure_mixed(self): - """Test mixed direct and grouped colors.""" - manager = ComponentColorManager() - manager.configure( - { - 'Boiler1': '#FF0000', - 'oranges': ['Solar1', 'Solar2'], - 'blues': ['Wind1', 'Wind2'], - } - ) - - # Direct color - assert manager.get_color('Boiler1') == '#FF0000' - - # Grouped colors - assert manager.get_color('Solar1') is not None - assert manager.get_color('Wind1') is not None - - def test_configure_updates_components_list(self): - """Test that configure() adds components to the list.""" - manager = ComponentColorManager() - assert len(manager.components) == 0 - - manager.configure({'Boiler1': '#FF0000', 'CHP': 'red'}) - - assert len(manager.components) == 2 - assert 'Boiler1' in manager.components - assert 'CHP' in manager.components - - -class TestColorFamilies: - """Test color family functionality.""" - - def test_default_families(self): - """Test that default families are available.""" - manager = ComponentColorManager([]) - - assert 'blues' in manager.color_families - assert 'oranges' in manager.color_families - assert 'greens' in manager.color_families - assert 'reds' in manager.color_families - - -class TestColorStability: - """Test color stability across different datasets.""" - - def test_same_component_same_color(self): - """Test that same component always gets same color.""" - manager = ComponentColorManager() - manager.configure( - { - 'oranges': ['Solar_PV'], - 'blues': ['Wind_Onshore'], - } - ) - - # Get colors multiple times - color1 = manager.get_color('Solar_PV') - color2 = manager.get_color('Solar_PV') - color3 = manager.get_color('Solar_PV') - - assert color1 == color2 == color3 - - def test_color_stability_with_different_datasets(self): - """Test that colors remain stable across different variable subsets.""" - manager = ComponentColorManager() - manager.configure( - { - 'oranges': ['Solar_PV'], - 'blues': ['Wind_Onshore'], - 'greys': ['Coal_Plant'], - 'reds': ['Gas_Plant'], - } - ) - - # Dataset 1: Only Solar and Wind - dataset1 = xr.Dataset( - { - 'Solar_PV(Bus)|flow_rate': (['time'], np.random.rand(10)), - 'Wind_Onshore(Bus)|flow_rate': (['time'], np.random.rand(10)), - }, - coords={'time': np.arange(10)}, - ) - - # Dataset 2: All components - dataset2 = xr.Dataset( - { - 'Solar_PV(Bus)|flow_rate': (['time'], np.random.rand(10)), - 'Wind_Onshore(Bus)|flow_rate': (['time'], np.random.rand(10)), - 'Coal_Plant(Bus)|flow_rate': (['time'], np.random.rand(10)), - 'Gas_Plant(Bus)|flow_rate': (['time'], np.random.rand(10)), - }, - coords={'time': np.arange(10)}, - ) - - colors1 = resolve_colors(dataset1, manager, engine='plotly') - colors2 = resolve_colors(dataset2, manager, engine='plotly') - - # Solar_PV and Wind_Onshore should have same colors in both datasets - assert colors1['Solar_PV(Bus)|flow_rate'] == colors2['Solar_PV(Bus)|flow_rate'] - assert colors1['Wind_Onshore(Bus)|flow_rate'] == colors2['Wind_Onshore(Bus)|flow_rate'] - - -class TestVariableExtraction: - """Test variable to component extraction.""" - - def test_extract_component_with_parentheses(self): - """Test extracting component from variable with parentheses.""" - manager = ComponentColorManager([]) - - variable = 'Solar_PV(ElectricityBus)|flow_rate' - component = manager.extract_component(variable) - - assert component == 'Solar_PV' - - def test_extract_component_with_pipe(self): - """Test extracting component from variable with pipe.""" - manager = ComponentColorManager([]) - - variable = 'Solar_PV|investment' - component = manager.extract_component(variable) - - assert component == 'Solar_PV' - - def test_extract_component_no_separators(self): - """Test extracting component from variable without separators.""" - manager = ComponentColorManager([]) - - variable = 'SimpleComponent' - component = manager.extract_component(variable) - - assert component == 'SimpleComponent' - - -class TestVariableColorResolution: - """Test getting colors for variables.""" - - def test_get_variable_color(self): - """Test getting color for a single variable.""" - manager = ComponentColorManager() - manager.configure({'oranges': ['Solar_PV']}) - - variable = 'Solar_PV(Bus)|flow_rate' - color = manager.get_variable_color(variable) - - assert color is not None - assert isinstance(color, str) - - def test_get_variable_colors_multiple(self): - """Test getting colors for multiple variables.""" - manager = ComponentColorManager() - manager.configure( - { - 'oranges': ['Solar_PV'], - 'blues': ['Wind_Onshore'], - 'greys': ['Coal_Plant'], - } - ) - - variables = ['Solar_PV(Bus)|flow_rate', 'Wind_Onshore(Bus)|flow_rate', 'Coal_Plant(Bus)|flow_rate'] - - colors = manager.get_variable_colors(variables) - - assert len(colors) == 3 - assert all(var in colors for var in variables) - assert all(isinstance(color, str) for color in colors.values()) - - def test_variable_extraction_in_color_resolution(self): - """Test that variable names are properly extracted to component names.""" - manager = ComponentColorManager() - manager.configure({'Solar_PV': '#FF0000'}) - - # Variable format with flow - variable_color = manager.get_variable_color('Solar_PV(Bus)|flow_rate') - component_color = manager.get_color('Solar_PV') - - # Should be the same color - assert variable_color == component_color - - -class TestIntegrationWithResolveColors: - """Test integration with resolve_colors function.""" - - def test_resolve_colors_with_manager(self): - """Test resolve_colors with ComponentColorManager.""" - manager = ComponentColorManager() - manager.configure( - { - 'oranges': ['Solar_PV'], - 'blues': ['Wind_Onshore'], - } - ) - - dataset = xr.Dataset( - { - 'Solar_PV(Bus)|flow_rate': (['time'], np.random.rand(10)), - 'Wind_Onshore(Bus)|flow_rate': (['time'], np.random.rand(10)), - }, - coords={'time': np.arange(10)}, - ) - - colors = resolve_colors(dataset, manager, engine='plotly') - - assert len(colors) == 2 - assert 'Solar_PV(Bus)|flow_rate' in colors - assert 'Wind_Onshore(Bus)|flow_rate' in colors - - def test_resolve_colors_with_dict(self): - """Test that resolve_colors still works with dict.""" - dataset = xr.Dataset( - {'var1': (['time'], np.random.rand(10)), 'var2': (['time'], np.random.rand(10))}, - coords={'time': np.arange(10)}, - ) - - color_dict = {'var1': '#FF0000', 'var2': '#00FF00'} - colors = resolve_colors(dataset, color_dict, engine='plotly') - - assert colors == color_dict - - -class TestMethodChaining: - """Test method chaining.""" - - def test_configure_returns_self(self): - """Test that configure() returns self for chaining.""" - manager = ComponentColorManager() - result = manager.configure({'Boiler': 'red'}) - - assert result is manager - - def test_chaining_with_initialization(self): - """Test method chaining with initialization.""" - # Test chaining configure() after __init__ - manager = ComponentColorManager(components=['Solar_PV', 'Wind_Onshore']) - manager.configure({'oranges': ['Solar_PV']}) - - assert len(manager.components) == 2 - assert manager.get_color('Solar_PV') is not None - - -class TestUnknownComponents: - """Test behavior with unknown components.""" - - def test_get_color_unknown_component(self): - """Test that unknown components get a default grey color.""" - manager = ComponentColorManager() - manager.configure({'Boiler': 'red'}) - - # Unknown component - color = manager.get_color('UnknownComponent') - - # Should return grey default - assert color == '#808080' - - def test_get_variable_color_unknown_component(self): - """Test that unknown components in variables get default color.""" - manager = ComponentColorManager() - manager.configure({'Boiler': 'red'}) - - # Unknown component - color = manager.get_variable_color('UnknownComponent(Bus)|flow') - - # Should return grey default - assert color == '#808080' - - -class TestColorCaching: - """Test that variable color caching works.""" - - def test_cache_is_used(self): - """Test that cache is used for repeated variable lookups.""" - manager = ComponentColorManager() - manager.configure({'Solar_PV': '#FF0000'}) - - # First call populates cache - color1 = manager.get_variable_color('Solar_PV(Bus)|flow_rate') - - # Second call should hit cache - color2 = manager.get_variable_color('Solar_PV(Bus)|flow_rate') - - assert color1 == color2 - assert 'Solar_PV(Bus)|flow_rate' in manager._variable_cache - - def test_cache_cleared_on_configure(self): - """Test that cache is cleared when colors are reconfigured.""" - manager = ComponentColorManager() - manager.configure({'Solar_PV': '#FF0000'}) - - # Populate cache - manager.get_variable_color('Solar_PV(Bus)|flow_rate') - assert len(manager._variable_cache) > 0 - - # Reconfigure - manager.configure({'Solar_PV': '#00FF00'}) - - # Cache should be cleared - assert len(manager._variable_cache) == 0 From f7ee19fd88a1027028b26cfa8b11fdda455a7a96 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:55:39 +0200 Subject: [PATCH 156/173] Simplified Colormanagement --- flixopt/plotting.py | 314 ++++++++++++++++++++++++++++++++++++++++++++ flixopt/results.py | 166 +++++------------------ 2 files changed, 345 insertions(+), 135 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 9c7563718..9ce890e42 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -347,6 +347,320 @@ def process_colors( return color_list +class ElementColorResolver: + """Resolve colors for flow system elements with pattern matching and colorscale support. + + Works with any element type (components, buses, flows) that has a _variable_names attribute. + Handles pattern matching, colorscale detection/sampling, and variable expansion. + + This centralizes all color resolution logic in the plotting module, keeping it separate + from the CalculationResults class. + + Example: + ```python + resolver = ElementColorResolver(results.components) + colors = resolver.resolve({'Solar*': 'Oranges', 'Wind*': 'Blues'}) + # Returns: {'Solar1(Bus)|flow_rate': '#ff8c00', 'Solar2(Bus)|flow_rate': '#ff7700', ...} + ``` + """ + + def __init__( + self, + elements: dict, + default_colorscale: str | None = None, + engine: PlottingEngine = 'plotly', + ): + """Initialize resolver. + + Args: + elements: Dict of element_label → element object (must have _variable_names attribute) + default_colorscale: Default colorscale for unmapped elements + engine: Plotting engine for ColorProcessor ('plotly' or 'matplotlib') + """ + self.elements = elements + self.processor = ColorProcessor( + engine=engine, + default_colorscale=default_colorscale or CONFIG.Plotting.default_qualitative_colorscale, + ) + + def resolve( + self, + config: dict[str, str | list[str]] | str | pathlib.Path | None = None, + reset: bool = True, + existing_colors: dict[str, str] | None = None, + ) -> dict[str, str]: + """Resolve config to variable→color dict. + + Args: + config: Color configuration: + - dict: Component/pattern to color/colorscale mapping + - str/Path: Path to YAML file + - None: Use default colorscale for all elements + reset: If True, reset all existing colors. If False, merge with existing at variable level. + existing_colors: Existing variable→color dict (for variable-level merging when reset=False) + + Returns: + dict[str, str]: Complete variable→color mapping + + Examples: + ```python + # Direct assignment + resolver.resolve({'Boiler1': 'red'}) + + # Pattern with color + resolver.resolve({'Solar*': 'orange'}) + + # Pattern with colorscale + resolver.resolve({'Solar*': 'Oranges'}) + + # Family grouping + resolver.resolve({'oranges': ['Solar1', 'Solar2']}) + + # Merge mode (preserves existing) + resolver.resolve({'NewComp': 'blue'}, reset=False, existing_colors=existing) + ``` + """ + # Load from file if needed + if isinstance(config, (str, pathlib.Path)): + config = load_yaml_config(config) + + # Extract existing element colors for merging (if reset=False) + existing_element_colors = None + if not reset and existing_colors: + existing_element_colors = self.extract_element_colors(existing_colors, self.elements) + + # Resolve element colors (with pattern matching) + element_colors = self._resolve_element_colors(config, reset, existing_element_colors) + + # Expand to variables + variable_colors = self._expand_to_variables(element_colors) + + # Variable-level merge: preserve existing variable colors not in new mapping + if not reset and existing_colors: + # Start with existing, then update with new + merged = existing_colors.copy() + merged.update(variable_colors) + return merged + + return variable_colors + + def _resolve_element_colors( + self, + config: dict[str, str | list[str]] | None, + reset: bool, + existing_element_colors: dict[str, str] | None, + ) -> dict[str, str]: + """Resolve config to element→color dict with pattern matching. + + Args: + config: Configuration dict or None + reset: Whether to reset existing colors + existing_element_colors: Existing element colors (for reset=False) + + Returns: + dict[str, str]: Element name → color mapping + """ + import fnmatch + + element_names = list(self.elements.keys()) + element_colors = {} if reset else (existing_element_colors or {}) + + # If no config, use default colorscale for all elements + if config is None: + colors = self.processor._generate_colors_from_colormap( + self.processor.default_colorscale, len(element_names) + ) + return dict(zip(element_names, colors, strict=False)) + + # Process config entries + for key, value in config.items(): + if isinstance(value, str): + # Check if key is a pattern or direct element name + if '*' in key or '?' in key: + # Pattern matching + matched = [e for e in element_names if fnmatch.fnmatch(e, key)] + if is_colorscale(value): + # Sample colorscale for matched elements + colors = self.processor._generate_colors_from_colormap(value, len(matched)) + element_colors.update(zip(matched, colors, strict=False)) + else: + # Apply same color to all matched elements + for elem in matched: + element_colors[elem] = value + else: + # Direct element→color assignment + element_colors[key] = value + + elif isinstance(value, list): + # Family grouping: colorscale → [elements] + colors = self.processor._generate_colors_from_colormap(key, len(value)) + element_colors.update(zip(value, colors, strict=False)) + + # Fill in missing elements with default colorscale + missing = [e for e in element_names if e not in element_colors] + if missing: + colors = self.processor._generate_colors_from_colormap(self.processor.default_colorscale, len(missing)) + element_colors.update(zip(missing, colors, strict=False)) + + return element_colors + + def _expand_to_variables(self, element_colors: dict[str, str]) -> dict[str, str]: + """Map element colors to all their variables. + + Args: + element_colors: Element name → color mapping + + Returns: + dict[str, str]: Variable name → color mapping + """ + variable_colors = {} + for element_name, color in element_colors.items(): + if element_name in self.elements: + # Access _variable_names from element object (ComponentResults, BusResults, etc.) + for var in self.elements[element_name]._variable_names: + variable_colors[var] = color + return variable_colors + + @staticmethod + def extract_element_colors(variable_colors: dict[str, str], elements: dict) -> dict[str, str]: + """Extract element→color from variable→color dict. + + Reverse operation: given a variable→color mapping, extract the corresponding + element→color mapping by looking at the first variable of each element. + + Args: + variable_colors: Variable name → color mapping + elements: Dict of element_label → element object + + Returns: + dict[str, str]: Element name → color mapping + + Example: + ```python + var_colors = {'Solar1(Bus)|flow': 'orange', 'Solar1(Bus)|size': 'orange'} + elem_colors = extract_element_colors(var_colors, results.components) + # Returns: {'Solar1': 'orange'} + ``` + """ + if not variable_colors: + return {} + element_colors = {} + for elem_name, elem_obj in elements.items(): + var_names = elem_obj._variable_names + if var_names and var_names[0] in variable_colors: + element_colors[elem_name] = variable_colors[var_names[0]] + return element_colors + + +def load_yaml_config(path: str | pathlib.Path) -> dict[str, str | list[str]]: + """Load YAML color configuration file. + + Args: + path: Path to YAML file + + Returns: + dict: Color configuration + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If file is not valid YAML dict + + Example: + ```python + # colors.yaml: + # Boiler1: red + # Solar*: Oranges + # oranges: + # - Solar1 + # - Solar2 + + config = load_yaml_config('colors.yaml') + ``` + """ + import yaml + + path = pathlib.Path(path) + if not path.exists(): + raise FileNotFoundError(f'Color configuration file not found: {path}') + + with open(path, encoding='utf-8') as f: + config = yaml.safe_load(f) + + if not isinstance(config, dict): + raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') + + return config + + +def is_colorscale(name: str) -> bool: + """Check if string is a colorscale name vs a direct color. + + Args: + name: Color or colorscale name + + Returns: + bool: True if it's a colorscale, False if it's a direct color + + Examples: + ```python + is_colorscale('#FF0000') # False (hex color) + is_colorscale('red') # False (CSS color) + is_colorscale('Oranges') # True (Plotly colorscale) + is_colorscale('viridis') # True (matplotlib colormap) + ``` + """ + # Direct color patterns + if name.startswith('#') or name.startswith('rgb'): + return False + + # Check if it's a known CSS color (common colors) + common_colors = { + 'red', + 'blue', + 'green', + 'yellow', + 'orange', + 'purple', + 'pink', + 'brown', + 'black', + 'white', + 'gray', + 'grey', + 'cyan', + 'magenta', + 'lime', + 'navy', + 'teal', + 'aqua', + 'maroon', + 'olive', + 'silver', + 'gold', + 'indigo', + 'violet', + } + if name.lower() in common_colors: + return False + + # Check Plotly colorscales + try: + import plotly.express as px + + if hasattr(px.colors.qualitative, name.title()) or hasattr(px.colors.sequential, name.title()): + return True + except Exception: + pass + + # Check matplotlib colorscales + try: + import matplotlib.pyplot as plt + + return name in plt.colormaps() + except Exception: + return False + + def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: """Convert DataFrame to Dataset if needed.""" if isinstance(data, xr.Dataset): diff --git a/flixopt/results.py b/flixopt/results.py index 8a7249e21..928299a07 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -397,155 +397,51 @@ def setup_colors( results.setup_colors('colors.yaml') ``` + Merge with existing colors: + + ```python + results.setup_colors({'Boiler1': 'red'}) + results.setup_colors({'CHP': 'blue'}, reset=False) # Keeps Boiler1 red + ``` + Disable automatic coloring: ```python results.colors = None # Plots use default colorscales ``` """ - if default_colorscale is None: - default_colorscale = CONFIG.Plotting.default_qualitative_colorscale + # Create resolver and delegate + resolver = plotting.ElementColorResolver( + self.components, + default_colorscale=default_colorscale, + engine='plotly', + ) - if isinstance(config, (str, pathlib.Path)): - config = self._load_yaml(config) + # Resolve colors (with variable-level merging if reset=False) + self.colors = resolver.resolve( + config=config, + reset=reset, + existing_colors=None if reset else self.colors, + ) - component_colors = self._expand_component_colors(config, default_colorscale, reset) - self.colors = self._expand_to_variables(component_colors) return self.colors - def _expand_component_colors( - self, config: dict[str, str | list[str]] | None, default_colorscale: str, reset: bool - ) -> dict[str, str]: - """Expand pattern matching and colorscale sampling to component→color dict.""" - import fnmatch - - component_names = list(self.components.keys()) - component_colors = {} if reset else self.get_component_colors() - - # If no config, use default colorscale for all components - if config is None: - colors = self._sample_colorscale(default_colorscale, len(component_names)) - return dict(zip(component_names, colors, strict=False)) - - # Process config entries - for key, value in config.items(): - if isinstance(value, str): - # Check if key is a pattern or direct component name - if '*' in key or '?' in key: - # Pattern matching - matched = [c for c in component_names if fnmatch.fnmatch(c, key)] - if self._is_colorscale(value): - # Sample colorscale for matched components - colors = self._sample_colorscale(value, len(matched)) - component_colors.update(zip(matched, colors, strict=False)) - else: - # Apply same color to all matched components - for comp in matched: - component_colors[comp] = value - else: - # Direct component→color assignment - component_colors[key] = value - - elif isinstance(value, list): - # Family grouping: colorscale → [components] - colors = self._sample_colorscale(key, len(value)) - component_colors.update(zip(value, colors, strict=False)) - - # Fill in missing components with default colorscale - missing = [c for c in component_names if c not in component_colors] - if missing: - colors = self._sample_colorscale(default_colorscale, len(missing)) - component_colors.update(zip(missing, colors, strict=False)) - - return component_colors + def get_component_colors(self) -> dict[str, str]: + """Extract component→color from variable→color dict. - def _expand_to_variables(self, component_colors: dict[str, str]) -> dict[str, str]: - """Map component colors to all their variables.""" - variable_colors = {} - for component, color in component_colors.items(): - if component in self.components: - for var in self.components[component]._variable_names: - variable_colors[var] = color - return variable_colors + Returns: + dict[str, str]: Component name → color mapping - def get_component_colors(self) -> dict[str, str]: - """Extract component→color from variable→color dict.""" + Example: + ```python + results.setup_colors({'Boiler1': 'red', 'Solar1': 'orange'}) + comp_colors = results.get_component_colors() + # Returns: {'Boiler1': 'red', 'Solar1': 'orange', ...} + ``` + """ if not self.colors: return {} - component_colors = {} - for comp in self.components: - var_names = self.components[comp]._variable_names - if var_names and var_names[0] in self.colors: - component_colors[comp] = self.colors[var_names[0]] - return component_colors - - def _is_colorscale(self, name: str) -> bool: - """Check if string is a colorscale vs direct color.""" - # Direct color patterns - if name.startswith('#') or name.startswith('rgb'): - return False - # Check if it's a known CSS color (lowercase, common colors) - common_colors = { - 'red', - 'blue', - 'green', - 'yellow', - 'orange', - 'purple', - 'pink', - 'brown', - 'black', - 'white', - 'gray', - 'grey', - 'cyan', - 'magenta', - 'lime', - 'navy', - 'teal', - 'aqua', - 'maroon', - 'olive', - 'silver', - 'gold', - 'indigo', - 'violet', - } - if name.lower() in common_colors: - return False - # Check Plotly colorscales (qualitative or sequential) - import plotly.express as px - - if hasattr(px.colors.qualitative, name.title()) or hasattr(px.colors.sequential, name.title()): - return True - # Check matplotlib colorscales - try: - import matplotlib.pyplot as plt - - return name in plt.colormaps() - except Exception: - return False - - def _sample_colorscale(self, name: str, n: int) -> list[str]: - """Sample n colors from a colorscale using ColorProcessor.""" - processor = plotting.ColorProcessor(engine='plotly', default_colorscale=name) - return processor._generate_colors_from_colormap(name, n) - - def _load_yaml(self, path: str | pathlib.Path) -> dict[str, str | list[str]]: - """Load YAML config file.""" - import yaml - - path = pathlib.Path(path) - if not path.exists(): - raise FileNotFoundError(f'Color configuration file not found: {path}') - - with open(path, encoding='utf-8') as f: - config = yaml.safe_load(f) - - if not isinstance(config, dict): - raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') - - return config + return plotting.ElementColorResolver.extract_element_colors(self.colors, self.components) def filter_solution( self, From 950f8e01d8261c7a3f23ad7655a7f49eae8b4839 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:07:21 +0200 Subject: [PATCH 157/173] Simplified Colormanagement --- flixopt/plotting.py | 43 ++----------------------------------------- flixopt/results.py | 17 ----------------- 2 files changed, 2 insertions(+), 58 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 9ce890e42..089f0206a 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -424,13 +424,8 @@ def resolve( if isinstance(config, (str, pathlib.Path)): config = load_yaml_config(config) - # Extract existing element colors for merging (if reset=False) - existing_element_colors = None - if not reset and existing_colors: - existing_element_colors = self.extract_element_colors(existing_colors, self.elements) - # Resolve element colors (with pattern matching) - element_colors = self._resolve_element_colors(config, reset, existing_element_colors) + element_colors = self._resolve_element_colors(config) # Expand to variables variable_colors = self._expand_to_variables(element_colors) @@ -447,15 +442,11 @@ def resolve( def _resolve_element_colors( self, config: dict[str, str | list[str]] | None, - reset: bool, - existing_element_colors: dict[str, str] | None, ) -> dict[str, str]: """Resolve config to element→color dict with pattern matching. Args: config: Configuration dict or None - reset: Whether to reset existing colors - existing_element_colors: Existing element colors (for reset=False) Returns: dict[str, str]: Element name → color mapping @@ -463,7 +454,7 @@ def _resolve_element_colors( import fnmatch element_names = list(self.elements.keys()) - element_colors = {} if reset else (existing_element_colors or {}) + element_colors = {} # If no config, use default colorscale for all elements if config is None: @@ -521,36 +512,6 @@ def _expand_to_variables(self, element_colors: dict[str, str]) -> dict[str, str] variable_colors[var] = color return variable_colors - @staticmethod - def extract_element_colors(variable_colors: dict[str, str], elements: dict) -> dict[str, str]: - """Extract element→color from variable→color dict. - - Reverse operation: given a variable→color mapping, extract the corresponding - element→color mapping by looking at the first variable of each element. - - Args: - variable_colors: Variable name → color mapping - elements: Dict of element_label → element object - - Returns: - dict[str, str]: Element name → color mapping - - Example: - ```python - var_colors = {'Solar1(Bus)|flow': 'orange', 'Solar1(Bus)|size': 'orange'} - elem_colors = extract_element_colors(var_colors, results.components) - # Returns: {'Solar1': 'orange'} - ``` - """ - if not variable_colors: - return {} - element_colors = {} - for elem_name, elem_obj in elements.items(): - var_names = elem_obj._variable_names - if var_names and var_names[0] in variable_colors: - element_colors[elem_name] = variable_colors[var_names[0]] - return element_colors - def load_yaml_config(path: str | pathlib.Path) -> dict[str, str | list[str]]: """Load YAML color configuration file. diff --git a/flixopt/results.py b/flixopt/results.py index 928299a07..80dcc16ea 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -426,23 +426,6 @@ def setup_colors( return self.colors - def get_component_colors(self) -> dict[str, str]: - """Extract component→color from variable→color dict. - - Returns: - dict[str, str]: Component name → color mapping - - Example: - ```python - results.setup_colors({'Boiler1': 'red', 'Solar1': 'orange'}) - comp_colors = results.get_component_colors() - # Returns: {'Boiler1': 'red', 'Solar1': 'orange', ...} - ``` - """ - if not self.colors: - return {} - return plotting.ElementColorResolver.extract_element_colors(self.colors, self.components) - def filter_solution( self, variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, From 31a3cc6cbdce21edb86505b341769110f83d7477 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:09:15 +0200 Subject: [PATCH 158/173] Add element name itself to color dict --- flixopt/plotting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 089f0206a..20008daad 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -508,7 +508,8 @@ def _expand_to_variables(self, element_colors: dict[str, str]) -> dict[str, str] for element_name, color in element_colors.items(): if element_name in self.elements: # Access _variable_names from element object (ComponentResults, BusResults, etc.) - for var in self.elements[element_name]._variable_names: + variable_colors[element_name] = color + for var in self.elements[element_name]._variables: variable_colors[var] = color return variable_colors From 87608b09c3aea2f2331c8220655c76162ce4daf4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:37:43 +0200 Subject: [PATCH 159/173] Fix examples --- examples/01_Simple/simple_example.py | 9 +++++---- examples/02_Complex/complex_example.py | 13 ++++++------- examples/02_Complex/complex_example_results.py | 2 -- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index acf98a838..5b828b60c 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -45,9 +45,11 @@ # --- Define Flow System Components --- # Boiler: Converts fuel (gas) into thermal energy (heat) - boiler = fx.Source( + boiler = fx.linear_converters.Boiler( label='Boiler', - outputs=[fx.Flow(label=str(i), bus='Fernwärme', size=5) for i in range(10)], + eta=0.5, + Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), + Q_fu=fx.Flow(label='Q_fu', bus='Gas'), ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel @@ -110,9 +112,8 @@ calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- - # Colors are automatically assigned using default colormap # Optional: Configure custom colors with - calculation.results.setup_colors({'CHP': 'red'}) + calculation.results.setup_colors({'CHP': 'red', 'Boiler': 'orange'}) calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_charge_state() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 7a4742284..77623d14c 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -205,11 +205,10 @@ # You can analyze results directly or save them to file and reload them later. calculation.results.to_file() + # But let's plot some results anyway # Optional: Configure custom colors (dict is simplest): - calculation.results.setup_colors({'BHKW': 'orange', 'Speicher': 'green'}) - - # Plot results (colors are automatically assigned to components) - calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') # Heatmap uses continuous colors (not ColorManager) - calculation.results['BHKW2'].plot_node_balance() # Uses ColorManager - calculation.results['Speicher'].plot_charge_state() # Uses ColorManager - calculation.results['Fernwärme'].plot_node_balance_pie() # Uses ColorManager + calculation.results.setup_colors({'BHKW*': 'orange', 'Speicher': 'blue'}) + calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') + calculation.results['BHKW2'].plot_node_balance() + calculation.results['Speicher'].plot_charge_state() + calculation.results['Fernwärme'].plot_node_balance_pie() diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 8eba4de50..b53b1ee7b 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -20,8 +20,6 @@ # --- Configure Color Mapping for Consistent Plot Colors (Optional) --- results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'}) # Dict (simplest) - # results.setup_colors('colors.yaml') # Or from file - # results.setup_colors().add_rule('Solar*', 'oranges') # Or programmatic # --- Basic overview --- results.plot_network(show=True) From c958c4cfd676154cb575156fed7017326aaf18d3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:45:06 +0200 Subject: [PATCH 160/173] Bugfix --- flixopt/config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 8b0ae1cf8..957cd320e 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -406,9 +406,7 @@ def to_dict(cls) -> dict: 'big_binary_bound': cls.Modeling.big_binary_bound, }, 'plotting': { - 'plotly_renderer': cls.Plotting.plotly_renderer, 'plotly_template': cls.Plotting.plotly_template, - 'matplotlib_backend': cls.Plotting.matplotlib_backend, 'default_show': cls.Plotting.default_show, 'default_save_path': cls.Plotting.default_save_path, 'default_engine': cls.Plotting.default_engine, From 309ecfab05dcd7a28598223479c0936a60364d85 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:45:16 +0200 Subject: [PATCH 161/173] Bugfix --- flixopt/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 20008daad..452a53efc 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -509,7 +509,7 @@ def _expand_to_variables(self, element_colors: dict[str, str]) -> dict[str, str] if element_name in self.elements: # Access _variable_names from element object (ComponentResults, BusResults, etc.) variable_colors[element_name] = color - for var in self.elements[element_name]._variables: + for var in self.elements[element_name]._variable_names: variable_colors[var] = color return variable_colors From 09ddf80770e36af5c9ba78681e6b42d99880d20d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:54:42 +0200 Subject: [PATCH 162/173] Remove coloring related stuff --- examples/01_Simple/simple_example.py | 2 - examples/02_Complex/complex_example.py | 2 - .../02_Complex/complex_example_results.py | 3 - examples/04_Scenarios/scenario_example.py | 2 - flixopt/aggregation.py | 2 +- flixopt/config.py | 137 +----------------- 6 files changed, 2 insertions(+), 146 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 5b828b60c..906c24622 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -112,8 +112,6 @@ calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- - # Optional: Configure custom colors with - calculation.results.setup_colors({'CHP': 'red', 'Boiler': 'orange'}) calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_charge_state() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 77623d14c..805cb08f6 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -206,8 +206,6 @@ calculation.results.to_file() # But let's plot some results anyway - # Optional: Configure custom colors (dict is simplest): - calculation.results.setup_colors({'BHKW*': 'orange', 'Speicher': 'blue'}) calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') calculation.results['BHKW2'].plot_node_balance() calculation.results['Speicher'].plot_charge_state() diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index b53b1ee7b..56251fd99 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -18,9 +18,6 @@ f'Original error: {e}' ) from e - # --- Configure Color Mapping for Consistent Plot Colors (Optional) --- - results.setup_colors({'Solar*': 'oranges', 'Wind*': 'blues'}) # Dict (simplest) - # --- Basic overview --- results.plot_network(show=True) results['Fernwärme'].plot_node_balance() diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 993349421..834e55782 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -196,8 +196,6 @@ # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - calculation.results.setup_colors() - calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # --- Analyze Results --- diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 18cac8013..bb82adeee 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -142,7 +142,7 @@ def describe_clusters(self) -> str: def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks - def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Path | None = None) -> go.Figure: + def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path | None = None) -> go.Figure: from . import plotting df_org = self.original_data.copy().rename( diff --git a/flixopt/config.py b/flixopt/config.py index 957cd320e..a7549a3ec 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -54,20 +54,6 @@ 'big_binary_bound': 100_000, } ), - 'plotting': MappingProxyType( - { - 'plotly_template': 'plotly_white', - 'default_show': True, - 'default_save_path': None, - 'default_engine': 'plotly', - 'default_dpi': 300, - 'default_figure_width': None, - 'default_figure_height': None, - 'default_facet_cols': 3, - 'default_sequential_colorscale': 'turbo', - 'default_qualitative_colorscale': 'plotly', - } - ), } ) @@ -199,67 +185,6 @@ class Modeling: epsilon: float = _DEFAULTS['modeling']['epsilon'] big_binary_bound: int = _DEFAULTS['modeling']['big_binary_bound'] - class Plotting: - """Plotting configuration. - - Configure backends via environment variables: - - Matplotlib: Set `MPLBACKEND` environment variable (e.g., 'Agg', 'TkAgg') - - Plotly: Set `PLOTLY_RENDERER` or use `plotly.io.renderers.default` - - Attributes: - plotly_template: Plotly theme/template applied to all plots. - default_show: Default value for the `show` parameter in plot methods. - default_save_path: Default directory for saving plots. - default_engine: Default plotting engine. - default_dpi: Default DPI for saved plots. - default_figure_width: Default plot width in pixels. - default_figure_height: Default plot height in pixels. - default_facet_cols: Default number of columns for faceted plots. - default_sequential_colorscale: Default colorscale for heatmaps and continuous data. - default_qualitative_colorscale: Default colormap for categorical plots (bar/line/area charts). - - Examples: - ```python - # Set consistent theming - CONFIG.Plotting.plotly_template = 'plotly_dark' - CONFIG.apply() - - # Configure default export and color settings - CONFIG.Plotting.default_dpi = 600 - CONFIG.Plotting.default_figure_width = 1200 - CONFIG.Plotting.default_figure_height = 800 - CONFIG.Plotting.default_sequential_colorscale = 'plasma' - CONFIG.Plotting.default_qualitative_colorscale = 'Dark24' - CONFIG.apply() - ``` - """ - - plotly_template: ( - Literal[ - 'plotly', - 'plotly_white', - 'plotly_dark', - 'ggplot2', - 'seaborn', - 'simple_white', - 'none', - 'gridon', - 'presentation', - 'xgridoff', - 'ygridoff', - ] - | None - ) = _DEFAULTS['plotting']['plotly_template'] - default_show: bool = _DEFAULTS['plotting']['default_show'] - default_save_path: str | None = _DEFAULTS['plotting']['default_save_path'] - default_engine: Literal['plotly', 'matplotlib'] = _DEFAULTS['plotting']['default_engine'] - default_dpi: int = _DEFAULTS['plotting']['default_dpi'] - default_figure_width: int | None = _DEFAULTS['plotting']['default_figure_width'] - default_figure_height: int | None = _DEFAULTS['plotting']['default_figure_height'] - default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols'] - default_sequential_colorscale: str = _DEFAULTS['plotting']['default_sequential_colorscale'] - default_qualitative_colorscale: str = _DEFAULTS['plotting']['default_qualitative_colorscale'] - config_name: str = _DEFAULTS['config_name'] @classmethod @@ -276,15 +201,12 @@ def reset(cls): for key, value in _DEFAULTS['modeling'].items(): setattr(cls.Modeling, key, value) - for key, value in _DEFAULTS['plotting'].items(): - setattr(cls.Plotting, key, value) - cls.config_name = _DEFAULTS['config_name'] cls.apply() @classmethod def apply(cls): - """Apply current configuration to logging and plotting systems.""" + """Apply current configuration to logging system.""" # Convert Colors class attributes to dict colors_dict = { 'DEBUG': cls.Logging.Colors.DEBUG, @@ -321,11 +243,6 @@ def apply(cls): colors=colors_dict, ) - # Apply plotting configuration - _apply_plotting_config( - plotly_template=cls.Plotting.plotly_template, - ) - @classmethod def load_from_file(cls, config_file: str | Path): """Load configuration from YAML file and apply it. @@ -365,9 +282,6 @@ def _apply_config_dict(cls, config_dict: dict): elif key == 'modeling' and isinstance(value, dict): for nested_key, nested_value in value.items(): setattr(cls.Modeling, nested_key, nested_value) - elif key == 'plotting' and isinstance(value, dict): - for nested_key, nested_value in value.items(): - setattr(cls.Plotting, nested_key, nested_value) elif hasattr(cls, key): setattr(cls, key, value) @@ -405,18 +319,6 @@ def to_dict(cls) -> dict: 'epsilon': cls.Modeling.epsilon, 'big_binary_bound': cls.Modeling.big_binary_bound, }, - 'plotting': { - 'plotly_template': cls.Plotting.plotly_template, - 'default_show': cls.Plotting.default_show, - 'default_save_path': cls.Plotting.default_save_path, - 'default_engine': cls.Plotting.default_engine, - 'default_dpi': cls.Plotting.default_dpi, - 'default_figure_width': cls.Plotting.default_figure_width, - 'default_figure_height': cls.Plotting.default_figure_height, - 'default_facet_cols': cls.Plotting.default_facet_cols, - 'default_sequential_colorscale': cls.Plotting.default_sequential_colorscale, - 'default_qualitative_colorscale': cls.Plotting.default_qualitative_colorscale, - }, } @@ -686,43 +588,6 @@ def _setup_logging( logger.addHandler(logging.NullHandler()) -def _apply_plotting_config( - plotly_template: Literal[ - 'plotly', - 'plotly_white', - 'plotly_dark', - 'ggplot2', - 'seaborn', - 'simple_white', - 'none', - 'gridon', - 'presentation', - 'xgridoff', - 'ygridoff', - ] - | None = 'plotly', -) -> None: - """Apply plotting configuration to plotly. - - Args: - plotly_template: Plotly template/theme to apply to all plots. - - Note: - Configure backends via environment variables: - - Matplotlib: Set MPLBACKEND environment variable before importing matplotlib - - Plotly: Set PLOTLY_RENDERER or use plotly.io.renderers.default directly - """ - # Configure Plotly template - try: - import plotly.io as pio - - if plotly_template is not None: - pio.templates.default = plotly_template - except ImportError: - # Plotly not installed, skip configuration - pass - - def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): """Change the logging level for the flixopt logger and all its handlers. From 2921ff3a12d43281eb1e72c53473fcdea8f90c15 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:05:31 +0200 Subject: [PATCH 163/173] Reverse color and CONFIG related changes in plotting.py --- flixopt/plotting.py | 459 ++++++-------------------------------------- 1 file changed, 63 insertions(+), 396 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 452a53efc..8e0d9ab20 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -8,7 +8,7 @@ Key Features: **Dual Backend Support**: Seamless switching between Plotly and Matplotlib **Energy System Focus**: Specialized plots for power flows, storage states, emissions - **Color Management**: Intelligent color processing with ColorProcessor for flexible coloring + **Color Management**: Intelligent color processing and palette management **Export Capabilities**: High-quality export for reports and publications **Integration Ready**: Designed for use with CalculationResults and standalone analysis @@ -74,7 +74,7 @@ Color specifications can take several forms to accommodate different use cases: **Named Colormaps** (str): - - Standard colormaps: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1' + - Standard colormaps: 'viridis', 'plasma', 'cividis', 'tab10', 'Set1' - Energy-focused: 'portland' (custom flixopt colormap for energy systems) - Backend-specific maps available in Plotly and Matplotlib @@ -91,7 +91,7 @@ Examples: ```python # Named colormap - colors = 'turbo' # Automatic color generation + colors = 'viridis' # Automatic color generation # Explicit color list colors = ['red', 'blue', 'green', '#FFD700'] @@ -138,75 +138,57 @@ class ColorProcessor: **Energy System Colors**: Built-in palettes optimized for energy system visualization Color Input Types: - - **Named Colormaps**: 'turbo', 'plasma', 'portland', etc. + - **Named Colormaps**: 'viridis', 'plasma', 'portland', 'tab10', etc. - **Color Lists**: ['red', 'blue', 'green'] or ['#FF0000', '#0000FF', '#00FF00'] - **Label Dictionaries**: {'Generator': 'red', 'Storage': 'blue', 'Load': 'green'} - Example: + Examples: + Basic color processing: + ```python - processor = ColorProcessor(engine='plotly', default_colorscale='turbo') + # Initialize for Plotly backend + processor = ColorProcessor(engine='plotly', default_colormap='viridis') + + # Process different color specifications colors = processor.process_colors('plasma', ['Gen1', 'Gen2', 'Storage']) + colors = processor.process_colors(['red', 'blue', 'green'], ['A', 'B', 'C']) + colors = processor.process_colors({'Wind': 'skyblue', 'Solar': 'gold'}, ['Wind', 'Solar', 'Gas']) + + # Switch to Matplotlib + processor = ColorProcessor(engine='matplotlib') + mpl_colors = processor.process_colors('tab10', component_labels) + ``` + + Energy system visualization: + + ```python + # Specialized energy system palette + energy_colors = { + 'Natural_Gas': '#8B4513', # Brown + 'Electricity': '#FFD700', # Gold + 'Heat': '#FF4500', # Red-orange + 'Cooling': '#87CEEB', # Sky blue + 'Hydrogen': '#E6E6FA', # Lavender + 'Battery': '#32CD32', # Lime green + } + + processor = ColorProcessor('plotly') + flow_colors = processor.process_colors(energy_colors, flow_labels) ``` Args: engine: Plotting backend ('plotly' or 'matplotlib'). Determines output color format. - default_colorscale: Fallback colormap when requested palettes are unavailable. - Common options: 'turbo', 'plasma', 'portland'. + default_colormap: Fallback colormap when requested palettes are unavailable. + Common options: 'viridis', 'plasma', 'tab10', 'portland'. """ - def __init__(self, engine: PlottingEngine = 'plotly', default_colorscale: str | None = None): + def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'): """Initialize the color processor with specified backend and defaults.""" if engine not in ['plotly', 'matplotlib']: raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}') self.engine = engine - self.default_colorscale = ( - default_colorscale if default_colorscale is not None else CONFIG.Plotting.default_qualitative_colorscale - ) - - def _get_sequential_colorscale(self, colormap_name: str, num_colors: int) -> list[str] | None: - try: - colorscale = px.colors.get_colorscale(colormap_name) - # Generate evenly spaced points - color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] - return px.colors.sample_colorscale(colorscale, color_points) - except PlotlyError: - return None - - def _get_plotly_colormap_robust(self, colormap_name: str, num_colors: int) -> list[str]: - # First try qualitative color sequences (Dark24, Plotly, Set1, etc.) - colormap_title = colormap_name.title() - if hasattr(px.colors.qualitative, colormap_title): - color_list = getattr(px.colors.qualitative, colormap_title) - # Cycle through colors if we need more than available - return [color_list[i % len(color_list)] for i in range(num_colors)] - - # Then try sequential/continuous colorscales (turbo, plasma, etc.) - colors = self._get_sequential_colorscale(colormap_name, num_colors) - if colors is not None: - return colors - - # Fallback to default_colorscale - logger.warning(f"Colormap '{colormap_name}' not found in Plotly. Trying default '{self.default_colorscale}'") - - # Try default as qualitative - default_title = self.default_colorscale.title() - if hasattr(px.colors.qualitative, default_title): - color_list = getattr(px.colors.qualitative, default_title) - return [color_list[i % len(color_list)] for i in range(num_colors)] - - # Try default as sequential - colors = self._get_sequential_colorscale(self.default_colorscale, num_colors) - if colors is not None: - return colors - - # Ultimate fallback: use built-in Plotly qualitative colormap - logger.warning( - f"Both '{colormap_name}' and default '{self.default_colorscale}' not found. " - f"Using hardcoded fallback 'Plotly' colormap" - ) - color_list = px.colors.qualitative.Plotly - return [color_list[i % len(color_list)] for i in range(num_colors)] + self.default_colormap = default_colormap def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list[Any]: """ @@ -220,23 +202,22 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> list of colors in the format appropriate for the engine """ if self.engine == 'plotly': - return self._get_plotly_colormap_robust(colormap_name, num_colors) + try: + colorscale = px.colors.get_colorscale(colormap_name) + except PlotlyError as e: + logger.error(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}") + colorscale = px.colors.get_colorscale(self.default_colormap) + + # Generate evenly spaced points + color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] + return px.colors.sample_colorscale(colorscale, color_points) else: # matplotlib try: cmap = plt.get_cmap(colormap_name, num_colors) except ValueError as e: - logger.warning( - f"Colormap '{colormap_name}' not found in Matplotlib. Trying default '{self.default_colorscale}': {e}" - ) - try: - cmap = plt.get_cmap(self.default_colorscale, num_colors) - except ValueError: - logger.warning( - f"Default colormap '{self.default_colorscale}' also not found in Matplotlib. " - f"Using hardcoded fallback 'tab10'" - ) - cmap = plt.get_cmap('tab10', num_colors) + logger.error(f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}") + cmap = plt.get_cmap(self.default_colormap, num_colors) return [cmap(i) for i in range(num_colors)] @@ -252,8 +233,8 @@ def _handle_color_list(self, colors: list[str], num_labels: int) -> list[str]: list of colors matching the number of labels """ if len(colors) == 0: - logger.error(f'Empty color list provided. Using {self.default_colorscale} instead.') - return self._generate_colors_from_colormap(self.default_colorscale, num_labels) + logger.error(f'Empty color list provided. Using {self.default_colormap} instead.') + return self._generate_colors_from_colormap(self.default_colormap, num_labels) if len(colors) < num_labels: logger.warning( @@ -282,18 +263,18 @@ def _handle_color_dict(self, colors: dict[str, str], labels: list[str]) -> list[ list of colors in the same order as labels """ if len(colors) == 0: - logger.warning(f'Empty color dictionary provided. Using {self.default_colorscale} instead.') - return self._generate_colors_from_colormap(self.default_colorscale, len(labels)) + logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.') + return self._generate_colors_from_colormap(self.default_colormap, len(labels)) # Find missing labels missing_labels = sorted(set(labels) - set(colors.keys())) if missing_labels: logger.warning( - f'Some labels have no color specified: {missing_labels}. Using {self.default_colorscale} for these.' + f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.' ) # Generate colors for missing labels - missing_colors = self._generate_colors_from_colormap(self.default_colorscale, len(missing_labels)) + missing_colors = self._generate_colors_from_colormap(self.default_colormap, len(missing_labels)) # Create a copy to avoid modifying the original colors_copy = colors.copy() @@ -336,9 +317,9 @@ def process_colors( color_list = self._handle_color_dict(colors, labels) else: logger.error( - f'Unsupported color specification type: {type(colors)}. Using {self.default_colorscale} instead.' + f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.' ) - color_list = self._generate_colors_from_colormap(self.default_colorscale, len(labels)) + color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels)) # Return either a list or a mapping if return_mapping: @@ -347,282 +328,6 @@ def process_colors( return color_list -class ElementColorResolver: - """Resolve colors for flow system elements with pattern matching and colorscale support. - - Works with any element type (components, buses, flows) that has a _variable_names attribute. - Handles pattern matching, colorscale detection/sampling, and variable expansion. - - This centralizes all color resolution logic in the plotting module, keeping it separate - from the CalculationResults class. - - Example: - ```python - resolver = ElementColorResolver(results.components) - colors = resolver.resolve({'Solar*': 'Oranges', 'Wind*': 'Blues'}) - # Returns: {'Solar1(Bus)|flow_rate': '#ff8c00', 'Solar2(Bus)|flow_rate': '#ff7700', ...} - ``` - """ - - def __init__( - self, - elements: dict, - default_colorscale: str | None = None, - engine: PlottingEngine = 'plotly', - ): - """Initialize resolver. - - Args: - elements: Dict of element_label → element object (must have _variable_names attribute) - default_colorscale: Default colorscale for unmapped elements - engine: Plotting engine for ColorProcessor ('plotly' or 'matplotlib') - """ - self.elements = elements - self.processor = ColorProcessor( - engine=engine, - default_colorscale=default_colorscale or CONFIG.Plotting.default_qualitative_colorscale, - ) - - def resolve( - self, - config: dict[str, str | list[str]] | str | pathlib.Path | None = None, - reset: bool = True, - existing_colors: dict[str, str] | None = None, - ) -> dict[str, str]: - """Resolve config to variable→color dict. - - Args: - config: Color configuration: - - dict: Component/pattern to color/colorscale mapping - - str/Path: Path to YAML file - - None: Use default colorscale for all elements - reset: If True, reset all existing colors. If False, merge with existing at variable level. - existing_colors: Existing variable→color dict (for variable-level merging when reset=False) - - Returns: - dict[str, str]: Complete variable→color mapping - - Examples: - ```python - # Direct assignment - resolver.resolve({'Boiler1': 'red'}) - - # Pattern with color - resolver.resolve({'Solar*': 'orange'}) - - # Pattern with colorscale - resolver.resolve({'Solar*': 'Oranges'}) - - # Family grouping - resolver.resolve({'oranges': ['Solar1', 'Solar2']}) - - # Merge mode (preserves existing) - resolver.resolve({'NewComp': 'blue'}, reset=False, existing_colors=existing) - ``` - """ - # Load from file if needed - if isinstance(config, (str, pathlib.Path)): - config = load_yaml_config(config) - - # Resolve element colors (with pattern matching) - element_colors = self._resolve_element_colors(config) - - # Expand to variables - variable_colors = self._expand_to_variables(element_colors) - - # Variable-level merge: preserve existing variable colors not in new mapping - if not reset and existing_colors: - # Start with existing, then update with new - merged = existing_colors.copy() - merged.update(variable_colors) - return merged - - return variable_colors - - def _resolve_element_colors( - self, - config: dict[str, str | list[str]] | None, - ) -> dict[str, str]: - """Resolve config to element→color dict with pattern matching. - - Args: - config: Configuration dict or None - - Returns: - dict[str, str]: Element name → color mapping - """ - import fnmatch - - element_names = list(self.elements.keys()) - element_colors = {} - - # If no config, use default colorscale for all elements - if config is None: - colors = self.processor._generate_colors_from_colormap( - self.processor.default_colorscale, len(element_names) - ) - return dict(zip(element_names, colors, strict=False)) - - # Process config entries - for key, value in config.items(): - if isinstance(value, str): - # Check if key is a pattern or direct element name - if '*' in key or '?' in key: - # Pattern matching - matched = [e for e in element_names if fnmatch.fnmatch(e, key)] - if is_colorscale(value): - # Sample colorscale for matched elements - colors = self.processor._generate_colors_from_colormap(value, len(matched)) - element_colors.update(zip(matched, colors, strict=False)) - else: - # Apply same color to all matched elements - for elem in matched: - element_colors[elem] = value - else: - # Direct element→color assignment - element_colors[key] = value - - elif isinstance(value, list): - # Family grouping: colorscale → [elements] - colors = self.processor._generate_colors_from_colormap(key, len(value)) - element_colors.update(zip(value, colors, strict=False)) - - # Fill in missing elements with default colorscale - missing = [e for e in element_names if e not in element_colors] - if missing: - colors = self.processor._generate_colors_from_colormap(self.processor.default_colorscale, len(missing)) - element_colors.update(zip(missing, colors, strict=False)) - - return element_colors - - def _expand_to_variables(self, element_colors: dict[str, str]) -> dict[str, str]: - """Map element colors to all their variables. - - Args: - element_colors: Element name → color mapping - - Returns: - dict[str, str]: Variable name → color mapping - """ - variable_colors = {} - for element_name, color in element_colors.items(): - if element_name in self.elements: - # Access _variable_names from element object (ComponentResults, BusResults, etc.) - variable_colors[element_name] = color - for var in self.elements[element_name]._variable_names: - variable_colors[var] = color - return variable_colors - - -def load_yaml_config(path: str | pathlib.Path) -> dict[str, str | list[str]]: - """Load YAML color configuration file. - - Args: - path: Path to YAML file - - Returns: - dict: Color configuration - - Raises: - FileNotFoundError: If file doesn't exist - ValueError: If file is not valid YAML dict - - Example: - ```python - # colors.yaml: - # Boiler1: red - # Solar*: Oranges - # oranges: - # - Solar1 - # - Solar2 - - config = load_yaml_config('colors.yaml') - ``` - """ - import yaml - - path = pathlib.Path(path) - if not path.exists(): - raise FileNotFoundError(f'Color configuration file not found: {path}') - - with open(path, encoding='utf-8') as f: - config = yaml.safe_load(f) - - if not isinstance(config, dict): - raise ValueError(f'Invalid config file structure. Expected dict, got {type(config).__name__}') - - return config - - -def is_colorscale(name: str) -> bool: - """Check if string is a colorscale name vs a direct color. - - Args: - name: Color or colorscale name - - Returns: - bool: True if it's a colorscale, False if it's a direct color - - Examples: - ```python - is_colorscale('#FF0000') # False (hex color) - is_colorscale('red') # False (CSS color) - is_colorscale('Oranges') # True (Plotly colorscale) - is_colorscale('viridis') # True (matplotlib colormap) - ``` - """ - # Direct color patterns - if name.startswith('#') or name.startswith('rgb'): - return False - - # Check if it's a known CSS color (common colors) - common_colors = { - 'red', - 'blue', - 'green', - 'yellow', - 'orange', - 'purple', - 'pink', - 'brown', - 'black', - 'white', - 'gray', - 'grey', - 'cyan', - 'magenta', - 'lime', - 'navy', - 'teal', - 'aqua', - 'maroon', - 'olive', - 'silver', - 'gold', - 'indigo', - 'violet', - } - if name.lower() in common_colors: - return False - - # Check Plotly colorscales - try: - import plotly.express as px - - if hasattr(px.colors.qualitative, name.title()) or hasattr(px.colors.sequential, name.title()): - return True - except Exception: - pass - - # Check matplotlib colorscales - try: - import matplotlib.pyplot as plt - - return name in plt.colormaps() - except Exception: - return False - - def _ensure_dataset(data: xr.Dataset | pd.DataFrame) -> xr.Dataset: """Convert DataFrame to Dataset if needed.""" if isinstance(data, xr.Dataset): @@ -689,11 +394,10 @@ def resolve_colors( def with_plotly( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', ylabel: str = '', xlabel: str = '', - fig: go.Figure | None = None, facet_by: str | list[str] | None = None, animate_by: str | None = None, facet_cols: int | None = None, @@ -713,10 +417,7 @@ def with_plotly( data: An xarray 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. Can be: - - A colormap name (e.g., 'turbo', 'plasma') - - A list of color strings (e.g., ['#ff0000', '#00ff00']) - - A dict mapping labels to colors (e.g., {'Solar': '#FFD700'}) + 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. @@ -764,24 +465,10 @@ def with_plotly( ```python fig = with_plotly(dataset, facet_by='scenario', animate_by='period') ``` - - Custom color mapping: - - ```python - colors = {'Solar': 'orange', 'Wind': 'blue', 'Battery': 'green', 'Gas': 'red'} - fig = with_plotly(dataset, colors=colors, mode='area') - ``` """ - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - 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}") - # Apply CONFIG defaults if not explicitly set - if facet_cols is None: - facet_cols = CONFIG.Plotting.default_facet_cols - # Ensure data is a Dataset and validate it data = _ensure_dataset(data) _validate_plotting_data(data, allow_empty=True) @@ -833,11 +520,6 @@ def with_plotly( fig.update_layout(title=title, xaxis_title=xlabel, yaxis_title=ylabel, showlegend=False) return fig - # 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 Dataset to long-form DataFrame for Plotly Express # Structure: time, variable, value, scenario, period, ... (all dims as columns) dim_names = list(data.dims) @@ -891,11 +573,10 @@ def with_plotly( else: raise ValueError(f'facet_by can have at most 2 dimensions, got {len(facet_by)}') - # Process colors using resolve_colors (handles validation and all color types) - color_discrete_map = resolve_colors(data, colors, engine='plotly') - - # Get unique variable names for area plot processing + # 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=True)} # Determine which dimension to use for x-axis # Collect dimensions used for faceting and animation @@ -937,9 +618,6 @@ def with_plotly( if facet_col and not facet_row: common_args['facet_col_wrap'] = facet_cols - # Apply user-provided Plotly Express kwargs (overrides defaults) - common_args.update(px_kwargs) - if mode == 'stacked_bar': fig = px.bar(**common_args) fig.update_traces(marker_line_width=0) @@ -1012,7 +690,7 @@ def with_plotly( def with_matplotlib( data: xr.Dataset | pd.DataFrame, mode: Literal['stacked_bar', 'line'] = 'stacked_bar', - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -1044,18 +722,7 @@ def with_matplotlib( - If `mode` is 'stacked_bar', bars are stacked for both positive and negative values. Negative values are stacked separately without extra labels in the legend. - If `mode` is 'line', stepped lines are drawn for each data series. - - Examples: - Custom color mapping: - - ```python - colors = {'Solar': 'orange', 'Wind': 'blue', 'Coal': 'red'} - fig, ax = with_matplotlib(dataset, colors=colors, mode='line') - ``` """ - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - if mode not in ('stacked_bar', 'line'): raise ValueError(f"'mode' must be one of {{'stacked_bar','line'}} for matplotlib, got {mode!r}") From b4584d47545940442ad17fa98687c11d8e29db51 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:12:15 +0200 Subject: [PATCH 164/173] Reverse color and CONFIG related changes in plotting.py --- flixopt/plotting.py | 127 ++++++++++++-------------------------------- 1 file changed, 35 insertions(+), 92 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 8e0d9ab20..d89a9327c 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1094,7 +1094,7 @@ def plot_network( def pie_with_plotly( data: xr.Dataset | pd.DataFrame, - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', legend_title: str = '', hole: float = 0.0, @@ -1129,24 +1129,7 @@ def pie_with_plotly( - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. - All dimensions are summed to get total values for each variable. - Scalar variables (with no dimensions) are used directly. - - Examples: - Simple pie chart: - - ```python - fig = pie_with_plotly(dataset, colors='turbo', title='Energy Mix') - ``` - - Custom color mapping: - - ```python - colors = {'Solar': 'orange', 'Wind': 'blue', 'Coal': 'red'} - fig = pie_with_plotly(dataset, colors=colors, title='Renewable Energy') - ``` """ - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - # Ensure data is a Dataset and validate it data = _ensure_dataset(data) _validate_plotting_data(data, allow_empty=True) @@ -1209,7 +1192,7 @@ def pie_with_plotly( def pie_with_matplotlib( data: xr.Dataset | pd.DataFrame, - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', legend_title: str = 'Categories', hole: float = 0.0, @@ -1237,20 +1220,6 @@ def pie_with_matplotlib( - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. - All dimensions are summed to get total values for each variable. - Scalar variables (with no dimensions) are used directly. - - Examples: - Simple pie chart: - - ```python - fig, ax = pie_with_matplotlib(dataset, colors='turbo', title='Energy Mix') - ``` - - Custom color mapping: - - ```python - colors = {'Solar': 'orange', 'Wind': 'blue', 'Coal': 'red'} - fig, ax = pie_with_matplotlib(dataset, colors=colors, title='Renewable Energy') - ``` """ if colors is None: colors = CONFIG.Plotting.default_qualitative_colorscale @@ -1341,7 +1310,7 @@ def pie_with_matplotlib( def dual_pie_with_plotly( data_left: xr.Dataset | pd.DataFrame, data_right: xr.Dataset | pd.DataFrame, - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1374,9 +1343,6 @@ def dual_pie_with_plotly( Returns: A Plotly figure object containing the generated dual pie chart. """ - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - from plotly.subplots import make_subplots # Ensure data is a Dataset and validate it @@ -1481,7 +1447,7 @@ def create_pie_trace(labels, values, side): def dual_pie_with_matplotlib( data_left: pd.Series, data_right: pd.Series, - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1509,9 +1475,6 @@ def dual_pie_with_matplotlib( Returns: A tuple containing the Matplotlib figure and list of axes objects used for the plot. """ - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - # Create figure and axes fig, axes = plt.subplots(1, 2, figsize=figsize) @@ -1667,11 +1630,11 @@ def draw_pie_on_axis(ax, data_series, colors_list, subtitle, hole_size): def heatmap_with_plotly( data: xr.DataArray, - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', facet_by: str | list[str] | None = None, animate_by: str | None = None, - facet_cols: int | 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', @@ -1695,7 +1658,7 @@ def heatmap_with_plotly( 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: - 'turbo', 'plasma', 'RdBu', 'portland'. + '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). @@ -1751,13 +1714,6 @@ def heatmap_with_plotly( fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period', reshape_time=('W', 'D')) ``` """ - if colors is None: - colors = CONFIG.Plotting.default_sequential_colorscale - - # Apply CONFIG defaults if not explicitly set - if facet_cols is None: - facet_cols = CONFIG.Plotting.default_facet_cols - # Handle empty data if data.size == 0: return go.Figure() @@ -1851,7 +1807,7 @@ def heatmap_with_plotly( # 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 CONFIG.Plotting.default_sequential_colorscale, + 'color_continuous_scale': colors if isinstance(colors, str) else 'viridis', 'title': title, } @@ -1875,9 +1831,7 @@ def heatmap_with_plotly( # Fallback: create a simple heatmap without faceting fallback_args = { 'img': data.values, - 'color_continuous_scale': colors - if isinstance(colors, str) - else CONFIG.Plotting.default_sequential_colorscale, + 'color_continuous_scale': colors if isinstance(colors, str) else 'viridis', 'title': title, } fallback_args.update(imshow_kwargs) @@ -1888,7 +1842,7 @@ def heatmap_with_plotly( def heatmap_with_matplotlib( data: xr.DataArray, - colors: ColorType | None = None, + colors: ColorType = 'viridis', title: str = '', figsize: tuple[float, float] = (12, 6), reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] @@ -1951,9 +1905,6 @@ def heatmap_with_matplotlib( fig, ax = heatmap_with_matplotlib(data_array, reshape_time=('D', 'h')) ``` """ - if colors is None: - colors = CONFIG.Plotting.default_sequential_colorscale - # Initialize kwargs if not provided if imshow_kwargs is None: imshow_kwargs = {} @@ -2006,7 +1957,7 @@ def heatmap_with_matplotlib( y_labels = 'y' # Process colormap - cmap = colors if isinstance(colors, str) else CONFIG.Plotting.default_sequential_colorscale + cmap = colors if isinstance(colors, str) else 'viridis' # Create the heatmap using imshow with user customizations imshow_defaults = {'cmap': cmap, 'aspect': 'auto', 'origin': 'upper', 'vmin': vmin, 'vmax': vmax} @@ -2038,9 +1989,9 @@ def export_figure( default_path: pathlib.Path, default_filetype: str | None = None, user_path: pathlib.Path | None = None, - show: bool | None = None, + show: bool = True, save: bool = False, - dpi: int | None = None, + dpi: int = 300, ) -> go.Figure | tuple[plt.Figure, plt.Axes]: """ Export a figure to a file and or show it. @@ -2058,13 +2009,6 @@ def export_figure( ValueError: If no default filetype is provided and the path doesn't specify a filetype. TypeError: If the figure type is not supported. """ - # Apply CONFIG defaults if not explicitly set - if show is None: - show = CONFIG.Plotting.default_show - - if dpi is None: - dpi = CONFIG.Plotting.default_dpi - filename = user_path or default_path filename = filename.with_name(filename.name.replace('|', '__')) if filename.suffix == '': @@ -2074,32 +2018,30 @@ def export_figure( if isinstance(figure_like, plotly.graph_objs.Figure): fig = figure_like - - # Apply default dimensions if configured - layout_updates = {} - if CONFIG.Plotting.default_figure_width is not None: - layout_updates['width'] = CONFIG.Plotting.default_figure_width - if CONFIG.Plotting.default_figure_height is not None: - layout_updates['height'] = CONFIG.Plotting.default_figure_height - if layout_updates: - fig.update_layout(**layout_updates) - if filename.suffix != '.html': logger.warning(f'To save a Plotly figure, using .html. Adjusting suffix for {filename}') filename = filename.with_suffix('.html') try: - # Respect show and save flags (tests should set CONFIG.Plotting.default_show=False) - if save and show: - # Save and auto-open in browser - plotly.offline.plot(fig, filename=str(filename)) - elif save and not show: - # Save without opening - fig.write_html(str(filename)) - elif show and not save: - # Show interactively without saving - fig.show() - # If neither save nor show: do nothing + is_test_env = 'PYTEST_CURRENT_TEST' in os.environ + + if is_test_env: + # Test environment: never open browser, only save if requested + if save: + fig.write_html(str(filename)) + # Ignore show flag in tests + else: + # Production environment: respect show and save flags + if save and show: + # Save and auto-open in browser + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + # Save without opening + fig.write_html(str(filename)) + elif show and not save: + # Show interactively without saving + fig.show() + # If neither save nor show: do nothing finally: # Cleanup to prevent socket warnings if hasattr(fig, '_renderer'): @@ -2110,11 +2052,12 @@ def export_figure( elif isinstance(figure_like, tuple): fig, ax = figure_like if show: - # Only show if using interactive backend (tests should set CONFIG.Plotting.default_show=False) + # Only show if using interactive backend and not in test environment backend = matplotlib.get_backend().lower() is_interactive = backend not in {'agg', 'pdf', 'ps', 'svg', 'template'} + is_test_env = 'PYTEST_CURRENT_TEST' in os.environ - if is_interactive: + if is_interactive and not is_test_env: plt.show() if save: From ac62b8711061c125589e89e0666a688b3a8ce063 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:17:50 +0200 Subject: [PATCH 165/173] Reverse color and CONFIG related changes in plotting.py --- flixopt/results.py | 266 +++++---------------------------------------- 1 file changed, 27 insertions(+), 239 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 80dcc16ea..d46efaef1 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -15,7 +15,6 @@ from . import io as fx_io from . import plotting -from .config import CONFIG from .flow_system import FlowSystem if TYPE_CHECKING: @@ -70,10 +69,6 @@ class CalculationResults: effects: Dictionary mapping effect names to EffectResults objects timesteps_extra: Extended time index including boundary conditions hours_per_timestep: Duration of each timestep for proper energy calculations - colors: Optional dict mapping variable names to colors for automatic coloring in plots. - When set, all plotting methods automatically use these colors when colors=None - (the default). Use `setup_colors()` to configure colors, which returns this dict. - Set to None to disable automatic coloring. Examples: Load and analyze saved results: @@ -259,9 +254,6 @@ def __init__( self._sizes = None self._effects_per_component = None - # Color dict for intelligent plot coloring - None by default, user configures explicitly - self.colors: dict[str, str] | None = None - def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults: if key in self.components: return self.components[key] @@ -328,104 +320,6 @@ def flow_system(self) -> FlowSystem: logger.level = old_level return self._flow_system - def setup_colors( - self, - config: dict[str, str | list[str]] | str | pathlib.Path | None = None, - *, - default_colorscale: str | None = None, - reset: bool = True, - ) -> dict[str, str]: - """Configure colors for plotting. Returns variable→color dict. - - Supports multiple configuration styles: - - Direct assignment: {'Boiler1': 'red'} - - Pattern matching: {'Solar*': 'orange'} or {'Solar*': 'Oranges'} - - Family grouping: {'oranges': ['Solar1', 'Solar2']} - - Args: - config: Optional color configuration: - - dict: Component/pattern to color/colorscale mapping - - str/Path: Path to YAML file - - None: Use default colorscale for all components - default_colorscale: Default colorscale for unmapped components. - Defaults to CONFIG.Plotting.default_qualitative_colorscale - reset: If True, reset all existing colors before applying config. - If False, only update/add specified components (default: True) - - Returns: - dict[str, str]: Complete variable→color mapping - - Examples: - Direct color assignment: - - ```python - results.setup_colors({'Boiler1': 'red', 'CHP': 'darkred'}) - ``` - - Pattern matching with color: - - ```python - results.setup_colors({'Solar*': 'orange', 'Wind*': 'blue'}) - ``` - - Pattern matching with colorscale (generates shades): - - ```python - results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues'}) - ``` - - Family grouping (colorscale samples): - - ```python - results.setup_colors( - { - 'oranges': ['Solar1', 'Solar2'], - 'blues': ['Wind1', 'Wind2'], - } - ) - ``` - - Load from YAML file: - - ```python - # colors.yaml: - # Boiler1: red - # Solar*: Oranges - # oranges: - # - Solar1 - # - Solar2 - results.setup_colors('colors.yaml') - ``` - - Merge with existing colors: - - ```python - results.setup_colors({'Boiler1': 'red'}) - results.setup_colors({'CHP': 'blue'}, reset=False) # Keeps Boiler1 red - ``` - - Disable automatic coloring: - - ```python - results.colors = None # Plots use default colorscales - ``` - """ - # Create resolver and delegate - resolver = plotting.ElementColorResolver( - self.components, - default_colorscale=default_colorscale, - engine='plotly', - ) - - # Resolve colors (with variable-level merging if reset=False) - self.colors = resolver.resolve( - config=config, - reset=reset, - existing_colors=None if reset else self.colors, - ) - - return self.colors - def filter_solution( self, variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, @@ -825,13 +719,13 @@ def plot_heatmap( self, variable_name: str | list[str], save: bool | pathlib.Path = False, - show: bool | None = None, - colors: plotting.ColorType | None = None, + 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 | 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', @@ -858,8 +752,7 @@ def plot_heatmap( 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 (default: None uses CONFIG.Plotting.default_sequential_colorscale). - See `flixopt.plotting.ColorType` for options. + 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. Applied BEFORE faceting/animation/reshaping. @@ -1131,8 +1024,8 @@ def __init__( def plot_node_balance( self, save: bool | pathlib.Path = False, - show: bool | None = None, - colors: plotting.ColorType | None = None, + show: bool = True, + colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', @@ -1140,7 +1033,7 @@ def plot_node_balance( drop_suffix: bool = True, facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', - facet_cols: int | None = None, + facet_cols: int = 3, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, **plot_kwargs: Any, @@ -1151,12 +1044,7 @@ def plot_node_balance( 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. Options: - - None (default): Use `self.colors` dict if configured, else fall back to CONFIG.Plotting.default_qualitative_colorscale - - Colormap name string (e.g., 'turbo', 'plasma') - - List of color strings - - Dict mapping variable names to colors - Use `results.setup_colors()` to configure automatic component-based coloring. + 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'. select: Optional data selection dict. Supports: - Single values: {'scenario': 'base', 'period': 2024} @@ -1275,9 +1163,7 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value - colors_to_use = colors or self._calculation_results.colors or CONFIG.Plotting.default_qualitative_colorscale - resolved_colors = plotting.resolve_colors(ds, colors_to_use, engine=engine) + resolved_colors = plotting.resolve_colors(ds, colors, engine=engine) # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': @@ -1329,10 +1215,10 @@ def plot_node_balance( def plot_node_balance_pie( self, lower_percentage_group: float = 5, - colors: plotting.ColorType | None = None, + colors: plotting.ColorType = 'viridis', text_info: str = 'percent+label+value', save: bool | pathlib.Path = False, - show: bool | None = None, + show: bool = True, engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, # Deprecated parameter (kept for backwards compatibility) @@ -1350,8 +1236,7 @@ def plot_node_balance_pie( Args: lower_percentage_group: Percentage threshold for "Others" grouping. - colors: Color scheme (default: None uses colors dict if configured, - else falls back to CONFIG.Plotting.default_qualitative_colorscale). + colors: Color scheme. Also see plotly. text_info: Information to display on pie slices. save: Whether to save plot. show: Whether to display plot. @@ -1465,9 +1350,7 @@ def plot_node_balance_pie( # Combine inputs and outputs to resolve colors for all variables combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) - # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value - colors_to_use = colors or self._calculation_results.colors or CONFIG.Plotting.default_qualitative_colorscale - resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) + resolved_colors = plotting.resolve_colors(combined_ds, colors, engine=engine) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( @@ -1602,14 +1485,14 @@ def charge_state(self) -> xr.DataArray: def plot_charge_state( self, save: bool | pathlib.Path = False, - show: bool | None = None, - colors: plotting.ColorType | None = None, + show: bool = True, + colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', 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 | None = None, + facet_cols: int = 3, # Deprecated parameter (kept for backwards compatibility) indexer: dict[FlowSystemDimensions, Any] | None = None, **plot_kwargs: Any, @@ -1619,8 +1502,7 @@ def plot_charge_state( 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: Color scheme (default: None uses colors dict if configured, - else falls back to CONFIG.Plotting.default_qualitative_colorscale). + 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. select: Optional data selection dict. Supports single values, lists, slices, and index arrays. @@ -1716,8 +1598,7 @@ def plot_charge_state( combined_ds = ds.assign({self._charge_state: charge_state_da}) # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value - colors_to_use = colors or self._calculation_results.colors or CONFIG.Plotting.default_qualitative_colorscale - resolved_colors = plotting.resolve_colors(combined_ds, colors_to_use, engine=engine) + resolved_colors = plotting.resolve_colors(combined_ds, colors, engine=engine) if engine == 'plotly': # Plot flows (node balance) with the specified mode @@ -1919,19 +1800,6 @@ class SegmentedCalculationResults: - Flow rate transitions at segment boundaries - Aggregated results over the full time horizon - Attributes: - segment_results: List of CalculationResults for each segment - all_timesteps: Complete time index spanning all segments - timesteps_per_segment: Number of timesteps in each segment - overlap_timesteps: Number of overlapping timesteps between segments - name: Identifier for this segmented calculation - folder: Directory path for result storage and loading - hours_per_timestep: Duration of each timestep - colors: Optional dict mapping variable names to colors for automatic coloring in plots. - When set, it is automatically propagated to all segment results, ensuring - consistent coloring across segments. Use `setup_colors()` to configure - colors across all segments. - Examples: Load and analyze segmented results: @@ -1987,17 +1855,6 @@ class SegmentedCalculationResults: storage_continuity = results.check_storage_continuity('Battery') ``` - Configure color management for consistent plotting across segments: - - ```python - # Dict-based configuration: - results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues', 'Battery': 'green'}) - - # Colors automatically propagate to all segments - results.segment_results[0]['ElectricityBus'].plot_node_balance() - results.segment_results[1]['ElectricityBus'].plot_node_balance() # Same colors - ``` - Design Considerations: **Boundary Effects**: Monitor solution quality at segment interfaces where foresight is limited compared to full-horizon optimization. @@ -2072,9 +1929,6 @@ def __init__( self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps) - # Color dict for intelligent plot coloring - None by default, user configures explicitly - self.colors: dict[str, str] | None = None - @property def meta_data(self) -> dict[str, int | list[str]]: return { @@ -2088,66 +1942,6 @@ def meta_data(self) -> dict[str, int | list[str]]: def segment_names(self) -> list[str]: return [segment.name for segment in self.segment_results] - def setup_colors( - self, - config: dict[str, str | list[str]] | str | pathlib.Path | None = None, - *, - default_colorscale: str | None = None, - reset: bool = True, - ) -> dict[str, str]: - """Configure colors for all segments. Returns variable→color dict. - - Colors are set on the first segment and then propagated to all other - segments for consistent coloring across the entire segmented calculation. - - Args: - config: Optional color configuration: - - dict: Component/pattern to color/colorscale mapping - - str/Path: Path to YAML file - - None: Use default colorscale for all components - default_colorscale: Default colorscale for unmapped components. - Defaults to CONFIG.Plotting.default_qualitative_colorscale - reset: If True, reset all existing colors before applying config. - If False, only update/add specified components (default: True) - - Returns: - dict[str, str]: Complete variable→color mapping - - Examples: - Dict-based configuration: - - ```python - results.setup_colors( - { - 'Boiler1': 'red', - 'Solar*': 'Oranges', - 'oranges': ['Solar1', 'Solar2'], - } - ) - - # All segments use the same colors - results.segment_results[0]['ElectricityBus'].plot_node_balance() - results.segment_results[1]['ElectricityBus'].plot_node_balance() - ``` - - Load from file: - - ```python - results.setup_colors('colors.yaml') - ``` - """ - # Setup colors on first segment - self.segment_results[0].setup_colors(config, default_colorscale=default_colorscale, reset=reset) - - # Propagate to all other segments - for segment in self.segment_results[1:]: - segment.colors = self.segment_results[0].colors - - # Store reference - self.colors = self.segment_results[0].colors - - return self.colors - def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Get variable solution removing segment overlaps. @@ -2169,13 +1963,13 @@ def plot_heatmap( reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] | Literal['auto'] | None = 'auto', - colors: str | None = None, + colors: str = 'portland', save: bool | pathlib.Path = False, - show: bool | None = None, + show: bool = True, engine: plotting.PlottingEngine = 'plotly', facet_by: str | list[str] | None = None, animate_by: str | None = None, - facet_cols: int | 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, @@ -2191,8 +1985,7 @@ def plot_heatmap( - '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 (default: None uses CONFIG.Plotting.default_sequential_colorscale). - See plotting.ColorType for options. + colors: Color scheme. See plotting.ColorType for options. save: Whether to save plot. show: Whether to display plot. engine: Plotting engine. @@ -2241,7 +2034,7 @@ def plot_heatmap( if color_map is not None: # Check for conflict with new parameter - if colors is not None: # Check if user explicitly set colors + 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'." ) @@ -2301,9 +2094,9 @@ def plot_heatmap( data: xr.DataArray | xr.Dataset, name: str | None = None, folder: pathlib.Path | None = None, - colors: plotting.ColorType | None = None, + colors: plotting.ColorType = 'viridis', save: bool | pathlib.Path = False, - show: bool | None = None, + show: bool = True, engine: plotting.PlottingEngine = 'plotly', select: dict[str, Any] | None = None, facet_by: str | list[str] | None = None, @@ -2331,8 +2124,7 @@ def plot_heatmap( 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 (default: None uses CONFIG.Plotting.default_sequential_colorscale). - See `flixopt.plotting.ColorType` for options. + 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'. @@ -2391,7 +2183,7 @@ def plot_heatmap( # Handle deprecated color_map parameter if color_map is not None: # Check for conflict with new parameter - if colors is not None: # User explicitly set colors + if colors != 'viridis': # User explicitly set colors raise ValueError( "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." ) @@ -2474,10 +2266,6 @@ def plot_heatmap( timeframes, timesteps_per_frame = reshape_time title += f' ({timeframes} vs {timesteps_per_frame})' - # Apply CONFIG default if colors is None - if colors is None: - colors = CONFIG.Plotting.default_sequential_colorscale - # Extract dpi before passing to plotting functions dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi From a96fd801d2fdef0782a9481d4be03843bce50dc7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:20:35 +0200 Subject: [PATCH 166/173] Reverse color and CONFIG related changes in plotting.py --- CHANGELOG.md | 13 ------------- tests/conftest.py | 5 ----- tests/test_results_plots.py | 4 ++-- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 955dd6b69..2974025cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,22 +53,9 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). ### ✨ Added -- **Simplified color management**: Configure consistent plot colors with explicit component grouping - - Direct colors: `results.setup_colors({'Boiler1': '#FF0000', 'CHP': 'darkred'})` - - Grouped colors: `results.setup_colors({'oranges': ['Solar1', 'Solar2'], 'blues': ['Wind1', 'Wind2']})` - - Mixed approach: Combine direct and grouped colors in a single call - - File-based: `results.setup_colors('colors.yaml')` (YAML only) -- **Heatmap fill control**: Control missing value handling with `fill='ffill'` or `fill='bfill'` -- **New CONFIG options for plot styling** - - `CONFIG.Plotting.default_sequential_colorscale` - Falls back to template's sequential colorscale when `None` - - `CONFIG.Plotting.default_qualitative_colorscale` - Falls back to template's colorway when `None` - - `CONFIG.Plotting.default_show` defaults to `True` - set to None to prevent unwanted GUI windows ### ♻️ Changed - **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides - - Removed hardcoded `plot_bgcolor`, `paper_bgcolor`, and `font` settings from plotting functions - - Change template via `CONFIG.Plotting.plotly_template = 'plotly_dark'; CONFIG.apply()` -- Plotting methods now use `color_manager` by default if configured ### 🗑️ Deprecated diff --git a/tests/conftest.py b/tests/conftest.py index 98929e467..ac5255562 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -838,7 +838,6 @@ def set_test_environment(): This fixture runs once per test session to: - Set matplotlib to use non-interactive 'Agg' backend - Set plotly to use non-interactive 'json' renderer - - Configure flixopt to not show plots by default - Prevent GUI windows from opening during tests """ import matplotlib @@ -849,8 +848,4 @@ def set_test_environment(): pio.renderers.default = 'json' # Use non-interactive renderer - # Configure flixopt to not show plots in tests - fx.CONFIG.Plotting.default_show = False - fx.CONFIG.apply() - yield diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index a656f7c44..1fd6cf7f5 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -28,7 +28,7 @@ def plotting_engine(request): @pytest.fixture( params=[ - 'turbo', # Test string colormap + 'viridis', # Test string colormap ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'], # Test color list { 'Boiler(Q_th)|flow_rate': '#ff0000', @@ -51,7 +51,7 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): # Matplotlib doesn't support faceting/animation, so disable them for matplotlib engine heatmap_kwargs = { 'reshape_time': ('D', 'h'), - 'colors': 'turbo', # Note: heatmap only accepts string colormap + 'colors': 'viridis', # Note: heatmap only accepts string colormap 'save': save, 'show': show, 'engine': plotting_engine, From 3793fbe81e87d9d2963325b6c98a25fd1cd8cb0f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:24:30 +0200 Subject: [PATCH 167/173] Reverse color and CONFIG related changes in plotting.py --- flixopt/aggregation.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index bb82adeee..cd22c2ad7 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -21,7 +21,6 @@ TSAM_AVAILABLE = False from .components import Storage -from .config import CONFIG from .structure import ( FlowSystemModel, Submodel, @@ -151,20 +150,10 @@ def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path df_agg = self.aggregated_data.copy().rename( columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns} ) - fig = plotting.with_plotly( - df_org.to_xarray(), - 'line', - colors=colormap or CONFIG.Plotting.default_qualitative_colorscale, - xlabel='Time in h', - ) + fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colormap, xlabel='Time in h') for trace in fig.data: trace.update(dict(line=dict(dash='dash'))) - fig2 = plotting.with_plotly( - df_agg.to_xarray(), - 'line', - colors=colormap or CONFIG.Plotting.default_qualitative_colorscale, - xlabel='Time in h', - ) + fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colormap, xlabel='Time in h') for trace in fig2.data: fig.add_trace(trace) From 407aa79ff24eb58acbd420469c072e187af17f4a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:27:35 +0200 Subject: [PATCH 168/173] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2974025cd..327bef0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### ♻️ Changed - **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides +- **Dataset first plotting**: Underlying plotting methods in plotting.py now use `xr.Dataset` as the main datatype, converting to it if they get a DataFrame passed ### 🗑️ Deprecated From 33bcd3b6bab01ae1caadd5bebd3670d118ebabbf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:28:47 +0200 Subject: [PATCH 169/173] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 327bef0af..c1fce0e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,12 +54,16 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### ✨ Added +### 💥 Breaking Changes + ### ♻️ Changed - **Template integration**: Plotly templates now fully control plot styling without hardcoded overrides - **Dataset first plotting**: Underlying plotting methods in plotting.py now use `xr.Dataset` as the main datatype, converting to it if they get a DataFrame passed ### 🗑️ Deprecated +### 🔥 Removed + ### 🐛 Fixed - Improved error messages for matplotlib with multidimensional data - Better dimension validation in `plot_heatmap()` From 8274162e5c4221a1d0b6efedc1a1a70953536eef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:33:22 +0200 Subject: [PATCH 170/173] Remove duplicate resolve color calls --- flixopt/results.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index d46efaef1..cadb14236 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1163,8 +1163,6 @@ def plot_node_balance( ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - resolved_colors = plotting.resolve_colors(ds, colors, engine=engine) - # Matplotlib requires only 'time' dimension; check for extras after selection if engine == 'matplotlib': extra_dims = [d for d in ds.dims if d != 'time'] @@ -1184,7 +1182,7 @@ def plot_node_balance( ds, facet_by=facet_by, animate_by=animate_by, - colors=resolved_colors, + colors=colors, mode=mode, title=title, facet_cols=facet_cols, @@ -1195,7 +1193,7 @@ def plot_node_balance( else: figure_like = plotting.with_matplotlib( ds, - colors=resolved_colors, + colors=colors, mode=mode, title=title, **plot_kwargs, @@ -1347,16 +1345,11 @@ def plot_node_balance_pie( suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'{self.label} (total flow hours){suffix}' - # Combine inputs and outputs to resolve colors for all variables - combined_ds = xr.Dataset({**inputs.data_vars, **outputs.data_vars}) - - resolved_colors = plotting.resolve_colors(combined_ds, colors, engine=engine) - if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs, data_right=outputs, - colors=resolved_colors, + colors=colors, title=title, text_info=text_info, subtitles=('Inputs', 'Outputs'), @@ -1370,7 +1363,7 @@ def plot_node_balance_pie( figure_like = plotting.dual_pie_with_matplotlib( data_left=inputs.to_pandas(), data_right=outputs.to_pandas(), - colors=resolved_colors, + colors=colors, title=title, subtitles=('Inputs', 'Outputs'), legend_title='Flows', @@ -1593,20 +1586,13 @@ def plot_charge_state( title = f'Operation Balance of {self.label}{suffix}' - # Combine flow balance and charge state for color resolution - # We need to include both in the color map for consistency - combined_ds = ds.assign({self._charge_state: charge_state_da}) - - # Resolve colors: None -> colors dict if set -> CONFIG default -> explicit value - resolved_colors = plotting.resolve_colors(combined_ds, colors, engine=engine) - if engine == 'plotly': # Plot flows (node balance) with the specified mode figure_like = plotting.with_plotly( ds, facet_by=facet_by, animate_by=animate_by, - colors=resolved_colors, + colors=colors, mode=mode, title=title, facet_cols=facet_cols, @@ -1622,7 +1608,7 @@ def plot_charge_state( charge_state_ds, facet_by=facet_by, animate_by=animate_by, - colors=resolved_colors, + colors=colors, mode='line', # Always line for charge_state title='', # No title needed for this temp figure facet_cols=facet_cols, @@ -1661,7 +1647,7 @@ def plot_charge_state( # For matplotlib, plot flows (node balance), then add charge_state as line fig, ax = plotting.with_matplotlib( ds, - colors=resolved_colors, + colors=colors, mode=mode, title=title, **plot_kwargs, From 34b9a75bded3f21bdcd647557ca27a8d4d75ad4c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 21:56:56 +0200 Subject: [PATCH 171/173] Improve pie plot --- flixopt/plotting.py | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index d89a9327c..df48561cd 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1362,7 +1362,7 @@ def dual_pie_with_plotly( ) # Helper function to extract labels and values from Dataset - def dataset_to_pie_data(dataset): + def dataset_to_pie_data(dataset: xr.Dataset, lower_percentage_group=None): labels = [] values = [] @@ -1385,11 +1385,48 @@ def dataset_to_pie_data(dataset): labels.append(str(var)) values.append(total_value) + # Apply minimum percentage threshold if needed + if lower_percentage_group and len(values) > 0: + total = sum(values) + if total > 0: + # Create list of (label, value) pairs and sort by value (ascending) + sorted_data = sorted(zip(labels, values, strict=False), key=lambda x: x[1]) + + # Calculate cumulative percentage contribution + cumulative_sum = 0 + to_group_indices = [] + + for i, (_, value) in enumerate(sorted_data): + new_cumulative = cumulative_sum + value + new_cumulative_percent = (new_cumulative / total) * 100 + + # Only add to group if adding this item keeps us at or below threshold + if new_cumulative_percent <= lower_percentage_group: + to_group_indices.append(i) + cumulative_sum = new_cumulative + else: + # Stop once we would exceed the threshold + break + + # Only group if there are at least 2 items to group + if len(to_group_indices) > 1: + # Calculate "Other" sum + other_sum = sum(sorted_data[i][1] for i in to_group_indices) + + # Keep only values that aren't in the "Other" group + labels = [sorted_data[i][0] for i in range(len(sorted_data)) if i not in to_group_indices] + values = [sorted_data[i][1] for i in range(len(sorted_data)) if i not in to_group_indices] + + # Add the "Other" category + if other_sum > 0: + labels.append('Other') + values.append(other_sum) + return labels, values # Get data for left and right - left_labels, left_values = dataset_to_pie_data(data_left) - right_labels, right_values = dataset_to_pie_data(data_right) + left_labels, left_values = dataset_to_pie_data(data_left, lower_percentage_group) + right_labels, right_values = dataset_to_pie_data(data_right, lower_percentage_group) # Get unique set of all labels for consistent coloring across both pies # Merge both datasets for color resolution From 2e06d44be14841e09089eb9a51673a1f46429cf8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:35:21 +0200 Subject: [PATCH 172/173] Simplify pie plot --- flixopt/plotting.py | 546 ++++++++++---------------------------------- 1 file changed, 116 insertions(+), 430 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index df48561cd..4d3dc9294 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1092,219 +1092,51 @@ def plot_network( ) -def pie_with_plotly( - data: xr.Dataset | pd.DataFrame, - colors: ColorType = 'viridis', - title: str = '', - legend_title: str = '', - hole: float = 0.0, - fig: go.Figure | None = None, - hover_template: str = '%{label}: %{value} (%{percent})', - text_info: str = 'percent+label+value', - text_position: str = 'inside', -) -> go.Figure: - """ - Create a pie chart with Plotly to visualize the proportion of values in a Dataset. - - Args: - data: An xarray Dataset containing the data to plot. All dimensions will be summed - to get the total for each variable. - colors: Color specification, can be: - - A string with a colorscale name (e.g., 'turbo', 'plasma') - - A list of color strings (e.g., ['#ff0000', '#00ff00']) - - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - title: The title of the plot. - legend_title: The title for the legend. - hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). - fig: A Plotly figure object to plot on. If not provided, a new figure will be created. - hover_template: Template for hover text. Use %{label}, %{value}, %{percent}. - text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', - 'label+value', 'percent+value', 'label+percent+value', or 'none'. - text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. - - Returns: - A Plotly figure object containing the generated pie chart. - - Notes: - - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. - - All dimensions are summed to get total values for each variable. - - Scalar variables (with no dimensions) are used directly. +def preprocess_series_for_pie(series: pd.Series, lower_percentage_threshold: float = 5.0) -> pd.Series: """ - # Ensure data is a Dataset and validate it - data = _ensure_dataset(data) - _validate_plotting_data(data, allow_empty=True) - - if len(data.data_vars) == 0: - logger.error('Empty Dataset provided for pie chart. Returning empty figure.') - return go.Figure() - - # Sum all dimensions for each variable to get total values - labels = [] - values = [] - - for var in data.data_vars: - var_data = data[var] - - # Sum across all dimensions to get total - if len(var_data.dims) > 0: - total_value = var_data.sum().item() - else: - # Scalar variable - total_value = var_data.item() - - # Check for negative values - if total_value < 0: - logger.warning(f'Negative value detected for {var}: {total_value}. Using absolute value.') - total_value = abs(total_value) - - labels.append(str(var)) - values.append(total_value) - - # Use resolve_colors for consistent color handling - color_discrete_map = resolve_colors(data, colors, engine='plotly') - processed_colors = [color_discrete_map.get(label, '#636EFA') for label in labels] - - # Create figure if not provided - fig = fig if fig is not None else go.Figure() - - # Add pie trace - fig.add_trace( - go.Pie( - labels=labels, - values=values, - hole=hole, - marker=dict(colors=processed_colors), - textinfo=text_info, - textposition=text_position, - insidetextorientation='radial', - hovertemplate=hover_template, - ) - ) - - # Update layout with plot-specific properties - fig.update_layout( - title=title, - legend_title=legend_title, - ) - - return fig - + Preprocess a series for pie chart display. -def pie_with_matplotlib( - data: xr.Dataset | pd.DataFrame, - colors: ColorType = 'viridis', - title: str = '', - legend_title: str = 'Categories', - hole: float = 0.0, - figsize: tuple[int, int] = (10, 8), -) -> tuple[plt.Figure, plt.Axes]: - """ - Create a pie chart with Matplotlib to visualize the proportion of values in a Dataset. + Groups items that are individually below the threshold percentage into an "Other" category. Args: - data: An xarray Dataset containing the data to plot. All dimensions will be summed - to get the total for each variable. - colors: Color specification, can be: - - A string with a colormap name (e.g., 'turbo', 'plasma') - - A list of color strings (e.g., ['#ff0000', '#00ff00']) - - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) - title: The title of the plot. - legend_title: The title for the legend. - hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). - figsize: The size of the figure (width, height) in inches. + series: Input series with category names as index and values + lower_percentage_threshold: Percentage threshold - items below this are grouped into "Other" Returns: - A tuple containing the Matplotlib figure and axes objects used for the plot. - - Notes: - - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. - - All dimensions are summed to get total values for each variable. - - Scalar variables (with no dimensions) are used directly. + Processed series with small items grouped into "Other" """ - if colors is None: - colors = CONFIG.Plotting.default_qualitative_colorscale - - # Ensure data is a Dataset and validate it - data = _ensure_dataset(data) - _validate_plotting_data(data, allow_empty=True) + # Handle negative values + if (series < 0).any(): + print('Warning: Negative values detected. Using absolute values.') + series = series.abs() - if len(data.data_vars) == 0: - logger.error('Empty Dataset provided for pie chart. Returning empty figure.') - fig, ax = plt.subplots(figsize=figsize) - return fig, ax + # Remove zeros + series = series[series > 0] - # Sum all dimensions for each variable to get total values - labels = [] - values = [] - - for var in data.data_vars: - var_data = data[var] - - # Sum across all dimensions to get total - if len(var_data.dims) > 0: - total_value = var_data.sum().item() - else: - # Scalar variable - total_value = var_data.item() - - # Check for negative values - if total_value < 0: - logger.warning(f'Negative value detected for {var}: {total_value}. Using absolute value.') - total_value = abs(total_value) - - labels.append(str(var)) - values.append(total_value) + if series.empty or lower_percentage_threshold <= 0: + return series - # Use resolve_colors for consistent color handling - color_discrete_map = resolve_colors(data, colors, engine='matplotlib') - processed_colors = [color_discrete_map.get(label, '#808080') for label in labels] + # Calculate percentage for each item + total = series.sum() + percentages = (series / total) * 100 - # Create figure and axis - fig, ax = plt.subplots(figsize=figsize) + # Find items below the threshold + below_threshold = percentages < lower_percentage_threshold - # Draw the pie chart - wedges, texts, autotexts = ax.pie( - values, - labels=labels, - colors=processed_colors, - autopct='%1.1f%%', - startangle=90, - shadow=False, - wedgeprops=dict(width=0.5) if hole > 0 else None, # Set width for donut - ) + # Only group if there are at least 2 items below threshold + if below_threshold.sum() > 1: + # Sum up the small items + other_sum = series[below_threshold].sum() - # Adjust the wedgeprops to make donut hole size consistent with plotly - # For matplotlib, the hole size is determined by the wedge width - # Convert hole parameter to wedge width - if hole > 0: - # Adjust hole size to match plotly's hole parameter - # In matplotlib, wedge width is relative to the radius (which is 1) - # For plotly, hole is a fraction of the radius - wedge_width = 1 - hole - for wedge in wedges: - wedge.set_width(wedge_width) - - # Customize the appearance - # Make autopct text more visible - for autotext in autotexts: - autotext.set_fontsize(10) - autotext.set_color('white') - - # Set aspect ratio to be equal to ensure a circular pie - ax.set_aspect('equal') - - # Add title - if title: - ax.set_title(title, fontsize=16) + # Keep items above threshold + result = series[~below_threshold].copy() - # Create a legend if there are many segments - if len(labels) > 6: - ax.legend(wedges, labels, title=legend_title, loc='center left', bbox_to_anchor=(1, 0, 0.5, 1)) + # Add "Other" category + result['Other'] = other_sum - # Apply tight layout - fig.tight_layout() + return result - return fig, ax + return series def dual_pie_with_plotly( @@ -1321,7 +1153,7 @@ def dual_pie_with_plotly( text_position: str = 'inside', ) -> go.Figure: """ - Create two pie charts side by side with Plotly, with consistent coloring across both charts. + Create two pie charts side by side with Plotly. Args: data_left: Dataset for the left pie chart. Variables are summed across all dimensions. @@ -1341,137 +1173,70 @@ def dual_pie_with_plotly( text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. Returns: - A Plotly figure object containing the generated dual pie chart. + Plotly Figure object """ - from plotly.subplots import make_subplots - - # Ensure data is a Dataset and validate it data_left = _ensure_dataset(data_left) data_right = _ensure_dataset(data_right) _validate_plotting_data(data_left, allow_empty=True) _validate_plotting_data(data_right, allow_empty=True) - # Check for empty data - if len(data_left.data_vars) == 0 and len(data_right.data_vars) == 0: - logger.error('Both datasets are empty. Returning empty figure.') - return go.Figure() - - # Create a subplot figure - fig = make_subplots( - rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 - ) - - # Helper function to extract labels and values from Dataset - def dataset_to_pie_data(dataset: xr.Dataset, lower_percentage_group=None): - labels = [] - values = [] - - for var in dataset.data_vars: - var_data = dataset[var] - - # Sum across all dimensions - if len(var_data.dims) > 0: - total_value = float(var_data.sum().values) - else: - total_value = float(var_data.values) - - # Handle negative values - if total_value < 0: - logger.warning(f'Negative value for {var}: {total_value}. Using absolute value.') - total_value = abs(total_value) - - # Only include if value > 0 - if total_value > 0: - labels.append(str(var)) - values.append(total_value) - - # Apply minimum percentage threshold if needed - if lower_percentage_group and len(values) > 0: - total = sum(values) - if total > 0: - # Create list of (label, value) pairs and sort by value (ascending) - sorted_data = sorted(zip(labels, values, strict=False), key=lambda x: x[1]) - - # Calculate cumulative percentage contribution - cumulative_sum = 0 - to_group_indices = [] - - for i, (_, value) in enumerate(sorted_data): - new_cumulative = cumulative_sum + value - new_cumulative_percent = (new_cumulative / total) * 100 - - # Only add to group if adding this item keeps us at or below threshold - if new_cumulative_percent <= lower_percentage_group: - to_group_indices.append(i) - cumulative_sum = new_cumulative - else: - # Stop once we would exceed the threshold - break - - # Only group if there are at least 2 items to group - if len(to_group_indices) > 1: - # Calculate "Other" sum - other_sum = sum(sorted_data[i][1] for i in to_group_indices) - - # Keep only values that aren't in the "Other" group - labels = [sorted_data[i][0] for i in range(len(sorted_data)) if i not in to_group_indices] - values = [sorted_data[i][1] for i in range(len(sorted_data)) if i not in to_group_indices] - - # Add the "Other" category - if other_sum > 0: - labels.append('Other') - values.append(other_sum) - - return labels, values - - # Get data for left and right - left_labels, left_values = dataset_to_pie_data(data_left, lower_percentage_group) - right_labels, right_values = dataset_to_pie_data(data_right, lower_percentage_group) - - # Get unique set of all labels for consistent coloring across both pies - # Merge both datasets for color resolution - combined_vars = list(set(data_left.data_vars) | set(data_right.data_vars)) - combined_ds = xr.Dataset( - {var: data_left[var] if var in data_left.data_vars else data_right[var] for var in combined_vars} - ) - - # Use resolve_colors for consistent color handling - color_discrete_map = resolve_colors(combined_ds, colors, engine='plotly') - color_map = {label: color_discrete_map.get(label, '#636EFA') for label in left_labels + right_labels} - - # Function to create a pie trace with consistently mapped colors - def create_pie_trace(labels, values, side): - if not labels: - return None + # Preprocess data + left_processed = preprocess_series_for_pie(data_left, lower_percentage_group) + right_processed = preprocess_series_for_pie(data_right, lower_percentage_group) - trace_colors = [color_map[label] for label in labels] + # Get all unique labels for consistent coloring + all_labels = sorted(set(left_processed.index) | set(right_processed.index)) - return go.Pie( - labels=labels, - values=values, - name=side, - marker=dict(colors=trace_colors), - hole=hole, - textinfo=text_info, - textposition=text_position, - insidetextorientation='radial', - hovertemplate=hover_template, - sort=True, # Sort values by default (largest first) + # Create color map + if isinstance(colors, dict): + color_map = colors + elif isinstance(colors, list): + color_map = {label: colors[i % len(colors)] for i, label in enumerate(all_labels)} + else: + # Use plotly's color sequence + import plotly.express as px + + color_sequence = getattr(px.colors.qualitative, colors.capitalize(), px.colors.qualitative.Plotly) + color_map = {label: color_sequence[i % len(color_sequence)] for i, label in enumerate(all_labels)} + + # Create figure with subplots + fig = go.Figure() + + # Add left pie + if not left_processed.empty: + fig.add_trace( + go.Pie( + labels=list(left_processed.index), + values=list(left_processed.values), + name=subtitles[0], + marker=dict(colors=[color_map.get(label, '#636EFA') for label in left_processed.index]), + hole=hole, + textinfo=text_info, + insidetextorientation='radial', + textposition=text_position, + hovertemplate=hover_template, + domain=dict(x=[0, 0.48]), + ) ) - # Add left pie if data exists - left_trace = create_pie_trace(left_labels, left_values, subtitles[0]) - if left_trace: - left_trace.domain = dict(x=[0, 0.48]) - fig.add_trace(left_trace, row=1, col=1) - - # Add right pie if data exists - right_trace = create_pie_trace(right_labels, right_values, subtitles[1]) - if right_trace: - right_trace.domain = dict(x=[0.52, 1]) - fig.add_trace(right_trace, row=1, col=2) + # Add right pie + if not right_processed.empty: + fig.add_trace( + go.Pie( + labels=list(right_processed.index), + values=list(right_processed.values), + name=subtitles[1], + marker=dict(colors=[color_map.get(label, '#636EFA') for label in right_processed.index]), + hole=hole, + textinfo=text_info, + textposition=text_position, + insidetextorientation='radial', + hovertemplate=hover_template, + domain=dict(x=[0.52, 1]), + ) + ) - # Update layout with plot-specific properties + # Update layout fig.update_layout( title=title, legend_title=legend_title, @@ -1493,7 +1258,7 @@ def dual_pie_with_matplotlib( figsize: tuple[int, int] = (14, 7), ) -> tuple[plt.Figure, list[plt.Axes]]: """ - Create two pie charts side by side with Matplotlib, with consistent coloring across both charts. + Create two pie charts side by side with Matplotlib. Args: data_left: Series for the left pie chart. @@ -1510,80 +1275,23 @@ def dual_pie_with_matplotlib( figsize: The size of the figure (width, height) in inches. Returns: - A tuple containing the Matplotlib figure and list of axes objects used for the plot. + Tuple of (Figure, list of Axes) """ - # Create figure and axes - fig, axes = plt.subplots(1, 2, figsize=figsize) - - # Check for empty data - if data_left.empty and data_right.empty: - logger.error('Both datasets are empty. Returning empty figure.') - return fig, axes - - # Process series to handle negative values and apply minimum percentage threshold - def preprocess_series(series: pd.Series): - """ - Preprocess a series for pie chart display by handling negative values - and grouping the smallest parts together if they collectively represent - less than the specified percentage threshold. - """ - # Handle negative values - if (series < 0).any(): - logger.error('Negative values detected in data. Using absolute values for pie chart.') - series = series.abs() - - # Remove zeros - series = series[series > 0] - - # Apply minimum percentage threshold if needed - if lower_percentage_group and not series.empty: - total = series.sum() - if total > 0: - # Sort series by value (ascending) - sorted_series = series.sort_values() - - # Calculate cumulative percentage contribution - cumulative_percent = (sorted_series.cumsum() / total) * 100 - - # Find entries that collectively make up less than lower_percentage_group - to_group = cumulative_percent <= lower_percentage_group - - if to_group.sum() > 1: - # Create "Other" category for the smallest values that together are < threshold - other_sum = sorted_series[to_group].sum() - - # Keep only values that aren't in the "Other" group - result_series = series[~series.index.isin(sorted_series[to_group].index)] - - # Add the "Other" category if it has a value - if other_sum > 0: - result_series['Other'] = other_sum - - return result_series - - return series - # Preprocess data - data_left_processed = preprocess_series(data_left) - data_right_processed = preprocess_series(data_right) + left_processed = preprocess_series_for_pie(data_left, lower_percentage_group) + right_processed = preprocess_series_for_pie(data_right, lower_percentage_group) - # Convert Series to DataFrames for pie_with_matplotlib - df_left = pd.DataFrame(data_left_processed).T if not data_left_processed.empty else pd.DataFrame() - df_right = pd.DataFrame(data_right_processed).T if not data_right_processed.empty else pd.DataFrame() + # Get all unique labels for consistent coloring + all_labels = sorted(set(left_processed.index) | set(right_processed.index)) - # Get unique set of all labels for consistent coloring - all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) - - # Get consistent color mapping for both charts using our unified function + # Create color map color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True) - # Configure colors for each DataFrame based on the consistent mapping - left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else [] - right_colors = [color_map[col] for col in df_right.columns] if not df_right.empty else [] + # Create figure + fig, axes = plt.subplots(1, 2, figsize=figsize) - # Helper function to draw pie chart on a specific axis - def draw_pie_on_axis(ax, data_series, colors_list, subtitle, hole_size): - """Draw a pie chart on a specific matplotlib axis.""" + def draw_pie(ax, data_series, subtitle): + """Draw a single pie chart.""" if data_series.empty: ax.set_title(subtitle) ax.axis('off') @@ -1591,76 +1299,54 @@ def draw_pie_on_axis(ax, data_series, colors_list, subtitle, hole_size): labels = list(data_series.index) values = list(data_series.values) + chart_colors = [color_map[label] for label in labels] - # Draw the pie chart + # Draw pie wedges, texts, autotexts = ax.pie( values, labels=labels, - colors=colors_list, + colors=chart_colors, autopct='%1.1f%%', startangle=90, - shadow=False, - wedgeprops=dict(width=0.5) if hole_size > 0 else None, + wedgeprops=dict(width=1 - hole) if hole > 0 else None, ) - # Adjust hole size - if hole_size > 0: - wedge_width = 1 - hole_size - for wedge in wedges: - wedge.set_width(wedge_width) - - # Customize text + # Style text for autotext in autotexts: autotext.set_fontsize(10) autotext.set_color('white') + autotext.set_weight('bold') - # Set aspect ratio and title ax.set_aspect('equal') - if subtitle: - ax.set_title(subtitle, fontsize=14) - - # Create left pie chart - draw_pie_on_axis(axes[0], data_left_processed, left_colors, subtitles[0], hole) + ax.set_title(subtitle, fontsize=14, pad=20) - # Create right pie chart - draw_pie_on_axis(axes[1], data_right_processed, right_colors, subtitles[1], hole) + # Draw both pies + draw_pie(axes[0], left_processed, subtitles[0]) + draw_pie(axes[1], right_processed, subtitles[1]) # Add main title if title: fig.suptitle(title, fontsize=16, y=0.98) - # Adjust layout - fig.tight_layout() - - # Create a unified legend if both charts have data - if not df_left.empty and not df_right.empty: - # Remove individual legends - for ax in axes: - if ax.get_legend(): - ax.get_legend().remove() + # Create unified legend + if not left_processed.empty or not right_processed.empty: + handles = [ + plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color_map[label], markersize=10) + for label in all_labels + ] - # Create handles for the unified legend - handles = [] - labels_for_legend = [] - - for label in all_labels: - color = color_map[label] - patch = plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=label) - handles.append(patch) - labels_for_legend.append(label) - - # Add unified legend fig.legend( handles=handles, - labels=labels_for_legend, + labels=all_labels, title=legend_title, loc='lower center', - bbox_to_anchor=(0.5, 0), - ncol=min(len(all_labels), 5), # Limit columns to 5 for readability + bbox_to_anchor=(0.5, -0.02), + ncol=min(len(all_labels), 5), ) - # Add padding at the bottom for the legend - fig.subplots_adjust(bottom=0.2) + fig.subplots_adjust(bottom=0.15) + + fig.tight_layout() return fig, axes From 0d857f042e0328347db19d0e3c4821ce498e827e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:42:00 +0200 Subject: [PATCH 173/173] Simplify pie plot --- flixopt/plotting.py | 179 +++++++++++++++++++++++++------------------- 1 file changed, 102 insertions(+), 77 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 4d3dc9294..6fe5de0e4 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1092,51 +1092,66 @@ def plot_network( ) -def preprocess_series_for_pie(series: pd.Series, lower_percentage_threshold: float = 5.0) -> pd.Series: +def preprocess_dataset_for_pie(data: xr.Dataset, lower_percentage_threshold: float = 5.0) -> xr.Dataset: """ - Preprocess a series for pie chart display. + Preprocess data for pie chart display. Groups items that are individually below the threshold percentage into an "Other" category. + Works with xarray Datasets by summing all dimensions for each variable. Args: - series: Input series with category names as index and values + data: Input data (xarray Dataset, DataFrame, or Series) lower_percentage_threshold: Percentage threshold - items below this are grouped into "Other" Returns: - Processed series with small items grouped into "Other" + Processed xarray Dataset with small items grouped into "Other" """ - # Handle negative values - if (series < 0).any(): - print('Warning: Negative values detected. Using absolute values.') - series = series.abs() + dataset = _ensure_dataset(data) + _validate_plotting_data(dataset, allow_empty=True) - # Remove zeros - series = series[series > 0] + # Sum all dimensions for each variable to get total values + values = {} + for var in data.data_vars: + var_data = data[var] + # Sum across all dimensions to get total + if len(var_data.dims) > 0: + total_value = float(var_data.sum().item()) + else: + total_value = float(var_data.item()) + + # Handle negative values + if total_value < 0: + print(f'Warning: Negative value for {var}: {total_value}. Using absolute value.') + total_value = abs(total_value) - if series.empty or lower_percentage_threshold <= 0: - return series + # Only keep positive values + if total_value > 0: + values[var] = total_value + + if not values or lower_percentage_threshold <= 0: + return data - # Calculate percentage for each item - total = series.sum() - percentages = (series / total) * 100 + # Calculate total and percentages + total = sum(values.values()) + percentages = {name: (val / total) * 100 for name, val in values.items()} - # Find items below the threshold - below_threshold = percentages < lower_percentage_threshold + # Find items below threshold + below_threshold = {name: val for name, val in values.items() if percentages[name] < lower_percentage_threshold} + above_threshold = {name: val for name, val in values.items() if percentages[name] >= lower_percentage_threshold} # Only group if there are at least 2 items below threshold - if below_threshold.sum() > 1: + if len(below_threshold) > 1: # Sum up the small items - other_sum = series[below_threshold].sum() + other_sum = sum(below_threshold.values()) - # Keep items above threshold - result = series[~below_threshold].copy() + # Create new dataset with items above threshold + "Other" + result_dict = above_threshold.copy() + result_dict['Other'] = other_sum - # Add "Other" category - result['Other'] = other_sum + # Convert back to Dataset + return xr.Dataset({name: xr.DataArray(val) for name, val in result_dict.items()}) - return result - - return series + return data def dual_pie_with_plotly( @@ -1148,9 +1163,9 @@ def dual_pie_with_plotly( legend_title: str = '', hole: float = 0.2, lower_percentage_group: float = 5.0, - hover_template: str = '%{label}: %{value} (%{percent})', text_info: str = 'percent+label', text_position: str = 'inside', + hover_template: str = '%{label}: %{value} (%{percent})', ) -> go.Figure: """ Create two pie charts side by side with Plotly. @@ -1158,10 +1173,7 @@ def dual_pie_with_plotly( Args: data_left: Dataset for the left pie chart. Variables are summed across all dimensions. data_right: Dataset for the right pie chart. Variables are summed across all dimensions. - colors: Color specification, can be: - - A string with a colorscale name (e.g., 'turbo', 'plasma') - - A list of color strings (e.g., ['#ff0000', '#00ff00']) - - A dictionary mapping variable names to colors (e.g., {'Solar': '#ff0000'}) + colors: Color specification (colorscale name, list of colors, or dict mapping) title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. @@ -1175,44 +1187,46 @@ def dual_pie_with_plotly( Returns: Plotly Figure object """ - data_left = _ensure_dataset(data_left) - data_right = _ensure_dataset(data_right) - _validate_plotting_data(data_left, allow_empty=True) - _validate_plotting_data(data_right, allow_empty=True) + # Preprocess data (converts to Dataset and groups small items) + left_processed = preprocess_dataset_for_pie(data_left, lower_percentage_group) + right_processed = preprocess_dataset_for_pie(data_right, lower_percentage_group) + + # Extract labels and values from Datasets + def extract_from_dataset(ds): + labels = [] + values = [] + for var in ds.data_vars: + var_data = ds[var] + if len(var_data.dims) > 0: + val = float(var_data.sum().item()) + else: + val = float(var_data.item()) + labels.append(str(var)) + values.append(val) + return labels, values - # Preprocess data - left_processed = preprocess_series_for_pie(data_left, lower_percentage_group) - right_processed = preprocess_series_for_pie(data_right, lower_percentage_group) + left_labels, left_values = extract_from_dataset(left_processed) + right_labels, right_values = extract_from_dataset(right_processed) # Get all unique labels for consistent coloring - all_labels = sorted(set(left_processed.index) | set(right_processed.index)) + all_labels = sorted(set(left_labels) | set(right_labels)) # Create color map - if isinstance(colors, dict): - color_map = colors - elif isinstance(colors, list): - color_map = {label: colors[i % len(colors)] for i, label in enumerate(all_labels)} - else: - # Use plotly's color sequence - import plotly.express as px - - color_sequence = getattr(px.colors.qualitative, colors.capitalize(), px.colors.qualitative.Plotly) - color_map = {label: color_sequence[i % len(color_sequence)] for i, label in enumerate(all_labels)} + color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True) - # Create figure with subplots + # Create figure fig = go.Figure() # Add left pie - if not left_processed.empty: + if left_labels: fig.add_trace( go.Pie( - labels=list(left_processed.index), - values=list(left_processed.values), + labels=left_labels, + values=left_values, name=subtitles[0], - marker=dict(colors=[color_map.get(label, '#636EFA') for label in left_processed.index]), + marker=dict(colors=[color_map.get(label, '#636EFA') for label in left_labels]), hole=hole, textinfo=text_info, - insidetextorientation='radial', textposition=text_position, hovertemplate=hover_template, domain=dict(x=[0, 0.48]), @@ -1220,17 +1234,16 @@ def dual_pie_with_plotly( ) # Add right pie - if not right_processed.empty: + if right_labels: fig.add_trace( go.Pie( - labels=list(right_processed.index), - values=list(right_processed.values), + labels=right_labels, + values=right_values, name=subtitles[1], - marker=dict(colors=[color_map.get(label, '#636EFA') for label in right_processed.index]), + marker=dict(colors=[color_map.get(label, '#636EFA') for label in right_labels]), hole=hole, textinfo=text_info, textposition=text_position, - insidetextorientation='radial', hovertemplate=hover_template, domain=dict(x=[0.52, 1]), ) @@ -1247,8 +1260,8 @@ def dual_pie_with_plotly( def dual_pie_with_matplotlib( - data_left: pd.Series, - data_right: pd.Series, + data_left: xr.Dataset | pd.DataFrame | pd.Series, + data_right: xr.Dataset | pd.DataFrame | pd.Series, colors: ColorType = 'viridis', title: str = '', subtitles: tuple[str, str] = ('Left Chart', 'Right Chart'), @@ -1263,10 +1276,7 @@ def dual_pie_with_matplotlib( Args: data_left: Series for the left pie chart. data_right: Series for the right pie chart. - colors: Color specification, can be: - - A string with a colormap name (e.g., 'turbo', 'plasma') - - A list of color strings (e.g., ['#ff0000', '#00ff00']) - - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'}) + colors: Color specification (colormap name, list of colors, or dict mapping) title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. @@ -1277,12 +1287,29 @@ def dual_pie_with_matplotlib( Returns: Tuple of (Figure, list of Axes) """ - # Preprocess data - left_processed = preprocess_series_for_pie(data_left, lower_percentage_group) - right_processed = preprocess_series_for_pie(data_right, lower_percentage_group) + # Preprocess data (converts to Dataset and groups small items) + left_processed = preprocess_dataset_for_pie(data_left, lower_percentage_group) + right_processed = preprocess_dataset_for_pie(data_right, lower_percentage_group) + + # Extract labels and values from Datasets + def extract_from_dataset(ds): + labels = [] + values = [] + for var in ds.data_vars: + var_data = ds[var] + if len(var_data.dims) > 0: + val = float(var_data.sum().item()) + else: + val = float(var_data.item()) + labels.append(str(var)) + values.append(val) + return labels, values + + left_labels, left_values = extract_from_dataset(left_processed) + right_labels, right_values = extract_from_dataset(right_processed) # Get all unique labels for consistent coloring - all_labels = sorted(set(left_processed.index) | set(right_processed.index)) + all_labels = sorted(set(left_labels) | set(right_labels)) # Create color map color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True) @@ -1290,15 +1317,13 @@ def dual_pie_with_matplotlib( # Create figure fig, axes = plt.subplots(1, 2, figsize=figsize) - def draw_pie(ax, data_series, subtitle): + def draw_pie(ax, labels, values, subtitle): """Draw a single pie chart.""" - if data_series.empty: + if not labels: ax.set_title(subtitle) ax.axis('off') return - labels = list(data_series.index) - values = list(data_series.values) chart_colors = [color_map[label] for label in labels] # Draw pie @@ -1321,15 +1346,15 @@ def draw_pie(ax, data_series, subtitle): ax.set_title(subtitle, fontsize=14, pad=20) # Draw both pies - draw_pie(axes[0], left_processed, subtitles[0]) - draw_pie(axes[1], right_processed, subtitles[1]) + draw_pie(axes[0], left_labels, left_values, subtitles[0]) + draw_pie(axes[1], right_labels, right_values, subtitles[1]) # Add main title if title: fig.suptitle(title, fontsize=16, y=0.98) # Create unified legend - if not left_processed.empty or not right_processed.empty: + if left_labels or right_labels: handles = [ plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color_map[label], markersize=10) for label in all_labels