flixopt 2.2.0b0__py3-none-any.whl → 3.0.0__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 (63) hide show
  1. flixopt/__init__.py +35 -1
  2. flixopt/aggregation.py +60 -81
  3. flixopt/calculation.py +381 -196
  4. flixopt/components.py +1022 -359
  5. flixopt/config.py +553 -191
  6. flixopt/core.py +475 -1315
  7. flixopt/effects.py +477 -214
  8. flixopt/elements.py +591 -344
  9. flixopt/features.py +403 -957
  10. flixopt/flow_system.py +781 -293
  11. flixopt/interface.py +1159 -189
  12. flixopt/io.py +50 -55
  13. flixopt/linear_converters.py +384 -92
  14. flixopt/modeling.py +759 -0
  15. flixopt/network_app.py +789 -0
  16. flixopt/plotting.py +273 -135
  17. flixopt/results.py +639 -383
  18. flixopt/solvers.py +25 -21
  19. flixopt/structure.py +928 -442
  20. flixopt/utils.py +34 -5
  21. flixopt-3.0.0.dist-info/METADATA +209 -0
  22. flixopt-3.0.0.dist-info/RECORD +26 -0
  23. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
  24. flixopt-3.0.0.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -49
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/release-notes/_template.txt +0 -32
  37. docs/release-notes/index.md +0 -7
  38. docs/release-notes/v2.0.0.md +0 -93
  39. docs/release-notes/v2.0.1.md +0 -12
  40. docs/release-notes/v2.1.0.md +0 -31
  41. docs/release-notes/v2.2.0.md +0 -55
  42. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  43. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  44. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  45. docs/user-guide/Mathematical Notation/Investment.md +0 -115
  46. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  47. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  48. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  49. docs/user-guide/Mathematical Notation/index.md +0 -22
  50. docs/user-guide/Mathematical Notation/others.md +0 -3
  51. docs/user-guide/index.md +0 -124
  52. flixopt/config.yaml +0 -10
  53. flixopt-2.2.0b0.dist-info/METADATA +0 -146
  54. flixopt-2.2.0b0.dist-info/RECORD +0 -59
  55. flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
  56. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  57. pics/architecture_flixOpt.png +0 -0
  58. pics/flixOpt_plotting.jpg +0 -0
  59. pics/flixopt-icon.svg +0 -1
  60. pics/pics.pptx +0 -0
  61. scripts/gen_ref_pages.py +0 -54
  62. tests/ressources/Zeitreihen2020.csv +0 -35137
  63. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/structure.py CHANGED
@@ -3,13 +3,18 @@ This module contains the core structure of the flixopt framework.
3
3
  These classes are not directly used by the end user, but are used by other modules.
4
4
  """
5
5
 
6
+ from __future__ import annotations
7
+
6
8
  import inspect
7
9
  import json
8
10
  import logging
9
- import pathlib
10
- from datetime import datetime
11
+ from dataclasses import dataclass
11
12
  from io import StringIO
12
- from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
13
+ from typing import (
14
+ TYPE_CHECKING,
15
+ Any,
16
+ Literal,
17
+ )
13
18
 
14
19
  import linopy
15
20
  import numpy as np
@@ -18,10 +23,13 @@ import xarray as xr
18
23
  from rich.console import Console
19
24
  from rich.pretty import Pretty
20
25
 
21
- from .config import CONFIG
22
- from .core import Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData
26
+ from . import io as fx_io
27
+ from .core import TimeSeriesData, get_dataarray_stats
23
28
 
24
29
  if TYPE_CHECKING: # for type checking and preventing circular imports
30
+ import pathlib
31
+ from collections.abc import Collection, ItemsView, Iterator
32
+
25
33
  from .effects import EffectCollectionModel
26
34
  from .flow_system import FlowSystem
27
35
 
@@ -43,271 +51,812 @@ def register_class_for_io(cls):
43
51
  return cls
44
52
 
45
53
 
46
- class SystemModel(linopy.Model):
54
+ class SubmodelsMixin:
55
+ """Mixin that provides submodel functionality for both FlowSystemModel and Submodel."""
56
+
57
+ submodels: Submodels
58
+
59
+ @property
60
+ def all_submodels(self) -> list[Submodel]:
61
+ """Get all submodels including nested ones recursively."""
62
+ direct_submodels = list(self.submodels.values())
63
+
64
+ # Recursively collect nested sub-models
65
+ nested_submodels = []
66
+ for submodel in direct_submodels:
67
+ nested_submodels.extend(submodel.all_submodels)
68
+
69
+ return direct_submodels + nested_submodels
70
+
71
+ def add_submodels(self, submodel: Submodel, short_name: str = None) -> Submodel:
72
+ """Register a sub-model with the model"""
73
+ if short_name is None:
74
+ short_name = submodel.__class__.__name__
75
+ if short_name in self.submodels:
76
+ raise ValueError(f'Short name "{short_name}" already assigned to model')
77
+ self.submodels.add(submodel, name=short_name)
78
+
79
+ return submodel
80
+
81
+
82
+ class FlowSystemModel(linopy.Model, SubmodelsMixin):
47
83
  """
48
- The SystemModel is the linopy Model that is used to create the mathematical model of the flow_system.
84
+ The FlowSystemModel is the linopy Model that is used to create the mathematical model of the flow_system.
49
85
  It is used to create and store the variables and constraints for the flow_system.
