flixopt 2.1.6__py3-none-any.whl → 2.1.8__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 (45) 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 +21 -21
  10. docs/user-guide/Mathematical Notation/Flow.md +3 -3
  11. docs/user-guide/Mathematical Notation/InvestParameters.md +3 -0
  12. docs/user-guide/Mathematical Notation/LinearConverter.md +5 -5
  13. docs/user-guide/Mathematical Notation/OnOffParameters.md +3 -0
  14. docs/user-guide/Mathematical Notation/Piecewise.md +1 -1
  15. docs/user-guide/Mathematical Notation/Storage.md +2 -2
  16. docs/user-guide/Mathematical Notation/index.md +1 -1
  17. docs/user-guide/Mathematical Notation/others.md +1 -1
  18. docs/user-guide/index.md +2 -2
  19. flixopt/__init__.py +4 -0
  20. flixopt/aggregation.py +33 -32
  21. flixopt/calculation.py +161 -65
  22. flixopt/components.py +687 -154
  23. flixopt/config.py +17 -8
  24. flixopt/core.py +69 -60
  25. flixopt/effects.py +146 -64
  26. flixopt/elements.py +297 -110
  27. flixopt/features.py +78 -71
  28. flixopt/flow_system.py +72 -50
  29. flixopt/interface.py +952 -113
  30. flixopt/io.py +15 -10
  31. flixopt/linear_converters.py +373 -81
  32. flixopt/network_app.py +445 -266
  33. flixopt/plotting.py +215 -87
  34. flixopt/results.py +382 -209
  35. flixopt/solvers.py +25 -21
  36. flixopt/structure.py +41 -39
  37. flixopt/utils.py +10 -7
  38. {flixopt-2.1.6.dist-info → flixopt-2.1.8.dist-info}/METADATA +64 -53
  39. flixopt-2.1.8.dist-info/RECORD +56 -0
  40. scripts/extract_release_notes.py +5 -5
  41. scripts/gen_ref_pages.py +1 -1
  42. flixopt-2.1.6.dist-info/RECORD +0 -54
  43. {flixopt-2.1.6.dist-info → flixopt-2.1.8.dist-info}/WHEEL +0 -0
  44. {flixopt-2.1.6.dist-info → flixopt-2.1.8.dist-info}/licenses/LICENSE +0 -0
  45. {flixopt-2.1.6.dist-info → flixopt-2.1.8.dist-info}/top_level.txt +0 -0
flixopt/config.py CHANGED
@@ -1,8 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
2
4
  import os
3
5
  import types
4
6
  from dataclasses import dataclass, fields, is_dataclass
5
- from typing import Annotated, Literal, Optional
7
+ from typing import Annotated, Literal, get_type_hints
6
8
 
7
9
  import yaml
8
10
  from rich.console import Console
@@ -37,11 +39,15 @@ def dataclass_from_dict_with_validation(cls, data: dict):
37
39
  if not is_dataclass(cls):
38
40
  raise TypeError(f'{cls} must be a dataclass')
39
41
 
42
+ # Get resolved type hints to handle postponed evaluation
43
+ type_hints = get_type_hints(cls)
44
+
40
45
  # Build kwargs for the dataclass constructor
41
46
  kwargs = {}
42
47
  for field in fields(cls):
43
48
  field_name = field.name
44
- field_type = field.type
49
+ # Use resolved type from get_type_hints instead of field.type
50
+ field_type = type_hints.get(field_name, field.type)
45
51
  field_value = data.get(field_name)
46
52
 
47
53
  # If the field type is a dataclass and the value is a dict, recursively initialize
@@ -57,7 +63,10 @@ def dataclass_from_dict_with_validation(cls, data: dict):
57
63
  class ValidatedConfig:
58
64
  def __setattr__(self, name, value):
59
65
  if field := self.__dataclass_fields__.get(name):
60
- if metadata := getattr(field.type, '__metadata__', None):
66
+ # Get resolved type hints to handle postponed evaluation
67
+ type_hints = get_type_hints(self.__class__, include_extras=True)
68
+ field_type = type_hints.get(name, field.type)
69
+ if metadata := getattr(field_type, '__metadata__', None):
61
70
  assert metadata[0](value), f'Invalid value passed to {name!r}: {value=}'
