flixopt 2.2.0b0__py3-none-any.whl → 2.2.0rc2__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 (48) hide show
  1. docs/examples/00-Minimal Example.md +1 -1
  2. docs/examples/01-Basic Example.md +1 -1
  3. docs/examples/02-Complex Example.md +1 -1
  4. docs/examples/index.md +1 -1
  5. docs/faq/contribute.md +26 -14
  6. docs/faq/index.md +1 -1
  7. docs/javascripts/mathjax.js +1 -1
  8. docs/user-guide/Mathematical Notation/Bus.md +1 -1
  9. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +13 -13
  10. docs/user-guide/Mathematical Notation/Flow.md +1 -1
  11. docs/user-guide/Mathematical Notation/LinearConverter.md +2 -2
  12. docs/user-guide/Mathematical Notation/Piecewise.md +1 -1
  13. docs/user-guide/Mathematical Notation/Storage.md +1 -1
  14. docs/user-guide/Mathematical Notation/index.md +1 -1
  15. docs/user-guide/Mathematical Notation/others.md +1 -1
  16. docs/user-guide/index.md +2 -2
  17. flixopt/__init__.py +5 -0
  18. flixopt/aggregation.py +0 -1
  19. flixopt/calculation.py +40 -72
  20. flixopt/commons.py +10 -1
  21. flixopt/components.py +326 -154
  22. flixopt/core.py +459 -966
  23. flixopt/effects.py +67 -270
  24. flixopt/elements.py +76 -84
  25. flixopt/features.py +172 -154
  26. flixopt/flow_system.py +70 -99
  27. flixopt/interface.py +315 -147
  28. flixopt/io.py +27 -56
  29. flixopt/linear_converters.py +3 -3
  30. flixopt/network_app.py +755 -0
  31. flixopt/plotting.py +16 -34
  32. flixopt/results.py +108 -806
  33. flixopt/structure.py +11 -67
  34. flixopt/utils.py +9 -6
  35. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/METADATA +63 -42
  36. flixopt-2.2.0rc2.dist-info/RECORD +54 -0
  37. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/WHEEL +1 -1
  38. scripts/extract_release_notes.py +45 -0
  39. docs/release-notes/_template.txt +0 -32
  40. docs/release-notes/index.md +0 -7
  41. docs/release-notes/v2.0.0.md +0 -93
  42. docs/release-notes/v2.0.1.md +0 -12
  43. docs/release-notes/v2.1.0.md +0 -31
  44. docs/release-notes/v2.2.0.md +0 -55
  45. docs/user-guide/Mathematical Notation/Investment.md +0 -115
  46. flixopt-2.2.0b0.dist-info/RECORD +0 -59
  47. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/licenses/LICENSE +0 -0
  48. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/top_level.txt +0 -0
flixopt/results.py CHANGED
@@ -2,8 +2,7 @@ import datetime
2
2
  import json
3
3
  import logging
4
4
  import pathlib
5
- import warnings
6
- from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
5
+ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union
7
6
 
8
7
  import linopy
9
8
  import matplotlib.pyplot as plt
@@ -15,8 +14,7 @@ import yaml
15
14
 
16
15
  from . import io as fx_io
17
16
  from . import plotting
18
- from .core import DataConverter, TimeSeriesCollection
19
- from .flow_system import FlowSystem
17
+ from .core import TimeSeriesCollection
20
18
 
21
19
  if TYPE_CHECKING:
22
20
  import pyvis
@@ -27,11 +25,6 @@ if TYPE_CHECKING:
27
25
  logger = logging.getLogger('flixopt')
28
26
 
29
27
 
30
- class _FlowSystemRestorationError(Exception):
31
- """Exception raised when a FlowSystem cannot be restored from dataset."""
32
- pass
33
-
34
-
35
28
  class CalculationResults:
36
29
  """Results container for Calculation results.
37
30
 
@@ -44,7 +37,7 @@ class CalculationResults:
44
37
 
45
38
  Attributes:
46
39
  solution (xr.Dataset): Dataset containing optimization results.
47
- flow_system_data (xr.Dataset): Dataset containing the flow system.
40
+ flow_system (xr.Dataset): Dataset containing the flow system.
48
41
  summary (Dict): Information about the calculation.
49
42
  name (str): Name identifier for the calculation.
50
43
  model (linopy.Model): The optimization model (if available).
@@ -99,7 +92,7 @@ class CalculationResults:
99
92
 
100
93
  return cls(
101
94
  solution=fx_io.load_dataset_from_netcdf(paths.solution),
102
- flow_system_data=fx_io.load_dataset_from_netcdf(paths.flow_system),
95
+ flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system),
103
96
  name=name,
104
97
  folder=folder,
105
98
  model=model,
@@ -125,7 +118,7 @@ class CalculationResults:
125
118
  """
126
119
  return cls(
127
120
  solution=calculation.model.solution,
128
- flow_system_data=calculation.flow_system.as_dataset(constants_in_dataset=True),
121
+ flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True),
129
122
  summary=calculation.summary,
130
123
  model=calculation.model,
131
124
  name=calculation.name,
@@ -135,84 +128,47 @@ class CalculationResults:
135
128
  def __init__(
136
129
  self,
137
130
  solution: xr.Dataset,
138
- flow_system_data: xr.Dataset,
131
+ flow_system: xr.Dataset,
139
132
  name: str,
140
133
  summary: Dict,
141
134
  folder: Optional[pathlib.Path] = None,
142
135
  model: Optional[linopy.Model] = None,
143
- **kwargs, # To accept old "flow_system" parameter
144
136
  ):
145
137
  """
146
138
  Args:
147
139
  solution: The solution of the optimization.
148
- flow_system_data: The flow_system that was used to create the calculation as a datatset.
140
+ flow_system: The flow_system that was used to create the calculation as a datatset.
149
141
  name: The name of the calculation.
150
142
  summary: Information about the calculation,
151
143
  folder: The folder where the results are saved.
152
144
  model: The linopy model that was used to solve the calculation.
153
- Deprecated:
154
- flow_system: Use flow_system_data instead.
155
145
  """
156
- # Handle potential old "flow_system" parameter for backward compatibility
157
- if 'flow_system' in kwargs and flow_system_data is None:
158
- flow_system_data = kwargs.pop('flow_system')
159
- warnings.warn(
160
- "The 'flow_system' parameter is deprecated. Use 'flow_system_data' instead."
161
- "Acess is now by '.flow_system_data', while '.flow_system' returns the restored FlowSystem.",
162
- DeprecationWarning,
163
- stacklevel=2,
164
- )
165
-
166
146
  self.solution = solution