86
+
87
+ Args:
88
+ flow_system: The flow_system that is used to create the model.
89
+ normalize_weights: Whether to automatically normalize the weights to sum up to 1 when solving.
50
90
  """
51
91
 
52
- def __init__(self, flow_system: 'FlowSystem'):
53
- """
54
- Args:
55
- flow_system: The flow_system that is used to create the model.
56
- """
92
+ def __init__(self, flow_system: FlowSystem, normalize_weights: bool):
57
93
  super().__init__(force_dim_names=True)
58
94
  self.flow_system = flow_system
59
- self.time_series_collection = flow_system.time_series_collection
60
- self.effects: Optional[EffectCollectionModel] = None
61
- self.scenario_weights = self._calculate_scenario_weights(flow_system.scenario_weights)
95
+ self.normalize_weights = normalize_weights
96
+ self.effects: EffectCollectionModel | None = None
97
+ self.submodels: Submodels = Submodels({})
62
98
 
63
99
  def do_modeling(self):
64
100
  self.effects = self.flow_system.effects.create_model(self)
65
- self.effects.do_modeling()
66
- component_models = [component.create_model(self) for component in self.flow_system.components.values()]
67
- bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()]
68
- for component_model in component_models:
69
- component_model.do_modeling()
70
- for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels
71
- bus_model.do_modeling()
72
-
73
- def _calculate_scenario_weights(self, weights: Optional[TimeSeries] = None) -> xr.DataArray:
74
- """Calculates the weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1.
75
- If no scenarios are present, s single weight of 1 is returned.
76
- """
77
- if weights is not None and not isinstance(weights, TimeSeries):
78
- raise TypeError(f'Weights must be a TimeSeries or None, got {type(weights)}')
79
- if self.time_series_collection.scenarios is None:
80
- return xr.DataArray(1)
81
- if weights is None:
82
- weights = xr.DataArray(
83
- np.ones(len(self.time_series_collection.scenarios)),
84
- coords={'scenario': self.time_series_collection.scenarios}
101
+ for component in self.flow_system.components.values():
102
+ component.create_model(self)
103
+ for bus in self.flow_system.buses.values():
104
+ bus.create_model(self)
105
+
106
+ # Add scenario equality constraints after all elements are modeled
107
+ self._add_scenario_equality_constraints()
108
+
109
+ def _add_scenario_equality_for_parameter_type(
110
+ self,
111
+ parameter_type: Literal['flow_rate', 'size'],
112
+ config: bool | list[str],
113
+ ):
114
+ """Add scenario equality constraints for a specific parameter type.
115
+
116
+ Args:
117
+ parameter_type: The type of parameter ('flow_rate' or 'size')
118
+ config: Configuration value (True = equalize all, False = equalize none, list = equalize these)
119
+ """
120
+ if config is False:
121
+ return # All vary per scenario, no constraints needed
122
+
123
+ suffix = f'|{parameter_type}'
124
+ if config is True:
125
+ # All should be scenario-independent
126
+ vars_to_constrain = [var for var in self.variables if var.endswith(suffix)]
127
+ else:
128
+ # Only those in the list should be scenario-independent
129
+ all_vars = [var for var in self.variables if var.endswith(suffix)]
130
+ to_equalize = {f'{element}{suffix}' for element in config}
131
+ vars_to_constrain = [var for var in all_vars if var in to_equalize]
132
+
133
+ # Validate that all specified variables exist
134
+ missing_vars = [v for v in vars_to_constrain if v not in self.variables]
135
+ if missing_vars:
136
+ param_name = 'scenario_independent_sizes' if parameter_type == 'size' else 'scenario_independent_flow_rates'
137
+ raise ValueError(f'{param_name} contains invalid labels: {missing_vars}')
138
+
139
+ logger.debug(f'Adding scenario equality constraints for {len(vars_to_constrain)} {parameter_type} variables')
140
+ for var in vars_to_constrain:
141
+ self.add_constraints(
142
+ self.variables[var].isel(scenario=0) == self.variables[var].isel(scenario=slice(1, None)),
143
+ name=f'{var}|scenario_independent',
85
144
  )
86
- elif isinstance(weights, TimeSeries):
87
- weights = weights.selected_data
88
145
 
89
- return weights / weights.sum()
146
+ def _add_scenario_equality_constraints(self):
147
+ """Add equality constraints to equalize variables across scenarios based on FlowSystem configuration."""
148
+ # Only proceed if we have scenarios
149
+ if self.flow_system.scenarios is None or len(self.flow_system.scenarios) <= 1:
150
+ return
151
+
152
+ self._add_scenario_equality_for_parameter_type('flow_rate', self.flow_system.scenario_independent_flow_rates)
153
+ self._add_scenario_equality_for_parameter_type('size', self.flow_system.scenario_independent_sizes)
90
154
 
91
155
  @property
92
156
  def solution(self):
93
157
  solution = super().solution
158
+ solution['objective'] = self.objective.value
94
159
  solution.attrs = {
95
160
  'Components': {
96
- comp.label_full: comp.model.results_structure()
161
+ comp.label_full: comp.submodel.results_structure()
97
162
  for comp in sorted(
98
163
  self.flow_system.components.values(), key=lambda component: component.label_full.upper()
99
164
  )
100
165
  },
101
166
  'Buses': {
102
- bus.label_full: bus.model.results_structure()
167
+ bus.label_full: bus.submodel.results_structure()
103
168
  for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper())
104
169
  },
105
170
  'Effects': {
106
- effect.label_full: effect.model.results_structure()
171
+ effect.label_full: effect.submodel.results_structure()
107
172
  for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper())
108
173
  },
109
174
  'Flows': {
110
- flow.label_full: flow.model.results_structure()
175
+ flow.label_full: flow.submodel.results_structure()
111
176
  for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper())
112
177
  },
113
178
  }
114
- return solution.reindex(time=self.time_series_collection.timesteps_extra)
179
+ return solution.reindex(time=self.flow_system.timesteps_extra)
115
180
 
116
181
  @property
117
182
  def hours_per_step(self):
118
- return self.time_series_collection.hours_per_timestep
183
+ return self.flow_system.hours_per_timestep
119
184
 
120
185
  @property
121
186
  def hours_of_previous_timesteps(self):
122
- return self.time_series_collection.hours_of_previous_timesteps
187
+ return self.flow_system.hours_of_previous_timesteps
123
188
 
124
189
  def get_coords(
125
- self, scenario_dim=True, time_dim=True, extra_timestep=False
126
- ) -> Optional[Union[Tuple[pd.Index], Tuple[pd.Index, pd.Index]]]:
190
+ self,
191
+ dims: Collection[str] | None = None,
192
+ extra_timestep: bool = False,
193
+ ) -> xr.Coordinates | None:
127
194
  """
128
195
  Returns the coordinates of the model
129
196
 
130
197
  Args:
131
- scenario_dim: If True, the scenario dimension is included in the coordinates
132
- time_dim: If True, the time dimension is included in the coordinates
133
- extra_timestep: If True, the extra timesteps are used instead of the regular timesteps
198
+ dims: The dimensions to include in the coordinates. If None, includes all dimensions
199
+ extra_timestep: If True, uses extra timesteps instead of regular timesteps
134
200
 
135
201
  Returns:
136
- The coordinates of the model. Might also be None if no scenarios are present and time_dim is False
202
+ The coordinates of the model, or None if no coordinates are available
203
+
204
+ Raises:
205
+ ValueError: If extra_timestep=True but 'time' is not in dims
137
206
  """
138
- if not scenario_dim and not time_dim:
139
- return None
140
- scenarios = self.time_series_collection.scenarios
141
- timesteps = (
142
- self.time_series_collection.timesteps if not extra_timestep else self.time_series_collection.timesteps_extra
143
- )
207
+ if extra_timestep and dims is not None and 'time' not in dims:
208
+ raise ValueError('extra_timestep=True requires "time" to be included in dims')
209
+
210
+ if dims is None:
211
+ coords = dict(self.flow_system.coords)
212
+ else:
213
+ coords = {k: v for k, v in self.flow_system.coords.items() if k in dims}
214
+
215
+ if extra_timestep and coords:
216
+ coords['time'] = self.flow_system.timesteps_extra
144
217
 
145
- if scenario_dim and time_dim:
146
- if scenarios is None:
147
- return (timesteps,)
148
- return timesteps, scenarios
218
+ return xr.Coordinates(coords) if coords else None
149
219
 
150
- if scenario_dim and not time_dim:
151
- if scenarios is None:
152
- return None
153
- return (scenarios,)
154
- if time_dim and not scenario_dim:
155
- return (timesteps,)
220
+ @property
221
+ def weights(self) -> int | xr.DataArray:
222
+ """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True"""
223
+ if self.flow_system.weights is not None:
224
+ weights = self.flow_system.weights
225
+ else:
226
+ weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario'])
227
+
228
+ if not self.normalize_weights:
229
+ return weights
230
+
231
+ return weights / weights.sum()
232
+
233
+ def __repr__(self) -> str:
234
+ """
235
+ Return a string representation of the FlowSystemModel, borrowed from linopy.Model.
236
+ """
237
+ # Extract content from existing representations
238
+ sections = {
239
+ f'Variables: [{len(self.variables)}]': self.variables.__repr__().split('\n', 2)[2],
240
+ f'Constraints: [{len(self.constraints)}]': self.constraints.__repr__().split('\n', 2)[2],
241
+ f'Submodels: [{len(self.submodels)}]': self.submodels.__repr__().split('\n', 2)[2],
242
+ 'Status': self.status,
243
+ }
156
244
 
157
- raise ValueError(f'Cannot get coordinates with both {scenario_dim=} and {time_dim=}')
245
+ # Format sections with headers and underlines
246
+ formatted_sections = []
247
+ for section_header, section_content in sections.items():
248
+ formatted_sections.append(f'{section_header}\n{"-" * len(section_header)}\n{section_content}')
249
+
250
+ title = f'FlowSystemModel ({self.type})'
251
+ all_sections = '\n'.join(formatted_sections)
252
+
253
+ return f'{title}\n{"=" * len(title)}\n\n{all_sections}'
158
254
 
159
255
 
160
256
  class Interface:
161
257
  """
162
- This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixopt.
258
+ Base class for all Elements and Models in flixopt that provides serialization capabilities.
259
+
260
+ This class enables automatic serialization/deserialization of objects containing xarray DataArrays
261
+ and nested Interface objects to/from xarray Datasets and NetCDF files. It uses introspection
262
+ of constructor parameters to automatically handle most serialization scenarios.
263
+
264
+ Key Features:
265
+ - Automatic extraction and restoration of xarray DataArrays
266
+ - Support for nested Interface objects
267
+ - NetCDF and JSON export/import
268
+ - Recursive handling of complex nested structures
269
+
270
+ Subclasses must implement:
271
+ transform_data(flow_system): Transform data to match FlowSystem dimensions
163
272
  """
164
273
 
165
- def transform_data(self, flow_system: 'FlowSystem'):
166
- """Transforms the data of the interface to match the FlowSystem's dimensions"""
167
- raise NotImplementedError('Every Interface needs a transform_data() method')
274
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
275
+ """Transform the data of the interface to match the FlowSystem's dimensions.
276
+
277
+ Args:
278
+ flow_system: The FlowSystem containing timing and dimensional information
279
+ name_prefix: The prefix to use for the names of the variables. Defaults to '', which results in no prefix.
168
280
 
169
- def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict:
281
+ Raises:
282
+ NotImplementedError: Must be implemented by subclasses
170
283
  """
171
- Generate a dictionary representation of the object's constructor arguments.
172
- Excludes default values and empty dictionaries and lists.
173
- Converts data to be compatible with JSON.
284
+ raise NotImplementedError('Every Interface subclass needs a transform_data() method')
174
285
 
