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,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
+ }