167
- self.flow_system_data = flow_system_data
147
+ self.flow_system = flow_system
168
148
  self.summary = summary
169
149
  self.name = name
170
150
  self.model = model
171
151
  self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results'
172
152
  self.components = {
173
- label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items()
153
+ label: ComponentResults.from_json(self, infos) for label, infos in self.solution.attrs['Components'].items()
174
154
  }
175
155
 
176
- self.buses = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()}
156
+ self.buses = {label: BusResults.from_json(self, infos) for label, infos in self.solution.attrs['Buses'].items()}
177
157
 
178
158
  self.effects = {
179
- label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items()
159
+ label: EffectResults.from_json(self, infos) for label, infos in self.solution.attrs['Effects'].items()
180
160
  }
181
161
 
182
- if 'Flows' not in self.solution.attrs:
183
- warnings.warn(
184
- 'No Data about flows found in the results. This data is only included since v2.2.0. Some functionality '
185
- 'is not availlable. We recommend to evaluate your results with a version <2.2.0.',
186
- stacklevel=2,
187
- )
188
- self.flows = {}
189
- else:
190
- self.flows = {
191
- label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items()
192
- }
193
-
194
162
  self.timesteps_extra = self.solution.indexes['time']
195
163
  self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra)
196
- self.scenarios = self.solution.indexes['scenario'] if 'scenario' in self.solution.indexes else None
197
-
198
- self._effect_share_factors = None
199
- self._flow_system = None
200
-
201
- self._flow_rates = None
202
- self._flow_hours = None
203
- self._sizes = None
204
- self._effects_per_component = {'operation': None, 'invest': None, 'total': None}
205
- self._flow_network_info_ = None
206
164
 
207
- def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults', 'FlowResults']:
165
+ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']:
208
166
  if key in self.components:
209
167
  return self.components[key]
210
168
  if key in self.buses:
211
169
  return self.buses[key]
212
170
  if key in self.effects:
213
171
  return self.effects[key]
214
- if key in self.flows:
215
- return self.flows[key]
216
172
  raise KeyError(f'No element with label {key} found.')
217
173
 
218
174
  @property
@@ -239,376 +195,20 @@ class CalculationResults:
239
195
  raise ValueError('The linopy model is not available.')
240
196
  return self.model.constraints
241
197
 
242
- @property
243
- def effect_share_factors(self):
244
- if self._effect_share_factors is None:
245
- effect_share_factors = self.flow_system.effects.calculate_effect_share_factors()
246
- self._effect_share_factors = {'operation': effect_share_factors[0],
247
- 'invest': effect_share_factors[1]}
248
- return self._effect_share_factors
249
-
250
- @property
251
- def flow_system(self) -> 'FlowSystem':
252
- """ The restored flow_system that was used to create the calculation.
253
- Contains all input parameters."""
254
- if self._flow_system is None:
255
- try:
256
- from . import FlowSystem
257
- current_logger_level = logger.getEffectiveLevel()
258
- logger.setLevel(logging.CRITICAL)
259
- self._flow_system = FlowSystem.from_dataset(self.flow_system_data)
260
- self._flow_system._connect_network()
261
- logger.setLevel(current_logger_level)
262
- except Exception as e:
263
- logger.critical(f'Not able to restore FlowSystem from dataset. Some functionality is not availlable. {e}')
264
- raise _FlowSystemRestorationError(f'Not able to restore FlowSystem from dataset. {e}') from e
265
- return self._flow_system
266
-
267
198
  def filter_solution(
268
- self,
269
- variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None,
270
- element: Optional[str] = None,
271
- timesteps: Optional[pd.DatetimeIndex] = None,
272
- scenarios: Optional[pd.Index] = None,
273
- contains: Optional[Union[str, List[str]]] = None,
274
- startswith: Optional[Union[str, List[str]]] = None,
199
+ self, variable_dims: Optional[Literal['scalar', 'time']] = None, element: Optional[str] = None
275
200
  ) -> xr.Dataset:
276
201
  """
277
202
  Filter the solution to a specific variable dimension and element.
278
203
  If no element is specified, all elements are included.
279
204
 
280
205
  Args:
281
- variable_dims: The dimension of which to get variables from.
282
- - 'scalar': Get scalar variables (without dimensions)
283
- - 'time': Get time-dependent variables (with a time dimension)
284
- - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension)
285
- - 'timeonly': Get time-dependent variables (with ONLY a time dimension)
286
- - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension)
206
+ variable_dims: The dimension of the variables to filter for.
287
207
  element: The element to filter for.
288
- timesteps: Optional time indexes to select. Can be:
289
- - pd.DatetimeIndex: Multiple timesteps
290
- - str/pd.Timestamp: Single timestep
291
- Defaults to all available timesteps.
292
- scenarios: Optional scenario indexes to select. Can be:
293
- - pd.Index: Multiple scenarios
294
- - str/int: Single scenario (int is treated as a label, not an index position)
295
- Defaults to all available scenarios.
296
- contains: Filter variables that contain this string or strings.
297
- If a list is provided, variables must contain ALL strings in the list.
298
- startswith: Filter variables that start with this string or strings.
299
- If a list is provided, variables must start with ANY of the strings in the list.
300
- """
301
- return filter_dataset(
302
- self.solution if element is None else self[element].solution,
303
- variable_dims=variable_dims,
304
- timesteps=timesteps,
305
- scenarios=scenarios,
306
- contains=contains,
307
- startswith=startswith,
308
- )
309
-
310
- def effects_per_component(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset:
311
- """Returns a dataset containing effect totals for each components (including their flows).
312
-
313
- Args:
314
- mode: Which effects to contain. (operation, invest, total)
315
-
316
- Returns:
317
- An xarray Dataset with an additional component dimension and effects as variables.
318
- """
319
- if mode not in ['operation', 'invest', 'total']:
320
- raise ValueError(f'Invalid mode {mode}')
321
- if self._effects_per_component[mode] is None:
322
- self._effects_per_component[mode] = self._create_effects_dataset(mode)
323
- return self._effects_per_component[mode]
324
-
325
- def flow_rates(
326
- self,
327
- start: Optional[Union[str, List[str]]] = None,
328
- end: Optional[Union[str, List[str]]] = None,
329
- component: Optional[Union[str, List[str]]] = None,
330
- ) -> xr.DataArray:
331
- """Returns a DataArray containing the flow rates of each Flow.
332
-
333
- Args:
334
- start: Optional source node(s) to filter by. Can be a single node name or a list of names.
335
- end: Optional destination node(s) to filter by. Can be a single node name or a list of names.
336
- component: Optional component(s) to filter by. Can be a single component name or a list of names.
337
-
338
- Further usage:
339
- Convert the dataarray to a dataframe:
340
- >>>results.flow_rates().to_pandas()
341
- Get the max or min over time:
342
- >>>results.flow_rates().max('time')
343
- Sum up the flow rates of flows with the same start and end:
344
- >>>results.flow_rates(end='Fernwärme').groupby('start').sum(dim='flow')
345
- To recombine filtered dataarrays, use `xr.concat` with dim 'flow':
346
- >>>xr.concat([results.flow_rates(start='Fernwärme'), results.flow_rates(end='Fernwärme')], dim='flow')
347
- """
348
- if self._flow_rates is None:
349
- self._flow_rates = self._assign_flow_coords(
350
- xr.concat([flow.flow_rate.rename(flow.label) for flow in self.flows.values()],
351
- dim=pd.Index(self.flows.keys(), name='flow'))
352
- ).rename('flow_rates')
353
- filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None}
354
- return filter_dataarray_by_coord(self._flow_rates, **filters)
355
-
356
- def flow_hours(
357
- self,
358
- start: Optional[Union[str, List[str]]] = None,
359
- end: Optional[Union[str, List[str]]] = None,
360
- component: Optional[Union[str, List[str]]] = None,
361
- ) -> xr.DataArray:
362
- """Returns a DataArray containing the flow hours of each Flow.
363
-
364
- Flow hours represent the total energy/material transferred over time,
365
- calculated by multiplying flow rates by the duration of each timestep.
366
-
367
- Args:
368
- start: Optional source node(s) to filter by. Can be a single node name or a list of names.
369
- end: Optional destination node(s) to filter by. Can be a single node name or a list of names.
370
- component: Optional component(s) to filter by. Can be a single component name or a list of names.
371
-
372
- Further usage:
373
- Convert the dataarray to a dataframe:
374
- >>>results.flow_hours().to_pandas()
375
- Sum up the flow hours over time:
376
- >>>results.flow_hours().sum('time')
377
- Sum up the flow hours of flows with the same start and end:
378
- >>>results.flow_hours(end='Fernwärme').groupby('start').sum(dim='flow')
379
- To recombine filtered dataarrays, use `xr.concat` with dim 'flow':
380
- >>>xr.concat([results.flow_hours(start='Fernwärme'), results.flow_hours(end='Fernwärme')], dim='flow')
381
-
382
- """
383
- if self._flow_hours is None:
384
- self._flow_hours = (self.flow_rates() * self.hours_per_timestep).rename('flow_hours')
385
- filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None}
386
- return filter_dataarray_by_coord(self._flow_hours, **filters)
387
-
388
- def sizes(
389
- self,
390
- start: Optional[Union[str, List[str]]] = None,
391
- end: Optional[Union[str, List[str]]] = None,
392
- component: Optional[Union[str, List[str]]] = None
393
- ) -> xr.DataArray:
394
- """Returns a dataset with the sizes of the Flows.
395
- Args:
396
- start: Optional source node(s) to filter by. Can be a single node name or a list of names.
397
- end: Optional destination node(s) to filter by. Can be a single node name or a list of names.
398
- component: Optional component(s) to filter by. Can be a single component name or a list of names.
399
-
400
- Further usage:
401
- Convert the dataarray to a dataframe:
402
- >>>results.sizes().to_pandas()
403
- To recombine filtered dataarrays, use `xr.concat` with dim 'flow':
404
- >>>xr.concat([results.sizes(start='Fernwärme'), results.sizes(end='Fernwärme')], dim='flow')
405
-
406
- """
407
- if self._sizes is None:
408
- self._sizes = self._assign_flow_coords(
409
- xr.concat([flow.size.rename(flow.label) for flow in self.flows.values()],
410
- dim=pd.Index(self.flows.keys(), name='flow'))
411
- ).rename('flow_sizes')
412
- filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None}
413
- return filter_dataarray_by_coord(self._sizes, **filters)
414
-
415
- def _assign_flow_coords(self, da: xr.DataArray):
416
- # Add start and end coordinates
417
- da = da.assign_coords({
418
- 'start': ('flow', [flow.start for flow in self.flows.values()]),
419
- 'end': ('flow', [flow.end for flow in self.flows.values()]),
420
- 'component': ('flow', [flow.component for flow in self.flows.values()]),
421
- })
422
-
423
- # Ensure flow is the last dimension if needed
424
- existing_dims = [d for d in da.dims if d != 'flow']
425
- da = da.transpose(*(existing_dims + ['flow']))
426
- return da
427
-
428
- def _get_flow_network_info(self) -> Dict[str, Dict[str, str]]:
429
- flow_network_info = {}
430
-
431
- for flow in self.flows.values():
432
- flow_network_info[flow.label] = {
433
- 'label': flow.label,
434
- 'start': flow.start,
435
- 'end': flow.end,
436
- }
437
- return flow_network_info
438
-
439
- def get_effect_shares(
440
- self,
441
- element: str,
442
- effect: str,
443
- mode: Optional[Literal['operation', 'invest']] = None,
444
- include_flows: bool = False
445
- ) -> xr.Dataset:
446
- """Retrieves individual effect shares for a specific element and effect.
447
- Either for operation, investment, or both modes combined.
448
- Only includes the direct shares.
449
-
450
- Args:
451
- element: The element identifier for which to retrieve effect shares.
452
- effect: The effect identifier for which to retrieve shares.
453
- mode: Optional. The mode to retrieve shares for. Can be 'operation', 'invest',
454
- or None to retrieve both. Defaults to None.
455
-
456
- Returns:
457
- An xarray Dataset containing the requested effect shares. If mode is None,
458
- returns a merged Dataset containing both operation and investment shares.
459
-
460
- Raises:
461
- ValueError: If the specified effect is not available or if mode is invalid.
462
- """
463
- if effect not in self.effects:
464
- raise ValueError(f'Effect {effect} is not available.')
465
-
466
- if mode is None:
467
- return xr.merge([self.get_effect_shares(element=element, effect=effect, mode='operation', include_flows=include_flows),
468
- self.get_effect_shares(element=element, effect=effect, mode='invest', include_flows=include_flows)])
469
-
470
- if mode not in ['operation', 'invest']:
471
- raise ValueError(f'Mode {mode} is not available. Choose between "operation" and "invest".')
472
-
473
- ds = xr.Dataset()
474
-
475
- label = f'{element}->{effect}({mode})'
476
- if label in self.solution:
477
- ds = xr.Dataset({label: self.solution[label]})
478
-
479
- if include_flows:
480
- if element not in self.components:
481
- raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}')
482
- flows = [label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs]
483
- return xr.merge(
484
- [ds] + [self.get_effect_shares(element=flow, effect=effect, mode=mode, include_flows=False)
485
- for flow in flows]
486
- )
487
-
488
- return ds
489
-
490
- def _compute_effect_total(
491
- self,
492
- element: str,
493
- effect: str,
494
- mode: Literal['operation', 'invest', 'total'] = 'total',
495
- include_flows: bool = False,
496
- ) -> xr.DataArray:
497
- """Calculates the total effect for a specific element and effect.
498
-
499
- This method computes the total direct and indirect effects for a given element
500
- and effect, considering the conversion factors between different effects.
501
-
502
- Args:
503
- element: The element identifier for which to calculate total effects.
504
- effect: The effect identifier to calculate.
505
- mode: The calculation mode. Options are:
506
- 'operation': Returns operation-specific effects.
507
- 'invest': Returns investment-specific effects.
508
- 'total': Returns the sum of operation effects (across all timesteps)
509
- and investment effects. Defaults to 'total'.
510
- include_flows: Whether to include effects from flows connected to this element.
511
-
512
- Returns:
513
- An xarray DataArray containing the total effects, named with pattern
514
- '{element}->{effect}' for mode='total' or '{element}->{effect}({mode})'
515
- for other modes.
516
-
517
- Raises:
518
- ValueError: If the specified effect is not available.
519
208
  """
