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.
- flixopt/__init__.py +57 -49
- flixopt/carrier.py +159 -0
- flixopt/clustering/__init__.py +51 -0
- flixopt/clustering/base.py +1746 -0
- flixopt/clustering/intercluster_helpers.py +201 -0
- flixopt/color_processing.py +372 -0
- flixopt/comparison.py +819 -0
- flixopt/components.py +848 -270
- flixopt/config.py +853 -496
- flixopt/core.py +111 -98
- flixopt/effects.py +294 -284
- flixopt/elements.py +484 -223
- flixopt/features.py +220 -118
- flixopt/flow_system.py +2026 -389
- flixopt/interface.py +504 -286
- flixopt/io.py +1718 -55
- flixopt/linear_converters.py +291 -230
- flixopt/modeling.py +304 -181
- flixopt/network_app.py +2 -1
- flixopt/optimization.py +788 -0
- flixopt/optimize_accessor.py +373 -0
- flixopt/plot_result.py +143 -0
- flixopt/plotting.py +1177 -1034
- flixopt/results.py +1331 -372
- flixopt/solvers.py +12 -4
- flixopt/statistics_accessor.py +2412 -0
- flixopt/stats_accessor.py +75 -0
- flixopt/structure.py +954 -120
- flixopt/topology_accessor.py +676 -0
- flixopt/transform_accessor.py +2277 -0
- flixopt/types.py +120 -0
- flixopt-6.0.0rc7.dist-info/METADATA +290 -0
- flixopt-6.0.0rc7.dist-info/RECORD +36 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
- flixopt/aggregation.py +0 -382
- flixopt/calculation.py +0 -672
- flixopt/commons.py +0 -51
- flixopt/utils.py +0 -86
- flixopt-3.0.1.dist-info/METADATA +0 -209
- flixopt-3.0.1.dist-info/RECORD +0 -26
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
flixopt/optimization.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the Optimization functionality for the flixopt framework.
|
|
3
|
+
It is used to optimize a FlowSystemModel for a given FlowSystem through a solver.
|
|
4
|
+
|
|
5
|
+
There are two Optimization types:
|
|
6
|
+
1. Optimization: Optimizes the FlowSystemModel for the full FlowSystem
|
|
7
|
+
2. SegmentedOptimization: Solves a FlowSystemModel for each individual Segment of the FlowSystem.
|
|
8
|
+
|
|
9
|
+
For time series aggregation (clustering), use FlowSystem.transform.cluster() instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import math
|
|
16
|
+
import pathlib
|
|
17
|
+
import sys
|
|
18
|
+
import timeit
|
|
19
|
+
import warnings
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from tqdm import tqdm
|
|
23
|
+
|
|
24
|
+
from . import io as fx_io
|
|
25
|
+
from .components import Storage
|
|
26
|
+
from .config import CONFIG, DEPRECATION_REMOVAL_VERSION, SUCCESS_LEVEL
|
|
27
|
+
from .effects import PENALTY_EFFECT_LABEL
|
|
28
|
+
from .features import InvestmentModel
|
|
29
|
+
from .results import Results, SegmentedResults
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import pandas as pd
|
|
33
|
+
import xarray as xr
|
|
34
|
+
|
|
35
|
+
from .flow_system import FlowSystem
|
|
36
|
+
from .solvers import _Solver
|
|
37
|
+
from .structure import FlowSystemModel
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger('flixopt')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class OptimizationProtocol(Protocol):
|
|
44
|
+
"""
|
|
45
|
+
Protocol defining the interface that all optimization types should implement.
|
|
46
|
+
|
|
47
|
+
This protocol ensures type consistency across different optimization approaches
|
|
48
|
+
without forcing them into an artificial inheritance hierarchy.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
name: Name of the optimization
|
|
52
|
+
flow_system: FlowSystem being optimized
|
|
53
|
+
folder: Directory where results are saved
|
|
54
|
+
results: Results object after solving
|
|
55
|
+
durations: Dictionary tracking time spent in different phases
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
flow_system: FlowSystem
|
|
60
|
+
folder: pathlib.Path
|
|
61
|
+
results: Results | SegmentedResults | None
|
|
62
|
+
durations: dict[str, float]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def modeled(self) -> bool:
|
|
66
|
+
"""Returns True if the optimization has been modeled."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def main_results(self) -> dict[str, int | float | dict]:
|
|
71
|
+
"""Returns main results including objective, effects, and investment decisions."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def summary(self) -> dict:
|
|
76
|
+
"""Returns summary information about the optimization."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _initialize_optimization_common(
|
|
81
|
+
obj: Any,
|
|
82
|
+
name: str,
|
|
83
|
+
flow_system: FlowSystem,
|
|
84
|
+
folder: pathlib.Path | None = None,
|
|
85
|
+
normalize_weights: bool | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Shared initialization logic for all optimization types.
|
|
89
|
+
|
|
90
|
+
This helper function encapsulates common initialization code to avoid duplication
|
|
91
|
+
across Optimization and SegmentedOptimization.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
obj: The optimization object being initialized
|
|
95
|
+
name: Name of the optimization
|
|
96
|
+
flow_system: FlowSystem to optimize
|
|
97
|
+
folder: Directory for saving results
|
|
98
|
+
normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem.
|
|
99
|
+
"""
|
|
100
|
+
obj.name = name
|
|
101
|
+
|
|
102
|
+
if flow_system.used_in_calculation:
|
|
103
|
+
logger.warning(
|
|
104
|
+
f'This FlowSystem is already used in an optimization:\n{flow_system}\n'
|
|
105
|
+
f'Creating a copy of the FlowSystem for Optimization "{obj.name}".'
|
|
106
|
+
)
|
|
107
|
+
flow_system = flow_system.copy()
|
|
108
|
+
|
|
109
|
+
# normalize_weights is deprecated but kept for backwards compatibility
|
|
110
|
+
if normalize_weights is not None:
|
|
111
|
+
warnings.warn(
|
|
112
|
+
f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. '
|
|
113
|
+
'Scenario weights are now always normalized when set on FlowSystem.\n',
|
|
114
|
+
DeprecationWarning,
|
|
115
|
+
stacklevel=3,
|
|
116
|
+
)
|
|
117
|
+
obj.normalize_weights = True # Always True now
|
|
118
|
+
|
|
119
|
+
flow_system._used_in_optimization = True
|
|
120
|
+
|
|
121
|
+
obj.flow_system = flow_system
|
|
122
|
+
obj.model = None
|
|
123
|
+
|
|
124
|
+
obj.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0}
|
|
125
|
+
obj.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder)
|
|
126
|
+
obj.results = None
|
|
127
|
+
|
|
128
|
+
if obj.folder.exists() and not obj.folder.is_dir():
|
|
129
|
+
raise NotADirectoryError(f'Path {obj.folder} exists and is not a directory.')
|
|
130
|
+
# Create folder and any necessary parent directories
|
|
131
|
+
obj.folder.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Optimization:
|
|
135
|
+
"""
|
|
136
|
+
Standard optimization that solves the complete problem using all time steps.
|
|
137
|
+
|
|
138
|
+
This is the default optimization approach that considers every time step,
|
|
139
|
+
providing the most accurate but computationally intensive solution.
|
|
140
|
+
|
|
141
|
+
For large problems, consider using FlowSystem.transform.cluster() (time aggregation)
|
|
142
|
+
or SegmentedOptimization (temporal decomposition) instead.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: name of optimization
|
|
146
|
+
flow_system: flow_system which should be optimized
|
|
147
|
+
folder: folder where results should be saved. If None, then the current working directory is used.
|
|
148
|
+
normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem.
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
Basic usage:
|
|
152
|
+
```python
|
|
153
|
+
from flixopt import Optimization
|
|
154
|
+
|
|
155
|
+
opt = Optimization(name='my_optimization', flow_system=energy_system, folder=Path('results'))
|
|
156
|
+
opt.do_modeling()
|
|
157
|
+
opt.solve(solver=gurobi)
|
|
158
|
+
results = opt.results
|
|
159
|
+
```
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
# Attributes set by __init__ / _initialize_optimization_common
|
|
163
|
+
name: str
|
|
164
|
+
flow_system: FlowSystem
|
|
165
|
+
folder: pathlib.Path
|
|
166
|
+
results: Results | None
|
|
167
|
+
durations: dict[str, float]
|
|
168
|
+
model: FlowSystemModel | None
|
|
169
|
+
normalize_weights: bool
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
name: str,
|
|
174
|
+
flow_system: FlowSystem,
|
|
175
|
+
folder: pathlib.Path | None = None,
|
|
176
|
+
normalize_weights: bool = True,
|
|
177
|
+
):
|
|
178
|
+
warnings.warn(
|
|
179
|
+
f'Optimization is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. '
|
|
180
|
+
'Use FlowSystem.optimize(solver) or FlowSystem.build_model() + FlowSystem.solve(solver) instead. '
|
|
181
|
+
'Access results via FlowSystem.solution.',
|
|
182
|
+
DeprecationWarning,
|
|
183
|
+
stacklevel=2,
|
|
184
|
+
)
|
|
185
|
+
_initialize_optimization_common(
|
|
186
|
+
self,
|
|
187
|
+
name=name,
|
|
188
|
+
flow_system=flow_system,
|
|
189
|
+
folder=folder,
|
|
190
|
+
normalize_weights=normalize_weights,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def do_modeling(self) -> Optimization:
|
|
194
|
+
t_start = timeit.default_timer()
|
|
195
|
+
self.flow_system.connect_and_transform()
|
|
196
|
+
|
|
197
|
+
self.model = self.flow_system.create_model()
|
|
198
|
+
self.model.do_modeling()
|
|
199
|
+
|
|
200
|
+
self.durations['modeling'] = round(timeit.default_timer() - t_start, 2)
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
def fix_sizes(self, ds: xr.Dataset | None = None, decimal_rounding: int | None = 5) -> Optimization:
|
|
204
|
+
"""Fix the sizes of the optimizations to specified values.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
ds: The dataset that contains the variable names mapped to their sizes. If None, the dataset is loaded from the results.
|
|
208
|
+
decimal_rounding: The number of decimal places to round the sizes to. If no rounding is applied, numerical errors might lead to infeasibility.
|
|
209
|
+
"""
|
|
210
|
+
if not self.modeled:
|
|
211
|
+
raise RuntimeError('Model was not created. Call do_modeling() first.')
|
|
212
|
+
|
|
213
|
+
if ds is None:
|
|
214
|
+
if self.results is None:
|
|
215
|
+
raise RuntimeError('No dataset provided and no results available to load sizes from.')
|
|
216
|
+
ds = self.results.solution
|
|
217
|
+
|
|
218
|
+
if decimal_rounding is not None:
|
|
219
|
+
ds = ds.round(decimal_rounding)
|
|
220
|
+
|
|
221
|
+
for name, da in ds.data_vars.items():
|
|
222
|
+
if '|size' not in name:
|
|
223
|
+
continue
|
|
224
|
+
if name not in self.model.variables:
|
|
225
|
+
logger.debug(f'Variable {name} not found in calculation model. Skipping.')
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
con = self.model.add_constraints(
|
|
229
|
+
self.model[name] == da,
|
|
230
|
+
name=f'{name}-fixed',
|
|
231
|
+
)
|
|
232
|
+
logger.debug(f'Fixed "{name}":\n{con}')
|
|
233
|
+
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def solve(
|
|
237
|
+
self, solver: _Solver, log_file: pathlib.Path | None = None, log_main_results: bool | None = None
|
|
238
|
+
) -> Optimization:
|
|
239
|
+
# Auto-call do_modeling() if not already done
|
|
240
|
+
if not self.modeled:
|
|
241
|
+
logger.info('Model not yet created. Calling do_modeling() automatically.')
|
|
242
|
+
self.do_modeling()
|
|
243
|
+
|
|
244
|
+
t_start = timeit.default_timer()
|
|
245
|
+
|
|
246
|
+
self.model.solve(
|
|
247
|
+
log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log',
|
|
248
|
+
solver_name=solver.name,
|
|
249
|
+
progress=CONFIG.Solving.log_to_console,
|
|
250
|
+
**solver.options,
|
|
251
|
+
)
|
|
252
|
+
self.durations['solving'] = round(timeit.default_timer() - t_start, 2)
|
|
253
|
+
logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.')
|
|
254
|
+
logger.info(f'Model status after solve: {self.model.status}')
|
|
255
|
+
|
|
256
|
+
if self.model.status == 'warning':
|
|
257
|
+
# Save the model and the flow_system to file in case of infeasibility
|
|
258
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
paths = fx_io.ResultsPaths(self.folder, self.name)
|
|
260
|
+
from .io import document_linopy_model
|
|
261
|
+
|
|
262
|
+
document_linopy_model(self.model, paths.model_documentation)
|
|
263
|
+
self.flow_system.to_netcdf(paths.flow_system, overwrite=True)
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.'
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Log the formatted output
|
|
269
|
+
should_log = log_main_results if log_main_results is not None else CONFIG.Solving.log_main_results
|
|
270
|
+
if should_log and logger.isEnabledFor(logging.INFO):
|
|
271
|
+
logger.log(
|
|
272
|
+
SUCCESS_LEVEL,
|
|
273
|
+
f'{" Main Results ":#^80}\n' + fx_io.format_yaml_string(self.main_results, compact_numeric_lists=True),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Store solution on FlowSystem for direct Element access
|
|
277
|
+
self.flow_system.solution = self.model.solution
|
|
278
|
+
|
|
279
|
+
self.results = Results.from_optimization(self)
|
|
280
|
+
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def main_results(self) -> dict[str, int | float | dict]:
|
|
285
|
+
if self.model is None:
|
|
286
|
+
raise RuntimeError('Optimization has not been solved yet. Call solve() before accessing main_results.')
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
penalty_effect = self.flow_system.effects.penalty_effect
|
|
290
|
+
penalty_section = {
|
|
291
|
+
'temporal': penalty_effect.submodel.temporal.total.solution.values,
|
|
292
|
+
'periodic': penalty_effect.submodel.periodic.total.solution.values,
|
|
293
|
+
'total': penalty_effect.submodel.total.solution.values,
|
|
294
|
+
}
|
|
295
|
+
except KeyError:
|
|
296
|
+
penalty_section = {'temporal': 0.0, 'periodic': 0.0, 'total': 0.0}
|
|
297
|
+
|
|
298
|
+
main_results = {
|
|
299
|
+
'Objective': self.model.objective.value,
|
|
300
|
+
'Penalty': penalty_section,
|
|
301
|
+
'Effects': {
|
|
302
|
+
f'{effect.label} [{effect.unit}]': {
|
|
303
|
+
'temporal': effect.submodel.temporal.total.solution.values,
|
|
304
|
+
'periodic': effect.submodel.periodic.total.solution.values,
|
|
305
|
+
'total': effect.submodel.total.solution.values,
|
|
306
|
+
}
|
|
307
|
+
for effect in sorted(self.flow_system.effects.values(), key=lambda e: e.label_full.upper())
|
|
308
|
+
if effect.label_full != PENALTY_EFFECT_LABEL
|
|
309
|
+
},
|
|
310
|
+
'Invest-Decisions': {
|
|
311
|
+
'Invested': {
|
|
312
|
+
model.label_of_element: model.size.solution
|
|
313
|
+
for component in self.flow_system.components.values()
|
|
314
|
+
for model in component.submodel.all_submodels
|
|
315
|
+
if isinstance(model, InvestmentModel)
|
|
316
|
+
and model.size.solution.max().item() >= CONFIG.Modeling.epsilon
|
|
317
|
+
},
|
|
318
|
+
'Not invested': {
|
|
319
|
+
model.label_of_element: model.size.solution
|
|
320
|
+
for component in self.flow_system.components.values()
|
|
321
|
+
for model in component.submodel.all_submodels
|
|
322
|
+
if isinstance(model, InvestmentModel) and model.size.solution.max().item() < CONFIG.Modeling.epsilon
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
'Buses with excess': [
|
|
326
|
+
{
|
|
327
|
+
bus.label_full: {
|
|
328
|
+
'virtual_supply': bus.submodel.virtual_supply.solution.sum('time'),
|
|
329
|
+
'virtual_demand': bus.submodel.virtual_demand.solution.sum('time'),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for bus in self.flow_system.buses.values()
|
|
333
|
+
if bus.allows_imbalance
|
|
334
|
+
and (
|
|
335
|
+
bus.submodel.virtual_supply.solution.sum().item() > 1e-3
|
|
336
|
+
or bus.submodel.virtual_demand.solution.sum().item() > 1e-3
|
|
337
|
+
)
|
|
338
|
+
],
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return fx_io.round_nested_floats(main_results)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def summary(self):
|
|
345
|
+
if self.model is None:
|
|
346
|
+
raise RuntimeError('Optimization has not been solved yet. Call solve() before accessing summary.')
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
'Name': self.name,
|
|
350
|
+
'Number of timesteps': len(self.flow_system.timesteps),
|
|
351
|
+
'Optimization Type': self.__class__.__name__,
|
|
352
|
+
'Constraints': self.model.constraints.ncons,
|
|
353
|
+
'Variables': self.model.variables.nvars,
|
|
354
|
+
'Main Results': self.main_results,
|
|
355
|
+
'Durations': self.durations,
|
|
356
|
+
'Config': CONFIG.to_dict(),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def modeled(self) -> bool:
|
|
361
|
+
return True if self.model is not None else False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class SegmentedOptimization:
|
|
365
|
+
"""Solve large optimization problems by dividing time horizon into (overlapping) segments.
|
|
366
|
+
|
|
367
|
+
This class addresses memory and computational limitations of large-scale optimization
|
|
368
|
+
problems by decomposing the time horizon into smaller overlapping segments that are
|
|
369
|
+
solved sequentially. Each segment uses final values from the previous segment as
|
|
370
|
+
initial conditions, ensuring dynamic continuity across the solution.
|
|
371
|
+
|
|
372
|
+
Key Concepts:
|
|
373
|
+
**Temporal Decomposition**: Divides long time horizons into manageable segments
|
|
374
|
+
**Overlapping Windows**: Segments share timesteps to improve storage dynamics
|
|
375
|
+
**Value Transfer**: Final states of one segment become initial states of the next
|
|
376
|
+
**Sequential Solving**: Each segment solved independently but with coupling
|
|
377
|
+
|
|
378
|
+
Limitations and Constraints:
|
|
379
|
+
**Investment Parameters**: InvestParameters are not supported in segmented optimizations
|
|
380
|
+
as investment decisions must be made for the entire time horizon, not per segment.
|
|
381
|
+
|
|
382
|
+
**Global Constraints**: Time-horizon-wide constraints (flow_hours_total_min/max,
|
|
383
|
+
load_factor_min/max) may produce suboptimal results as they cannot be enforced
|
|
384
|
+
globally across segments.
|
|
385
|
+
|
|
386
|
+
**Storage Dynamics**: While overlap helps, storage optimization may be suboptimal
|
|
387
|
+
compared to full-horizon solutions due to limited foresight in each segment.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
name: Unique identifier for the calculation, used in result files and logging.
|
|
391
|
+
flow_system: The FlowSystem to optimize, containing all components, flows, and buses.
|
|
392
|
+
timesteps_per_segment: Number of timesteps in each segment (excluding overlap).
|
|
393
|
+
Must be > 2 to avoid internal side effects. Larger values provide better
|
|
394
|
+
optimization at the cost of memory and computation time.
|
|
395
|
+
overlap_timesteps: Number of additional timesteps added to each segment.
|
|
396
|
+
Improves storage optimization by providing lookahead. Higher values
|
|
397
|
+
improve solution quality but increase computational cost.
|
|
398
|
+
nr_of_previous_values: Number of previous timestep values to transfer between
|
|
399
|
+
segments for initialization. Typically 1 is sufficient.
|
|
400
|
+
folder: Directory for saving results. Defaults to current working directory + 'results'.
|
|
401
|
+
|
|
402
|
+
Examples:
|
|
403
|
+
Annual optimization with monthly segments:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
# 8760 hours annual data with monthly segments (730 hours) and 48-hour overlap
|
|
407
|
+
segmented_calc = SegmentedOptimization(
|
|
408
|
+
name='annual_energy_system',
|
|
409
|
+
flow_system=energy_system,
|
|
410
|
+
timesteps_per_segment=730, # ~1 month
|
|
411
|
+
overlap_timesteps=48, # 2 days overlap
|
|
412
|
+
folder=Path('results/segmented'),
|
|
413
|
+
)
|
|
414
|
+
segmented_calc.do_modeling_and_solve(solver='gurobi')
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Weekly optimization with daily overlap:
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
# Weekly segments for detailed operational planning
|
|
421
|
+
weekly_calc = SegmentedOptimization(
|
|
422
|
+
name='weekly_operations',
|
|
423
|
+
flow_system=industrial_system,
|
|
424
|
+
timesteps_per_segment=168, # 1 week (hourly data)
|
|
425
|
+
overlap_timesteps=24, # 1 day overlap
|
|
426
|
+
nr_of_previous_values=1,
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Large-scale system with minimal overlap:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
# Large system with minimal overlap for computational efficiency
|
|
434
|
+
large_calc = SegmentedOptimization(
|
|
435
|
+
name='large_scale_grid',
|
|
436
|
+
flow_system=grid_system,
|
|
437
|
+
timesteps_per_segment=100, # Shorter segments
|
|
438
|
+
overlap_timesteps=5, # Minimal overlap
|
|
439
|
+
)
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Design Considerations:
|
|
443
|
+
**Segment Size**: Balance between solution quality and computational efficiency.
|
|
444
|
+
Larger segments provide better optimization but require more memory and time.
|
|
445
|
+
|
|
446
|
+
**Overlap Duration**: More overlap improves storage dynamics and reduces
|
|
447
|
+
end-effects but increases computational cost. Typically 5-10% of segment length.
|
|
448
|
+
|
|
449
|
+
**Storage Systems**: Systems with large storage components benefit from longer
|
|
450
|
+
overlaps to capture charge/discharge cycles effectively.
|
|
451
|
+
|
|
452
|
+
**Investment Decisions**: Use Optimization for problems requiring investment
|
|
453
|
+
optimization, as SegmentedOptimization cannot handle investment parameters.
|
|
454
|
+
|
|
455
|
+
Common Use Cases:
|
|
456
|
+
- **Annual Planning**: Long-term planning with seasonal variations
|
|
457
|
+
- **Large Networks**: Spatially or temporally large energy systems
|
|
458
|
+
- **Memory-Limited Systems**: When full optimization exceeds available memory
|
|
459
|
+
- **Operational Planning**: Detailed short-term optimization with limited foresight
|
|
460
|
+
- **Sensitivity Analysis**: Quick approximate solutions for parameter studies
|
|
461
|
+
|
|
462
|
+
Performance Tips:
|
|
463
|
+
- Start with Optimization and use this class if memory issues occur
|
|
464
|
+
- Use longer overlaps for systems with significant storage
|
|
465
|
+
- Monitor solution quality at segment boundaries for discontinuities
|
|
466
|
+
|
|
467
|
+
Warning:
|
|
468
|
+
The evaluation of the solution is a bit more complex than Optimization
|
|
469
|
+
due to the overlapping individual solutions.
|
|
470
|
+
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
# Attributes set by __init__ / _initialize_optimization_common
|
|
474
|
+
name: str
|
|
475
|
+
flow_system: FlowSystem
|
|
476
|
+
folder: pathlib.Path
|
|
477
|
+
results: SegmentedResults | None
|
|
478
|
+
durations: dict[str, float]
|
|
479
|
+
model: None # SegmentedOptimization doesn't use a single model
|
|
480
|
+
normalize_weights: bool
|
|
481
|
+
|
|
482
|
+
def __init__(
|
|
483
|
+
self,
|
|
484
|
+
name: str,
|
|
485
|
+
flow_system: FlowSystem,
|
|
486
|
+
timesteps_per_segment: int,
|
|
487
|
+
overlap_timesteps: int,
|
|
488
|
+
nr_of_previous_values: int = 1,
|
|
489
|
+
folder: pathlib.Path | None = None,
|
|
490
|
+
):
|
|
491
|
+
warnings.warn(
|
|
492
|
+
f'SegmentedOptimization is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. '
|
|
493
|
+
'A replacement API for segmented optimization will be provided in a future release.',
|
|
494
|
+
DeprecationWarning,
|
|
495
|
+
stacklevel=2,
|
|
496
|
+
)
|
|
497
|
+
_initialize_optimization_common(
|
|
498
|
+
self,
|
|
499
|
+
name=name,
|
|
500
|
+
flow_system=flow_system,
|
|
501
|
+
folder=folder,
|
|
502
|
+
)
|
|
503
|
+
self.timesteps_per_segment = timesteps_per_segment
|
|
504
|
+
self.overlap_timesteps = overlap_timesteps
|
|
505
|
+
self.nr_of_previous_values = nr_of_previous_values
|
|
506
|
+
|
|
507
|
+
# Validate overlap_timesteps early
|
|
508
|
+
if self.overlap_timesteps < 0:
|
|
509
|
+
raise ValueError('overlap_timesteps must be non-negative.')
|
|
510
|
+
|
|
511
|
+
# Validate timesteps_per_segment early (before using in arithmetic)
|
|
512
|
+
if self.timesteps_per_segment <= 2:
|
|
513
|
+
raise ValueError('timesteps_per_segment must be greater than 2 due to internal side effects.')
|
|
514
|
+
|
|
515
|
+
# Validate nr_of_previous_values
|
|
516
|
+
if self.nr_of_previous_values < 0:
|
|
517
|
+
raise ValueError('nr_of_previous_values must be non-negative.')
|
|
518
|
+
if self.nr_of_previous_values > self.timesteps_per_segment:
|
|
519
|
+
raise ValueError('nr_of_previous_values cannot exceed timesteps_per_segment.')
|
|
520
|
+
|
|
521
|
+
self.sub_optimizations: list[Optimization] = []
|
|
522
|
+
|
|
523
|
+
self.segment_names = [
|
|
524
|
+
f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment))
|
|
525
|
+
]
|
|
526
|
+
self._timesteps_per_segment = self._calculate_timesteps_per_segment()
|
|
527
|
+
|
|
528
|
+
if self.timesteps_per_segment_with_overlap > len(self.all_timesteps):
|
|
529
|
+
raise ValueError(
|
|
530
|
+
f'timesteps_per_segment_with_overlap ({self.timesteps_per_segment_with_overlap}) '
|
|
531
|
+
f'cannot exceed total timesteps ({len(self.all_timesteps)}).'
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
self.flow_system._connect_network() # Connect network to ensure that all Flows know their Component
|
|
535
|
+
# Storing all original start values
|
|
536
|
+
self._original_start_values = {
|
|
537
|
+
**{flow.label_full: flow.previous_flow_rate for flow in self.flow_system.flows.values()},
|
|
538
|
+
**{
|
|
539
|
+
comp.label_full: comp.initial_charge_state
|
|
540
|
+
for comp in self.flow_system.components.values()
|
|
541
|
+
if isinstance(comp, Storage)
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
self._transfered_start_values: list[dict[str, Any]] = []
|
|
545
|
+
|
|
546
|
+
def _create_sub_optimizations(self):
|
|
547
|
+
for i, (segment_name, timesteps_of_segment) in enumerate(
|
|
548
|
+
zip(self.segment_names, self._timesteps_per_segment, strict=True)
|
|
549
|
+
):
|
|
550
|
+
calc = Optimization(f'{self.name}-{segment_name}', self.flow_system.sel(time=timesteps_of_segment))
|
|
551
|
+
calc.flow_system._connect_network() # Connect to have Correct names of Flows!
|
|
552
|
+
|
|
553
|
+
self.sub_optimizations.append(calc)
|
|
554
|
+
logger.info(
|
|
555
|
+
f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] '
|
|
556
|
+
f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):'
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
def _solve_single_segment(
|
|
560
|
+
self,
|
|
561
|
+
i: int,
|
|
562
|
+
optimization: Optimization,
|
|
563
|
+
solver: _Solver,
|
|
564
|
+
log_file: pathlib.Path | None,
|
|
565
|
+
log_main_results: bool,
|
|
566
|
+
suppress_output: bool,
|
|
567
|
+
) -> None:
|
|
568
|
+
"""Solve a single segment optimization."""
|
|
569
|
+
if i > 0 and self.nr_of_previous_values > 0:
|
|
570
|
+
self._transfer_start_values(i)
|
|
571
|
+
|
|
572
|
+
optimization.do_modeling()
|
|
573
|
+
|
|
574
|
+
# Check for unsupported Investments, but only in first run
|
|
575
|
+
if i == 0:
|
|
576
|
+
invest_elements = [
|
|
577
|
+
model.label_full
|
|
578
|
+
for component in optimization.flow_system.components.values()
|
|
579
|
+
for model in component.submodel.all_submodels
|
|
580
|
+
if isinstance(model, InvestmentModel)
|
|
581
|
+
]
|
|
582
|
+
if invest_elements:
|
|
583
|
+
raise ValueError(
|
|
584
|
+
f'Investments are not supported in SegmentedOptimization. '
|
|
585
|
+
f'Found InvestmentModels: {invest_elements}. '
|
|
586
|
+
f'Please use Optimization instead for problems with investments.'
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
log_path = pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log'
|
|
590
|
+
|
|
591
|
+
if suppress_output:
|
|
592
|
+
with fx_io.suppress_output():
|
|
593
|
+
optimization.solve(solver, log_file=log_path, log_main_results=log_main_results)
|
|
594
|
+
else:
|
|
595
|
+
optimization.solve(solver, log_file=log_path, log_main_results=log_main_results)
|
|
596
|
+
|
|
597
|
+
def do_modeling_and_solve(
|
|
598
|
+
self,
|
|
599
|
+
solver: _Solver,
|
|
600
|
+
log_file: pathlib.Path | None = None,
|
|
601
|
+
log_main_results: bool = False,
|
|
602
|
+
show_individual_solves: bool = False,
|
|
603
|
+
) -> SegmentedOptimization:
|
|
604
|
+
"""Model and solve all segments of the segmented optimization.
|
|
605
|
+
|
|
606
|
+
This method creates sub-optimizations for each time segment, then iteratively
|
|
607
|
+
models and solves each segment. It supports two output modes: a progress bar
|
|
608
|
+
for compact output, or detailed individual solve information.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
solver: The solver instance to use for optimization (e.g., Gurobi, HiGHS).
|
|
612
|
+
log_file: Optional path to the solver log file. If None, defaults to
|
|
613
|
+
folder/name.log.
|
|
614
|
+
log_main_results: Whether to log main results (objective, effects, etc.)
|
|
615
|
+
after each segment solve. Defaults to False.
|
|
616
|
+
show_individual_solves: If True, shows detailed output for each segment
|
|
617
|
+
solve with logger messages. If False (default), shows a compact progress
|
|
618
|
+
bar with suppressed solver output for cleaner display.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Self, for method chaining.
|
|
622
|
+
|
|
623
|
+
Note:
|
|
624
|
+
The method automatically transfers all start values between segments to ensure
|
|
625
|
+
continuity of storage states and flow rates across segment boundaries.
|
|
626
|
+
"""
|
|
627
|
+
logger.info(f'{"":#^80}')
|
|
628
|
+
logger.info(f'{" Segmented Solving ":#^80}')
|
|
629
|
+
self._create_sub_optimizations()
|
|
630
|
+
|
|
631
|
+
if show_individual_solves:
|
|
632
|
+
# Path 1: Show individual solves with detailed output
|
|
633
|
+
for i, optimization in enumerate(self.sub_optimizations):
|
|
634
|
+
logger.info(
|
|
635
|
+
f'Solving segment {i + 1}/{len(self.sub_optimizations)}: '
|
|
636
|
+
f'{optimization.flow_system.timesteps[0]} -> {optimization.flow_system.timesteps[-1]}'
|
|
637
|
+
)
|
|
638
|
+
self._solve_single_segment(i, optimization, solver, log_file, log_main_results, suppress_output=False)
|
|
639
|
+
else:
|
|
640
|
+
# Path 2: Show only progress bar with suppressed output
|
|
641
|
+
progress_bar = tqdm(
|
|
642
|
+
enumerate(self.sub_optimizations),
|
|
643
|
+
total=len(self.sub_optimizations),
|
|
644
|
+
desc='Solving segments',
|
|
645
|
+
unit='segment',
|
|
646
|
+
file=sys.stdout,
|
|
647
|
+
disable=not CONFIG.Solving.log_to_console,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
for i, optimization in progress_bar:
|
|
652
|
+
progress_bar.set_description(
|
|
653
|
+
f'Solving ({optimization.flow_system.timesteps[0]} -> {optimization.flow_system.timesteps[-1]})'
|
|
654
|
+
)
|
|
655
|
+
self._solve_single_segment(
|
|
656
|
+
i, optimization, solver, log_file, log_main_results, suppress_output=True
|
|
657
|
+
)
|
|
658
|
+
finally:
|
|
659
|
+
progress_bar.close()
|
|
660
|
+
|
|
661
|
+
for calc in self.sub_optimizations:
|
|
662
|
+
for key, value in calc.durations.items():
|
|
663
|
+
self.durations[key] += value
|
|
664
|
+
|
|
665
|
+
logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.')
|
|
666
|
+
|
|
667
|
+
self.results = SegmentedResults.from_optimization(self)
|
|
668
|
+
|
|
669
|
+
return self
|
|
670
|
+
|
|
671
|
+
def _transfer_start_values(self, i: int):
|
|
672
|
+
"""
|
|
673
|
+
This function gets the last values of the previous solved segment and
|
|
674
|
+
inserts them as start values for the next segment
|
|
675
|
+
"""
|
|
676
|
+
timesteps_of_prior_segment = self.sub_optimizations[i - 1].flow_system.timesteps_extra
|
|
677
|
+
|
|
678
|
+
start = self.sub_optimizations[i].flow_system.timesteps[0]
|
|
679
|
+
start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values]
|
|
680
|
+
end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - 1]
|
|
681
|
+
|
|
682
|
+
logger.debug(
|
|
683
|
+
f'Start of next segment: {start}. Indices of previous values: {start_previous_values} -> {end_previous_values}'
|
|
684
|
+
)
|
|
685
|
+
current_flow_system = self.sub_optimizations[i - 1].flow_system
|
|
686
|
+
next_flow_system = self.sub_optimizations[i].flow_system
|
|
687
|
+
|
|
688
|
+
start_values_of_this_segment = {}
|
|
689
|
+
|
|
690
|
+
for current_flow in current_flow_system.flows.values():
|
|
691
|
+
next_flow = next_flow_system.flows[current_flow.label_full]
|
|
692
|
+
next_flow.previous_flow_rate = current_flow.submodel.flow_rate.solution.sel(
|
|
693
|
+
time=slice(start_previous_values, end_previous_values)
|
|
694
|
+
).values
|
|
695
|
+
start_values_of_this_segment[current_flow.label_full] = next_flow.previous_flow_rate
|
|
696
|
+
|
|
697
|
+
for current_comp in current_flow_system.components.values():
|
|
698
|
+
next_comp = next_flow_system.components[current_comp.label_full]
|
|
699
|
+
if isinstance(next_comp, Storage):
|
|
700
|
+
next_comp.initial_charge_state = current_comp.submodel.charge_state.solution.sel(time=start).item()
|
|
701
|
+
start_values_of_this_segment[current_comp.label_full] = next_comp.initial_charge_state
|
|
702
|
+
|
|
703
|
+
self._transfered_start_values.append(start_values_of_this_segment)
|
|
704
|
+
|
|
705
|
+
def _calculate_timesteps_per_segment(self) -> list[pd.DatetimeIndex]:
|
|
706
|
+
timesteps_per_segment = []
|
|
707
|
+
for i, _ in enumerate(self.segment_names):
|
|
708
|
+
start = self.timesteps_per_segment * i
|
|
709
|
+
end = min(start + self.timesteps_per_segment_with_overlap, len(self.all_timesteps))
|
|
710
|
+
timesteps_per_segment.append(self.all_timesteps[start:end])
|
|
711
|
+
return timesteps_per_segment
|
|
712
|
+
|
|
713
|
+
@property
|
|
714
|
+
def timesteps_per_segment_with_overlap(self):
|
|
715
|
+
return self.timesteps_per_segment + self.overlap_timesteps
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def start_values_of_segments(self) -> list[dict[str, Any]]:
|
|
719
|
+
"""Gives an overview of the start values of all Segments"""
|
|
720
|
+
return [{name: value for name, value in self._original_start_values.items()}] + [
|
|
721
|
+
start_values for start_values in self._transfered_start_values
|
|
722
|
+
]
|
|
723
|
+
|
|
724
|
+
@property
|
|
725
|
+
def all_timesteps(self) -> pd.DatetimeIndex:
|
|
726
|
+
return self.flow_system.timesteps
|
|
727
|
+
|
|
728
|
+
@property
|
|
729
|
+
def modeled(self) -> bool:
|
|
730
|
+
"""Returns True if all segments have been modeled."""
|
|
731
|
+
if len(self.sub_optimizations) == 0:
|
|
732
|
+
return False
|
|
733
|
+
return all(calc.modeled for calc in self.sub_optimizations)
|
|
734
|
+
|
|
735
|
+
@property
|
|
736
|
+
def main_results(self) -> dict[str, int | float | dict]:
|
|
737
|
+
"""Aggregated main results from all segments.
|
|
738
|
+
|
|
739
|
+
Note:
|
|
740
|
+
For SegmentedOptimization, results are aggregated from SegmentedResults
|
|
741
|
+
which handles the overlapping segments properly. Individual segment results
|
|
742
|
+
should not be summed directly as they contain overlapping timesteps.
|
|
743
|
+
|
|
744
|
+
The objective value shown is the sum of all segment objectives and includes
|
|
745
|
+
double-counting from overlapping regions. It does not represent a true
|
|
746
|
+
full-horizon objective value.
|
|
747
|
+
"""
|
|
748
|
+
if self.results is None:
|
|
749
|
+
raise RuntimeError(
|
|
750
|
+
'SegmentedOptimization has not been solved yet. '
|
|
751
|
+
'Call do_modeling_and_solve() first to access main_results.'
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Use SegmentedResults to get the proper aggregated solution
|
|
755
|
+
return {
|
|
756
|
+
'Note': 'SegmentedOptimization results are aggregated via SegmentedResults',
|
|
757
|
+
'Number of segments': len(self.sub_optimizations),
|
|
758
|
+
'Total timesteps': len(self.all_timesteps),
|
|
759
|
+
'Objective (sum of segments, includes overlaps)': sum(
|
|
760
|
+
calc.model.objective.value for calc in self.sub_optimizations if calc.modeled
|
|
761
|
+
),
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
@property
|
|
765
|
+
def summary(self):
|
|
766
|
+
"""Summary of the segmented optimization with aggregated information from all segments."""
|
|
767
|
+
if len(self.sub_optimizations) == 0:
|
|
768
|
+
raise RuntimeError(
|
|
769
|
+
'SegmentedOptimization has no segments yet. Call do_modeling_and_solve() first to access summary.'
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# Aggregate constraints and variables from all segments
|
|
773
|
+
total_constraints = sum(calc.model.constraints.ncons for calc in self.sub_optimizations if calc.modeled)
|
|
774
|
+
total_variables = sum(calc.model.variables.nvars for calc in self.sub_optimizations if calc.modeled)
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
'Name': self.name,
|
|
778
|
+
'Number of timesteps': len(self.flow_system.timesteps),
|
|
779
|
+
'Optimization Type': self.__class__.__name__,
|
|
780
|
+
'Number of segments': len(self.sub_optimizations),
|
|
781
|
+
'Timesteps per segment': self.timesteps_per_segment,
|
|
782
|
+
'Overlap timesteps': self.overlap_timesteps,
|
|
783
|
+
'Constraints (total across segments)': total_constraints,
|
|
784
|
+
'Variables (total across segments)': total_variables,
|
|
785
|
+
'Main Results': self.main_results if self.results else 'Not yet solved',
|
|
786
|
+
'Durations': self.durations,
|
|
787
|
+
'Config': CONFIG.to_dict(),
|
|
788
|
+
}
|