62
71
  super().__setattr__(name, value)
63
72
 
@@ -96,7 +105,7 @@ class CONFIG:
96
105
  logging: LoggingConfig = None
97
106
 
98
107
  @classmethod
99
- def load_config(cls, user_config_file: Optional[str] = None):
108
+ def load_config(cls, user_config_file: str | None = None):
100
109
  """
101
110
  Initialize configuration using defaults or user-specified file.
102
111
  """
@@ -104,12 +113,12 @@ class CONFIG:
104
113
  default_config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
105
114
 
106
115
  if user_config_file is None:
107
- with open(default_config_path, 'r') as file:
116
+ with open(default_config_path) as file:
108
117
  new_config = yaml.safe_load(file)
109
118
  elif not os.path.exists(user_config_file):
110
119
  raise FileNotFoundError(f'Config file not found: {user_config_file}')
111
120
  else:
112
- with open(user_config_file, 'r') as user_file:
121
+ with open(user_config_file) as user_file:
113
122
  new_config = yaml.safe_load(user_file)
114
123
 
115
124
  # Convert the merged config to ConfigSchema
@@ -186,7 +195,7 @@ class ColoredMultilineFormater(MultilineFormater):
186
195
  return '\n'.join(formatted_lines)
187
196
 
188
197
 
189
- def _get_logging_handler(log_file: Optional[str] = None, use_rich_handler: bool = False) -> logging.Handler:
198
+ def _get_logging_handler(log_file: str | None = None, use_rich_handler: bool = False) -> logging.Handler:
190
199
  """Returns a logging handler for the given log file."""
191
200
  if use_rich_handler and log_file is None:
192
201
  # RichHandler for console output
@@ -225,7 +234,7 @@ def _get_logging_handler(log_file: Optional[str] = None, use_rich_handler: bool
225
234
 
226
235
  def setup_logging(
227
236
  default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO',
228
- log_file: Optional[str] = 'flixopt.log',
237
+ log_file: str | None = 'flixopt.log',
229
238
  use_rich_handler: bool = False,
230
239
  ):
231
240
  """Setup logging configuration"""
flixopt/core.py CHANGED
@@ -8,7 +8,8 @@ import json
8
8
  import logging
9
9
  import pathlib
10
10
  from collections import Counter
11
- from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union
11
+ from collections.abc import Iterator
12
+ from typing import Any, Literal, Optional
12
13
 
13
14
  import numpy as np
14
15
  import pandas as pd
@@ -16,15 +17,12 @@ import xarray as xr
16
17
 
17
18
  logger = logging.getLogger('flixopt')
18
19
 
19
- Scalar = Union[int, float]
20
+ Scalar = int | float
20
21
  """A type representing a single number, either integer or float."""
21
22
 
22
- NumericData = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray]
23
+ NumericData = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray
23
24
  """Represents any form of numeric data, from simple scalars to complex data structures."""
24
25
 
25
- NumericDataTS = Union[NumericData, 'TimeSeriesData']
26
- """Represents either standard numeric data or TimeSeriesData."""
27
-
28
26
 
29
27
  class PlausibilityError(Exception):
30
28
  """Error for a failing Plausibility check."""
@@ -62,17 +60,21 @@ class DataConverter:
62
60
  return xr.DataArray(data, coords=coords, dims=dims)
63
61
  elif isinstance(data, pd.DataFrame):
64
62
  if not data.index.equals(timesteps):
65
- raise ConversionError(f"DataFrame index doesn't match timesteps index. "
66
- f"Its missing the following time steps: {timesteps.difference(data.index)}. "
67
- f"Some parameters might need an extra timestep at the end.")
63
+ raise ConversionError(
64
+ f"DataFrame index doesn't match timesteps index. "
65
+ f'Its missing the following time steps: {timesteps.difference(data.index)}. '
66
+ f'Some parameters might need an extra timestep at the end.'
67
+ )
68
68
  if not len(data.columns) == 1:
69
69
  raise ConversionError('DataFrame must have exactly one column')