175
- Args:
176
- use_numpy: Whether to convert NumPy arrays to lists. Defaults to True.
177
- If True, numeric numpy arrays (`np.ndarray`) are preserved as-is.
178
- If False, they are converted to lists.
179
- use_element_label: Whether to use the element label instead of the infos of the element. Defaults to False.
180
- Note that Elements used as keys in dictionaries are always converted to their labels.
286
+ def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]:
287
+ """
288
+ Convert all DataArrays to references and extract them.
289
+ This is the core method that both to_dict() and to_dataset() build upon.
181
290
 
182
291
  Returns:
183
- A dictionary representation of the object's constructor arguments.
292
+ Tuple of (reference_structure, extracted_arrays_dict)
184
293
 
294
+ Raises:
295
+ ValueError: If DataArrays don't have unique names or are duplicated
185
296
  """
186
- # Get the constructor arguments and their default values
187
- init_params = sorted(
188
- inspect.signature(self.__init__).parameters.items(),
189
- key=lambda x: (x[0].lower() != 'label', x[0].lower()), # Prioritize 'label'
190
- )
191
- # Build a dict of attribute=value pairs, excluding defaults
192
- details = {'class': ':'.join([cls.__name__ for cls in self.__class__.__mro__])}
193
- for name, param in init_params:
194
- if name == 'self':
297
+ # Get constructor parameters using caching for performance
298
+ if not hasattr(self, '_cached_init_params'):
299
+ self._cached_init_params = list(inspect.signature(self.__init__).parameters.keys())
300
+
301
+ # Process all constructor parameters
302
+ reference_structure = {'__class__': self.__class__.__name__}
303
+ all_extracted_arrays = {}
304
+
305
+ for name in self._cached_init_params:
306
+ if name == 'self': # Skip self and timesteps. Timesteps are directly stored in Datasets
307
+ continue
308
+
309
+ value = getattr(self, name, None)
310
+
311
+ if value is None:
195
312
  continue
196
- value, default = getattr(self, name, None), param.default
197
- # Ignore default values and empty dicts and list
198
- if np.all(value == default) or (isinstance(value, (dict, list)) and not value):
313
+ if isinstance(value, pd.Index):
314
+ logger.debug(f'Skipping {name=} because it is an Index')
199
315
  continue
200
- details[name] = copy_and_convert_datatypes(value, use_numpy, use_element_label)
201
- return details
202
316
 
203
- def to_json(self, path: Union[str, pathlib.Path]):
317
+ # Extract arrays and get reference structure
318
+ processed_value, extracted_arrays = self._extract_dataarrays_recursive(value, name)
319
+
320
+ # Check for array name conflicts
321
+ conflicts = set(all_extracted_arrays.keys()) & set(extracted_arrays.keys())
322
+ if conflicts:
323
+ raise ValueError(
324
+ f'DataArray name conflicts detected: {conflicts}. '
325
+ f'Each DataArray must have a unique name for serialization.'
326
+ )
327
+
328
+ # Add extracted arrays to the collection
329
+ all_extracted_arrays.update(extracted_arrays)
330
+
331
+ # Only store in structure if it's not None/empty after processing
332
+ if processed_value is not None and not self._is_empty_container(processed_value):
333
+ reference_structure[name] = processed_value
334
+
335
+ return reference_structure, all_extracted_arrays
336
+
337
+ @staticmethod
338
+ def _is_empty_container(obj) -> bool:
339
+ """Check if object is an empty container (dict, list, tuple, set)."""
340
+ return isinstance(obj, (dict, list, tuple, set)) and len(obj) == 0
341
+
342
+ def _extract_dataarrays_recursive(self, obj, context_name: str = '') -> tuple[Any, dict[str, xr.DataArray]]:
204
343
  """
205
- Saves the element to a json file.
206
- This not meant to be reloaded and recreate the object, but rather used to document or compare the object.
344
+ Recursively extract DataArrays from nested structures.
207
345
 
208
346
  Args:
209
- path: The path to the json file.
210
- """
211
- data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True))
212
- with open(path, 'w', encoding='utf-8') as f:
213
- json.dump(data, f, indent=4, ensure_ascii=False)
347
+ obj: Object to process
348
+ context_name: Name context for better error messages
214
349
 
215
- def to_dict(self) -> Dict:
216
- """Convert the object to a dictionary representation."""
217
- data = {'__class__': self.__class__.__name__}
350
+ Returns:
351
+ Tuple of (processed_object_with_references, extracted_arrays_dict)
218
352
 