520
- if effect not in self.effects:
521
- raise ValueError(f'Effect {effect} is not available.')
522
-
523
- if mode == 'total':
524
- operation = self._compute_effect_total(element=element, effect=effect, mode='operation', include_flows=include_flows)
525
- invest = self._compute_effect_total(element=element, effect=effect, mode='invest', include_flows=include_flows)
526
- if invest.isnull().all() and operation.isnull().all():
527
- return xr.DataArray(np.nan)
528
- if operation.isnull().all():
529
- return invest.rename(f'{element}->{effect}')
530
- operation = operation.sum('time')
531
- if invest.isnull().all():
532
- return operation.rename(f'{element}->{effect}')
533
- if 'time' in operation.indexes:
534
- operation = operation.sum('time')
535
- return invest + operation
536
-
537
- total = xr.DataArray(0)
538
- share_exists = False
539
-
540
- relevant_conversion_factors = {
541
- key[0]: value for key, value in self.effect_share_factors[mode].items() if key[1] == effect
542
- }
543
- relevant_conversion_factors[effect] = 1 # Share to itself is 1
544
-
545
- for target_effect, conversion_factor in relevant_conversion_factors.items():
546
- label = f'{element}->{target_effect}({mode})'
547
- if label in self.solution:
548
- share_exists = True
549
- da = self.solution[label]
550
- total = da * conversion_factor + total
551
-
552
- if include_flows:
553
- if element not in self.components:
554
- raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}')
555
- flows = [label.split('|')[0] for label in
556
- self.components[element].inputs + self.components[element].outputs]
557
- for flow in flows:
558
- label = f'{flow}->{target_effect}({mode})'
559
- if label in self.solution:
560
- share_exists = True
561
- da = self.solution[label]
562
- total = da * conversion_factor + total
563
- if not share_exists:
564
- total = xr.DataArray(np.nan)
565
- return total.rename(f'{element}->{effect}({mode})')
566
-
567
- def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset:
568
- """Creates a dataset containing effect totals for all components (including their flows).
569
- The dataset does contain the direct as well as the indirect effects of each component.
570
-
571
- Args:
572
- mode: The calculation mode ('operation', 'invest', or 'total').
573
-
574
- Returns:
575
- An xarray Dataset with components as dimension and effects as variables.
576
- """
577
- # Create an empty dataset
578
- ds = xr.Dataset()
579
-
580
- # Add each effect as a variable to the dataset
581
- for effect in self.effects:
582
- # Create a list of DataArrays, one for each component
583
- component_arrays = [
584
- self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True).expand_dims(
585
- component=[component]
586
- ) # Add component dimension to each array
587
- for component in list(self.components)
588
- ]
589
-
590
- # Combine all components into one DataArray for this effect
591
- if component_arrays:
592
- effect_array = xr.concat(component_arrays, dim='component', coords='minimal')
593
- # Add this effect as a variable to the dataset
594
- ds[effect] = effect_array
595
-
596
- # For now include a test to ensure correctness
597
- suffix = {
598
- 'operation': '(operation)|total_per_timestep',
599
- 'invest': '(invest)|total',
600
- 'total': '|total',
601
- }
602
- for effect in self.effects:
603
- label = f'{effect}{suffix[mode]}'
604
- computed = ds[effect].sum('component')
605
- found = self.solution[label]
606
- if not np.allclose(computed.values, found.fillna(0).values):
607
- logger.critical(
608
- f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}'
609
- )
610
-
611
- return ds
209
+ if element is not None:
210
+ return filter_dataset(self[element].solution, variable_dims)
211
+ return filter_dataset(self.solution, variable_dims)
612
212
 
613
213
  def plot_heatmap(
614
214
  self,
@@ -619,32 +219,10 @@ class CalculationResults:
619
219
  save: Union[bool, pathlib.Path] = False,
620
220
  show: bool = True,
621
221
  engine: plotting.PlottingEngine = 'plotly',
622
- scenario: Optional[Union[str, int]] = None,
623
222
  ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
624
- """
625
- Plots a heatmap of the solution of a variable.
626
-
627
- Args:
628
- variable_name: The name of the variable to plot.
629
- heatmap_timeframes: The timeframes to use for the heatmap.
630
- heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap.
631
- color_map: The color map to use for the heatmap.
632
- save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location.
633
- show: Whether to show the plot or not.
634
- engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'.
635
- scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present
636
- """
637
- dataarray = self.solution[variable_name]
638
-
639
- scenario_suffix = ''
640
- if 'scenario' in dataarray.indexes:
641
- chosen_scenario = scenario or self.scenarios[0]
642
- dataarray = dataarray.sel(scenario=chosen_scenario).drop_vars('scenario')
643
- scenario_suffix = f'--{chosen_scenario}'
644
-
645
223
  return plot_heatmap(
646
- dataarray=dataarray,
647
- name=f'{variable_name}{scenario_suffix}',
224
+ dataarray=self.solution[variable_name],
225
+ name=variable_name,
648
226
  folder=self.folder,
649
227
  heatmap_timeframes=heatmap_timeframes,
650
228
  heatmap_timesteps_per_frame=heatmap_timesteps_per_frame,
@@ -666,9 +244,16 @@ class CalculationResults:
666
244
  show: bool = False,
667
245
  ) -> 'pyvis.network.Network':
668
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
669
254
  if path is None:
670
255
  path = self.folder / f'{self.name}--network.html'
671
- return self.flow_system.plot_network(controls=controls, path=path, show=show)
256
+ return flow_system.plot_network(controls=controls, path=path, show=show)
672
257
 
673
258
  def to_file(
674
259
  self,
@@ -701,7 +286,7 @@ class CalculationResults:
701
286
  paths = fx_io.CalculationResultsPaths(folder, name)
702
287
 
703
288
  fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression)
704
- fx_io.save_dataset_to_netcdf(self.flow_system_data, paths.flow_system, compression=compression)
289
+ fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression)
705
290
 
