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.
- flixopt/__init__.py +35 -1
- flixopt/aggregation.py +60 -81
- flixopt/calculation.py +381 -196
- flixopt/components.py +1022 -359
- flixopt/config.py +553 -191
- flixopt/core.py +475 -1315
- flixopt/effects.py +477 -214
- flixopt/elements.py +591 -344
- flixopt/features.py +403 -957
- flixopt/flow_system.py +781 -293
- flixopt/interface.py +1159 -189
- flixopt/io.py +50 -55
- flixopt/linear_converters.py +384 -92
- flixopt/modeling.py +759 -0
- flixopt/network_app.py +789 -0
- flixopt/plotting.py +273 -135
- flixopt/results.py +639 -383
- flixopt/solvers.py +25 -21
- flixopt/structure.py +928 -442
- flixopt/utils.py +34 -5
- flixopt-3.0.0.dist-info/METADATA +209 -0
- flixopt-3.0.0.dist-info/RECORD +26 -0
- {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
- flixopt-3.0.0.dist-info/top_level.txt +1 -0
- docs/examples/00-Minimal Example.md +0 -5
- docs/examples/01-Basic Example.md +0 -5
- docs/examples/02-Complex Example.md +0 -10
- docs/examples/03-Calculation Modes.md +0 -5
- docs/examples/index.md +0 -5
- docs/faq/contribute.md +0 -49
- docs/faq/index.md +0 -3
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +0 -1
- docs/javascripts/mathjax.js +0 -18
- docs/release-notes/_template.txt +0 -32
- docs/release-notes/index.md +0 -7
- docs/release-notes/v2.0.0.md +0 -93
- docs/release-notes/v2.0.1.md +0 -12
- docs/release-notes/v2.1.0.md +0 -31
- docs/release-notes/v2.2.0.md +0 -55
- docs/user-guide/Mathematical Notation/Bus.md +0 -33
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
- docs/user-guide/Mathematical Notation/Flow.md +0 -26
- docs/user-guide/Mathematical Notation/Investment.md +0 -115
- docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
- docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
- docs/user-guide/Mathematical Notation/Storage.md +0 -44
- docs/user-guide/Mathematical Notation/index.md +0 -22
- docs/user-guide/Mathematical Notation/others.md +0 -3
- docs/user-guide/index.md +0 -124
- flixopt/config.yaml +0 -10
- flixopt-2.2.0b0.dist-info/METADATA +0 -146
- flixopt-2.2.0b0.dist-info/RECORD +0 -59
- flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixOpt_plotting.jpg +0 -0
- pics/flixopt-icon.svg +0 -1
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +0 -54
- tests/ressources/Zeitreihen2020.csv +0 -35137
- {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
|
|
10
|
-
from datetime import datetime
|
|
11
|
+
from dataclasses import dataclass
|
|
11
12
|
from io import StringIO
|
|
12
|
-
from typing import
|
|
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 .
|
|
22
|
-
from .core import
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
60
|
-
self.effects:
|
|
61
|
-
self.
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
183
|
+
return self.flow_system.hours_per_timestep
|
|
119
184
|
|
|
120
185
|
@property
|
|
121
186
|
def hours_of_previous_timesteps(self):
|
|
122
|
-
return self.
|
|
187
|
+
return self.flow_system.hours_of_previous_timesteps
|
|
123
188
|
|
|
124
189
|
def get_coords(
|
|
125
|
-
self,
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
146
|
-
if scenarios is None:
|
|
147
|
-
return (timesteps,)
|
|
148
|
-
return timesteps, scenarios
|
|
218
|
+
return xr.Coordinates(coords) if coords else None
|
|
149
219
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
166
|
-
"""
|
|
167
|
-
|
|
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
|
-
|
|
281
|
+
Raises:
|
|
282
|
+
NotImplementedError: Must be implemented by subclasses
|
|
170
283
|
"""
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
inspect.signature(self.__init__).parameters.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
data = {'__class__': self.__class__.__name__}
|
|
350
|
+
Returns:
|
|
351
|
+
Tuple of (processed_object_with_references, extracted_arrays_dict)
|
|
218
352
|
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
if
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
elif isinstance(
|
|
240
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
271
|
-
|
|
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
|
|
275
|
-
"""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
720
|
+
def from_netcdf(cls, path: str | pathlib.Path) -> Interface:
|
|
286
721
|
"""
|
|
287
|
-
|
|
722
|
+
Load an instance from a NetCDF file.
|
|
288
723
|
|
|
289
724
|
Args:
|
|
290
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
self
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
self.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
for
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
485
|
-
return
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1165
|
+
def results_structure(self):
|
|
1166
|
+
return {
|
|
1167
|
+
'label': self.label_full,
|
|
1168
|
+
'variables': list(self.variables),
|
|
1169
|
+
'constraints': list(self.constraints),
|
|
1170
|
+
}
|