219
- # Get the constructor parameters
220
- init_params = inspect.signature(self.__init__).parameters
353
+ Raises:
354
+ ValueError: If DataArrays don't have unique names
355
+ """
356
+ extracted_arrays = {}
357
+
358
+ # Handle DataArrays directly - use their unique name
359
+ if isinstance(obj, xr.DataArray):
360
+ if not obj.name:
361
+ raise ValueError(
362
+ f'DataArrays must have a unique name for serialization. '
363
+ f'Unnamed DataArray found in {context_name}. Please set array.name = "unique_name"'
364
+ )
221
365
 
222
- for name in init_params:
223
- if name == 'self':
224
- continue
366
+ array_name = str(obj.name) # Ensure string type
367
+ if array_name in extracted_arrays:
368
+ raise ValueError(
369
+ f'DataArray name "{array_name}" is duplicated in {context_name}. '
370
+ f'Each DataArray must have a unique name for serialization.'
371
+ )
225
372
 
226
- value = getattr(self, name, None)
227
- data[name] = self._serialize_value(value)
228
-
229
- return data
230
-
231
- def _serialize_value(self, value: Any):
232
- """Helper method to serialize a value based on its type."""
233
- if value is None:
234
- return None
235
- elif isinstance(value, Interface):
236
- return value.to_dict()
237
- elif isinstance(value, (list, tuple)):
238
- return self._serialize_list(value)
239
- elif isinstance(value, dict):
240
- return self._serialize_dict(value)
373
+ extracted_arrays[array_name] = obj
374
+ return f':::{array_name}', extracted_arrays
375
+
376
+ # Handle Interface objects - extract their DataArrays too
377
+ elif isinstance(obj, Interface):
378
+ try:
379
+ interface_structure, interface_arrays = obj._create_reference_structure()
380
+ extracted_arrays.update(interface_arrays)
381
+ return interface_structure, extracted_arrays
382
+ except Exception as e:
383
+ raise ValueError(f'Failed to process nested Interface object in {context_name}: {e}') from e
384
+
385
+ # Handle sequences (lists, tuples)
386
+ elif isinstance(obj, (list, tuple)):
387
+ processed_items = []
388
+ for i, item in enumerate(obj):
389
+ item_context = f'{context_name}[{i}]' if context_name else f'item[{i}]'
390
+ processed_item, nested_arrays = self._extract_dataarrays_recursive(item, item_context)
391
+ extracted_arrays.update(nested_arrays)
392
+ processed_items.append(processed_item)
393
+ return processed_items, extracted_arrays
394
+
395
+ # Handle dictionaries
396
+ elif isinstance(obj, dict):
397
+ processed_dict = {}
398
+ for key, value in obj.items():
399
+ key_context = f'{context_name}.{key}' if context_name else str(key)
400
+ processed_value, nested_arrays = self._extract_dataarrays_recursive(value, key_context)
401
+ extracted_arrays.update(nested_arrays)
402
+ processed_dict[key] = processed_value
403
+ return processed_dict, extracted_arrays
404
+
405
+ # Handle sets (convert to list for JSON compatibility)
406
+ elif isinstance(obj, set):
407
+ processed_items = []
408
+ for i, item in enumerate(obj):
409
+ item_context = f'{context_name}.set_item[{i}]' if context_name else f'set_item[{i}]'
410
+ processed_item, nested_arrays = self._extract_dataarrays_recursive(item, item_context)
411
+ extracted_arrays.update(nested_arrays)
412
+ processed_items.append(processed_item)
413
+ return processed_items, extracted_arrays
414
+
415
+ # For all other types, serialize to basic types
241
416
  else:
242
- return value
417
+ return self._serialize_to_basic_types(obj), extracted_arrays
418
+
419
+ def _handle_deprecated_kwarg(
420
+ self,
421
+ kwargs: dict,
422
+ old_name: str,
423
+ new_name: str,
424
+ current_value: Any = None,
425
+ transform: callable = None,
426
+ check_conflict: bool = True,
427
+ ) -> Any:
428
+ """
429
+ Handle a deprecated keyword argument by issuing a warning and returning the appropriate value.
430
+
431
+ This centralizes the deprecation pattern used across multiple classes (Source, Sink, InvestParameters, etc.).
432
+
433
+ Args:
434
+ kwargs: Dictionary of keyword arguments to check and modify
435
+ old_name: Name of the deprecated parameter
436
+ new_name: Name of the replacement parameter
437
+ current_value: Current value of the new parameter (if already set)
438
+ transform: Optional callable to transform the old value before returning (e.g., lambda x: [x] to wrap in list)
439
+ check_conflict: Whether to check if both old and new parameters are specified (default: True).
440
+ Note: For parameters with non-None default values (e.g., bool parameters with default=False),
441
+ set check_conflict=False since we cannot distinguish between an explicit value and the default.
442
+
443
+ Returns:
444
+ The value to use (either from old parameter or current_value)
445
+
446
+ Raises:
447
+ ValueError: If both old and new parameters are specified and check_conflict is True
448
+
449
+ Example:
450
+ # For parameters where None is the default (conflict checking works):
451
+ value = self._handle_deprecated_kwarg(kwargs, 'old_param', 'new_param', current_value)
452
+
453
+ # For parameters with non-None defaults (disable conflict checking):
454
+ mandatory = self._handle_deprecated_kwarg(
455
+ kwargs, 'optional', 'mandatory', mandatory,
456
+ transform=lambda x: not x,
457
+ check_conflict=False # Cannot detect if mandatory was explicitly passed
458
+ )
459
+ """
460
+ import warnings
461
+
462
+ old_value = kwargs.pop(old_name, None)
463
+ if old_value is not None:
464
+ warnings.warn(
465
+ f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead.',
466
+ DeprecationWarning,
467
+ stacklevel=3, # Stack: this method -> __init__ -> caller
468
+ )
469
+ # Check for conflicts: only raise error if both were explicitly provided
470
+ if check_conflict and current_value is not None:
471
+ raise ValueError(f'Either {old_name} or {new_name} can be specified, but not both.')
472
+
473
+ # Apply transformation if provided
474
+ if transform is not None:
475
+ return transform(old_value)
476
+ return old_value
477
+
478
+ return current_value
479
+
480
+ def _validate_kwargs(self, kwargs: dict, class_name: str = None) -> None:
481
+ """
482
+ Validate that no unexpected keyword arguments are present in kwargs.
483
+
484
+ This method uses inspect to get the actual function signature and filters out
485
+ any parameters that are not defined in the __init__ method, while also
486
+ handling the special case of 'kwargs' itself which can appear during deserialization.
487
+
488
+ Args:
489
+ kwargs: Dictionary of keyword arguments to validate
490
+ class_name: Optional class name for error messages. If None, uses self.__class__.__name__
491
+
492
+ Raises:
493
+ TypeError: If unexpected keyword arguments are found
494
+ """
495
+ if not kwargs:
496
+ return
497
+
498
+ import inspect
243
499
 
244
- def _serialize_list(self, items):
245
- """Serialize a list of items."""
246
- return [self._serialize_value(item) for item in items]
500
+ sig = inspect.signature(self.__init__)
501
+ known_params = set(sig.parameters.keys()) - {'self', 'kwargs'}
502
+ # Also filter out 'kwargs' itself which can appear during deserialization
503
+ extra_kwargs = {k: v for k, v in kwargs.items() if k not in known_params and k != 'kwargs'}
247
504
 
248
- def _serialize_dict(self, d):
249
- """Serialize a dictionary of items."""
250
- return {k: self._serialize_value(v) for k, v in d.items()}
505
+ if extra_kwargs:
506
+ class_name = class_name or self.__class__.__name__
507
+ unexpected_params = ', '.join(f"'{param}'" for param in extra_kwargs.keys())
508
+ raise TypeError(f'{class_name}.__init__() got unexpected keyword argument(s): {unexpected_params}')
251
509
 
252
510
  @classmethod
253
- def _deserialize_dict(cls, data: Dict) -> Union[Dict, 'Interface']:
254
- if '__class__' in data:
255
- class_name = data.pop('__class__')
256
- try:
257
- class_type = CLASS_REGISTRY[class_name]
258
- if issubclass(class_type, Interface):
259
- # Use _deserialize_dict to process the arguments
260
- processed_data = {k: cls._deserialize_value(v) for k, v in data.items()}
261
- return class_type(**processed_data)
262
- else:
263
- raise ValueError(f'Class "{class_name}" is not an Interface.')
264
- except (AttributeError, KeyError) as e:
265
- raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e
266
- else:
267
- return {k: cls._deserialize_value(v) for k, v in data.items()}
511
+ def _resolve_dataarray_reference(
512
+ cls, reference: str, arrays_dict: dict[str, xr.DataArray]
513
+ ) -> xr.DataArray | TimeSeriesData:
514
+ """
515
+ Resolve a single DataArray reference (:::name) to actual DataArray or TimeSeriesData.
516
+
517
+ Args:
518
+ reference: Reference string starting with ":::"
519
+ arrays_dict: Dictionary of available DataArrays
520
+
521
+ Returns:
522
+ Resolved DataArray or TimeSeriesData object
523
+
524
+ Raises:
525
+ ValueError: If referenced array is not found
526
+ """
527
+ array_name = reference[3:] # Remove ":::" prefix
528
+ if array_name not in arrays_dict:
529
+ raise ValueError(f"Referenced DataArray '{array_name}' not found in dataset")
530
+
531
+ array = arrays_dict[array_name]
532
+
533
+ # Handle null values with warning
534
+ if array.isnull().any():
535
+ logger.error(f"DataArray '{array_name}' contains null values. Dropping all-null along present dims.")
536
+ if 'time' in array.dims:
537
+ array = array.dropna(dim='time', how='all')
538
+
539
+ # Check if this should be restored as TimeSeriesData
540
+ if TimeSeriesData.is_timeseries_data(array):
541
+ return TimeSeriesData.from_dataarray(array)
542
+
543
+ return array
268
544
 
269
545
  @classmethod
270
- def _deserialize_list(cls, data: List) -> List:
271
- return [cls._deserialize_value(value) for value in data]
546
+ def _resolve_reference_structure(cls, structure, arrays_dict: dict[str, xr.DataArray]):
547
+ """
548
+ Convert reference structure back to actual objects using provided arrays.
549
+
550
+ Args:
551
+ structure: Structure containing references (:::name) or special type markers
552
+ arrays_dict: Dictionary of available DataArrays
553
+
554
+ Returns:
555
+ Structure with references resolved to actual DataArrays or objects
556
+
557
+ Raises:
558
+ ValueError: If referenced arrays are not found or class is not registered
559
+ """
560
+ # Handle DataArray references
561
+ if isinstance(structure, str) and structure.startswith(':::'):
562
+ return cls._resolve_dataarray_reference(structure, arrays_dict)
563
+
564
+ elif isinstance(structure, list):
565
+ resolved_list = []
566
+ for item in structure:
567
+ resolved_item = cls._resolve_reference_structure(item, arrays_dict)
568
+ if resolved_item is not None: # Filter out None values from missing references
569
+ resolved_list.append(resolved_item)
570
+ return resolved_list
571
+
572
+ elif isinstance(structure, dict):
573
+ if structure.get('__class__'):
574
+ class_name = structure['__class__']
575
+ if class_name not in CLASS_REGISTRY:
576
+ raise ValueError(
577
+ f"Class '{class_name}' not found in CLASS_REGISTRY. "
578
+ f'Available classes: {list(CLASS_REGISTRY.keys())}'
579
+ )
580
+
581
+ # This is a nested Interface object - restore it recursively
582
+ nested_class = CLASS_REGISTRY[class_name]
583
+ # Remove the __class__ key and process the rest
584
+ nested_data = {k: v for k, v in structure.items() if k != '__class__'}
585
+ # Resolve references in the nested data
586
+ resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict)
587
+
588
+ try:
589
+ return nested_class(**resolved_nested_data)
590
+ except Exception as e:
591
+ raise ValueError(f'Failed to create instance of {class_name}: {e}') from e
592
+ else:
593
+ # Regular dictionary - resolve references in values
594
+ resolved_dict = {}
595
+ for key, value in structure.items():
596
+ resolved_value = cls._resolve_reference_structure(value, arrays_dict)
597
+ if resolved_value is not None or value is None: # Keep None values if they were originally None
598
+ resolved_dict[key] = resolved_value
599
+ return resolved_dict
600
+
601
+ else:
602
+ return structure
603
+
604
+ def _serialize_to_basic_types(self, obj):
605
+ """
606
+ Convert object to basic Python types only (no DataArrays, no custom objects).
607
+
608
+ Args:
609
+ obj: Object to serialize
610
+
611
+ Returns:
612
+ Object converted to basic Python types (str, int, float, bool, list, dict)
613
+ """
614
+ if obj is None or isinstance(obj, (str, int, float, bool)):
615
+ return obj
616
+ elif isinstance(obj, np.integer):
617
+ return int(obj)
618
+ elif isinstance(obj, np.floating):
619
+ return float(obj)
620
+ elif isinstance(obj, np.bool_):
621
+ return bool(obj)
622
+ elif isinstance(obj, (np.ndarray, pd.Series, pd.DataFrame)):
623
+ return obj.tolist() if hasattr(obj, 'tolist') else list(obj)
624
+ elif isinstance(obj, dict):
625
+ return {k: self._serialize_to_basic_types(v) for k, v in obj.items()}
626
+ elif isinstance(obj, (list, tuple)):
627
+ return [self._serialize_to_basic_types(item) for item in obj]
628
+ elif isinstance(obj, set):
629
+ return [self._serialize_to_basic_types(item) for item in obj]
630
+ elif hasattr(obj, 'isoformat'): # datetime objects
631
+ return obj.isoformat()
632
+ elif hasattr(obj, '__dict__'): # Custom objects with attributes
633
+ logger.warning(f'Converting custom object {type(obj)} to dict representation: {obj}')
634
+ return {str(k): self._serialize_to_basic_types(v) for k, v in obj.__dict__.items()}
635
+ else:
636
+ # For any other object, try to convert to string as fallback
637
+ logger.error(f'Converting unknown type {type(obj)} to string: {obj}')
638
+ return str(obj)
639
+
640
+ def to_dataset(self) -> xr.Dataset:
641
+ """
642
+ Convert the object to an xarray Dataset representation.
643
+ All DataArrays become dataset variables, everything else goes to attrs.
644
+
645
+ Its recommended to only call this method on Interfaces with all numeric data stored as xr.DataArrays.
646
+ Interfaces inside a FlowSystem are automatically converted this form after connecting and transforming the FlowSystem.
647
+
648
+ Returns:
649
+ xr.Dataset: Dataset containing all DataArrays with basic objects only in attributes
650
+
651
+ Raises:
652
+ ValueError: If serialization fails due to naming conflicts or invalid data
653
+ """
654
+ try:
655
+ reference_structure, extracted_arrays = self._create_reference_structure()
656
+ # Create the dataset with extracted arrays as variables and structure as attrs
657
+ return xr.Dataset(extracted_arrays, attrs=reference_structure)
658
+ except Exception as e:
659
+ raise ValueError(
660
+ f'Failed to convert {self.__class__.__name__} to dataset. Its recommended to only call this method on '
661
+ f'a fully connected and transformed FlowSystem, or Interfaces inside such a FlowSystem.'
662
+ f'Original Error: {e}'
663
+ ) from e
664
+
665
+ def to_netcdf(self, path: str | pathlib.Path, compression: int = 0):
666
+ """
667
+ Save the object to a NetCDF file.
668
+
669
+ Args:
670
+ path: Path to save the NetCDF file
671
+ compression: Compression level (0-9)
672
+
673
+ Raises:
674
+ ValueError: If serialization fails
675
+ IOError: If file cannot be written
676
+ """
677
+ try:
678
+ ds = self.to_dataset()
679
+ fx_io.save_dataset_to_netcdf(ds, path, compression=compression)
680
+ except Exception as e:
681
+ raise OSError(f'Failed to save {self.__class__.__name__} to NetCDF file {path}: {e}') from e
272
682
 
273
683
  @classmethod
274
- def _deserialize_value(cls, value: Any):
275
- """Helper method to deserialize a value based on its type."""
276
- if value is None:
277
- return None
278
- elif isinstance(value, dict):
279
- return cls._deserialize_dict(value)
280
- elif isinstance(value, list):
281
- return cls._deserialize_list(value)
282
- return value
684
+ def from_dataset(cls, ds: xr.Dataset) -> Interface:
685
+ """
686
+ Create an instance from an xarray Dataset.
687
+
688
+ Args:
689
+ ds: Dataset containing the object data
690
+
691
+ Returns:
692
+ Interface instance
693
+
694
+ Raises:
695
+ ValueError: If dataset format is invalid or class mismatch
696
+ """
697
+ try:
698
+ # Get class name and verify it matches
699
+ class_name = ds.attrs.get('__class__')
700
+ if class_name and class_name != cls.__name__:
701
+ logger.warning(f"Dataset class '{class_name}' doesn't match target class '{cls.__name__}'")
702
+
703
+ # Get the reference structure from attrs
704
+ reference_structure = dict(ds.attrs)
705
+
706
+ # Remove the class name since it's not a constructor parameter
707
+ reference_structure.pop('__class__', None)
708
+
709
+ # Create arrays dictionary from dataset variables
710
+ arrays_dict = {name: array for name, array in ds.data_vars.items()}
711
+
712
+ # Resolve all references using the centralized method
713
+ resolved_params = cls._resolve_reference_structure(reference_structure, arrays_dict)
714
+
715
+ return cls(**resolved_params)
716
+ except Exception as e:
717
+ raise ValueError(f'Failed to create {cls.__name__} from dataset: {e}') from e
283
718
 
284
719
  @classmethod
285
- def from_dict(cls, data: Dict) -> 'Interface':
720
+ def from_netcdf(cls, path: str | pathlib.Path) -> Interface:
286
721
  """