706
291
  with open(paths.summary, 'w', encoding='utf-8') as f:
707
292
  yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000)
@@ -722,6 +307,10 @@ class CalculationResults:
722
307
 
723
308
 
724
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
+
725
314
  def __init__(
726
315
  self, calculation_results: CalculationResults, label: str, variables: List[str], constraints: List[str]
727
316
  ):
@@ -756,49 +345,28 @@ class _ElementResults:
756
345
  raise ValueError('The linopy model is not available.')
757
346
  return self._calculation_results.model.constraints[self._constraint_names]
758
347
 
759
- def filter_solution(
760
- self,
761
- variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None,
762
- timesteps: Optional[pd.DatetimeIndex] = None,
763
- scenarios: Optional[pd.Index] = None,
764
- contains: Optional[Union[str, List[str]]] = None,
765
- startswith: Optional[Union[str, List[str]]] = None,
766
- ) -> xr.Dataset:
348
+ def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = None) -> xr.Dataset:
767
349
  """
768
- Filter the solution to a specific variable dimension and element.
769
- If no element is specified, all elements are included.
350
+ Filter the solution of the element by dimension.
770
351
 
771
352
  Args:
772
- variable_dims: The dimension of which to get variables from.
773
- - 'scalar': Get scalar variables (without dimensions)
774
- - 'time': Get time-dependent variables (with a time dimension)
775
- - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension)
776
- - 'timeonly': Get time-dependent variables (with ONLY a time dimension)
777
- - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension)
778
- timesteps: Optional time indexes to select. Can be:
779
- - pd.DatetimeIndex: Multiple timesteps
780
- - str/pd.Timestamp: Single timestep
781
- Defaults to all available timesteps.
782
- scenarios: Optional scenario indexes to select. Can be:
783
- - pd.Index: Multiple scenarios
784
- - str/int: Single scenario (int is treated as a label, not an index position)
785
- Defaults to all available scenarios.
786
- contains: Filter variables that contain this string or strings.
787
- If a list is provided, variables must contain ALL strings in the list.
788
- startswith: Filter variables that start with this string or strings.
789
- If a list is provided, variables must start with ANY of the strings in the list.
353
+ variable_dims: The dimension of the variables to filter for.
790
354
  """
791
- return filter_dataset(
792
- self.solution,
793
- variable_dims=variable_dims,
794
- timesteps=timesteps,
795
- scenarios=scenarios,
796
- contains=contains,
797
- startswith=startswith,
798
- )
355
+ return filter_dataset(self.solution, variable_dims)
799
356
 
800
357
 
801
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
+
802
370
  def __init__(
803
371
  self,
804
372
  calculation_results: CalculationResults,
@@ -807,12 +375,10 @@ class _NodeResults(_ElementResults):
807
375
  constraints: List[str],
808
376
  inputs: List[str],
809
377
  outputs: List[str],
810
- flows: List[str],
811
378
  ):
812
379
  super().__init__(calculation_results, label, variables, constraints)
813
380
  self.inputs = inputs
814
381
  self.outputs = outputs
815
- self.flows = flows
816
382
 
817
383
  def plot_node_balance(
818
384
  self,
@@ -820,47 +386,28 @@ class _NodeResults(_ElementResults):
820
386
  show: bool = True,
821
387
  colors: plotting.ColorType = 'viridis',
822
388
  engine: plotting.PlottingEngine = 'plotly',
823
- scenario: Optional[Union[str, int]] = None,
824
- mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
825
- style: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar',
826
- drop_suffix: bool = True,
827
389
  ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]:
828
390
  """
829
391
  Plots the node balance of the Component or Bus.
830
392
  Args:
831
393
  save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location.
832
394
  show: Whether to show the plot or not.
833
- colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options.
834
395
  engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'.
835
- scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present
836
- mode: The mode to use for the dataset. Can be 'flow_rate' or 'flow_hours'.
837
- - 'flow_rate': Returns the flow_rates of the Node.
838
- - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours.
839
- drop_suffix: Whether to drop the suffix from the variable names.
840
396
  """
841
- ds = self.node_balance(with_last_timestep=True, mode=mode, drop_suffix=drop_suffix)
842
-
843
- title = f'{self.label} (flow rates)' if mode == 'flow_rate' else f'{self.label} (flow hours)'
844
-
845
- if 'scenario' in ds.indexes:
846
- chosen_scenario = scenario or self._calculation_results.scenarios[0]
847
- ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario')
848
- title = f'{title} - {chosen_scenario}'
849
-
850
397
  if engine == 'plotly':
851
398
  figure_like = plotting.with_plotly(
852
- ds.to_dataframe(),
399
+ self.node_balance(with_last_timestep=True).to_dataframe(),
853
400
  colors=colors,
854
- style=style,
855
- title=title,
401
+ mode='area',
402
+ title=f'Flow rates of {self.label}',
856
403
  )
857
404
  default_filetype = '.html'
858
405
  elif engine == 'matplotlib':
859
406
  figure_like = plotting.with_matplotlib(
860
- ds.to_dataframe(),
407
+ self.node_balance(with_last_timestep=True).to_dataframe(),
861
408
  colors=colors,
862
- style=style,
863
- title=title,
409
+ mode='bar',
410
+ title=f'Flow rates of {self.label}',
864
411
  )
865
412
  default_filetype = '.png'
866
413
  else:
@@ -868,7 +415,7 @@ class _NodeResults(_ElementResults):
868
415
 