70
70
  return xr.DataArray(data.values.flatten(), coords=coords, dims=dims)
71
71
  elif isinstance(data, pd.Series):
72
72
  if not data.index.equals(timesteps):
73
- raise ConversionError(f"Series index doesn't match timesteps index. "
74
- f"Its missing the following time steps: {timesteps.difference(data.index)}. "
75
- f"Some parameters might need an extra timestep at the end.")
73
+ raise ConversionError(
74
+ f"Series index doesn't match timesteps index. "
75
+ f'Its missing the following time steps: {timesteps.difference(data.index)}. '
76
+ f'Some parameters might need an extra timestep at the end.'
77
+ )
76
78
  return xr.DataArray(data.values, coords=coords, dims=dims)
77
79
  elif isinstance(data, np.ndarray):
78
80
  if data.ndim != 1:
@@ -97,34 +99,38 @@ class DataConverter:
97
99
 
98
100
 
99
101
  class TimeSeriesData:
100
- # TODO: Move to Interface.py
101
- def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None):
102
- """
103
- timeseries class for transmit timeseries AND special characteristics of timeseries,
104
- i.g. to define weights needed in calculation_type 'aggregated'
105
- EXAMPLE solar:
106
- you have several solar timeseries. These should not be overweighted
107
- compared to the remaining timeseries (i.g. heat load, price)!
108
- fixed_relative_profile_solar1 = TimeSeriesData(sol_array_1, type = 'solar')
109
- fixed_relative_profile_solar2 = TimeSeriesData(sol_array_2, type = 'solar')
110
- fixed_relative_profile_solar3 = TimeSeriesData(sol_array_3, type = 'solar')
111
- --> this 3 series of same type share one weight, i.e. internally assigned each weight = 1/3
112
- (instead of standard weight = 1)
113
-
114
- Args:
115
- data: The timeseries data, which can be a scalar, array, or numpy array.
116
- agg_group: The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None.
117
- agg_weight: The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None.
102
+ """
103
+ TimeSeriesData wraps time series data with aggregation metadata for optimization.
104
+
105
+ This class combines time series data with special characteristics needed for aggregated calculations.
106
+ It allows grouping related time series to prevent overweighting in optimization models.
107
+
108
+ Example:
109
+ When you have multiple solar time series, they should share aggregation weight:
110
+ ```python
111
+ solar1 = TimeSeriesData(sol_array_1, agg_group='solar')
112
+ solar2 = TimeSeriesData(sol_array_2, agg_group='solar')
113
+ solar3 = TimeSeriesData(sol_array_3, agg_group='solar')
114
+ # These 3 series share one weight (each gets weight = 1/3 instead of 1)
115
+ ```
116
+
117
+ Args:
118
+ data: The timeseries data, which can be a scalar, array, or numpy array.
119
+ agg_group: The group this TimeSeriesData belongs to. agg_weight is split between group members. Default is None.
120
+ agg_weight: The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None.
121
+
122
+ Raises:
123
+ ValueError: If both agg_group and agg_weight are set.
124
+ """
118
125
 
119
- Raises:
120
- Exception: If both agg_group and agg_weight are set, an exception is raised.
121
- """
126
+ # TODO: Move to Interface.py
127
+ def __init__(self, data: NumericData, agg_group: str | None = None, agg_weight: float | None = None):
122
128
  self.data = data
123
129
  self.agg_group = agg_group
124
130
  self.agg_weight = agg_weight
125
131
  if (agg_group is not None) and (agg_weight is not None):
126
132
  raise ValueError('Either <agg_group> or explicit <agg_weigth> can be used. Not both!')
127
- self.label: Optional[str] = None
133
+ self.label: str | None = None
128
134
 
129
135
  def __repr__(self):
130
136
  # Get the constructor arguments and their current values
@@ -139,6 +145,10 @@ class TimeSeriesData:
139
145
  return str(self.data)
140
146
 
141
147
 
148
+ NumericDataTS = NumericData | TimeSeriesData
149
+ """Represents either standard numeric data or TimeSeriesData."""
150
+
151
+
142
152
  class TimeSeries:
143
153
  """
144
154
  A class representing time series data with active and stored states.
@@ -159,8 +169,8 @@ class TimeSeries:
159
169
  data: NumericData,
160
170
  name: str,
161
171
  timesteps: pd.DatetimeIndex,
162
- aggregation_weight: Optional[float] = None,
163
- aggregation_group: Optional[str] = None,
172
+ aggregation_weight: float | None = None,
173
+ aggregation_group: str | None = None,
164
174
  needs_extra_timestep: bool = False,
165
175
  ) -> 'TimeSeries':
166
176
  """
@@ -186,7 +196,7 @@ class TimeSeries:
186
196
  )
187
197
 
188
198
  @classmethod
189
- def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries':
199
+ def from_json(cls, data: dict[str, Any] | None = None, path: str | None = None) -> 'TimeSeries':
190
200
  """
191
201
  Load a TimeSeries from a dictionary or json file.
192
202
 
@@ -204,7 +214,7 @@ class TimeSeries:
204
214
  raise ValueError("Exactly one of 'path' or 'data' must be provided")
205
215
 
206
216
  if path is not None:
207
- with open(path, 'r') as f:
217
+ with open(path) as f:
208
218
  data = json.load(f)
209
219
 
210
220
  # Convert ISO date strings to datetime objects
@@ -223,8 +233,8 @@ class TimeSeries:
223
233
  self,
224
234
  data: xr.DataArray,
225
235
  name: str,
226
- aggregation_weight: Optional[float] = None,
227
- aggregation_group: Optional[str] = None,
236
+ aggregation_weight: float | None = None,
237
+ aggregation_group: str | None = None,
228
238
  needs_extra_timestep: bool = False,
229
239
  ):
230
240
  """
@@ -270,7 +280,7 @@ class TimeSeries:
270
280
  self._stored_data = self._backup.copy(deep=True)
271
281
  self.reset()
272
282
 
273
- def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]:
283
+ def to_json(self, path: pathlib.Path | None = None) -> dict[str, Any]:
274
284
  """
275
285
  Save the TimeSeries to a dictionary or JSON file.
276
286
 
@@ -326,7 +336,7 @@ class TimeSeries:
326
336
  return self._active_timesteps
327
337
 
328
338
  @active_timesteps.setter
329
- def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]):
339
+ def active_timesteps(self, timesteps: pd.DatetimeIndex | None):
330
340
  """
331
341
  Set active_timesteps and refresh active_data.
332
342
 
@@ -538,8 +548,8 @@ class TimeSeriesCollection:
538
548
  def __init__(
539
549
  self,
540
550
  timesteps: pd.DatetimeIndex,
541
- hours_of_last_timestep: Optional[float] = None,
542
- hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None,
551
+ hours_of_last_timestep: float | None = None,
552
+ hours_of_previous_timesteps: float | np.ndarray | None = None,
543
553
  ):
544
554
  """
545
555
  Args:
@@ -567,22 +577,22 @@ class TimeSeriesCollection:
567
577
  self._active_hours_per_timestep = None
568
578
 
569
579
  # Dictionary of time series by name
570
- self.time_series_data: Dict[str, TimeSeries] = {}
580
+ self.time_series_data: dict[str, TimeSeries] = {}
571
581
 
572
582
  # Aggregation
573
- self.group_weights: Dict[str, float] = {}
574
- self.weights: Dict[str, float] = {}
583
+ self.group_weights: dict[str, float] = {}
584
+ self.weights: dict[str, float] = {}
575
585
 
576
586
  @classmethod
577
587
  def with_uniform_timesteps(
578
- cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None
588
+ cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: float | None = None
579
589
  ) -> 'TimeSeriesCollection':
580
590
  """Create a collection with uniform timesteps."""
581
591
  timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time')
582
592
  return cls(timesteps, hours_of_previous_timesteps=hours_per_step)
583
593
 
584
594
  def create_time_series(
585
- self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False
595
+ self, data: NumericData | TimeSeriesData, name: str, needs_extra_timestep: bool = False
586
596
  ) -> TimeSeries:
587
597
  """
588
598
  Creates a TimeSeries from the given data and adds it to the collection.