287
- Create an instance from a dictionary representation.
722
+ Load an instance from a NetCDF file.
288
723
 
289
724
  Args:
290
- data: Dictionary containing the data for the object.
725
+ path: Path to the NetCDF file
726
+
727
+ Returns:
728
+ Interface instance
729
+
730
+ Raises:
731
+ IOError: If file cannot be read
732
+ ValueError: If file format is invalid
291
733
  """
292
- return cls._deserialize_dict(data)
734
+ try:
735
+ ds = fx_io.load_dataset_from_netcdf(path)
736
+ return cls.from_dataset(ds)
737
+ except Exception as e:
738
+ raise OSError(f'Failed to load {cls.__name__} from NetCDF file {path}: {e}') from e
293
739
 
294
- def __repr__(self):
295
- # Get the constructor arguments and their current values
296
- init_signature = inspect.signature(self.__init__)
297
- init_args = init_signature.parameters
740
+ def get_structure(self, clean: bool = False, stats: bool = False) -> dict:
741
+ """
742
+ Get object structure as a dictionary.
743
+
744
+ Args:
745
+ clean: If True, remove None and empty dicts and lists.
746
+ stats: If True, replace DataArray references with statistics
298
747
 
299
- # Create a dictionary with argument names and their values
300
- args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self')
301
- return f'{self.__class__.__name__}({args_str})'
748
+ Returns:
749
+ Dictionary representation of the object structure
750
+ """
751
+ reference_structure, extracted_arrays = self._create_reference_structure()
752
+
753
+ if stats:
754
+ # Replace references with statistics
755
+ reference_structure = self._replace_references_with_stats(reference_structure, extracted_arrays)
756
+
757
+ if clean:
758
+ return fx_io.remove_none_and_empty(reference_structure)
759
+ return reference_structure
760
+
761
+ def _replace_references_with_stats(self, structure, arrays_dict: dict[str, xr.DataArray]):
762
+ """Replace DataArray references with statistical summaries."""
763
+ if isinstance(structure, str) and structure.startswith(':::'):
764
+ array_name = structure[3:]
765
+ if array_name in arrays_dict:
766
+ return get_dataarray_stats(arrays_dict[array_name])
767
+ return structure
768
+
769
+ elif isinstance(structure, dict):
770
+ return {k: self._replace_references_with_stats(v, arrays_dict) for k, v in structure.items()}
771
+
772
+ elif isinstance(structure, list):
773
+ return [self._replace_references_with_stats(item, arrays_dict) for item in structure]
774
+
775
+ return structure
776
+
777
+ def to_json(self, path: str | pathlib.Path):
778
+ """
779
+ Save the object to a JSON file.
780
+ This is meant for documentation and comparison, not for reloading.
781
+
782
+ Args:
783
+ path: The path to the JSON file.
784
+
785
+ Raises:
786
+ IOError: If file cannot be written
787
+ """
788
+ try:
789
+ # Use the stats mode for JSON export (cleaner output)
790
+ data = self.get_structure(clean=True, stats=True)
791
+ with open(path, 'w', encoding='utf-8') as f:
792
+ json.dump(data, f, indent=4, ensure_ascii=False)
793
+ except Exception as e:
794
+ raise OSError(f'Failed to save {self.__class__.__name__} to JSON file {path}: {e}') from e
795
+
796
+ def __repr__(self):
797
+ """Return a detailed string representation for debugging."""
798
+ try:
799
+ # Get the constructor arguments and their current values
800
+ init_signature = inspect.signature(self.__init__)
801
+ init_args = init_signature.parameters
802
+
803
+ # Create a dictionary with argument names and their values, with better formatting
804
+ args_parts = []
805
+ for name in init_args:
806
+ if name == 'self':
807
+ continue
808
+ value = getattr(self, name, None)
809
+ # Truncate long representations
810
+ value_repr = repr(value)
811
+ if len(value_repr) > 50:
812
+ value_repr = value_repr[:47] + '...'
813
+ args_parts.append(f'{name}={value_repr}')
814
+
815
+ args_str = ', '.join(args_parts)
816
+ return f'{self.__class__.__name__}({args_str})'
817
+ except Exception:
818
+ # Fallback if introspection fails
819
+ return f'{self.__class__.__name__}(<repr_failed>)'
302
820
 
303
821
  def __str__(self):
304
- return get_str_representation(self.infos(use_numpy=True, use_element_label=True))
822
+ """Return a user-friendly string representation."""
823
+ try:
824
+ data = self.get_structure(clean=True, stats=True)
825
+ with StringIO() as output_buffer:
826
+ console = Console(file=output_buffer, width=1000) # Adjust width as needed
827
+ console.print(Pretty(data, expand_all=True, indent_guides=True))
828
+ return output_buffer.getvalue()
829
+ except Exception:
830
+ # Fallback if structure generation fails
831
+ return f'{self.__class__.__name__} instance'
832
+
833
+ def copy(self) -> Interface:
834
+ """
835
+ Create a copy of the Interface object.
836
+
837
+ Uses the existing serialization infrastructure to ensure proper copying
838
+ of all DataArrays and nested objects.
839
+
840
+ Returns:
841
+ A new instance of the same class with copied data.
842
+ """
843
+ # Convert to dataset, copy it, and convert back
844
+ dataset = self.to_dataset().copy(deep=True)
845
+ return self.__class__.from_dataset(dataset)
846
+
847
+ def __copy__(self):
848
+ """Support for copy.copy()."""
849
+ return self.copy()
850
+
851
+ def __deepcopy__(self, memo):
852
+ """Support for copy.deepcopy()."""
853
+ return self.copy()
305
854
 
306
855
 
307
856
  class Element(Interface):
308
857
  """This class is the basic Element of flixopt. Every Element has a label"""
309
858
 
310
- def __init__(self, label: str, meta_data: Dict = None):
859
+ def __init__(self, label: str, meta_data: dict | None = None):
311
860
  """