869
416
  return plotting.export_figure(
870
417
  figure_like=figure_like,
871
- default_path=self._calculation_results.folder / title,
418
+ default_path=self._calculation_results.folder / f'{self.label} (flow rates)',
872
419
  default_filetype=default_filetype,
873
420
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
874
421
  show=show,
@@ -883,7 +430,6 @@ class _NodeResults(_ElementResults):
883
430
  save: Union[bool, pathlib.Path] = False,
884
431
  show: bool = True,
885
432
  engine: plotting.PlottingEngine = 'plotly',
886
- scenario: Optional[Union[str, int]] = None,
887
433
  ) -> plotly.graph_objects.Figure:
888
434
  """
889
435
  Plots a pie chart of the flow hours of the inputs and outputs of buses or components.
@@ -895,40 +441,32 @@ class _NodeResults(_ElementResults):
895
441
  save: Whether to save the figure.
896
442
  show: Whether to show the figure.
897
443
  engine: Plotting engine to use. Only 'plotly' is implemented atm.
898
- scenario: If scenarios are present: The scenario to plot. If None, the first scenario is used.
899
- drop_suffix: Whether to drop the suffix from the variable names.
900
444
  """
901
- inputs = sanitize_dataset(
902
- ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep,
903
- threshold=1e-5,
904
- drop_small_vars=True,
905
- zero_small_values=True,
906
- drop_suffix='|',
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
907
453
  )
908
- outputs = sanitize_dataset(
909
- ds=self.solution[self.outputs] * self._calculation_results.hours_per_timestep,
910
- threshold=1e-5,
911
- drop_small_vars=True,
912
- zero_small_values=True,
913
- drop_suffix='|',
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
914
462
  )
915
- inputs = inputs.sum('time')
916
- outputs = outputs.sum('time')
917
-
918
- title = f'{self.label} (total flow hours)'
919
-
920
- if 'scenario' in inputs.indexes:
921
- chosen_scenario = scenario or self._calculation_results.scenarios[0]
922
- inputs = inputs.sel(scenario=chosen_scenario).drop_vars('scenario')
923
- outputs = outputs.sel(scenario=chosen_scenario).drop_vars('scenario')
924
- title = f'{title} - {chosen_scenario}'
925
463
 
926
464
  if engine == 'plotly':
927
465
  figure_like = plotting.dual_pie_with_plotly(
928
- data_left=inputs.to_pandas(),
929
- data_right=outputs.to_pandas(),
466
+ inputs.to_dataframe().sum(),
467
+ outputs.to_dataframe().sum(),
930
468
  colors=colors,
931
- title=title,
469
+ title=f'Flow hours of {self.label}',
932
470
  text_info=text_info,
933
471
  subtitles=('Inputs', 'Outputs'),
934
472
  legend_title='Flows',
@@ -938,10 +476,10 @@ class _NodeResults(_ElementResults):
938
476
  elif engine == 'matplotlib':
939
477
  logger.debug('Parameter text_info is not supported for matplotlib')
940
478
  figure_like = plotting.dual_pie_with_matplotlib(
941
- data_left=inputs.to_pandas(),
942
- data_right=outputs.to_pandas(),
479
+ inputs.to_dataframe().sum(),
480
+ outputs.to_dataframe().sum(),
943
481
  colors=colors,
944
- title=title,
482
+ title=f'Total flow hours of {self.label}',
945
483
  subtitles=('Inputs', 'Outputs'),
946
484
  legend_title='Flows',
947
485
  lower_percentage_group=lower_percentage_group,
@@ -952,7 +490,7 @@ class _NodeResults(_ElementResults):
952
490
 
953
491
  return plotting.export_figure(
954
492
  figure_like=figure_like,
955
- default_path=self._calculation_results.folder / title,
493
+ default_path=self._calculation_results.folder / f'{self.label} (total flow hours)',
956
494
  default_filetype=default_filetype,
957
495
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
958
496
  show=show,
@@ -965,25 +503,9 @@ class _NodeResults(_ElementResults):
965
503
  negate_outputs: bool = False,
966
504
  threshold: Optional[float] = 1e-5,
967
505
  with_last_timestep: bool = False,
968
- mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate',
969
- drop_suffix: bool = False,
970
506
  ) -> xr.Dataset:
971
- """
972
- Returns a dataset with the node balance of the Component or Bus.
973
- Args:
974
- negate_inputs: Whether to negate the input flow_rates of the Node.
975
- negate_outputs: Whether to negate the output flow_rates of the Node.
976
- threshold: The threshold for small values. Variables with all values below the threshold are dropped.
977
- with_last_timestep: Whether to include the last timestep in the dataset.
978
- mode: The mode to use for the dataset. Can be 'flow_rate' or 'flow_hours'.
979
- - 'flow_rate': Returns the flow_rates of the Node.
980
- - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours.
981
- drop_suffix: Whether to drop the suffix from the variable names.
982
- """
983
- ds = self.solution[self.inputs + self.outputs]
984
-
985
- ds = sanitize_dataset(
986
- ds=ds,
507
+ return sanitize_dataset(
508
+ ds=self.solution[self.inputs + self.outputs],
987
509
  threshold=threshold,
988
510
  timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None,
989
511
  negate=(
@@ -995,15 +517,8 @@ class _NodeResults(_ElementResults):
995
517
  if negate_inputs
996
518
  else None
997
519
  ),
998
- drop_suffix='|' if drop_suffix else None,
999
520
  )
1000
521
 
1001
- if mode == 'flow_hours':
1002
- ds = ds * self._calculation_results.hours_per_timestep
1003
- ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars})
1004
-
1005
- return ds
1006
-
1007
522
 
1008
523
  class BusResults(_NodeResults):
1009
524
  """Results for a Bus"""
@@ -1033,8 +548,6 @@ class ComponentResults(_NodeResults):
1033
548
  show: bool = True,
1034
549
  colors: plotting.ColorType = 'viridis',
1035
550
  engine: plotting.PlottingEngine = 'plotly',
1036
- style: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar',
1037
- scenario: Optional[Union[str, int]] = None,
1038
551
  ) -> plotly.graph_objs.Figure:
1039
552
  """
1040
553
  Plots the charge state of a Storage.
@@ -1043,56 +556,37 @@ class ComponentResults(_NodeResults):
1043
556
  show: Whether to show the plot or not.
1044
557
  colors: The c
1045
558
  engine: Plotting engine to use. Only 'plotly' is implemented atm.
1046
- style: The plotting mode for the flow_rate
1047
- scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present
1048
559
 
1049
560
  Raises:
1050
561
  ValueError: If the Component is not a Storage.
1051
562
  """
563
+ if engine != 'plotly':
564
+ raise NotImplementedError(
565
+ f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.'
566
+ )
567
+
1052
568
  if not self.is_storage:
1053
569
  raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage')
1054
570
 
1055
- ds = self.node_balance(with_last_timestep=True)
1056
- charge_state = self.charge_state
1057
-
1058
- scenario_suffix = ''
1059
- if 'scenario' in ds.indexes:
1060
- chosen_scenario = scenario or self._calculation_results.scenarios[0]
1061
- ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario')
1062
- charge_state = charge_state.sel(scenario=chosen_scenario).drop_vars('scenario')
1063
- scenario_suffix = f'--{chosen_scenario}'
1064
- if engine == 'plotly':
1065
- fig = plotting.with_plotly(
1066
- ds.to_dataframe(),
1067
- colors=colors,
1068
- style=style,
1069
- title=f'Operation Balance of {self.label}{scenario_suffix}',
1070
- )
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
+ )
1071
577
 
1072
- # TODO: Use colors for charge state?
578
+ # TODO: Use colors for charge state?
1073
579
 
1074
- charge_state = charge_state.to_dataframe()
1075
- fig.add_trace(
1076
- plotly.graph_objs.Scatter(
1077
- x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state
1078
- )
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
1079
584
  )
1080
- elif engine=='matplotlib':
1081
- fig, ax = plotting.with_matplotlib(
1082
- ds.to_dataframe(),
1083
- colors=colors,
1084
- style=style,
1085
- title=f'Operation Balance of {self.label}{scenario_suffix}',
1086
- )
1087
-
1088
- charge_state = charge_state.to_dataframe()
1089
- ax.plot(charge_state.index, charge_state.values.flatten(), label=self._charge_state)
1090
- fig.tight_layout()
1091
- fig = fig, ax
585
+ )
1092
586
 
1093
587
  return plotting.export_figure(
1094
588
  fig,
1095
- default_path=self._calculation_results.folder / f'{self.label} (charge state){scenario_suffix}',
589
+ default_path=self._calculation_results.folder / f'{self.label} (charge state)',
1096
590
  default_filetype='.html',
1097
591
  user_path=None if isinstance(save, bool) else pathlib.Path(save),
1098
592
  show=show,
@@ -1139,45 +633,6 @@ class EffectResults(_ElementResults):
1139
633
  return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]]
1140
634
 
1141
635
 
1142
- class FlowResults(_ElementResults):
1143
- def __init__(
1144
- self,
1145
- calculation_results: CalculationResults,
1146
- label: str,
1147
- variables: List[str],
1148
- constraints: List[str],
1149
- start: str,
1150
- end: str,
1151
- component: str,
1152
- ):
1153
- super().__init__(calculation_results, label, variables, constraints)
1154
- self.start = start
1155
- self.end = end
1156
- self.component = component
1157
-
1158
- @property
1159
- def flow_rate(self) -> xr.DataArray:
1160
- return self.solution[f'{self.label}|flow_rate']
1161
-
1162
- @property
1163
- def flow_hours(self) -> xr.DataArray:
1164
- return (self.flow_rate * self._calculation_results.hours_per_timestep).rename(f'{self.label}|flow_hours')
1165
-
1166
- @property
1167
- def size(self) -> xr.DataArray:
1168
- name = f'{self.label}|size'
1169
- if name in self.solution:
1170
- return self.solution[name]
1171
- try:
1172
- return DataConverter.as_dataarray(
1173
- self._calculation_results.flow_system.flows[self.label].size,
1174
- scenarios=self._calculation_results.scenarios
1175
- ).rename(name)
1176
- except _FlowSystemRestorationError:
1177
- logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN')
1178
- return xr.DataArray(np.nan).rename(name)
1179
-
1180
-
1181
636
  class SegmentedCalculationResults:
1182
637
  """
1183
638
  Class to store the results of a SegmentedCalculation.
@@ -1369,7 +824,6 @@ def sanitize_dataset(
1369
824
  negate: Optional[List[str]] = None,
1370
825
  drop_small_vars: bool = True,
1371
826
  zero_small_values: bool = False,
1372
- drop_suffix: Optional[str] = None,
1373
827
  ) -> xr.Dataset:
1374
828
  """
1375
829
  Sanitizes a dataset by handling small values (dropping or zeroing) and optionally reindexing the time axis.