@@ -591,7 +601,6 @@ class TimeSeriesCollection:
591
601
  data: The data to create the TimeSeries from.
592
602
  name: The name of the TimeSeries.
593
603
  needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps.
594
- The data to create the TimeSeries from.
595
604
 
596
605
  Returns:
597
606
  The created TimeSeries.
@@ -626,7 +635,7 @@ class TimeSeriesCollection:
626
635
 
627
636
  return time_series
628
637
 
629
- def calculate_aggregation_weights(self) -> Dict[str, float]:
638
+ def calculate_aggregation_weights(self) -> dict[str, float]:
630
639
  """Calculate and return aggregation weights for all time series."""
631
640
  self.group_weights = self._calculate_group_weights()
632
641
  self.weights = self._calculate_weights()
@@ -636,7 +645,7 @@ class TimeSeriesCollection:
636
645
 
637
646
  return self.weights
638
647
 
639
- def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None):
648
+ def activate_timesteps(self, active_timesteps: pd.DatetimeIndex | None = None):
640
649
  """
641
650
  Update active timesteps for the collection and all time series.
642
651
  If no arguments are provided, the active timesteps are reset.
@@ -812,7 +821,7 @@ class TimeSeriesCollection:
812
821
 
813
822
  @staticmethod
814
823
  def _create_timesteps_with_extra(
815
- timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float]
824
+ timesteps: pd.DatetimeIndex, hours_of_last_timestep: float | None
816
825
  ) -> pd.DatetimeIndex:
817
826
  """Create timesteps with an extra step at the end."""
818
827
  if hours_of_last_timestep is not None:
@@ -827,8 +836,8 @@ class TimeSeriesCollection:
827
836
 
828
837
  @staticmethod
829
838
  def _calculate_hours_of_previous_timesteps(
830
- timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]]
831
- ) -> Union[float, np.ndarray]:
839
+ timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: float | np.ndarray | None
840
+ ) -> float | np.ndarray:
832
841
  """Calculate duration of regular timesteps."""
833
842
  if hours_of_previous_timesteps is not None:
834
843
  return hours_of_previous_timesteps
@@ -847,7 +856,7 @@ class TimeSeriesCollection:
847
856
  data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step'
848
857
  )
849
858
 
850
- def _calculate_group_weights(self) -> Dict[str, float]:
859
+ def _calculate_group_weights(self) -> dict[str, float]:
851
860
  """Calculate weights for aggregation groups."""
852
861
  # Count series in each group
853
862
  groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None]
@@ -856,7 +865,7 @@ class TimeSeriesCollection:
856
865
  # Calculate weight for each group (1/count)
857
866
  return {group: 1 / count for group, count in group_counts.items()}
858
867
 
859
- def _calculate_weights(self) -> Dict[str, float]:
868
+ def _calculate_weights(self) -> dict[str, float]:
860
869
  """Calculate weights for all time series."""
861
870
  # Calculate weight for each time series
862
871
  weights = {}
@@ -898,7 +907,7 @@ class TimeSeriesCollection:
898
907
  """Get the number of TimeSeries in the collection."""
899
908
  return len(self.time_series_data)
900
909
 
901
- def __contains__(self, item: Union[str, TimeSeries]) -> bool:
910
+ def __contains__(self, item: str | TimeSeries) -> bool:
902
911
  """Check if a TimeSeries exists in the collection."""
903
912
  if isinstance(item, str):
904
913
  return item in self.time_series_data
@@ -907,12 +916,12 @@ class TimeSeriesCollection:
907
916
  return False
908
917
 
909
918
  @property
910
- def non_constants(self) -> List[TimeSeries]:
919
+ def non_constants(self) -> list[TimeSeries]:
911
920
  """Get time series with varying values."""
912
921
  return [ts for ts in self.time_series_data.values() if not ts.all_equal]
913
922
 
914
923
  @property
915
- def constants(self) -> List[TimeSeries]:
924
+ def constants(self) -> list[TimeSeries]:
916
925
  """Get time series with constant values."""
917
926
  return [ts for ts in self.time_series_data.values() if ts.all_equal]
918
927