flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__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.
Files changed (42) hide show
  1. flixopt/__init__.py +57 -49
  2. flixopt/carrier.py +159 -0
  3. flixopt/clustering/__init__.py +51 -0
  4. flixopt/clustering/base.py +1746 -0
  5. flixopt/clustering/intercluster_helpers.py +201 -0
  6. flixopt/color_processing.py +372 -0
  7. flixopt/comparison.py +819 -0
  8. flixopt/components.py +848 -270
  9. flixopt/config.py +853 -496
  10. flixopt/core.py +111 -98
  11. flixopt/effects.py +294 -284
  12. flixopt/elements.py +484 -223
  13. flixopt/features.py +220 -118
  14. flixopt/flow_system.py +2026 -389
  15. flixopt/interface.py +504 -286
  16. flixopt/io.py +1718 -55
  17. flixopt/linear_converters.py +291 -230
  18. flixopt/modeling.py +304 -181
  19. flixopt/network_app.py +2 -1
  20. flixopt/optimization.py +788 -0
  21. flixopt/optimize_accessor.py +373 -0
  22. flixopt/plot_result.py +143 -0
  23. flixopt/plotting.py +1177 -1034
  24. flixopt/results.py +1331 -372
  25. flixopt/solvers.py +12 -4
  26. flixopt/statistics_accessor.py +2412 -0
  27. flixopt/stats_accessor.py +75 -0
  28. flixopt/structure.py +954 -120
  29. flixopt/topology_accessor.py +676 -0
  30. flixopt/transform_accessor.py +2277 -0
  31. flixopt/types.py +120 -0
  32. flixopt-6.0.0rc7.dist-info/METADATA +290 -0
  33. flixopt-6.0.0rc7.dist-info/RECORD +36 -0
  34. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
  35. flixopt/aggregation.py +0 -382
  36. flixopt/calculation.py +0 -672
  37. flixopt/commons.py +0 -51
  38. flixopt/utils.py +0 -86
  39. flixopt-3.0.1.dist-info/METADATA +0 -209
  40. flixopt-3.0.1.dist-info/RECORD +0 -26
  41. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  42. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,373 @@