@@ -1381,7 +835,6 @@ def sanitize_dataset(
1381
835
  negate: The variables to negate. If None, no variables are negated.
1382
836
  drop_small_vars: If True, drops variables where all values are below threshold.
1383
837
  zero_small_values: If True, sets values below threshold to zero.
1384
- drop_suffix: Drop suffix of data var names. Split by the provided str.
1385
838
 
1386
839
  Returns:
1387
840
  xr.Dataset: The sanitized dataset.
@@ -1420,177 +873,26 @@ def sanitize_dataset(
1420
873
  if timesteps is not None and not ds.indexes['time'].equals(timesteps):
1421
874
  ds = ds.reindex({'time': timesteps}, fill_value=np.nan)
1422
875
 
1423
- if drop_suffix is not None:
1424
- if not isinstance(drop_suffix, str):
1425
- raise ValueError(f'Only pass str values to drop suffixes. Got {drop_suffix}')
1426
- unique_dict = {}
1427
- for var in ds.data_vars:
1428
- new_name = var.split(drop_suffix)[0]
1429
-
1430
- # If name already exists, keep original name
1431
- if new_name in unique_dict.values():
1432
- unique_dict[var] = var
1433
- else:
1434
- unique_dict[var] = new_name
1435
- ds = ds.rename(unique_dict)
1436
-
1437
876
  return ds
1438
877
 
1439
878
 
1440
879
  def filter_dataset(
1441
880
  ds: xr.Dataset,
1442
- variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None,
1443
- timesteps: Optional[Union[pd.DatetimeIndex, str, pd.Timestamp]] = None,
1444
- scenarios: Optional[Union[pd.Index, str, int]] = None,
1445
- contains: Optional[Union[str, List[str]]] = None,
1446
- startswith: Optional[Union[str, List[str]]] = None,
881
+ variable_dims: Optional[Literal['scalar', 'time']] = None,
1447
882
  ) -> xr.Dataset:
1448
883
  """
1449
- Filters a dataset by its dimensions, indexes, and with string filters for variable names.
884
+ Filters a dataset by its dimensions.
1450
885
 
1451
886
  Args:
1452
887
  ds: The dataset to filter.
1453
- variable_dims: The dimension of which to get variables from.
1454
- - 'scalar': Get scalar variables (without dimensions)
1455
- - 'time': Get time-dependent variables (with a time dimension)
1456
- - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension)
1457
- - 'timeonly': Get time-dependent variables (with ONLY a time dimension)
1458
- - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension)
1459
- timesteps: Optional time indexes to select. Can be:
1460
- - pd.DatetimeIndex: Multiple timesteps
1461
- - str/pd.Timestamp: Single timestep
1462
- Defaults to all available timesteps.
1463
- scenarios: Optional scenario indexes to select. Can be:
1464
- - pd.Index: Multiple scenarios
1465
- - str/int: Single scenario (int is treated as a label, not an index position)
1466
- Defaults to all available scenarios.
1467
- contains: Filter variables that contain this string or strings.
1468
- If a list is provided, variables must contain ALL strings in the list.
1469
- startswith: Filter variables that start with this string or strings.
1470
- If a list is provided, variables must start with ANY of the strings in the list.
1471
-
1472
- Returns:
1473
- Filtered dataset with specified variables and indexes.
1474
- """
1475
- # First filter by dimensions
1476
- filtered_ds = ds.copy()
1477
- if variable_dims is not None:
1478
- if variable_dims == 'scalar':
1479
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if not filtered_ds[v].dims]]
1480
- elif variable_dims == 'time':
1481
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if 'time' in filtered_ds[v].dims]]
1482
- elif variable_dims == 'scenario':
1483
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if 'scenario' in filtered_ds[v].dims]]
1484
- elif variable_dims == 'timeonly':
1485
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if filtered_ds[v].dims == ('time',)]]
1486
- elif variable_dims == 'scenarioonly':
1487
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if filtered_ds[v].dims == ('scenario',)]]
1488
- else:
1489
- raise ValueError(f'Unknown variable_dims "{variable_dims}" for filter_dataset')
1490
-
1491
- # Filter by 'contains' parameter
1492
- if contains is not None:
1493
- if isinstance(contains, str):
1494
- # Single string - keep variables that contain this string
1495
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if contains in v]]
1496
- elif isinstance(contains, list) and all(isinstance(s, str) for s in contains):
1497
- # List of strings - keep variables that contain ALL strings in the list
1498
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if all(s in v for s in contains)]]
1499
- else:
1500
- raise TypeError(f"'contains' must be a string or list of strings, got {type(contains)}")
1501
-
1502
- # Filter by 'startswith' parameter
1503
- if startswith is not None:
1504
- if isinstance(startswith, str):
1505
- # Single string - keep variables that start with this string
1506
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if v.startswith(startswith)]]
1507
- elif isinstance(startswith, list) and all(isinstance(s, str) for s in startswith):
1508
- # List of strings - keep variables that start with ANY of the strings in the list
1509
- filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if any(v.startswith(s) for s in startswith)]]
1510
- else:
1511
- raise TypeError(f"'startswith' must be a string or list of strings, got {type(startswith)}")
1512
-
1513
- # Handle time selection if needed
1514
- if timesteps is not None and 'time' in filtered_ds.dims:
1515
- try:
1516
- filtered_ds = filtered_ds.sel(time=timesteps)
1517
- except KeyError as e:
1518
- available_times = set(filtered_ds.indexes['time'])
1519
- requested_times = set([timesteps]) if not isinstance(timesteps, pd.Index) else set(timesteps)
1520
- missing_times = requested_times - available_times
1521
- raise ValueError(
1522
- f'Timesteps not found in dataset: {missing_times}. Available times: {available_times}'
1523
- ) from e
1524
-
1525
- # Handle scenario selection if needed
1526
- if scenarios is not None and 'scenario' in filtered_ds.dims:
1527
- try:
1528
- filtered_ds = filtered_ds.sel(scenario=scenarios)
1529
- except KeyError as e:
1530
- available_scenarios = set(filtered_ds.indexes['scenario'])
1531
- requested_scenarios = set([scenarios]) if not isinstance(scenarios, pd.Index) else set(scenarios)
1532
- missing_scenarios = requested_scenarios - available_scenarios
1533
- raise ValueError(
1534
- f'Scenarios not found in dataset: {missing_scenarios}. Available scenarios: {available_scenarios}'
1535
- ) from e
1536
-
1537
- return filtered_ds
1538
-
1539
-
1540
- def filter_dataarray_by_coord(
1541
- da: xr.DataArray,
1542
- **kwargs: Optional[Union[str, List[str]]]
1543
- ) -> xr.DataArray:
1544
- """Filter flows by node and component attributes.
1545
-
1546
- Filters are applied in the order they are specified. All filters must match for an edge to be included.
1547
-
1548
- To recombine filtered dataarrays, use `xr.concat`.
1549
-
1550
- xr.concat([res.sizes(start='Fernwärme'), res.sizes(end='Fernwärme')], dim='flow')
1551
-
1552
- Args:
1553
- da: Flow DataArray with network metadata coordinates.
1554
- **kwargs: Coord filters as name=value pairs.
1555
-
1556
- Returns:
1557
- Filtered DataArray with matching edges.
1558
-
1559
- Raises:
1560
- AttributeError: If required coordinates are missing.
1561
- ValueError: If specified nodes don't exist or no matches found.
888
+ variable_dims: The dimension of the variables to filter for.
1562
889
  """
1563
- # Helper function to process filters
1564
- def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]):
1565
- # Verify coord exists
1566
- if coord_name not in array.coords:
1567
- raise AttributeError(f"Missing required coordinate '{coord_name}'")
1568
-
1569
- # Convert single value to list
1570
- val_list = [coord_values] if isinstance(coord_values, str) else coord_values
1571
-
1572
- # Verify coord_values exist
1573
- available = set(array[coord_name].values)
1574
- missing = [v for v in val_list if v not in available]
1575
- if missing:
1576
- raise ValueError(f"{coord_name.title()} value(s) not found: {missing}")
1577
-
1578
- # Apply filter
1579
- return array.where(
1580
- array[coord_name].isin(val_list) if isinstance(coord_values, list) else array[coord_name] == coord_values,
1581
- drop=True
1582
- )
1583
-
1584
- # Apply filters from kwargs
1585
- filters = {k: v for k, v in kwargs.items() if v is not None}
1586
- try:
1587
- for coord, values in filters.items():
1588
- da = apply_filter(da, coord, values)
1589
- except ValueError as e:
1590
- raise ValueError(f"No edges match criteria: {filters}") from e
1591
-
1592
- # Verify results exist
1593
- if da.size == 0:
1594
- raise ValueError(f"No edges match criteria: {filters}")
890
+ if variable_dims is None:
891
+ return ds
1595
892
 
1596
- return da
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=}')