flixopt 1.0.12__py3-none-any.whl → 2.0.1__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.

Files changed (73) hide show
  1. docs/examples/00-Minimal Example.md +5 -0
  2. docs/examples/01-Basic Example.md +5 -0
  3. docs/examples/02-Complex Example.md +10 -0
  4. docs/examples/03-Calculation Modes.md +5 -0
  5. docs/examples/index.md +5 -0
  6. docs/faq/contribute.md +49 -0
  7. docs/faq/index.md +3 -0
  8. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  9. docs/images/architecture_flixOpt.png +0 -0
  10. docs/images/flixopt-icon.svg +1 -0
  11. docs/javascripts/mathjax.js +18 -0
  12. docs/release-notes/_template.txt +32 -0
  13. docs/release-notes/index.md +7 -0
  14. docs/release-notes/v2.0.0.md +93 -0
  15. docs/release-notes/v2.0.1.md +12 -0
  16. docs/user-guide/Mathematical Notation/Bus.md +33 -0
  17. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
  18. docs/user-guide/Mathematical Notation/Flow.md +26 -0
  19. docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
  20. docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
  21. docs/user-guide/Mathematical Notation/Storage.md +44 -0
  22. docs/user-guide/Mathematical Notation/index.md +22 -0
  23. docs/user-guide/Mathematical Notation/others.md +3 -0
  24. docs/user-guide/index.md +124 -0
  25. {flixOpt → flixopt}/__init__.py +5 -2
  26. {flixOpt → flixopt}/aggregation.py +113 -140
  27. flixopt/calculation.py +455 -0
  28. {flixOpt → flixopt}/commons.py +7 -4
  29. flixopt/components.py +630 -0
  30. {flixOpt → flixopt}/config.py +9 -8
  31. {flixOpt → flixopt}/config.yaml +3 -3
  32. flixopt/core.py +970 -0
  33. flixopt/effects.py +386 -0
  34. flixopt/elements.py +534 -0
  35. flixopt/features.py +1042 -0
  36. flixopt/flow_system.py +409 -0
  37. flixopt/interface.py +265 -0
  38. flixopt/io.py +308 -0
  39. flixopt/linear_converters.py +331 -0
  40. flixopt/plotting.py +1340 -0
  41. flixopt/results.py +898 -0
  42. flixopt/solvers.py +77 -0
  43. flixopt/structure.py +630 -0
  44. flixopt/utils.py +62 -0
  45. flixopt-2.0.1.dist-info/METADATA +145 -0
  46. flixopt-2.0.1.dist-info/RECORD +57 -0
  47. {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
  48. flixopt-2.0.1.dist-info/top_level.txt +6 -0
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixopt-icon.svg +1 -0
  52. pics/pics.pptx +0 -0
  53. scripts/gen_ref_pages.py +54 -0
  54. site/release-notes/_template.txt +32 -0
  55. flixOpt/calculation.py +0 -629
  56. flixOpt/components.py +0 -614
  57. flixOpt/core.py +0 -182
  58. flixOpt/effects.py +0 -410
  59. flixOpt/elements.py +0 -489
  60. flixOpt/features.py +0 -942
  61. flixOpt/flow_system.py +0 -351
  62. flixOpt/interface.py +0 -203
  63. flixOpt/linear_converters.py +0 -325
  64. flixOpt/math_modeling.py +0 -1145
  65. flixOpt/plotting.py +0 -712
  66. flixOpt/results.py +0 -563
  67. flixOpt/solvers.py +0 -21
  68. flixOpt/structure.py +0 -733
  69. flixOpt/utils.py +0 -134
  70. flixopt-1.0.12.dist-info/METADATA +0 -174
  71. flixopt-1.0.12.dist-info/RECORD +0 -29
  72. flixopt-1.0.12.dist-info/top_level.txt +0 -3
  73. {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info/licenses}/LICENSE +0 -0
flixopt/results.py ADDED
@@ -0,0 +1,898 @@
1
+ import datetime
2
+ import json
3
+ import logging
4
+ import pathlib
5
+ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union
6
+
7
+ import linopy
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import pandas as pd
11
+ import plotly
12
+ import xarray as xr
13
+ import yaml
14
+
15
+ from . import io as fx_io
16
+ from . import plotting
17
+ from .core import TimeSeriesCollection
18
+
19
+ if TYPE_CHECKING:
20
+ import pyvis
21
+
22
+ from .calculation import Calculation, SegmentedCalculation
23
+
24
+
25
+ logger = logging.getLogger('flixopt')
26
+
27
+
28
+ class CalculationResults:
29
+ """Results container for Calculation results.
30
+
31
+ This class is used to collect the results of a Calculation.
32
+ It provides access to component, bus, and effect
33
+ results, and includes methods for filtering, plotting, and saving results.
34
+
35
+ The recommended way to create instances is through the class methods
36
+ `from_file()` or `from_calculation()`, rather than direct initialization.
37
+
38
+ Attributes:
39
+ solution (xr.Dataset): Dataset containing optimization results.
40
+ flow_system (xr.Dataset): Dataset containing the flow system.
41
+ summary (Dict): Information about the calculation.
42
+ name (str): Name identifier for the calculation.
43
+ model (linopy.Model): The optimization model (if available).
44
+ folder (pathlib.Path): Path to the results directory.
45
+ components (Dict[str, ComponentResults]): Results for each component.
46
+ buses (Dict[str, BusResults]): Results for each bus.
47
+ effects (Dict[str, EffectResults]): Results for each effect.
48
+ timesteps_extra (pd.DatetimeIndex): The extended timesteps.
49
+ hours_per_timestep (xr.DataArray): Duration of each timestep in hours.
50
+
51
+ Example:
52
+ Load results from saved files:
53
+
54
+ >>> results = CalculationResults.from_file('results_dir', 'optimization_run_1')
55
+ >>> element_result = results['Boiler']
56
+ >>> results.plot_heatmap('Boiler(Q_th)|flow_rate')
57
+ >>> results.to_file(compression=5)
58
+ >>> results.to_file(folder='new_results_dir', compression=5) # Save the results to a new folder
59
+ """
60
+
61
+ @classmethod
62
+ def from_file(cls, folder: Union[str, pathlib.Path], name: str):
63
+ """Create CalculationResults instance by loading from saved files.
64
+
65
+ This method loads the calculation results from previously saved files,
66
+ including the solution, flow system, model (if available), and metadata.
67
+
68
+ Args:
69
+ folder: Path to the directory containing the saved files.
70
+ name: Base name of the saved files (without file extensions).
71
+
72
+ Returns:
73
+ CalculationResults: A new instance containing the loaded data.
74
+
75
+ Raises:
76
+ FileNotFoundError: If required files cannot be found.
77
+ ValueError: If files exist but cannot be properly loaded.
78
+ """
79
+ folder = pathlib.Path(folder)
80
+ paths = fx_io.CalculationResultsPaths(folder, name)
81
+
82
+ model = None
83
+ if paths.linopy_model.exists():
84
+ try:
85
+ logger.info(f'loading the linopy model "{name}" from file ("{paths.linopy_model}")')
86
+ model = linopy.read_netcdf(paths.linopy_model)
87
+ except Exception as e:
88
+ logger.critical(f'Could not load the linopy model "{name}" from file ("{paths.linopy_model}"): {e}')
89
+
90
+ with open(paths.summary, 'r', encoding='utf-8') as f:
91
+ summary = yaml.load(f, Loader=yaml.FullLoader)
92
+
93
+ return cls(
94
+ solution=fx_io.load_dataset_from_netcdf(paths.solution),
95
+ flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system),
96
+ name=name,
97
+ folder=folder,
98
+ model=model,
99
+ summary=summary,
100
+ )
101
+
102
+ @classmethod
103
+ def from_calculation(cls, calculation: 'Calculation'):
104
+ """Create CalculationResults directly from a Calculation object.
105
+
106
+ This method extracts the solution, flow system, and other relevant
107
+ information directly from an existing Calculation object.
108
+
109
+ Args:
110
+ calculation: A Calculation object containing a solved model.
111
+
112
+ Returns:
113
+ CalculationResults: A new instance containing the results from
114
+ the provided calculation.
115
+
116
+ Raises:
117
+ AttributeError: If the calculation doesn't have required attributes.
118
+ """
119
+ return cls(
120
+ solution=calculation.model.solution,
121
+ flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True),
122
+ summary=calculation.summary,
123
+ model=calculation.model,
124
+ name=calculation.name,
125
+ folder=calculation.folder,
126
+ )
127
+
128
+ def __init__(
129
+ self,
130
+ solution: xr.Dataset,
131
+ flow_system: xr.Dataset,
132
+ name: str,
133
+ summary: Dict,
134
+ folder: Optional[pathlib.Path] = None,
135
+ model: Optional[linopy.Model] = None,
136
+ ):
137
+ """
138
+ Args:
139
+ solution: The solution of the optimization.
140
+ flow_system: The flow_system that was used to create the calculation as a datatset.
141
+ name: The name of the calculation.
142
+ summary: Information about the calculation,
143
+ folder: The folder where the results are saved.
144
+ model: The linopy model that was used to solve the calculation.
145
+ """
146
+ self.solution = solution
147
+ self.flow_system = flow_system
148
+ self.summary = summary
149
+ self.name = name
150
+ self.model = model
151
+ self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results'
152
+ self.components = {
153
+ label: ComponentResults.from_json(self, infos) for label, infos in self.solution.attrs['Components'].items()
154
+ }
155
+
156
+ self.buses = {label: BusResults.from_json(self, infos) for label, infos in self.solution.attrs['Buses'].items()}
157
+
158
+ self.effects = {
159
+ label: EffectResults.from_json(self, infos) for label, infos in self.solution.attrs['Effects'].items()
160
+ }
161
+
162
+ self.timesteps_extra = self.solution.indexes['time']
163
+ self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra)
164
+
165
+ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']:
166
+ if key in self.components:
167
+ return self.components[key]
168
+ if key in self.buses:
169
+ return self.buses[key]
170
+ if key in self.effects:
171
+ return self.effects[key]
172
+ raise KeyError(f'No element with label {key} found.')
173
+
174
+ @property
175
+ def storages(self) -> List['ComponentResults']:
176
+ """All storages in the results."""
177
+ return [comp for comp in self.components.values() if comp.is_storage]
178
+
179
+ @property
180
+ def objective(self) -> float:
181
+ """The objective result of the optimization."""
182
+ return self.summary['Main Results']['Objective']
183
+
184
+ @property
185
+ def variables(self) -> linopy.Variables:
186
+ """The variables of the optimization. Only available if the linopy.Model is available."""
187
+ if self.model is None:
188
+ raise ValueError('The linopy model is not available.')
189
+ return self.model.variables
190
+
191
+ @property
192
+ def constraints(self) -> linopy.Constraints:
193
+ """The constraints of the optimization. Only available if the linopy.Model is available."""
194
+ if self.model is None:
195
+ raise ValueError('The linopy model is not available.')
196
+ return self.model.constraints
197
+
198
+ def filter_solution(
199
+ self, variable_dims: Optional[Literal['scalar', 'time']] = None, element: Optional[str] = None
200
+ ) -> xr.Dataset:
201
+ """
202
+ Filter the solution to a specific variable dimension and element.
203
+ If no element is specified, all elements are included.
204
+
205
+ Args:
206
+ variable_dims: The dimension of the variables to filter for.
207
+ element: The element to filter for.
208
+ """
209
+ if element is not None:
210
+ return filter_dataset(self[element].solution, variable_dims)
211
+ return filter_dataset(self.solution, variable_dims)
212
+
213
+ def plot_heatmap(
214
+ self,
215
+ variable_name: str,
216
+ heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D',
217
+ heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h',
218
+ color_map: str = 'portland',
219
+ save: Union[bool, pathlib.Path] = False,
220
+ show: bool = True,
221
+ engine: plotting.PlottingEngine = 'plotly',
222
+ ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
223
+ return plot_heatmap(
224
+ dataarray=self.solution[variable_name],
225
+ name=variable_name,
226
+ folder=self.folder,
227
+ heatmap_timeframes=heatmap_timeframes,
228
+ heatmap_timesteps_per_frame=heatmap_timesteps_per_frame,
229
+ color_map=color_map,
230
+ save=save,
231
+ show=show,
232
+ engine=engine,
233
+ )
234
+
235
+ def plot_network(
236
+ self,
237
+ controls: Union[
238
+ bool,
239
+ List[
240
+ Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']
241
+ ],
242
+ ] = True,
243
+ path: Optional[pathlib.Path] = None,
244
+ show: bool = False,
245
+ ) -> 'pyvis.network.Network':
246
+ """See flixopt.flow_system.FlowSystem.plot_network"""
247
+ try:
248
+ from .flow_system import FlowSystem
249
+
250
+ flow_system = FlowSystem.from_dataset(self.flow_system)
251
+ except Exception as e:
252
+ logger.critical(f'Could not reconstruct the flow_system from dataset: {e}')
253
+ return None
254
+ if path is None:
255
+ path = self.folder / f'{self.name}--network.html'
256
+ return flow_system.plot_network(controls=controls, path=path, show=show)
257
+
258
+ def to_file(
259
+ self,
260
+ folder: Optional[Union[str, pathlib.Path]] = None,
261
+ name: Optional[str] = None,
262
+ compression: int = 5,
263
+ document_model: bool = True,
264
+ save_linopy_model: bool = False,
265
+ ):
266
+ """
267
+ Save the results to a file
268
+ Args:
269
+ folder: The folder where the results should be saved. Defaults to the folder of the calculation.
270
+ name: The name of the results file. If not provided, Defaults to the name of the calculation.
271
+ compression: The compression level to use when saving the solution file (0-9). 0 means no compression.
272
+ document_model: Wether to document the mathematical formulations in the model.
273
+ save_linopy_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc4 file.
274
+ The model file size is rougly 100 times larger than the solution file.
275
+ """
276
+ folder = self.folder if folder is None else pathlib.Path(folder)
277
+ name = self.name if name is None else name
278
+ if not folder.exists():
279
+ try:
280
+ folder.mkdir(parents=False)
281
+ except FileNotFoundError as e:
282
+ raise FileNotFoundError(
283
+ f'Folder {folder} and its parent do not exist. Please create them first.'
284
+ ) from e
285
+
286
+ paths = fx_io.CalculationResultsPaths(folder, name)
287
+
288
+ fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression)
289
+ fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression)
290
+
291
+ with open(paths.summary, 'w', encoding='utf-8') as f:
292
+ yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000)
293
+
294
+ if save_linopy_model:
295
+ if self.model is None:
296
+ logger.critical('No model in the CalculationResults. Saving the model is not possible.')
297
+ else:
298
+ self.model.to_netcdf(paths.linopy_model)
299
+
300
+ if document_model:
301
+ if self.model is None:
302
+ logger.critical('No model in the CalculationResults. Documenting the model is not possible.')
303
+ else:
304
+ fx_io.document_linopy_model(self.model, path=paths.model_documentation)
305
+
306
+ logger.info(f'Saved calculation results "{name}" to {paths.model_documentation.parent}')
307
+
308
+
309
+ class _ElementResults:
310
+ @classmethod
311
+ def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults':
312
+ return cls(calculation_results, json_data['label'], json_data['variables'], json_data['constraints'])
313
+
314
+ def __init__(
315
+ self, calculation_results: CalculationResults, label: str, variables: List[str], constraints: List[str]
316
+ ):
317
+ self._calculation_results = calculation_results
318
+ self.label = label
319
+ self._variable_names = variables
320
+ self._constraint_names = constraints
321
+
322
+ self.solution = self._calculation_results.solution[self._variable_names]
323
+
324
+ @property
325
+ def variables(self) -> linopy.Variables:
326
+ """
327
+ Returns the variables of the element.
328
+
329
+ Raises:
330
+ ValueError: If the linopy model is not availlable.
331
+ """
332
+ if self._calculation_results.model is None:
333
+ raise ValueError('The linopy model is not available.')
334
+ return self._calculation_results.model.variables[self._variable_names]
335
+
336
+ @property
337
+ def constraints(self) -> linopy.Constraints:
338
+ """
339
+ Returns the variables of the element.
340
+
341
+ Raises:
342
+ ValueError: If the linopy model is not availlable.
343
+ """
344
+ if self._calculation_results.model is None:
345
+ raise ValueError('The linopy model is not available.')
346
+ return self._calculation_results.model.constraints[self._variable_names]
347
+
348
+ def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = None) -> xr.Dataset:
349
+ """
350
+ Filter the solution of the element by dimension.
351
+
352
+ Args:
353
+ variable_dims: The dimension of the variables to filter for.
354
+ """
355
+ return filter_dataset(self.solution, variable_dims)
356
+
357
+
358
+ class _NodeResults(_ElementResults):
359
+ @classmethod
360
+ def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults':
361
+ return cls(
362
+ calculation_results,
363
+ json_data['label'],
364
+ json_data['variables'],
365
+ json_data['constraints'],
366
+ json_data['inputs'],
367
+ json_data['outputs'],
368
+ )
369
+
370
+ def __init__(
371
+ self,
372
+ calculation_results: CalculationResults,
373
+ label: str,
374
+ variables: List[str],
375
+ constraints: List[str],
376
+ inputs: List[str],
377
+ outputs: List[str],
378
+ ):
379
+ super().__init__(calculation_results, label, variables, constraints)
380
+ self.inputs = inputs
381
+ self.outputs = outputs
382
+
383
+ def plot_node_balance(
384
+ self,
385
+ save: Union[bool, pathlib.Path] = False,
386
+ show: bool = True,
387
+ colors: plotting.ColorType = 'viridis',
388
+ engine: plotting.PlottingEngine = 'plotly',
389
+ ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
390
+ """
391
+ Plots the node balance of the Component or Bus.
392
+ Args:
393
+ save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location.
394
+ show: Whether to show the plot or not.
395
+ engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'.
396
+ """
397
+ if engine == 'plotly':
398
+ figure_like = plotting.with_plotly(
399
+ self.node_balance(with_last_timestep=True).to_dataframe(),
400
+ colors=colors,
401
+ mode='area',
402
+ title=f'Flow rates of {self.label}',
403
+ )
404
+ default_filetype = '.html'
405
+ elif engine == 'matplotlib':
406
+ figure_like = plotting.with_matplotlib(
407
+ self.node_balance(with_last_timestep=True).to_dataframe(),
408
+ colors=colors,
409
+ mode='bar',
410
+ title=f'Flow rates of {self.label}',
411
+ )
412
+ default_filetype = '.png'
413
+ else:
414
+ raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"')
415
+
416
+ return plotting.export_figure(
417
+ figure_like=figure_like,
418
+ default_path=self._calculation_results.folder / f'{self.label} (flow rates)',
419
+ default_filetype=default_filetype,
420
+ user_path=None if isinstance(save, bool) else pathlib.Path(save),
421
+ show=show,
422
+ save=True if save else False,
423
+ )
424
+
425
+ def plot_node_balance_pie(
426
+ self,
427
+ lower_percentage_group: float = 5,
428
+ colors: plotting.ColorType = 'viridis',
429
+ text_info: str = 'percent+label+value',
430
+ save: Union[bool, pathlib.Path] = False,
431
+ show: bool = True,
432
+ engine: plotting.PlottingEngine = 'plotly',
433
+ ) -> plotly.graph_objects.Figure:
434
+ """
435
+ Plots a pie chart of the flow hours of the inputs and outputs of buses or components.
436
+
437
+ Args:
438
+ colors: a colorscale or a list of colors to use for the plot
439
+ lower_percentage_group: The percentage of flow_hours that is grouped in "Others" (0...100)
440
+ text_info: What information to display on the pie plot
441
+ save: Whether to save the figure.
442
+ show: Whether to show the figure.
443
+ engine: Plotting engine to use. Only 'plotly' is implemented atm.
444
+ """
445
+ inputs = (
446
+ sanitize_dataset(
447
+ ds=self.solution[self.inputs],
448
+ threshold=1e-5,
449
+ drop_small_vars=True,
450
+ zero_small_values=True,
451
+ )
452
+ * self._calculation_results.hours_per_timestep
453
+ )
454
+ outputs = (
455
+ sanitize_dataset(
456
+ ds=self.solution[self.outputs],
457
+ threshold=1e-5,
458
+ drop_small_vars=True,
459
+ zero_small_values=True,
460
+ )
461
+ * self._calculation_results.hours_per_timestep
462
+ )
463
+
464
+ if engine == 'plotly':
465
+ figure_like = plotting.dual_pie_with_plotly(
466
+ inputs.to_dataframe().sum(),
467
+ outputs.to_dataframe().sum(),
468
+ colors=colors,
469
+ title=f'Flow hours of {self.label}',
470
+ text_info=text_info,
471
+ subtitles=('Inputs', 'Outputs'),
472
+ legend_title='Flows',
473
+ lower_percentage_group=lower_percentage_group,
474
+ )
475
+ default_filetype = '.html'
476
+ elif engine == 'matplotlib':
477
+ logger.debug('Parameter text_info is not supported for matplotlib')
478
+ figure_like = plotting.dual_pie_with_matplotlib(
479
+ inputs.to_dataframe().sum(),
480
+ outputs.to_dataframe().sum(),
481
+ colors=colors,
482
+ title=f'Total flow hours of {self.label}',
483
+ subtitles=('Inputs', 'Outputs'),
484
+ legend_title='Flows',
485
+ lower_percentage_group=lower_percentage_group,
486
+ )
487
+ default_filetype = '.png'
488
+ else:
489
+ raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"')
490
+
491
+ return plotting.export_figure(
492
+ figure_like=figure_like,
493
+ default_path=self._calculation_results.folder / f'{self.label} (total flow hours)',
494
+ default_filetype=default_filetype,
495
+ user_path=None if isinstance(save, bool) else pathlib.Path(save),
496
+ show=show,
497
+ save=True if save else False,
498
+ )
499
+
500
+ def node_balance(
501
+ self,
502
+ negate_inputs: bool = True,
503
+ negate_outputs: bool = False,
504
+ threshold: Optional[float] = 1e-5,
505
+ with_last_timestep: bool = False,
506
+ ) -> xr.Dataset:
507
+ return sanitize_dataset(
508
+ ds=self.solution[self.inputs + self.outputs],
509
+ threshold=threshold,
510
+ timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None,
511
+ negate=(
512
+ self.outputs + self.inputs
513
+ if negate_outputs and negate_inputs
514
+ else self.outputs
515
+ if negate_outputs
516
+ else self.inputs
517
+ if negate_inputs
518
+ else None
519
+ ),
520
+ )
521
+
522
+
523
+ class BusResults(_NodeResults):
524
+ """Results for a Bus"""
525
+
526
+
527
+ class ComponentResults(_NodeResults):
528
+ """Results for a Component"""
529
+
530
+ @property
531
+ def is_storage(self) -> bool:
532
+ return self._charge_state in self._variable_names
533
+
534
+ @property
535
+ def _charge_state(self) -> str:
536
+ return f'{self.label}|charge_state'
537
+
538
+ @property
539
+ def charge_state(self) -> xr.DataArray:
540
+ """Get the solution of the charge state of the Storage."""
541
+ if not self.is_storage:
542
+ raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage')
543
+ return self.solution[self._charge_state]
544
+
545
+ def plot_charge_state(
546
+ self,
547
+ save: Union[bool, pathlib.Path] = False,
548
+ show: bool = True,
549
+ colors: plotting.ColorType = 'viridis',
550
+ engine: plotting.PlottingEngine = 'plotly',
551
+ ) -> plotly.graph_objs.Figure:
552
+ """
553
+ Plots the charge state of a Storage.
554
+ Args:
555
+ save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location.
556
+ show: Whether to show the plot or not.
557
+ colors: The c
558
+ engine: Plotting engine to use. Only 'plotly' is implemented atm.
559
+
560
+ Raises:
561
+ ValueError: If the Component is not a Storage.
562
+ """
563
+ if engine != 'plotly':
564
+ raise NotImplementedError(
565
+ f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.'
566
+ )
567
+
568
+ if not self.is_storage:
569
+ raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage')
570
+
571
+ fig = plotting.with_plotly(
572
+ self.node_balance(with_last_timestep=True).to_dataframe(),
573
+ colors=colors,
574
+ mode='area',
575
+ title=f'Operation Balance of {self.label}',
576
+ )
577
+
578
+ # TODO: Use colors for charge state?
579
+
580
+ charge_state = self.charge_state.to_dataframe()
581
+ fig.add_trace(
582
+ plotly.graph_objs.Scatter(
583
+ x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state
584
+ )
585
+ )
586
+
587
+ return plotting.export_figure(
588
+ fig,
589
+ default_path=self._calculation_results.folder / f'{self.label} (charge state)',
590
+ default_filetype='.html',
591
+ user_path=None if isinstance(save, bool) else pathlib.Path(save),
592
+ show=show,
593
+ save=True if save else False,
594
+ )
595
+
596
+ def node_balance_with_charge_state(
597
+ self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5
598
+ ) -> xr.Dataset:
599
+ """
600
+ Returns a dataset with the node balance of the Storage including its charge state.
601
+ Args:
602
+ negate_inputs: Whether to negate the inputs of the Storage.
603
+ negate_outputs: Whether to negate the outputs of the Storage.
604
+ threshold: The threshold for small values.
605
+
606
+ Raises:
607
+ ValueError: If the Component is not a Storage.
608
+ """
609
+ if not self.is_storage:
610
+ raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage')
611
+ variable_names = self.inputs + self.outputs + [self._charge_state]
612
+ return sanitize_dataset(
613
+ ds=self.solution[variable_names],
614
+ threshold=threshold,
615
+ timesteps=self._calculation_results.timesteps_extra,
616
+ negate=(
617
+ self.outputs + self.inputs
618
+ if negate_outputs and negate_inputs
619
+ else self.outputs
620
+ if negate_outputs
621
+ else self.inputs
622
+ if negate_inputs
623
+ else None
624
+ ),
625
+ )
626
+
627
+
628
+ class EffectResults(_ElementResults):
629
+ """Results for an Effect"""
630
+
631
+ def get_shares_from(self, element: str):
632
+ """Get the shares from an Element (without subelements) to the Effect"""
633
+ return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]]
634
+
635
+
636
+ class SegmentedCalculationResults:
637
+ """
638
+ Class to store the results of a SegmentedCalculation.
639
+ """
640
+
641
+ @classmethod
642
+ def from_calculation(cls, calculation: 'SegmentedCalculation'):
643
+ return cls(
644
+ [calc.results for calc in calculation.sub_calculations],
645
+ all_timesteps=calculation.all_timesteps,
646
+ timesteps_per_segment=calculation.timesteps_per_segment,
647
+ overlap_timesteps=calculation.overlap_timesteps,
648
+ name=calculation.name,
649
+ folder=calculation.folder,
650
+ )
651
+
652
+ @classmethod
653
+ def from_file(cls, folder: Union[str, pathlib.Path], name: str):
654
+ """Create SegmentedCalculationResults directly from file"""
655
+ folder = pathlib.Path(folder)
656
+ path = folder / name
657
+ nc_file = path.with_suffix('.nc4')
658
+ logger.info(f'loading calculation "{name}" from file ("{nc_file}")')
659
+ with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f:
660
+ meta_data = json.load(f)
661
+ return cls(
662
+ [CalculationResults.from_file(folder, name) for name in meta_data['sub_calculations']],
663
+ all_timesteps=pd.DatetimeIndex(
664
+ [datetime.datetime.fromisoformat(date) for date in meta_data['all_timesteps']], name='time'
665
+ ),
666
+ timesteps_per_segment=meta_data['timesteps_per_segment'],
667
+ overlap_timesteps=meta_data['overlap_timesteps'],
668
+ name=name,
669
+ folder=folder,
670
+ )
671
+
672
+ def __init__(
673
+ self,
674
+ segment_results: List[CalculationResults],
675
+ all_timesteps: pd.DatetimeIndex,
676
+ timesteps_per_segment: int,
677
+ overlap_timesteps: int,
678
+ name: str,
679
+ folder: Optional[pathlib.Path] = None,
680
+ ):
681
+ self.segment_results = segment_results
682
+ self.all_timesteps = all_timesteps
683
+ self.timesteps_per_segment = timesteps_per_segment
684
+ self.overlap_timesteps = overlap_timesteps
685
+ self.name = name
686
+ self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results'
687
+ self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.all_timesteps)
688
+
689
+ @property
690
+ def meta_data(self) -> Dict[str, Union[int, List[str]]]:
691
+ return {
692
+ 'all_timesteps': [datetime.datetime.isoformat(date) for date in self.all_timesteps],
693
+ 'timesteps_per_segment': self.timesteps_per_segment,
694
+ 'overlap_timesteps': self.overlap_timesteps,
695
+ 'sub_calculations': [calc.name for calc in self.segment_results],
696
+ }
697
+
698
+ @property
699
+ def segment_names(self) -> List[str]:
700
+ return [segment.name for segment in self.segment_results]
701
+
702
+ def solution_without_overlap(self, variable_name: str) -> xr.DataArray:
703
+ """Returns the solution of a variable without overlapping timesteps"""
704
+ dataarrays = [
705
+ result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment))
706
+ for result in self.segment_results[:-1]
707
+ ] + [self.segment_results[-1].solution[variable_name]]
708
+ return xr.concat(dataarrays, dim='time')
709
+
710
+ def plot_heatmap(
711
+ self,
712
+ variable_name: str,
713
+ heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D',
714
+ heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h',
715
+ color_map: str = 'portland',
716
+ save: Union[bool, pathlib.Path] = False,
717
+ show: bool = True,
718
+ engine: plotting.PlottingEngine = 'plotly',
719
+ ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
720
+ """
721
+ Plots a heatmap of the solution of a variable.
722
+
723
+ Args:
724
+ variable_name: The name of the variable to plot.
725
+ heatmap_timeframes: The timeframes to use for the heatmap.
726
+ heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap.
727
+ color_map: The color map to use for the heatmap.
728
+ save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location.
729
+ show: Whether to show the plot or not.
730
+ engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'.
731
+ """
732
+ return plot_heatmap(
733
+ dataarray=self.solution_without_overlap(variable_name),
734
+ name=variable_name,
735
+ folder=self.folder,
736
+ heatmap_timeframes=heatmap_timeframes,
737
+ heatmap_timesteps_per_frame=heatmap_timesteps_per_frame,
738
+ color_map=color_map,
739
+ save=save,
740
+ show=show,
741
+ engine=engine,
742
+ )
743
+
744
+ def to_file(
745
+ self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, compression: int = 5
746
+ ):
747
+ """Save the results to a file"""
748
+ folder = self.folder if folder is None else pathlib.Path(folder)
749
+ name = self.name if name is None else name
750
+ path = folder / name
751
+ if not folder.exists():
752
+ try:
753
+ folder.mkdir(parents=False)
754
+ except FileNotFoundError as e:
755
+ raise FileNotFoundError(
756
+ f'Folder {folder} and its parent do not exist. Please create them first.'
757
+ ) from e
758
+ for segment in self.segment_results:
759
+ segment.to_file(folder=folder, name=f'{name}-{segment.name}', compression=compression)
760
+
761
+ with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f:
762
+ json.dump(self.meta_data, f, indent=4, ensure_ascii=False)
763
+ logger.info(f'Saved calculation "{name}" to {path}')
764
+
765
+
766
+ def plot_heatmap(
767
+ dataarray: xr.DataArray,
768
+ name: str,
769
+ folder: pathlib.Path,
770
+ heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D',
771
+ heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h',
772
+ color_map: str = 'portland',
773
+ save: Union[bool, pathlib.Path] = False,
774
+ show: bool = True,
775
+ engine: plotting.PlottingEngine = 'plotly',
776
+ ):
777
+ """
778
+ Plots a heatmap of the solution of a variable.
779
+
780
+ Args:
781
+ dataarray: The dataarray to plot.
782
+ name: The name of the variable to plot.
783
+ folder: The folder to save the plot to.
784
+ heatmap_timeframes: The timeframes to use for the heatmap.
785
+ heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap.
786
+ color_map: The color map to use for the heatmap.
787
+ save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location.
788
+ show: Whether to show the plot or not.
789
+ engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'.
790
+ """
791
+ heatmap_data = plotting.heat_map_data_from_df(
792
+ dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill'
793
+ )
794
+
795
+ xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]'
796
+
797
+ if engine == 'plotly':
798
+ figure_like = plotting.heat_map_plotly(
799
+ heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel
800
+ )
801
+ default_filetype = '.html'
802
+ elif engine == 'matplotlib':
803
+ figure_like = plotting.heat_map_matplotlib(
804
+ heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel
805
+ )
806
+ default_filetype = '.png'
807
+ else:
808
+ raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"')
809
+
810
+ return plotting.export_figure(
811
+ figure_like=figure_like,
812
+ default_path=folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame})',
813
+ default_filetype=default_filetype,
814
+ user_path=None if isinstance(save, bool) else pathlib.Path(save),
815
+ show=show,
816
+ save=True if save else False,
817
+ )
818
+
819
+
820
+ def sanitize_dataset(
821
+ ds: xr.Dataset,
822
+ timesteps: Optional[pd.DatetimeIndex] = None,
823
+ threshold: Optional[float] = 1e-5,
824
+ negate: Optional[List[str]] = None,
825
+ drop_small_vars: bool = True,
826
+ zero_small_values: bool = False,
827
+ ) -> xr.Dataset:
828
+ """
829
+ Sanitizes a dataset by handling small values (dropping or zeroing) and optionally reindexing the time axis.
830
+
831
+ Args:
832
+ ds: The dataset to sanitize.
833
+ timesteps: The timesteps to reindex the dataset to. If None, the original timesteps are kept.
834
+ threshold: The threshold for small values processing. If None, no processing is done.
835
+ negate: The variables to negate. If None, no variables are negated.
836
+ drop_small_vars: If True, drops variables where all values are below threshold.
837
+ zero_small_values: If True, sets values below threshold to zero.
838
+
839
+ Returns:
840
+ xr.Dataset: The sanitized dataset.
841
+ """
842
+ # Create a copy to avoid modifying the original
843
+ ds = ds.copy()
844
+
845
+ # Step 1: Negate specified variables
846
+ if negate is not None:
847
+ for var in negate:
848
+ if var in ds:
849
+ ds[var] = -ds[var]
850
+
851
+ # Step 2: Handle small values
852
+ if threshold is not None:
853
+ ds_no_nan_abs = xr.apply_ufunc(np.abs, ds).fillna(0) # Replace NaN with 0 (below threshold) for the comparison
854
+
855
+ # Option 1: Drop variables where all values are below threshold
856
+ if drop_small_vars:
857
+ vars_to_drop = [var for var in ds.data_vars if (ds_no_nan_abs[var] <= threshold).all()]
858
+ ds = ds.drop_vars(vars_to_drop)
859
+
860
+ # Option 2: Set small values to zero
861
+ if zero_small_values:
862
+ for var in ds.data_vars:
863
+ # Create a boolean mask of values below threshold
864
+ mask = ds_no_nan_abs[var] <= threshold
865
+ # Only proceed if there are values to zero out
866
+ if mask.any():
867
+ # Create a copy to ensure we don't modify data with views
868
+ ds[var] = ds[var].copy()
869
+ # Set values below threshold to zero
870
+ ds[var] = ds[var].where(~mask, 0)
871
+
872
+ # Step 3: Reindex to specified timesteps if needed
873
+ if timesteps is not None and not ds.indexes['time'].equals(timesteps):
874
+ ds = ds.reindex({'time': timesteps}, fill_value=np.nan)
875
+
876
+ return ds
877
+
878
+
879
+ def filter_dataset(
880
+ ds: xr.Dataset,
881
+ variable_dims: Optional[Literal['scalar', 'time']] = None,
882
+ ) -> xr.Dataset:
883
+ """
884
+ Filters a dataset by its dimensions.
885
+
886
+ Args:
887
+ ds: The dataset to filter.
888
+ variable_dims: The dimension of the variables to filter for.
889
+ """
890
+ if variable_dims is None:
891
+ return ds
892
+
893
+ if variable_dims == 'scalar':
894
+ return ds[[name for name, da in ds.data_vars.items() if len(da.dims) == 0]]
895
+ elif variable_dims == 'time':
896
+ return ds[[name for name, da in ds.data_vars.items() if 'time' in da.dims]]
897
+ else:
898
+ raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}')