312
861
  Args:
313
862
  label: The label of the element
@@ -315,13 +864,14 @@ class Element(Interface):
315
864
  """
316
865
  self.label = Element._valid_label(label)
317
866
  self.meta_data = meta_data if meta_data is not None else {}
318
- self.model: Optional[ElementModel] = None
867
+ self.submodel: ElementModel | None = None
319
868
 
320
869
  def _plausibility_checks(self) -> None:
321
- """This function is used to do some basic plausibility checks for each Element during initialization"""
870
+ """This function is used to do some basic plausibility checks for each Element during initialization.
871
+ This is run after all data is transformed to the correct format/type"""
322
872
  raise NotImplementedError('Every Element needs a _plausibility_checks() method')
323
873
 
324
- def create_model(self, model: SystemModel) -> 'ElementModel':
874
+ def create_model(self, model: FlowSystemModel) -> ElementModel:
325
875
  raise NotImplementedError('Every Element needs a create_model() method')
326
876
 
327
877
  @property
@@ -345,71 +895,105 @@ class Element(Interface):
345
895
  f'Use any other symbol instead'
346
896
  )
347
897
  if label.endswith(' '):
348
- logger.warning(f'Label "{label}" ends with a space. This will be removed.')
898
+ logger.error(f'Label "{label}" ends with a space. This will be removed.')
349
899
  return label.rstrip()
350
900
  return label
351
901
 
352
902
 
353
- class Model:
354
- """Stores Variables and Constraints."""
903
+ class Submodel(SubmodelsMixin):
904
+ """Stores Variables and Constraints. Its a subset of a FlowSystemModel.
905
+ Variables and constraints are stored in the main FlowSystemModel, and are referenced here.
906
+ Can have other Submodels assigned, and can be a Submodel of another Submodel.
907
+ """
355
908
 
356
- def __init__(
357
- self, model: SystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None
358
- ):
909
+ def __init__(self, model: FlowSystemModel, label_of_element: str, label_of_model: str | None = None):
359
910
  """
360
911
  Args:
361
- model: The SystemModel that is used to create the model.
912
+ model: The FlowSystemModel that is used to create the model.
362
913
  label_of_element: The label of the parent (Element). Used to construct the full label of the model.
