flixopt 3.1.0__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flixopt might be problematic. Click here for more details.

flixopt/aggregation.py CHANGED
@@ -20,7 +20,9 @@ try:
20
20
  except ImportError:
21
21
  TSAM_AVAILABLE = False
22
22
 
23
+ from .color_processing import process_colors
23
24
  from .components import Storage
25
+ from .config import CONFIG
24
26
  from .structure import (
25
27
  FlowSystemModel,
26
28
  Submodel,
@@ -141,7 +143,7 @@ class Aggregation:
141
143
  def use_extreme_periods(self):
142
144
  return self.time_series_for_high_peaks or self.time_series_for_low_peaks
143
145
 
144
- def plot(self, colormap: str = 'viridis', show: bool = True, save: pathlib.Path | None = None) -> go.Figure:
146
+ def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Path | None = None) -> go.Figure:
145
147
  from . import plotting
146
148
 
147
149
  df_org = self.original_data.copy().rename(
@@ -150,13 +152,20 @@ class Aggregation:
150
152
  df_agg = self.aggregated_data.copy().rename(
151
153
  columns={col: f'Aggregated - {col}' for col in self.aggregated_data.columns}
152
154
  )
153
- fig = plotting.with_plotly(df_org, 'line', colors=colormap)
155
+ colors = list(
156
+ process_colors(colormap or CONFIG.Plotting.default_qualitative_colorscale, list(df_org.columns)).values()
157
+ )
158
+ fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colors, xlabel='Time in h')
154
159
  for trace in fig.data:
155
160
  trace.update(dict(line=dict(dash='dash')))
156
- fig = plotting.with_plotly(df_agg, 'line', colors=colormap, fig=fig)
161
+ fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colors, xlabel='Time in h')
162
+ for trace in fig2.data:
163
+ fig.add_trace(trace)
157
164
 
158
165
  fig.update_layout(
159
- title='Original vs Aggregated Data (original = ---)', xaxis_title='Index', yaxis_title='Value'
166
+ title='Original vs Aggregated Data (original = ---)',
167
+ xaxis_title='Time in h',
168
+ yaxis_title='Value',
160
169
  )
161
170
 
162
171
  plotting.export_figure(
flixopt/calculation.py CHANGED
@@ -22,7 +22,6 @@ import numpy as np
22
22
  import yaml
23
23
 
24
24
  from . import io as fx_io
25
- from . import utils as utils
26
25
  from .aggregation import Aggregation, AggregationModel, AggregationParameters
27
26
  from .components import Storage
28
27
  from .config import CONFIG
@@ -144,7 +143,7 @@ class Calculation:
144
143
  ],
145
144
  }
146
145
 
147
- return utils.round_nested_floats(main_results)
146
+ return fx_io.round_nested_floats(main_results)
148
147
 
149
148
  @property
150
149
  def summary(self):
