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.
- docs/examples/00-Minimal Example.md +5 -0
- docs/examples/01-Basic Example.md +5 -0
- docs/examples/02-Complex Example.md +10 -0
- docs/examples/03-Calculation Modes.md +5 -0
- docs/examples/index.md +5 -0
- docs/faq/contribute.md +49 -0
- docs/faq/index.md +3 -0
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +1 -0
- docs/javascripts/mathjax.js +18 -0
- docs/release-notes/_template.txt +32 -0
- docs/release-notes/index.md +7 -0
- docs/release-notes/v2.0.0.md +93 -0
- docs/release-notes/v2.0.1.md +12 -0
- docs/user-guide/Mathematical Notation/Bus.md +33 -0
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
- docs/user-guide/Mathematical Notation/Flow.md +26 -0
- docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
- docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
- docs/user-guide/Mathematical Notation/Storage.md +44 -0
- docs/user-guide/Mathematical Notation/index.md +22 -0
- docs/user-guide/Mathematical Notation/others.md +3 -0
- docs/user-guide/index.md +124 -0
- {flixOpt → flixopt}/__init__.py +5 -2
- {flixOpt → flixopt}/aggregation.py +113 -140
- flixopt/calculation.py +455 -0
- {flixOpt → flixopt}/commons.py +7 -4
- flixopt/components.py +630 -0
- {flixOpt → flixopt}/config.py +9 -8
- {flixOpt → flixopt}/config.yaml +3 -3
- flixopt/core.py +970 -0
- flixopt/effects.py +386 -0
- flixopt/elements.py +534 -0
- flixopt/features.py +1042 -0
- flixopt/flow_system.py +409 -0
- flixopt/interface.py +265 -0
- flixopt/io.py +308 -0
- flixopt/linear_converters.py +331 -0
- flixopt/plotting.py +1340 -0
- flixopt/results.py +898 -0
- flixopt/solvers.py +77 -0
- flixopt/structure.py +630 -0
- flixopt/utils.py +62 -0
- flixopt-2.0.1.dist-info/METADATA +145 -0
- flixopt-2.0.1.dist-info/RECORD +57 -0
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
- flixopt-2.0.1.dist-info/top_level.txt +6 -0
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixopt-icon.svg +1 -0
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +54 -0
- site/release-notes/_template.txt +32 -0
- flixOpt/calculation.py +0 -629
- flixOpt/components.py +0 -614
- flixOpt/core.py +0 -182
- flixOpt/effects.py +0 -410
- flixOpt/elements.py +0 -489
- flixOpt/features.py +0 -942
- flixOpt/flow_system.py +0 -351
- flixOpt/interface.py +0 -203
- flixOpt/linear_converters.py +0 -325
- flixOpt/math_modeling.py +0 -1145
- flixOpt/plotting.py +0 -712
- flixOpt/results.py +0 -563
- flixOpt/solvers.py +0 -21
- flixOpt/structure.py +0 -733
- flixOpt/utils.py +0 -134
- flixopt-1.0.12.dist-info/METADATA +0 -174
- flixopt-1.0.12.dist-info/RECORD +0 -29
- flixopt-1.0.12.dist-info/top_level.txt +0 -3
- {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=}')
|