363
- label: The label of the model. Used to construct the full label of the model.
364
- label_full: The full label of the model. Can overwrite the full label constructed from the other labels.
914
+ label_of_model: The label of the model. Used as a prefix in all variables and constraints.
365
915
  """
366
916
  self._model = model
367
917
  self.label_of_element = label_of_element
368
- self._label = label
369
- self._label_full = label_full
918
+ self.label_of_model = label_of_model if label_of_model is not None else self.label_of_element
919
+
920
+ self._variables: dict[str, linopy.Variable] = {} # Mapping from short name to variable
921
+ self._constraints: dict[str, linopy.Constraint] = {} # Mapping from short name to constraint
922
+ self.submodels: Submodels = Submodels({})
923
+
924
+ logger.debug(f'Creating {self.__class__.__name__} "{self.label_full}"')
925
+ self._do_modeling()
926
+
927
+ def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable:
928
+ """Create and register a variable in one step"""
929
+ if kwargs.get('name') is None:
930
+ if short_name is None:
931
+ raise ValueError('Short name must be provided when no name is given')
932
+ kwargs['name'] = f'{self.label_of_model}|{short_name}'
933
+
934
+ variable = self._model.add_variables(**kwargs)
935
+ self.register_variable(variable, short_name)
936
+ return variable
937
+
938
+ def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint:
939
+ """Create and register a constraint in one step"""
940
+ if kwargs.get('name') is None:
941
+ if short_name is None:
942
+ raise ValueError('Short name must be provided when no name is given')
943
+ kwargs['name'] = f'{self.label_of_model}|{short_name}'
944
+
945
+ constraint = self._model.add_constraints(expression, **kwargs)
946
+ self.register_constraint(constraint, short_name)
947
+ return constraint
948
+
949
+ def register_variable(self, variable: linopy.Variable, short_name: str = None) -> linopy.Variable:
950
+ """Register a variable with the model"""
951
+ if short_name is None:
952
+ short_name = variable.name
953
+ elif short_name in self._variables:
954
+ raise ValueError(f'Short name "{short_name}" already assigned to model variables')
955
+
956
+ self._variables[short_name] = variable
957
+ return variable
958
+
959
+ def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> linopy.Constraint:
960
+ """Register a constraint with the model"""
961
+ if short_name is None:
962
+ short_name = constraint.name
963
+ elif short_name in self._constraints:
964
+ raise ValueError(f'Short name "{short_name}" already assigned to model constraint')
965
+
966
+ self._constraints[short_name] = constraint
967
+ return constraint
968
+
969
+ def __getitem__(self, key: str) -> linopy.Variable:
970
+ """Get a variable by its short name"""
971
+ if key in self._variables:
972
+ return self._variables[key]
973
+ raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"')
974
+
975
+ def __contains__(self, name: str) -> bool:
976
+ """Check if a variable exists in the model"""
977
+ return name in self._variables or name in self.variables
978
+
979
+ def get(self, name: str, default=None):
980
+ """Get variable by short name, returning default if not found"""
981
+ try:
982
+ return self[name]
983
+ except KeyError:
984
+ return default
370
985
 
371
- self._variables_direct: List[str] = []
372
- self._constraints_direct: List[str] = []
373
- self.sub_models: List[Model] = []
374
-
375
- self._variables_short: Dict[str, str] = {}
376
- self._constraints_short: Dict[str, str] = {}
377
- self._sub_models_short: Dict[str, str] = {}
378
- logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"')
379
-
380
- def do_modeling(self):
381
- raise NotImplementedError('Every Model needs a do_modeling() method')
382
-
383
- def add(
384
- self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None
385
- ) -> Union[linopy.Variable, linopy.Constraint, 'Model']:
386
- """
387
- Add a variable, constraint or sub-model to the model
388
-
389
- Args:
390
- item: The variable, constraint or sub-model to add to the model
391
- short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used.
392
- """
393
- # TODO: Check uniquenes of short names
394
- if isinstance(item, linopy.Variable):
395
- self._variables_direct.append(item.name)
396
- self._variables_short[item.name] = short_name or item.name
397
- elif isinstance(item, linopy.Constraint):
398
- self._constraints_direct.append(item.name)
399
- self._constraints_short[item.name] = short_name or item.name
400
- elif isinstance(item, Model):
401
- self.sub_models.append(item)
402
- self._sub_models_short[item.label_full] = short_name or item.label_full
403
- else:
404
- raise ValueError(
405
- f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}'
406
- )
407
- return item
986
+ def get_coords(
987
+ self,
988
+ dims: Collection[str] | None = None,
989
+ extra_timestep: bool = False,
990
+ ) -> xr.Coordinates | None:
991
+ return self._model.get_coords(dims=dims, extra_timestep=extra_timestep)
408
992
 
409
993
  def filter_variables(
410
994
  self,
411
- filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None,
412
- length: Literal['scalar', 'time'] = None,
995
+ filter_by: Literal['binary', 'continuous', 'integer'] | None = None,
996
+ length: Literal['scalar', 'time'] | None = None,
413
997
  ):
414
998
  if filter_by is None:
415
999
  all_variables = self.variables
@@ -429,256 +1013,158 @@ class Model:
429
1013
  return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]]
430
1014
  raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None')
431
1015
 
432
- @property
433
- def label(self) -> str:
434
- return self._label if self._label else self.label_of_element
435
-
436
1016
  @property
437
1017
  def label_full(self) -> str:
438
- """Used to construct the names of variables and constraints"""
439
- if self._label_full:
440
- return self._label_full
441
- elif self._label:
442
- return f'{self.label_of_element}|{self.label}'
443
- return self.label_of_element
1018
+ return self.label_of_model
444
1019
 
445
1020
  @property
446
1021
  def variables_direct(self) -> linopy.Variables:
447
- return self._model.variables[self._variables_direct]
1022
+ """Variables of the model, excluding those of sub-models"""
1023
+ return self._model.variables[[var.name for var in self._variables.values()]]
448
1024
 
449
1025
  @property
450
1026
  def constraints_direct(self) -> linopy.Constraints:
451
- return self._model.constraints[self._constraints_direct]
1027
+ """Constraints of the model, excluding those of sub-models"""
1028
+ return self._model.constraints[[con.name for con in self._constraints.values()]]
452
1029
 
453
1030
  @property
454
- def _variables(self) -> List[str]:
455
- all_variables = self._variables_direct.copy()
456
- for sub_model in self.sub_models:
457
- for variable in sub_model._variables:
458
- if variable in all_variables:
459
- raise KeyError(
460
- f"Duplicate key found: '{variable}' in both {self.label_full} and {sub_model.label_full}!"
461
- )
462
- all_variables.append(variable)
463
- return all_variables
1031
+ def constraints(self) -> linopy.Constraints:
1032
+ """All constraints of the model, including those of all sub-models"""
1033
+ names = list(self.constraints_direct) + [
1034
+ constraint_name for submodel in self.submodels.values() for constraint_name in submodel.constraints
1035
+ ]
464
1036
 
465
- @property
466
- def _constraints(self) -> List[str]:
467
- all_constraints = self._constraints_direct.copy()
468
- for sub_model in self.sub_models:
469
- for constraint in sub_model._constraints:
470
- if constraint in all_constraints:
471
- raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!")
472
- all_constraints.append(constraint)
473
- return all_constraints
1037
+ return self._model.constraints[names]
474
1038
 
475
1039
  @property
476
1040
  def variables(self) -> linopy.Variables:
477
- return self._model.variables[self._variables]
1041
+ """All variables of the model, including those of all sub-models"""
1042
+ names = list(self.variables_direct) + [
1043
+ variable_name for submodel in self.submodels.values() for variable_name in submodel.variables
1044
+ ]
478
1045
 
479
- @property
480
- def constraints(self) -> linopy.Constraints:
481
- return self._model.constraints[self._constraints]
1046
+ return self._model.variables[names]
1047
+
1048
+ def __repr__(self) -> str:
1049
+ """
1050
+ Return a string representation of the linopy model.
1051
+ """
1052
+ # Extract content from existing representations
1053
+ sections = {
1054
+ f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': self.variables.__repr__().split(
1055
+ '\n', 2
1056
+ )[2],
1057
+ f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': self.constraints.__repr__().split(
1058
+ '\n', 2
1059
+ )[2],
1060
+ f'Submodels: [{len(self.submodels)}]': self.submodels.__repr__().split('\n', 2)[2],
1061
+ }
1062
+
1063
+ # Format sections with headers and underlines
1064
+ formatted_sections = []
1065
+ for section_header, section_content in sections.items():
1066
+ formatted_sections.append(f'{section_header}\n{"-" * len(section_header)}\n{section_content}')
1067
+
1068
+ model_string = f'Submodel "{self.label_of_model}":'
1069
+ all_sections = '\n'.join(formatted_sections)
1070
+
1071
+ return f'{model_string}\n{"=" * len(model_string)}\n\n{all_sections}'
482
1072
 
483
1073
  @property
484
- def all_sub_models(self) -> List['Model']:
485
- return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models]
1074
+ def hours_per_step(self):
1075
+ return self._model.hours_per_step
486
1076
 
1077
+ def _do_modeling(self):
1078
+ """Called at the end of initialization. Override in subclasses to create variables and constraints."""
1079
+ pass
487
1080
 
488
- class ElementModel(Model):
489
- """Stores the mathematical Variables and Constraints for Elements"""
490
1081
 
491
- def __init__(self, model: SystemModel, element: Element):
492
- """
493
- Args:
494
- model: The SystemModel that is used to create the model.
495
- element: The element this model is created for.
496
- """
497
- super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full)
498
- self.element = element
1082
+ @dataclass(repr=False)
1083
+ class Submodels:
1084
+ """A simple collection for storing submodels with easy access and representation."""
499
1085
 
500
- def results_structure(self):
501
- return {
502
- 'label': self.label_full,
503
- 'variables': list(self.variables),
504
- 'constraints': list(self.constraints),
505
- }
1086
+ data: dict[str, Submodel]
506
1087
 
1088
+ def __getitem__(self, name: str) -> Submodel:
1089
+ """Get a submodel by its name."""
1090
+ return self.data[name]
507
1091
 
508
- def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any:
509
- """
510
- Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays
511
- and custom `Element` objects based on the specified options.
1092
+ def __getattr__(self, name: str) -> Submodel:
1093
+ """Get a submodel by attribute access."""
1094
+ if name in self.data:
1095
+ return self.data[name]
1096
+ raise AttributeError(f"Submodels has no attribute '{name}'")
512
1097
 
513
- The function handles various data types and transforms them into a consistent, readable format:
514
- - Primitive types (`int`, `float`, `str`, `bool`, `None`) are returned as-is.
515
- - Numpy scalars are converted to their corresponding Python scalar types.
516
- - Collections (`list`, `tuple`, `set`, `dict`) are recursively processed to ensure all elements are compatible.
517
- - Numpy arrays are preserved or converted to lists, depending on `use_numpy`.
518
- - Custom `Element` objects can be represented either by their `label` or their initialization parameters as a dictionary.
519
- - Timestamps (`datetime`) are converted to ISO 8601 strings.
1098
+ def __len__(self) -> int:
1099
+ return len(self.data)
520
1100
 