1
+ """
2
+ Optimization accessor for FlowSystem.
3
+
4
+ This module provides the OptimizeAccessor class that enables the
5
+ `flow_system.optimize(...)` pattern with extensible optimization methods.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ from typing import TYPE_CHECKING
13
+
14
+ import xarray as xr
15
+ from tqdm import tqdm
16
+
17
+ from .config import CONFIG
18
+ from .io import suppress_output
19
+
20
+ if TYPE_CHECKING:
21
+ from .flow_system import FlowSystem
22
+ from .solvers import _Solver
23
+
24
+ logger = logging.getLogger('flixopt')
25
+
26
+
27
+ class OptimizeAccessor:
28
+ """
29
+ Accessor for optimization methods on FlowSystem.
30
+
31
+ This class provides the optimization API for FlowSystem, accessible via
32
+ `flow_system.optimize`. It supports both direct calling (standard optimization)
33
+ and method access for specialized optimization modes.
34
+
35
+ Examples:
36
+ Standard optimization (via __call__):
37
+
38
+ >>> flow_system.optimize(solver)
39
+ >>> print(flow_system.solution)
40
+
41
+ Rolling horizon optimization:
42
+
43
+ >>> segments = flow_system.optimize.rolling_horizon(solver, horizon=168)
44
+ >>> print(flow_system.solution) # Combined result
45
+ """
46
+
47
+ def __init__(self, flow_system: FlowSystem) -> None:
48
+ """
49
+ Initialize the accessor with a reference to the FlowSystem.
50
+
51
+ Args:
52
+ flow_system: The FlowSystem to optimize.
53
+ """
54
+ self._fs = flow_system
55
+
56
+ def __call__(self, solver: _Solver, normalize_weights: bool | None = None) -> FlowSystem:
57
+ """
58
+ Build and solve the optimization model in one step.
59
+
60
+ This is a convenience method that combines `build_model()` and `solve()`.
61
+ Use this for simple optimization workflows. For more control (e.g., inspecting
62
+ the model before solving, or adding custom constraints), use `build_model()`
63
+ and `solve()` separately.
64
+
65
+ Args:
66
+ solver: The solver to use (e.g., HighsSolver, GurobiSolver).
67
+ normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem.
68
+
69
+ Returns:
70
+ The FlowSystem, for method chaining.
71
+
72
+ Examples:
73
+ Simple optimization:
74
+
75
+ >>> flow_system.optimize(HighsSolver())
76
+ >>> print(flow_system.solution['Boiler(Q_th)|flow_rate'])
77
+
78
+ Access element solutions directly:
79
+
80
+ >>> flow_system.optimize(solver)
81
+ >>> boiler = flow_system.components['Boiler']
82
+ >>> print(boiler.solution)
83
+
84
+ Method chaining:
85
+
86
+ >>> solution = flow_system.optimize(solver).solution
87
+ """
88
+ if normalize_weights is not None:
89
+ import warnings
90
+
91
+ from .config import DEPRECATION_REMOVAL_VERSION
92
+
93
+ warnings.warn(
94
+ f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. '
95
+ 'Scenario weights are now always normalized when set on FlowSystem.\n',
96
+ DeprecationWarning,
97
+ stacklevel=2,
98
+ )
99
+ self._fs.build_model()
100
+ self._fs.solve(solver)
101
+ return self._fs
102
+
103
+ def rolling_horizon(
104
+ self,
105
+ solver: _Solver,
106
+ horizon: int = 100,
107
+ overlap: int = 0,
108
+ nr_of_previous_values: int = 1,
109
+ ) -> list[FlowSystem]:
110
+ """
111
+ Solve the optimization using a rolling horizon approach.
112
+
113
+ Divides the time horizon into overlapping segments that are solved sequentially.
114
+ Each segment uses final values from the previous segment as initial conditions,
115
+ ensuring dynamic continuity across the solution. The combined solution is stored
116
+ on the original FlowSystem.
117
+
118
+ This approach is useful for:
119
+ - Large-scale problems that exceed memory limits
120
+ - Annual planning with seasonal variations
121
+ - Operational planning with limited foresight
122
+
123
+ Args:
124
+ solver: The solver to use (e.g., HighsSolver, GurobiSolver).
125
+ horizon: Number of timesteps in each segment (excluding overlap).
126
+ Must be > 2. Larger values provide better optimization at the cost
127
+ of memory and computation time. Default: 100.
128
+ overlap: Number of additional timesteps added to each segment for lookahead.
129
+ Improves storage optimization by providing foresight. Higher values
130
+ improve solution quality but increase computational cost. Default: 0.
131
+ nr_of_previous_values: Number of previous timestep values to transfer between
132
+ segments for initialization (e.g., for uptime/downtime tracking). Default: 1.
133
+
134
+ Returns:
135
+ List of segment FlowSystems, each with their individual solution.
136
+ The combined solution (with overlaps trimmed) is stored on the original FlowSystem.
137
+
138
+ Raises:
139
+ ValueError: If horizon <= 2 or overlap < 0.
140
+ ValueError: If horizon + overlap > total timesteps.
141
+ ValueError: If InvestParameters are used (not supported in rolling horizon).
142
+
143
+ Examples:
144
+ Basic rolling horizon optimization:
145
+
146
+ >>> segments = flow_system.optimize.rolling_horizon(
147
+ ... solver,
148
+ ... horizon=168, # Weekly segments
149
+ ... overlap=24, # 1-day lookahead
150
+ ... )
151
+ >>> print(flow_system.solution) # Combined result
152
+
153
+ Inspect individual segments:
154
+
155
+ >>> for i, seg in enumerate(segments):
156
+ ... print(f'Segment {i}: {seg.solution["costs"].item():.2f}')
157
+
158
+ Note:
159
+ - InvestParameters are not supported as investment decisions require
160
+ full-horizon optimization.
161
+ - Global constraints (flow_hours_max, etc.) may produce suboptimal results
162
+ as they cannot be enforced globally across segments.
163
+ - Storage optimization may be suboptimal compared to full-horizon solutions
164
+ due to limited foresight in each segment.
165
+ """
166
+
167
+ # Validation
168
+ if horizon <= 2:
169
+ raise ValueError('horizon must be greater than 2 to avoid internal side effects.')
170
+ if overlap < 0:
171
+ raise ValueError('overlap must be non-negative.')
172
+ if nr_of_previous_values < 0:
173
+ raise ValueError('nr_of_previous_values must be non-negative.')
174
+ if nr_of_previous_values > horizon:
175
+ raise ValueError('nr_of_previous_values cannot exceed horizon.')
176
+
177
+ total_timesteps = len(self._fs.timesteps)
178
+ horizon_with_overlap = horizon + overlap
179
+
180
+ if horizon_with_overlap > total_timesteps:
181
+ raise ValueError(
182
+ f'horizon + overlap ({horizon_with_overlap}) cannot exceed total timesteps ({total_timesteps}).'
183
+ )
184
+
185
+ # Ensure flow system is connected
186
+ if not self._fs.connected_and_transformed:
187
+ self._fs.connect_and_transform()
188
+
189
+ # Calculate segment indices
190
+ segment_indices = self._calculate_segment_indices(total_timesteps, horizon, overlap)
191
+ n_segments = len(segment_indices)
192
+ logger.info(
193
+ f'Starting Rolling Horizon Optimization - Segments: {n_segments}, Horizon: {horizon}, Overlap: {overlap}'
194
+ )
195
+
196
+ # Create and solve segments
197
+ segment_flow_systems: list[FlowSystem] = []
198
+
199
+ progress_bar = tqdm(
200
+ enumerate(segment_indices),
201
+ total=n_segments,
202
+ desc='Solving segments',
203
+ unit='segment',
204
+ file=sys.stdout,
205
+ disable=not CONFIG.Solving.log_to_console,
206
+ )
207
+
208
+ try:
209
+ for i, (start_idx, end_idx) in progress_bar:
210
+ progress_bar.set_description(f'Segment {i + 1}/{n_segments} (timesteps {start_idx}-{end_idx})')
211
+
212
+ # Suppress output when progress bar is shown (including logger and solver)
213
+ if CONFIG.Solving.log_to_console:
214
+ # Temporarily raise logger level to suppress INFO messages
215
+ original_level = logger.level
216
+ logger.setLevel(logging.WARNING)
217
+ try:
218
+ with suppress_output():
219
+ segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx))
220
+ if i > 0 and nr_of_previous_values > 0:
221
+ self._transfer_state(
222
+ source_fs=segment_flow_systems[i - 1],
223
+ target_fs=segment_fs,
224
+ horizon=horizon,
225
+ nr_of_previous_values=nr_of_previous_values,
226
+ )
227
+ segment_fs.build_model()
228
+ if i == 0:
229
+ self._check_no_investments(segment_fs)
230
+ segment_fs.solve(solver)
231
+ finally:
232
+ logger.setLevel(original_level)
233
+ else:
234
+ segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx))
235
+ if i > 0 and nr_of_previous_values > 0:
236
+ self._transfer_state(
237
+ source_fs=segment_flow_systems[i - 1],
238
+ target_fs=segment_fs,
239
+ horizon=horizon,
240
+ nr_of_previous_values=nr_of_previous_values,
241
+ )
242
+ segment_fs.build_model()
243
+ if i == 0:
244
+ self._check_no_investments(segment_fs)
245
+ segment_fs.solve(solver)
246
+
247
+ segment_flow_systems.append(segment_fs)
248
+
249
+ finally:
250
+ progress_bar.close()
251
+
252
+ # Combine segment solutions
253
+ logger.info('Combining segment solutions...')
254
+ self._finalize_solution(segment_flow_systems, horizon)
255
+
256
+ logger.info(f'Rolling horizon optimization completed: {n_segments} segments solved.')
257
+
258
+ return segment_flow_systems
259
+
260
+ def _calculate_segment_indices(self, total_timesteps: int, horizon: int, overlap: int) -> list[tuple[int, int]]:
261
+ """Calculate start and end indices for each segment."""
262
+ segments = []
263
+ start = 0
264
+ while start < total_timesteps:
265
+ end = min(start + horizon + overlap, total_timesteps)
266
+ segments.append((start, end))
267
+ start += horizon # Move by horizon (not horizon + overlap)
268
+ if end == total_timesteps:
269
+ break
270
+ return segments
271
+
272
+ def _transfer_state(
273
+ self,
274
+ source_fs: FlowSystem,
275
+ target_fs: FlowSystem,
276
+ horizon: int,
277
+ nr_of_previous_values: int,
278
+ ) -> None:
279
+ """Transfer final state from source segment to target segment.
280
+
281
+ Transfers:
282
+ - Flow previous_flow_rate: Last nr_of_previous_values from non-overlap portion
283
+ - Storage initial_charge_state: Charge state at end of non-overlap portion
284
+ """
285
+ from .components import Storage
286
+
287
+ solution = source_fs.solution
288
+ time_slice = slice(horizon - nr_of_previous_values, horizon)
289
+
290
+ # Transfer flow rates (for uptime/downtime tracking)
291
+ for label, target_flow in target_fs.flows.items():
292
+ var_name = f'{label}|flow_rate'
293
+ if var_name in solution:
294
+ values = solution[var_name].isel(time=time_slice).values
295
+ target_flow.previous_flow_rate = values.item() if values.size == 1 else values
296
+
297
+ # Transfer storage charge states
298
+ for label, target_comp in target_fs.components.items():
299
+ if isinstance(target_comp, Storage):
300
+ var_name = f'{label}|charge_state'
301
+ if var_name in solution:
302
+ target_comp.initial_charge_state = solution[var_name].isel(time=horizon).item()
303
+
304
+ def _check_no_investments(self, segment_fs: FlowSystem) -> None:
305
+ """Check that no InvestParameters are used (not supported in rolling horizon)."""
306
+ from .features import InvestmentModel
307
+
308
+ invest_elements = []
309
+ for component in segment_fs.components.values():
310
+ for model in component.submodel.all_submodels:
311
+ if isinstance(model, InvestmentModel):
312
+ invest_elements.append(model.label_full)
313
+
314
+ if invest_elements:
315
+ raise ValueError(
316
+ f'InvestParameters are not supported in rolling horizon optimization. '
317
+ f'Found InvestmentModels: {invest_elements}. '
318
+ f'Use standard optimize() for problems with investments.'
319
+ )
320
+
321
+ def _finalize_solution(
322
+ self,
323
+ segment_flow_systems: list[FlowSystem],
324
+ horizon: int,
325
+ ) -> None:
326
+ """Combine segment solutions and compute derived values directly (no re-solve)."""
327
+ # Combine all solution variables from segments
328
+ combined_solution = self._combine_solutions(segment_flow_systems, horizon)
329
+
330
+ # Assign combined solution to the original FlowSystem
331
+ self._fs._solution = combined_solution
332
+
333
+ def _combine_solutions(
334
+ self,
335
+ segment_flow_systems: list[FlowSystem],
336
+ horizon: int,
337
+ ) -> xr.Dataset:
338
+ """Combine segment solutions into a single Dataset.
339
+
340
+ - Time-dependent variables: concatenated with overlap trimming
341
+ - Effect temporal/total: recomputed from per-timestep values
342
+ - Other scalars (including periodic): NaN (not meaningful for rolling horizon)
343
+ """
344
+ if not segment_flow_systems:
345
+ raise ValueError('No segments to combine.')
346
+
347
+ effect_labels = set(self._fs.effects.keys())
348
+ combined_vars: dict[str, xr.DataArray] = {}
349
+ first_solution = segment_flow_systems[0].solution
350
+ first_variables = first_solution.variables
351
+
352
+ # Step 1: Time-dependent → concatenate; Scalars → NaN
353
+ for var_name in first_solution.data_vars:
354
+ if 'time' in first_variables[var_name].dims:
355
+ arrays = [
356
+ seg.solution[var_name].isel(
357
+ time=slice(None, horizon if i < len(segment_flow_systems) - 1 else None)
358
+ )
359
+ for i, seg in enumerate(segment_flow_systems)
360
+ ]
361
+ combined_vars[var_name] = xr.concat(arrays, dim='time')
362
+ else:
363
+ combined_vars[var_name] = xr.DataArray(float('nan'))
364
+
365
+ # Step 2: Recompute effect totals from per-timestep values
366
+ for effect in effect_labels:
367
+ per_ts = f'{effect}(temporal)|per_timestep'
368
+ if per_ts in combined_vars:
369
+ temporal_sum = combined_vars[per_ts].sum(dim='time', skipna=True)
370
+ combined_vars[f'{effect}(temporal)'] = temporal_sum
371
+ combined_vars[effect] = temporal_sum # Total = temporal (periodic is NaN/unsupported)
372
+
373
+ return xr.Dataset(combined_vars)
flixopt/plot_result.py ADDED
@@ -0,0 +1,143 @@
1
+ """Plot result container for unified plotting API.
2
+
3
+ This module provides the PlotResult class that wraps plotting outputs
4
+ across the entire flixopt package, ensuring a consistent interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ import plotly.graph_objects as go
16
+ import xarray as xr
17
+
18
+
19
+ @dataclass
20
+ class PlotResult:
21
+ """Container returned by all plot methods. Holds both data and figure.
22
+
23
+ This class provides a unified interface for all plotting methods across
24
+ the flixopt package, enabling consistent method chaining and export options.
25
+
26
+ Attributes:
27
+ data: Prepared xarray Dataset used for the plot.
28
+ figure: Plotly figure object.
29
+
30
+ Examples:
31
+ Basic usage with chaining:
32
+
33
+ >>> result = flow_system.statistics.plot.balance('Bus')
34
+ >>> result.show().to_html('plot.html')
35
+
36
+ Accessing underlying data:
37
+
38
+ >>> result = flow_system.statistics.plot.flows()
39
+ >>> df = result.data.to_dataframe()
40
+ >>> result.to_csv('data.csv')
41
+
42
+ Customizing the figure:
43
+
44
+ >>> result = clustering.plot.compare()
45
+ >>> result.update(title='My Custom Title').show()
46
+ """
47
+
48
+ data: xr.Dataset
49
+ figure: go.Figure
50
+
51
+ def _repr_html_(self) -> str:
52
+ """Return HTML representation for Jupyter notebook display."""
53
+ return self.figure.to_html(full_html=False, include_plotlyjs='cdn')
54
+
55
+ def show(self) -> PlotResult:
56
+ """Display the figure. Returns self for chaining."""
57
+ self.figure.show()
58
+ return self
59
+
60
+ def update(self, **layout_kwargs: Any) -> PlotResult:
61
+ """Update figure layout. Returns self for chaining.
62
+
63
+ Args:
64
+ **layout_kwargs: Arguments passed to plotly's update_layout().
65
+
66
+ Returns:
67
+ Self for method chaining.
68
+
69
+ Examples:
70
+ >>> result.update(title='New Title', height=600)
71
+ """
72
+ self.figure.update_layout(**layout_kwargs)
73
+ return self
74
+
75
+ def update_traces(self, **trace_kwargs: Any) -> PlotResult:
76
+ """Update figure traces. Returns self for chaining.
77
+
78
+ Args:
79
+ **trace_kwargs: Arguments passed to plotly's update_traces().
80
+
81
+ Returns:
82
+ Self for method chaining.
83
+
84
+ Examples:
85
+ >>> result.update_traces(line_width=2, marker_size=8)
86
+ """
87
+ self.figure.update_traces(**trace_kwargs)
88
+ return self
89
+
90
+ def to_html(self, path: str | Path) -> PlotResult:
91
+ """Save figure as interactive HTML. Returns self for chaining.
92
+
93
+ Args:
94
+ path: File path for the HTML output.
95
+
96
+ Returns:
97
+ Self for method chaining.
98
+ """
99
+ self.figure.write_html(str(path))
100
+ return self
101
+
102
+ def to_image(self, path: str | Path, **kwargs: Any) -> PlotResult:
103
+ """Save figure as static image. Returns self for chaining.
104
+
105
+ Args:
106
+ path: File path for the image (format inferred from extension).
107
+ **kwargs: Additional arguments passed to write_image().
108
+
109
+ Returns:
110
+ Self for method chaining.
111
+
112
+ Examples:
113
+ >>> result.to_image('plot.png', scale=2)
114
+ >>> result.to_image('plot.svg')
115
+ """
116
+ self.figure.write_image(str(path), **kwargs)
117
+ return self
118
+
119
+ def to_csv(self, path: str | Path, **kwargs: Any) -> PlotResult:
120
+ """Export the underlying data to CSV. Returns self for chaining.
121
+
122
+ Args:
123
+ path: File path for the CSV output.
124
+ **kwargs: Additional arguments passed to to_csv().
125
+
126
+ Returns:
127
+ Self for method chaining.
128
+ """
129
+ self.data.to_dataframe().to_csv(path, **kwargs)
130
+ return self
131
+
132
+ def to_netcdf(self, path: str | Path, **kwargs: Any) -> PlotResult:
133
+ """Export the underlying data to netCDF. Returns self for chaining.
134
+
135
+ Args:
136
+ path: File path for the netCDF output.
137
+ **kwargs: Additional arguments passed to to_netcdf().
138
+
139
+ Returns:
140
+ Self for method chaining.
141
+ """
142
+ self.data.to_netcdf(path, **kwargs)
143
+ return self