@@ -253,7 +252,7 @@ class FullCalculation(Calculation):
253
252
  logger.info(
254
253
  f'{" Main Results ":#^80}\n'
255
254
  + yaml.dump(
256
- utils.round_nested_floats(self.main_results),
255
+ self.main_results,
257
256
  default_flow_style=False,
258
257
  sort_keys=False,
259
258
  allow_unicode=True,
@@ -0,0 +1,261 @@
1
+ """Simplified color handling for visualization.
2
+
3
+ This module provides clean color processing that transforms various input formats
4
+ into a label-to-color mapping dictionary, without needing to know about the plotting engine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ import matplotlib.colors as mcolors
12
+ import matplotlib.pyplot as plt
13
+ import plotly.express as px
14
+ from plotly.exceptions import PlotlyError
15
+
16
+ logger = logging.getLogger('flixopt')
17
+
18
+
19
+ def _rgb_string_to_hex(color: str) -> str:
20
+ """Convert Plotly RGB/RGBA string format to hex.
21
+
22
+ Args:
23
+ color: Color in format 'rgb(R, G, B)', 'rgba(R, G, B, A)' or already in hex
24
+
25
+ Returns:
26
+ Color in hex format '#RRGGBB'
27
+ """
28
+ color = color.strip()
29
+
30
+ # If already hex, return as-is
31
+ if color.startswith('#'):
32
+ return color
33
+
34
+ # Try to parse rgb() or rgba()
35
+ try:
36
+ if color.startswith('rgb('):
37
+ # Extract RGB values from 'rgb(R, G, B)' format
38
+ rgb_str = color[4:-1] # Remove 'rgb(' and ')'
39
+ elif color.startswith('rgba('):
40
+ # Extract RGBA values from 'rgba(R, G, B, A)' format
41
+ rgb_str = color[5:-1] # Remove 'rgba(' and ')'
42
+ else:
43
+ return color
44
+
45
+ # Split on commas and parse first three components
46
+ components = rgb_str.split(',')
47
+ if len(components) < 3:
48
+ return color
49
+
50
+ # Parse and clamp the first three components
51
+ r = max(0, min(255, int(round(float(components[0].strip())))))
52
+ g = max(0, min(255, int(round(float(components[1].strip())))))
53
+ b = max(0, min(255, int(round(float(components[2].strip())))))
54
+
55
+ return f'#{r:02x}{g:02x}{b:02x}'
56
+ except (ValueError, IndexError):
57
+ # If parsing fails, return original
58
+ return color
59
+
60
+
61
+ def process_colors(
62
+ colors: None | str | list[str] | dict[str, str],
63
+ labels: list[str],
64
+ default_colorscale: str = 'turbo',
65
+ ) -> dict[str, str]:
66
+ """Process color input and return a label-to-color mapping.
67
+
68
+ This function takes flexible color input and always returns a dictionary
69
+ mapping each label to a specific color string. The plotting engine can then
70
+ use this mapping as needed.
71
+
72
+ Args:
73
+ colors: Color specification in one of four formats:
74
+ - None: Use the default colorscale
75
+ - str: Name of a colorscale (e.g., 'turbo', 'plasma', 'Set1', 'portland')
76
+ - list[str]: List of color strings (hex, named colors, etc.)
77
+ - dict[str, str]: Direct label-to-color mapping
78
+ labels: List of labels that need colors assigned
79
+ default_colorscale: Fallback colorscale name if requested scale not found
80
+
81
+ Returns:
82
+ Dictionary mapping each label to a color string
83
+
84
+ Examples:
85
+ >>> # Using None - applies default colorscale
86
+ >>> process_colors(None, ['A', 'B', 'C'])
87
+ {'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}
88
+
89
+ >>> # Using a colorscale name
90
+ >>> process_colors('plasma', ['A', 'B', 'C'])
91
+ {'A': '#0d0887', 'B': '#7e03a8', 'C': '#cc4778'}
92
+
93
+ >>> # Using a list of colors
94
+ >>> process_colors(['red', 'blue', 'green'], ['A', 'B', 'C'])
95
+ {'A': 'red', 'B': 'blue', 'C': 'green'}
96
+
97
+ >>> # Using a pre-made mapping
98
+ >>> process_colors({'A': 'red', 'B': 'blue'}, ['A', 'B', 'C'])
99
+ {'A': 'red', 'B': 'blue', 'C': '#0d0887'} # C gets color from default scale
100
+ """
101
+ if not labels:
102
+ return {}
103
+
104
+ # Case 1: Already a mapping dictionary
105
+ if isinstance(colors, dict):
106
+ return _fill_missing_colors(colors, labels, default_colorscale)
107
+
108
+ # Case 2: None or colorscale name (string)
109
+ if colors is None or isinstance(colors, str):
110
+ colorscale_name = colors if colors is not None else default_colorscale
111
+ color_list = _get_colors_from_scale(colorscale_name, len(labels), default_colorscale)
112
+ return dict(zip(labels, color_list, strict=False))
113
+
114
+ # Case 3: List of colors
115
+ if isinstance(colors, list):
116
+ if len(colors) == 0:
117
+ logger.warning(f'Empty color list provided. Using {default_colorscale} instead.')
118
+ color_list = _get_colors_from_scale(default_colorscale, len(labels), default_colorscale)
119
+ return dict(zip(labels, color_list, strict=False))
120
+
121
+ if len(colors) < len(labels):
122
+ logger.debug(
123
+ f'Not enough colors provided ({len(colors)}) for all labels ({len(labels)}). Colors will cycle.'
124
+ )
125
+
126
+ # Cycle through colors if we don't have enough
127
+ return {label: colors[i % len(colors)] for i, label in enumerate(labels)}
128
+
129
+ raise TypeError(f'colors must be None, str, list, or dict, got {type(colors)}')
130
+
131
+
132
+ def _fill_missing_colors(
133
+ color_mapping: dict[str, str],
134
+ labels: list[str],
135
+ default_colorscale: str,
136
+ ) -> dict[str, str]:
137
+ """Fill in missing labels in a color mapping using a colorscale.
138
+
139
+ Args:
140
+ color_mapping: Partial label-to-color mapping
141
+ labels: All labels that need colors
142
+ default_colorscale: Colorscale to use for missing labels
143
+
144
+ Returns:
145
+ Complete label-to-color mapping
146
+ """
147
+ missing_labels = [label for label in labels if label not in color_mapping]
148
+
149
+ if not missing_labels:
150
+ return color_mapping.copy()
151
+
152
+ # Log warning about missing labels
153
+ logger.debug(f'Labels missing colors: {missing_labels}. Using {default_colorscale} for these.')
154
+
155
+ # Get colors for missing labels
156
+ missing_colors = _get_colors_from_scale(default_colorscale, len(missing_labels), default_colorscale)
157
+
158
+ # Combine existing and new colors
159
+ result = color_mapping.copy()
160
+ result.update(dict(zip(missing_labels, missing_colors, strict=False)))
161
+ return result
162
+
163
+
164
+ def _get_colors_from_scale(
165
+ colorscale_name: str,
166
+ num_colors: int,
167
+ fallback_scale: str,
168
+ ) -> list[str]:
169
+ """Extract a list of colors from a named colorscale.
170
+
171
+ Tries to get colors from the named scale (Plotly first, then Matplotlib),
172
+ falls back to the fallback scale if not found.
173
+
174
+ Args:
175
+ colorscale_name: Name of the colorscale to try
176
+ num_colors: Number of colors needed
177
+ fallback_scale: Fallback colorscale name if first fails
178
+
179
+ Returns:
180
+ List of color strings (hex format)
181
+ """
182
+ # Try to get the requested colorscale
183
+ colors = _try_get_colorscale(colorscale_name, num_colors)
184
+
185
+ if colors is not None:
186
+ return colors
187
+
188
+ # Fallback to default
189
+ logger.warning(f"Colorscale '{colorscale_name}' not found. Using '{fallback_scale}' instead.")
190
+
191
+ colors = _try_get_colorscale(fallback_scale, num_colors)
192
+
193
+ if colors is not None:
194
+ return colors
195
+
196
+ # Ultimate fallback: just use basic colors
197
+ logger.warning(f"Fallback colorscale '{fallback_scale}' also not found. Using basic colors.")
198
+ basic_colors = [
199
+ '#1f77b4',
200
+ '#ff7f0e',
201
+ '#2ca02c',
202
+ '#d62728',
203
+ '#9467bd',
204
+ '#8c564b',
205
+ '#e377c2',
206
+ '#7f7f7f',
207
+ '#bcbd22',
208
+ '#17becf',
209
+ ]
210
+ return [basic_colors[i % len(basic_colors)] for i in range(num_colors)]
211
+
212
+
213
+ def _try_get_colorscale(colorscale_name: str, num_colors: int) -> list[str] | None:
214
+ """Try to get colors from Plotly or Matplotlib colorscales.
215
+
216
+ Tries Plotly colorscales first (both qualitative and sequential),
217
+ then falls back to Matplotlib colorscales.
218
+
219
+ Args:
220
+ colorscale_name: Name of the colorscale
221
+ num_colors: Number of colors needed
222
+
223
+ Returns:
224
+ List of color strings (hex format) if successful, None if colorscale not found
225
+ """
226
+ # First try Plotly qualitative (discrete) color sequences
227
+ colorscale_title = colorscale_name.title()
228
+ if hasattr(px.colors.qualitative, colorscale_title):
229
+ color_list = getattr(px.colors.qualitative, colorscale_title)
230
+ # Convert to hex format for matplotlib compatibility
231
+ return [_rgb_string_to_hex(color_list[i % len(color_list)]) for i in range(num_colors)]
232
+
233
+ # Then try Plotly sequential/continuous colorscales
234
+ try:
235
+ colorscale = px.colors.get_colorscale(colorscale_name)
236
+ # Sample evenly from the colorscale
237
+ if num_colors == 1:
238
+ sample_points = [0.5]
239
+ else:
240
+ sample_points = [i / (num_colors - 1) for i in range(num_colors)]
241
+ colors = px.colors.sample_colorscale(colorscale, sample_points)
242
+ # Convert to hex format for matplotlib compatibility
243
+ return [_rgb_string_to_hex(c) for c in colors]
244
+ except (PlotlyError, ValueError):
245
+ pass
246
+
247
+ # Finally try Matplotlib colorscales
248
+ try:
249
+ cmap = plt.get_cmap(colorscale_name)
250
+
251
+ # Sample evenly from the colorscale
252
+ if num_colors == 1:
253
+ colors = [cmap(0.5)]
254
+ else:
255
+ colors = [cmap(i / (num_colors - 1)) for i in range(num_colors)]
256
+
257
+ # Convert RGBA tuples to hex strings
258
+ return [mcolors.rgb2hex(color[:3]) for color in colors]
259
+
260
+ except (ValueError, KeyError):
261
+ return None
flixopt/components.py CHANGED
@@ -1304,16 +1304,18 @@ class Sink(Component):
1304
1304
  prevent_simultaneous_flow_rates: bool = False,
1305
1305
  **kwargs,
1306
1306
  ):
1307
- """
1308
- Initialize a Sink (consumes flow from the system).
1309
-
1310
- Supports legacy `sink=` keyword for backward compatibility (deprecated): if `sink` is provided it is used as the single input flow and a DeprecationWarning is issued; specifying both `inputs` and `sink` raises ValueError.
1311
-
1312
- Parameters:
1313
- label (str): Unique element label.
1314
- inputs (list[Flow], optional): Input flows for the sink.
1315
- meta_data (dict, optional): Arbitrary metadata attached to the element.
1316
- prevent_simultaneous_flow_rates (bool, optional): If True, prevents simultaneous nonzero flow rates across the element's inputs by wiring that restriction into the base Component setup.
1307
+ """Initialize a Sink (consumes flow from the system).
1308
+
1309
+ Supports legacy `sink=` keyword for backward compatibility (deprecated): if `sink` is provided
1310
+ it is used as the single input flow and a DeprecationWarning is issued; specifying both
1311
+ `inputs` and `sink` raises ValueError.
1312
+
1313
+ Args:
1314
+ label: Unique element label.
1315
+ inputs: Input flows for the sink.
1316
+ meta_data: Arbitrary metadata attached to the element.
1317
+ prevent_simultaneous_flow_rates: If True, prevents simultaneous nonzero flow rates
1318
+ across the element's inputs by wiring that restriction into the base Component setup.
1317
1319
 
1318
1320
  Note:
1319
1321
  The deprecated `sink` kwarg is accepted for compatibility but will be removed in future releases.
flixopt/config.py CHANGED
@@ -8,7 +8,6 @@ from pathlib import Path
8
8
  from types import MappingProxyType
9
9
  from typing import Literal
10
10
 
11
- import yaml
12
11
  from rich.console import Console
13
12
  from rich.logging import RichHandler
14
13
  from rich.style import Style
@@ -54,6 +53,16 @@ _DEFAULTS = MappingProxyType(
54
53
  'big_binary_bound': 100_000,
55
54
  }
56
55
  ),
56
+ 'plotting': MappingProxyType(
57
+ {
58
+ 'default_show': True,
59
+ 'default_engine': 'plotly',
60
+ 'default_dpi': 300,
61
+ 'default_facet_cols': 3,
62
+ 'default_sequential_colorscale': 'turbo',
63
+ 'default_qualitative_colorscale': 'plotly',
64
+ }
65
+ ),
57
66
  }
58
67
  )
59
68
 
@@ -185,6 +194,42 @@ class CONFIG:
185
194
  epsilon: float = _DEFAULTS['modeling']['epsilon']
186
195
  big_binary_bound: int = _DEFAULTS['modeling']['big_binary_bound']
187
196
 
197
+ class Plotting:
198
+ """Plotting configuration.
199
+
200
+ Configure backends via environment variables:
201
+ - Matplotlib: Set `MPLBACKEND` environment variable (e.g., 'Agg', 'TkAgg')
202
+ - Plotly: Set `PLOTLY_RENDERER` or use `plotly.io.renderers.default`
203
+
204
+ Attributes:
205
+ default_show: Default value for the `show` parameter in plot methods.
206
+ default_engine: Default plotting engine.
207
+ default_dpi: Default DPI for saved plots.
208
+ default_facet_cols: Default number of columns for faceted plots.
209
+ default_sequential_colorscale: Default colorscale for heatmaps and continuous data.
210
+ default_qualitative_colorscale: Default colormap for categorical plots (bar/line/area charts).
211
+
212
+ Examples:
213
+ ```python
214
+ # Set consistent theming
215
+ CONFIG.Plotting.plotly_template = 'plotly_dark'
216
+ CONFIG.apply()
217
+
218
+ # Configure default export and color settings
219
+ CONFIG.Plotting.default_dpi = 600
220
+ CONFIG.Plotting.default_sequential_colorscale = 'plasma'
221
+ CONFIG.Plotting.default_qualitative_colorscale = 'Dark24'
222
+ CONFIG.apply()
223
+ ```
224
+ """
225
+
226
+ default_show: bool = _DEFAULTS['plotting']['default_show']
227
+ default_engine: Literal['plotly', 'matplotlib'] = _DEFAULTS['plotting']['default_engine']
228
+ default_dpi: int = _DEFAULTS['plotting']['default_dpi']
229
+ default_facet_cols: int = _DEFAULTS['plotting']['default_facet_cols']
230
+ default_sequential_colorscale: str = _DEFAULTS['plotting']['default_sequential_colorscale']
231
+ default_qualitative_colorscale: str = _DEFAULTS['plotting']['default_qualitative_colorscale']
232
+
188
233
  config_name: str = _DEFAULTS['config_name']
189
234
 
190
235
  @classmethod
@@ -253,13 +298,15 @@ class CONFIG:
253
298
  Raises:
254
299
  FileNotFoundError: If the config file does not exist.
255
300
  """
301
+ # Import here to avoid circular import
302
+ from . import io as fx_io
303
+
256
304
  config_path = Path(config_file)
257
305
  if not config_path.exists():
258
306
  raise FileNotFoundError(f'Config file not found: {config_file}')
259
307
 
260
- with config_path.open() as file:
261
- config_dict = yaml.safe_load(file) or {}
262
- cls._apply_config_dict(config_dict)
308
+ config_dict = fx_io.load_yaml(config_path)
309
+ cls._apply_config_dict(config_dict)
263
310
 
264
311
  cls.apply()
265
312
 
@@ -319,6 +366,14 @@ class CONFIG:
319
366
  'epsilon': cls.Modeling.epsilon,
320
367
  'big_binary_bound': cls.Modeling.big_binary_bound,
321
368
  },
369
+ 'plotting': {
370
+ 'default_show': cls.Plotting.default_show,
371
+ 'default_engine': cls.Plotting.default_engine,
372
+ 'default_dpi': cls.Plotting.default_dpi,
373
+ 'default_facet_cols': cls.Plotting.default_facet_cols,
374
+ 'default_sequential_colorscale': cls.Plotting.default_sequential_colorscale,
375
+ 'default_qualitative_colorscale': cls.Plotting.default_qualitative_colorscale,
376
+ },
322
377
  }
323
378
 
324
379
 
flixopt/effects.py CHANGED
@@ -480,19 +480,17 @@ class EffectCollection:
480
480
  def create_effect_values_dict(
481
481
  self, effect_values_user: PeriodicEffectsUser | TemporalEffectsUser
482
482
  ) -> dict[str, Scalar | TemporalDataUser] | None:
483
- """
484
- Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
485
-
486
- Examples
487
- --------
488
- effect_values_user = 20 -> {'<standard_effect_label>': 20}
489
- effect_values_user = {None: 20} -> {'<standard_effect_label>': 20}
490
- effect_values_user = None -> None
491
- effect_values_user = {'effect1': 20, 'effect2': 0.3} -> {'effect1': 20, 'effect2': 0.3}
492
-
493
- Returns
494
- -------
495
- dict or None
483
+ """Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
484
+
485
+ Examples:
486
+ ```python
487
+ effect_values_user = 20 -> {'<standard_effect_label>': 20}
488
+ effect_values_user = {None: 20} -> {'<standard_effect_label>': 20}
489
+ effect_values_user = None -> None
490
+ effect_values_user = {'effect1': 20, 'effect2': 0.3} -> {'effect1': 20, 'effect2': 0.3}
491
+ ```
492
+
493
+ Returns:
496
494
  A dictionary keyed by effect label, or None if input is None.
497
495
  Note: a standard effect must be defined when passing scalars or None labels.
498
496
  """
flixopt/flow_system.py CHANGED
@@ -4,7 +4,6 @@ This module contains the FlowSystem class, which is used to collect instances of
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import json
8
7
  import logging
9
8
  import warnings
10
9
  from typing import TYPE_CHECKING, Any, Literal, Optional
@@ -13,6 +12,7 @@ import numpy as np
13
12
  import pandas as pd
14
13
  import xarray as xr
15
14
 
15
+ from .config import CONFIG
16
16
  from .core import (
17
17
  ConversionError,
18
18
  DataConverter,
@@ -484,7 +484,7 @@ class FlowSystem(Interface):
484
484
  | list[
485
485
  Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']
486
486
  ] = True,
487
- show: bool = False,
487
+ show: bool | None = None,
488
488
  ) -> pyvis.network.Network | None:
489
489
  """
490
490
  Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file.
@@ -514,7 +514,9 @@ class FlowSystem(Interface):
514
514
  from . import plotting
515
515
 
516
516
  node_infos, edge_infos = self.network_infos()
517
- return plotting.plot_network(node_infos, edge_infos, path, controls, show)
517
+ return plotting.plot_network(
518
+ node_infos, edge_infos, path, controls, show if show is not None else CONFIG.Plotting.default_show
519
+ )
518
520
 
519
521
  def start_network_app(self):
520
522
  """Visualizes the network structure of a FlowSystem using Dash, Cytoscape, and networkx.
flixopt/interface.py CHANGED
@@ -712,6 +712,8 @@ class InvestParameters(Interface):
712
712
  Combinable with effects_of_investment and effects_of_investment_per_size.
713
713
  effects_of_retirement: Costs incurred if NOT investing (demolition, penalties).
714
714
  Dict: {'effect_name': value}.
715
+ linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods.
716
+ For convenience, pass a tuple containing the first and last period (2025, 2039), linking them and those in between
715
717
 
716
718
  Deprecated Args:
717
719
  fix_effects: **Deprecated**. Use `effects_of_investment` instead.
@@ -724,7 +726,6 @@ class InvestParameters(Interface):
724
726
  Will be removed in version 4.0.
725
727
  optional: DEPRECATED. Use `mandatory` instead. Opposite of `mandatory`.
726
728
  Will be removed in version 4.0.
727
- linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods.
728
729
 
729
730
  Cost Annualization Requirements:
730
731
  All cost values must be properly weighted to match the optimization model's time horizon.