521
- Args:
522
- data: The input data to process, which may be deeply nested and contain a mix of types.
523
- use_numpy: If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists.
524
- Default is `True`.
525
- use_element_label: If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary
526
- based on their initialization parameters. Default is `False`.
527
-
528
- Returns:
529
- A transformed version of the input data, containing only JSON-compatible types:
530
- - `int`, `float`, `str`, `bool`, `None`
531
- - `list`, `dict`
532
- - `np.ndarray` (if `use_numpy=True`. This is NOT JSON-compatible)
533
-
534
- Raises:
535
- TypeError: If the data cannot be converted to the specified types.
536
-
537
- Examples:
538
- >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')})
539
- {'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}}
540
-
541
- >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False)
542
- {'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}}
543
-
544
- Notes:
545
- - The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data.
546
- - Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output.
547
- - Numpy arrays with non-numeric data types are automatically converted to lists.
548
- """
549
- if isinstance(data, np.integer): # This must be checked before checking for regular int and float!
550
- return int(data)
551
- elif isinstance(data, np.floating):
552
- return float(data)
553
-
554
- elif isinstance(data, (int, float, str, bool, type(None))):
555
- return data
556
- elif isinstance(data, datetime):
557
- return data.isoformat()
558
-
559
- elif isinstance(data, (tuple, set)):
560
- return copy_and_convert_datatypes([item for item in data], use_numpy, use_element_label)
561
- elif isinstance(data, dict):
562
- return {
563
- copy_and_convert_datatypes(key, use_numpy, use_element_label=True): copy_and_convert_datatypes(
564
- value, use_numpy, use_element_label
565
- )
566
- for key, value in data.items()
567
- }
568
- elif isinstance(data, list): # Shorten arrays/lists to be readable
569
- if use_numpy and all([isinstance(value, (int, float)) for value in data]):
570
- return np.array([item for item in data])
571
- else:
572
- return [copy_and_convert_datatypes(item, use_numpy, use_element_label) for item in data]
1101
+ def __iter__(self) -> Iterator[str]:
1102
+ return iter(self.data)
573
1103
 
574
- elif isinstance(data, np.ndarray):
575
- if not use_numpy:
576
- return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label)
577
- elif use_numpy and np.issubdtype(data.dtype, np.number):
578
- return data
579
- else:
580
- logger.critical(
581
- f'An np.array with non-numeric content was found: {data=}.It will be converted to a list instead'
582
- )
583
- return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label)
584
-
585
- elif isinstance(data, TimeSeries):
586
- return copy_and_convert_datatypes(data.selected_data, use_numpy, use_element_label)
587
- elif isinstance(data, TimeSeriesData):
588
- return copy_and_convert_datatypes(data.data, use_numpy, use_element_label)
589
- elif isinstance(data, (pd.Series, pd.DataFrame)):
590
- #TODO: This can be improved
591
- return copy_and_convert_datatypes(data.values, use_numpy, use_element_label)
592
-
593
- elif isinstance(data, Interface):
594
- if use_element_label and isinstance(data, Element):
595
- return data.label
596
- return data.infos(use_numpy, use_element_label)
597
- elif isinstance(data, xr.DataArray):
598
- # TODO: This is a temporary basic work around
599
- return copy_and_convert_datatypes(data.values, use_numpy, use_element_label)
600
- else:
601
- raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}')
602
-
603
-
604
- def get_compact_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> Dict:
605
- """
606
- Generate a compact json serializable representation of deeply nested data.
607
- Numpy arrays are statistically described if they exceed a threshold and converted to lists.
1104
+ def __contains__(self, name: str) -> bool:
1105
+ return name in self.data
608
1106
 
609
- Args:
610
- data (Any): The data to format and represent.
611
- array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described.
612
- decimals (int): Number of decimal places in which to describe the arrays.
1107
+ def __repr__(self) -> str:
1108
+ """Simple representation of the submodels collection."""
1109
+ if not self.data:
1110
+ return 'flixopt.structure.Submodels:\n----------------------------\n <empty>\n'
613
1111
 
614
- Returns:
615
- Dict: A dictionary representation of the data
616
- """
1112
+ total_vars = sum(len(submodel.variables) for submodel in self.data.values())
1113
+ total_cons = sum(len(submodel.constraints) for submodel in self.data.values())
617
1114
 
618
- def format_np_array_if_found(value: Any) -> Any:
619
- """Recursively processes the data, formatting NumPy arrays."""
620
- if isinstance(value, (int, float, str, bool, type(None))):
621
- return value
622
- elif isinstance(value, np.ndarray):
623
- return describe_numpy_arrays(value)
624
- elif isinstance(value, dict):
625
- return {format_np_array_if_found(k): format_np_array_if_found(v) for k, v in value.items()}
626
- elif isinstance(value, (list, tuple, set)):
627
- return [format_np_array_if_found(v) for v in value]
628
- else:
629
- logger.warning(
630
- f'Unexpected value found when trying to format numpy array numpy array: {type(value)=}; {value=}'
631
- )
632
- return value
633
-
634
- def describe_numpy_arrays(arr: np.ndarray) -> Union[str, List]:
635
- """Shortens NumPy arrays if they exceed the specified length."""
636
-
637
- def normalized_center_of_mass(array: Any) -> float:
638
- # position in array (0 bis 1 normiert)
639
- if array.ndim >= 2: # No good way to calculate center of mass for 2D arrays
640
- return np.nan
641
- positions = np.linspace(0, 1, len(array)) # weights w_i
642
- # mass center
643
- if np.sum(array) == 0:
644
- return np.nan
645
- else:
646
- return np.sum(positions * array) / np.sum(array)
647
-
648
- if arr.size > array_threshold: # Calculate basic statistics
649
- fmt = f'.{decimals}f'
650
- return (
651
- f'Array (min={np.min(arr):{fmt}}, max={np.max(arr):{fmt}}, mean={np.mean(arr):{fmt}}, '
652
- f'median={np.median(arr):{fmt}}, std={np.std(arr):{fmt}}, len={len(arr)}, '
653
- f'center={normalized_center_of_mass(arr):{fmt}})'
654
- )
655
- else:
656
- return np.around(arr, decimals=decimals).tolist()
1115
+ title = (
1116
+ f'flixopt.structure.Submodels ({total_vars} vars, {total_cons} constraints, {len(self.data)} submodels):'
1117
+ )
1118
+ underline = '-' * len(title)
657
1119
 
658
- # Process the data to handle NumPy arrays
659
- formatted_data = format_np_array_if_found(copy_and_convert_datatypes(data, use_numpy=True))
1120
+ if not self.data:
1121
+ return f'{title}\n{underline}\n <empty>\n'
1122
+ sub_models_string = ''
1123
+ for name, submodel in self.data.items():
1124
+ type_name = submodel.__class__.__name__
1125
+ var_count = len(submodel.variables)
1126
+ con_count = len(submodel.constraints)
1127
+ sub_models_string += f'\n * {name} [{type_name}] ({var_count}v/{con_count}c)'
660
1128
 
661
- return formatted_data
1129
+ return f'{title}\n{underline}{sub_models_string}\n'
662
1130
 
1131
+ def items(self) -> ItemsView[str, Submodel]:
1132
+ return self.data.items()
663
1133
 
664
- def get_str_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> str:
665
- """
666
- Generate a string representation of deeply nested data using `rich.print`.
667
- NumPy arrays are shortened to the specified length and converted to strings.
1134
+ def keys(self):
1135
+ return self.data.keys()
1136
+
1137
+ def values(self):
1138
+ return self.data.values()
1139
+
1140
+ def add(self, submodel: Submodel, name: str) -> None:
1141
+ """Add a submodel to the collection."""
1142
+ self.data[name] = submodel
1143
+
1144
+ def get(self, name: str, default=None):
1145
+ """Get submodel by name, returning default if not found."""
1146
+ return self.data.get(name, default)
668
1147
 
669
- Args:
670
- data (Any): The data to format and represent.
671
- array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described.
672
- decimals (int): Number of decimal places in which to describe the arrays.
673
1148
 
674
- Returns:
675
- str: The formatted string representation of the data.
1149
+ class ElementModel(Submodel):
1150
+ """
1151
+ Stores the mathematical Variables and Constraints for Elements.
1152
+ ElementModels are directly registered in the main FlowSystemModel
676
1153
  """
677
1154
 
678
- formatted_data = get_compact_representation(data, array_threshold, decimals)
1155
+ def __init__(self, model: FlowSystemModel, element: Element):
1156
+ """
1157
+ Args:
1158
+ model: The FlowSystemModel that is used to create the model.
1159
+ element: The element this model is created for.
1160
+ """
1161
+ self.element = element
1162
+ super().__init__(model, label_of_element=element.label_full, label_of_model=element.label_full)
1163
+ self._model.add_submodels(self, short_name=self.label_of_model)
679
1164
 
680
- # Use Rich to format and print the data
681
- with StringIO() as output_buffer:
682
- console = Console(file=output_buffer, width=1000) # Adjust width as needed
683
- console.print(Pretty(formatted_data, expand_all=True, indent_guides=True))
684
- return output_buffer.getvalue()
1165
+ def results_structure(self):
1166
+ return {
1167
+ 'label': self.label_full,
1168
+ 'variables': list(self.variables),
1169
+ 'constraints': list(self.constraints),
1170
+ }