flixopt 2.2.0rc2__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 +33 -4
- flixopt/aggregation.py +60 -80
- flixopt/calculation.py +395 -178
- flixopt/commons.py +1 -10
- flixopt/components.py +939 -448
- flixopt/config.py +553 -191
- flixopt/core.py +513 -846
- flixopt/effects.py +644 -178
- flixopt/elements.py +610 -355
- flixopt/features.py +394 -966
- flixopt/flow_system.py +736 -219
- flixopt/interface.py +1104 -302
- flixopt/io.py +103 -79
- flixopt/linear_converters.py +387 -95
- flixopt/modeling.py +759 -0
- flixopt/network_app.py +73 -39
- flixopt/plotting.py +294 -138
- flixopt/results.py +1253 -299
- flixopt/solvers.py +25 -21
- flixopt/structure.py +938 -396
- flixopt/utils.py +38 -12
- flixopt-3.0.0.dist-info/METADATA +209 -0
- flixopt-3.0.0.dist-info/RECORD +26 -0
- 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 -61
- 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/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/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.0rc2.dist-info/METADATA +0 -167
- flixopt-2.2.0rc2.dist-info/RECORD +0 -54
- flixopt-2.2.0rc2.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/extract_release_notes.py +0 -45
- scripts/gen_ref_pages.py +0 -54
- tests/ressources/Zeitreihen2020.csv +0 -35137
- {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/WHEEL +0 -0
- {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/effects.py
CHANGED
|
@@ -5,19 +5,24 @@ Different Datatypes are used to represent the effects with assigned values by th
|
|
|
5
5
|
which are then transformed into the internal data structure.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
8
10
|
import logging
|
|
9
11
|
import warnings
|
|
10
|
-
from
|
|
12
|
+
from collections import deque
|
|
13
|
+
from typing import TYPE_CHECKING, Literal
|
|
11
14
|
|
|
12
15
|
import linopy
|
|
13
16
|
import numpy as np
|
|
14
|
-
import
|
|
17
|
+
import xarray as xr
|
|
15
18
|
|
|
16
|
-
from .core import
|
|
19
|
+
from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser
|
|
17
20
|
from .features import ShareAllocationModel
|
|
18
|
-
from .structure import Element, ElementModel,
|
|
21
|
+
from .structure import Element, ElementModel, FlowSystemModel, Submodel, register_class_for_io
|
|
19
22
|
|
|
20
23
|
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
|
|
21
26
|
from .flow_system import FlowSystem
|
|
22
27
|
|
|
23
28
|
logger = logging.getLogger('flixopt')
|
|
@@ -26,8 +31,132 @@ logger = logging.getLogger('flixopt')
|
|
|
26
31
|
@register_class_for_io
|
|
27
32
|
class Effect(Element):
|
|
28
33
|
"""
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
Represents system-wide impacts like costs, emissions, resource consumption, or other effects.
|
|
35
|
+
|
|
36
|
+
Effects capture the broader impacts of system operation and investment decisions beyond
|
|
37
|
+
the primary energy/material flows. Each Effect accumulates contributions from Components,
|
|
38
|
+
Flows, and other system elements. One Effect is typically chosen as the optimization
|
|
39
|
+
objective, while others can serve as constraints or tracking metrics.
|
|
40
|
+
|
|
41
|
+
Effects support comprehensive modeling including operational and investment contributions,
|
|
42
|
+
cross-effect relationships (e.g., carbon pricing), and flexible constraint formulation.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
label: The label of the Element. Used to identify it in the FlowSystem.
|
|
46
|
+
unit: The unit of the effect (e.g., '€', 'kg_CO2', 'kWh_primary', 'm²').
|
|
47
|
+
This is informative only and does not affect optimization calculations.
|
|
48
|
+
description: Descriptive name explaining what this effect represents.
|
|
49
|
+
is_standard: If True, this is a standard effect allowing direct value input
|
|
50
|
+
without effect dictionaries. Used for simplified effect specification (and less boilerplate code).
|
|
51
|
+
is_objective: If True, this effect serves as the optimization objective function.
|
|
52
|
+
Only one effect can be marked as objective per optimization.
|
|
53
|
+
share_from_temporal: Temporal cross-effect contributions.
|
|
54
|
+
Maps temporal contributions from other effects to this effect
|
|
55
|
+
share_from_periodic: Periodic cross-effect contributions.
|
|
56
|
+
Maps periodic contributions from other effects to this effect.
|
|
57
|
+
minimum_temporal: Minimum allowed total contribution across all timesteps.
|
|
58
|
+
maximum_temporal: Maximum allowed total contribution across all timesteps.
|
|
59
|
+
minimum_per_hour: Minimum allowed contribution per hour.
|
|
60
|
+
maximum_per_hour: Maximum allowed contribution per hour.
|
|
61
|
+
minimum_periodic: Minimum allowed total periodic contribution.
|
|
62
|
+
maximum_periodic: Maximum allowed total periodic contribution.
|
|
63
|
+
minimum_total: Minimum allowed total effect (temporal + periodic combined).
|
|
64
|
+
maximum_total: Maximum allowed total effect (temporal + periodic combined).
|
|
65
|
+
meta_data: Used to store additional information. Not used internally but saved
|
|
66
|
+
in results. Only use Python native types.
|
|
67
|
+
|
|
68
|
+
**Deprecated Parameters** (for backwards compatibility):
|
|
69
|
+
minimum_operation: Use `minimum_temporal` instead.
|
|
70
|
+
maximum_operation: Use `maximum_temporal` instead.
|
|
71
|
+
minimum_invest: Use `minimum_periodic` instead.
|
|
72
|
+
maximum_invest: Use `maximum_periodic` instead.
|
|
73
|
+
minimum_operation_per_hour: Use `minimum_per_hour` instead.
|
|
74
|
+
maximum_operation_per_hour: Use `maximum_per_hour` instead.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
Basic cost objective:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
cost_effect = Effect(
|
|
81
|
+
label='system_costs',
|
|
82
|
+
unit='€',
|
|
83
|
+
description='Total system costs',
|
|
84
|
+
is_objective=True,
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
CO2 emissions:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
co2_effect = Effect(
|
|
92
|
+
label='CO2',
|
|
93
|
+
unit='kg_CO2',
|
|
94
|
+
description='Carbon dioxide emissions',
|
|
95
|
+
maximum_total=1_000_000, # 1000 t CO2 annual limit
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Land use constraint:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
land_use = Effect(
|
|
103
|
+
label='land_usage',
|
|
104
|
+
unit='m²',
|
|
105
|
+
description='Land area requirement',
|
|
106
|
+
maximum_total=50_000, # Maximum 5 hectares available
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Primary energy tracking:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
primary_energy = Effect(
|
|
114
|
+
label='primary_energy',
|
|
115
|
+
unit='kWh_primary',
|
|
116
|
+
description='Primary energy consumption',
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Cost objective with carbon and primary energy pricing:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
cost_effect = Effect(
|
|
124
|
+
label='system_costs',
|
|
125
|
+
unit='€',
|
|
126
|
+
description='Total system costs',
|
|
127
|
+
is_objective=True,
|
|
128
|
+
share_from_temporal={
|
|
129
|
+
'primary_energy': 0.08, # 0.08 €/kWh_primary
|
|
130
|
+
'CO2': 0.2, # Carbon pricing: 0.2 €/kg_CO2 into costs if used on a cost effect
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Water consumption with tiered constraints:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
water_usage = Effect(
|
|
139
|
+
label='water_consumption',
|
|
140
|
+
unit='m³',
|
|
141
|
+
description='Industrial water usage',
|
|
142
|
+
minimum_per_hour=10, # Minimum 10 m³/h for process stability
|
|
143
|
+
maximum_per_hour=500, # Maximum 500 m³/h capacity limit
|
|
144
|
+
maximum_total=100_000, # Annual permit limit: 100,000 m³
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Note:
|
|
149
|
+
Effect bounds can be None to indicate no constraint in that direction.
|
|
150
|
+
|
|
151
|
+
Cross-effect relationships enable sophisticated modeling like carbon pricing,
|
|
152
|
+
resource valuation, or multi-criteria optimization with weighted objectives.
|
|
153
|
+
|
|
154
|
+
The unit field is purely informational - ensure dimensional consistency
|
|
155
|
+
across all contributions to each effect manually.
|
|
156
|
+
|
|
157
|
+
Effects are accumulated as:
|
|
158
|
+
- Total = Σ(temporal contributions) + Σ(periodic contributions)
|
|
159
|
+
|
|
31
160
|
"""
|
|
32
161
|
|
|
33
162
|
def __init__(
|
|
@@ -35,77 +164,223 @@ class Effect(Element):
|
|
|
35
164
|
label: str,
|
|
36
165
|
unit: str,
|
|
37
166
|
description: str,
|
|
38
|
-
meta_data:
|
|
167
|
+
meta_data: dict | None = None,
|
|
39
168
|
is_standard: bool = False,
|
|
40
169
|
is_objective: bool = False,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
minimum_total:
|
|
50
|
-
maximum_total:
|
|
170
|
+
share_from_temporal: TemporalEffectsUser | None = None,
|
|
171
|
+
share_from_periodic: PeriodicEffectsUser | None = None,
|
|
172
|
+
minimum_temporal: PeriodicEffectsUser | None = None,
|
|
173
|
+
maximum_temporal: PeriodicEffectsUser | None = None,
|
|
174
|
+
minimum_periodic: PeriodicEffectsUser | None = None,
|
|
175
|
+
maximum_periodic: PeriodicEffectsUser | None = None,
|
|
176
|
+
minimum_per_hour: TemporalDataUser | None = None,
|
|
177
|
+
maximum_per_hour: TemporalDataUser | None = None,
|
|
178
|
+
minimum_total: Scalar | None = None,
|
|
179
|
+
maximum_total: Scalar | None = None,
|
|
180
|
+
**kwargs,
|
|
51
181
|
):
|
|
52
|
-
"""
|
|
53
|
-
Args:
|
|
54
|
-
label: The label of the Element. Used to identify it in the FlowSystem
|
|
55
|
-
unit: The unit of effect, i.g. €, kg_CO2, kWh_primaryEnergy
|
|
56
|
-
description: The long name
|
|
57
|
-
is_standard: true, if Standard-Effect (for direct input of value without effect (alternatively to dict)) , else false
|
|
58
|
-
is_objective: true, if optimization target
|
|
59
|
-
specific_share_to_other_effects_operation: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional
|
|
60
|
-
share to other effects (only operation)
|
|
61
|
-
specific_share_to_other_effects_invest: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional
|
|
62
|
-
share to other effects (only invest).
|
|
63
|
-
minimum_operation: minimal sum (only operation) of the effect.
|
|
64
|
-
maximum_operation: maximal sum (nur operation) of the effect.
|
|
65
|
-
minimum_operation_per_hour: max. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep!
|
|
66
|
-
maximum_operation_per_hour: min. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep!
|
|
67
|
-
minimum_invest: minimal sum (only invest) of the effect
|
|
68
|
-
maximum_invest: maximal sum (only invest) of the effect
|
|
69
|
-
minimum_total: min sum of effect (invest+operation).
|
|
70
|
-
maximum_total: max sum of effect (invest+operation).
|
|
71
|
-
meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
|
|
72
|
-
"""
|
|
73
182
|
super().__init__(label, meta_data=meta_data)
|
|
74
|
-
self.label = label
|
|
75
183
|
self.unit = unit
|
|
76
184
|
self.description = description
|
|
77
185
|
self.is_standard = is_standard
|
|
78
186
|
self.is_objective = is_objective
|
|
79
|
-
self.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
187
|
+
self.share_from_temporal: TemporalEffectsUser = share_from_temporal if share_from_temporal is not None else {}
|
|
188
|
+
self.share_from_periodic: PeriodicEffectsUser = share_from_periodic if share_from_periodic is not None else {}
|
|
189
|
+
|
|
190
|
+
# Handle backwards compatibility for deprecated parameters using centralized helper
|
|
191
|
+
minimum_temporal = self._handle_deprecated_kwarg(
|
|
192
|
+
kwargs, 'minimum_operation', 'minimum_temporal', minimum_temporal
|
|
193
|
+
)
|
|
194
|
+
maximum_temporal = self._handle_deprecated_kwarg(
|
|
195
|
+
kwargs, 'maximum_operation', 'maximum_temporal', maximum_temporal
|
|
196
|
+
)
|
|
197
|
+
minimum_periodic = self._handle_deprecated_kwarg(kwargs, 'minimum_invest', 'minimum_periodic', minimum_periodic)
|
|
198
|
+
maximum_periodic = self._handle_deprecated_kwarg(kwargs, 'maximum_invest', 'maximum_periodic', maximum_periodic)
|
|
199
|
+
minimum_per_hour = self._handle_deprecated_kwarg(
|
|
200
|
+
kwargs, 'minimum_operation_per_hour', 'minimum_per_hour', minimum_per_hour
|
|
201
|
+
)
|
|
202
|
+
maximum_per_hour = self._handle_deprecated_kwarg(
|
|
203
|
+
kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Validate any remaining unexpected kwargs
|
|
207
|
+
self._validate_kwargs(kwargs)
|
|
208
|
+
|
|
209
|
+
# Set attributes directly
|
|
210
|
+
self.minimum_temporal = minimum_temporal
|
|
211
|
+
self.maximum_temporal = maximum_temporal
|
|
212
|
+
self.minimum_periodic = minimum_periodic
|
|
213
|
+
self.maximum_periodic = maximum_periodic
|
|
214
|
+
self.minimum_per_hour = minimum_per_hour
|
|
215
|
+
self.maximum_per_hour = maximum_per_hour
|
|
89
216
|
self.minimum_total = minimum_total
|
|
90
217
|
self.maximum_total = maximum_total
|
|
91
218
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
219
|
+
# Backwards compatible properties (deprecated)
|
|
220
|
+
@property
|
|
221
|
+
def minimum_operation(self):
|
|
222
|
+
"""DEPRECATED: Use 'minimum_temporal' property instead."""
|
|
223
|
+
warnings.warn(
|
|
224
|
+
"Property 'minimum_operation' is deprecated. Use 'minimum_temporal' instead.",
|
|
225
|
+
DeprecationWarning,
|
|
226
|
+
stacklevel=2,
|
|
227
|
+
)
|
|
228
|
+
return self.minimum_temporal
|
|
229
|
+
|
|
230
|
+
@minimum_operation.setter
|
|
231
|
+
def minimum_operation(self, value):
|
|
232
|
+
"""DEPRECATED: Use 'minimum_temporal' property instead."""
|
|
233
|
+
warnings.warn(
|
|
234
|
+
"Property 'minimum_operation' is deprecated. Use 'minimum_temporal' instead.",
|
|
235
|
+
DeprecationWarning,
|
|
236
|
+
stacklevel=2,
|
|
237
|
+
)
|
|
238
|
+
self.minimum_temporal = value
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def maximum_operation(self):
|
|
242
|
+
"""DEPRECATED: Use 'maximum_temporal' property instead."""
|
|
243
|
+
warnings.warn(
|
|
244
|
+
"Property 'maximum_operation' is deprecated. Use 'maximum_temporal' instead.",
|
|
245
|
+
DeprecationWarning,
|
|
246
|
+
stacklevel=2,
|
|
95
247
|
)
|
|
96
|
-
self.
|
|
97
|
-
|
|
98
|
-
|
|
248
|
+
return self.maximum_temporal
|
|
249
|
+
|
|
250
|
+
@maximum_operation.setter
|
|
251
|
+
def maximum_operation(self, value):
|
|
252
|
+
"""DEPRECATED: Use 'maximum_temporal' property instead."""
|
|
253
|
+
warnings.warn(
|
|
254
|
+
"Property 'maximum_operation' is deprecated. Use 'maximum_temporal' instead.",
|
|
255
|
+
DeprecationWarning,
|
|
256
|
+
stacklevel=2,
|
|
99
257
|
)
|
|
258
|
+
self.maximum_temporal = value
|
|
100
259
|
|
|
101
|
-
|
|
102
|
-
|
|
260
|
+
@property
|
|
261
|
+
def minimum_invest(self):
|
|
262
|
+
"""DEPRECATED: Use 'minimum_periodic' property instead."""
|
|
263
|
+
warnings.warn(
|
|
264
|
+
"Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.",
|
|
265
|
+
DeprecationWarning,
|
|
266
|
+
stacklevel=2,
|
|
267
|
+
)
|
|
268
|
+
return self.minimum_periodic
|
|
269
|
+
|
|
270
|
+
@minimum_invest.setter
|
|
271
|
+
def minimum_invest(self, value):
|
|
272
|
+
"""DEPRECATED: Use 'minimum_periodic' property instead."""
|
|
273
|
+
warnings.warn(
|
|
274
|
+
"Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.",
|
|
275
|
+
DeprecationWarning,
|
|
276
|
+
stacklevel=2,
|
|
277
|
+
)
|
|
278
|
+
self.minimum_periodic = value
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def maximum_invest(self):
|
|
282
|
+
"""DEPRECATED: Use 'maximum_periodic' property instead."""
|
|
283
|
+
warnings.warn(
|
|
284
|
+
"Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.",
|
|
285
|
+
DeprecationWarning,
|
|
286
|
+
stacklevel=2,
|
|
287
|
+
)
|
|
288
|
+
return self.maximum_periodic
|
|
289
|
+
|
|
290
|
+
@maximum_invest.setter
|
|
291
|
+
def maximum_invest(self, value):
|
|
292
|
+
"""DEPRECATED: Use 'maximum_periodic' property instead."""
|
|
293
|
+
warnings.warn(
|
|
294
|
+
"Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.",
|
|
295
|
+
DeprecationWarning,
|
|
296
|
+
stacklevel=2,
|
|
103
297
|
)
|
|
298
|
+
self.maximum_periodic = value
|
|
104
299
|
|
|
105
|
-
|
|
300
|
+
@property
|
|
301
|
+
def minimum_operation_per_hour(self):
|
|
302
|
+
"""DEPRECATED: Use 'minimum_per_hour' property instead."""
|
|
303
|
+
warnings.warn(
|
|
304
|
+
"Property 'minimum_operation_per_hour' is deprecated. Use 'minimum_per_hour' instead.",
|
|
305
|
+
DeprecationWarning,
|
|
306
|
+
stacklevel=2,
|
|
307
|
+
)
|
|
308
|
+
return self.minimum_per_hour
|
|
309
|
+
|
|
310
|
+
@minimum_operation_per_hour.setter
|
|
311
|
+
def minimum_operation_per_hour(self, value):
|
|
312
|
+
"""DEPRECATED: Use 'minimum_per_hour' property instead."""
|
|
313
|
+
warnings.warn(
|
|
314
|
+
"Property 'minimum_operation_per_hour' is deprecated. Use 'minimum_per_hour' instead.",
|
|
315
|
+
DeprecationWarning,
|
|
316
|
+
stacklevel=2,
|
|
317
|
+
)
|
|
318
|
+
self.minimum_per_hour = value
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def maximum_operation_per_hour(self):
|
|
322
|
+
"""DEPRECATED: Use 'maximum_per_hour' property instead."""
|
|
323
|
+
warnings.warn(
|
|
324
|
+
"Property 'maximum_operation_per_hour' is deprecated. Use 'maximum_per_hour' instead.",
|
|
325
|
+
DeprecationWarning,
|
|
326
|
+
stacklevel=2,
|
|
327
|
+
)
|
|
328
|
+
return self.maximum_per_hour
|
|
329
|
+
|
|
330
|
+
@maximum_operation_per_hour.setter
|
|
331
|
+
def maximum_operation_per_hour(self, value):
|
|
332
|
+
"""DEPRECATED: Use 'maximum_per_hour' property instead."""
|
|
333
|
+
warnings.warn(
|
|
334
|
+
"Property 'maximum_operation_per_hour' is deprecated. Use 'maximum_per_hour' instead.",
|
|
335
|
+
DeprecationWarning,
|
|
336
|
+
stacklevel=2,
|
|
337
|
+
)
|
|
338
|
+
self.maximum_per_hour = value
|
|
339
|
+
|
|
340
|
+
def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
|
|
341
|
+
prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
|
|
342
|
+
self.minimum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour)
|
|
343
|
+
|
|
344
|
+
self.maximum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour)
|
|
345
|
+
|
|
346
|
+
self.share_from_temporal = flow_system.fit_effects_to_model_coords(
|
|
347
|
+
label_prefix=None,
|
|
348
|
+
effect_values=self.share_from_temporal,
|
|
349
|
+
label_suffix=f'(temporal)->{prefix}(temporal)',
|
|
350
|
+
dims=['time', 'period', 'scenario'],
|
|
351
|
+
)
|
|
352
|
+
self.share_from_periodic = flow_system.fit_effects_to_model_coords(
|
|
353
|
+
label_prefix=None,
|
|
354
|
+
effect_values=self.share_from_periodic,
|
|
355
|
+
label_suffix=f'(periodic)->{prefix}(periodic)',
|
|
356
|
+
dims=['period', 'scenario'],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
self.minimum_temporal = flow_system.fit_to_model_coords(
|
|
360
|
+
f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario']
|
|
361
|
+
)
|
|
362
|
+
self.maximum_temporal = flow_system.fit_to_model_coords(
|
|
363
|
+
f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario']
|
|
364
|
+
)
|
|
365
|
+
self.minimum_periodic = flow_system.fit_to_model_coords(
|
|
366
|
+
f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario']
|
|
367
|
+
)
|
|
368
|
+
self.maximum_periodic = flow_system.fit_to_model_coords(
|
|
369
|
+
f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario']
|
|
370
|
+
)
|
|
371
|
+
self.minimum_total = flow_system.fit_to_model_coords(
|
|
372
|
+
f'{prefix}|minimum_total',
|
|
373
|
+
self.minimum_total,
|
|
374
|
+
dims=['period', 'scenario'],
|
|
375
|
+
)
|
|
376
|
+
self.maximum_total = flow_system.fit_to_model_coords(
|
|
377
|
+
f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario']
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def create_model(self, model: FlowSystemModel) -> EffectModel:
|
|
106
381
|
self._plausibility_checks()
|
|
107
|
-
self.
|
|
108
|
-
return self.
|
|
382
|
+
self.submodel = EffectModel(model, self)
|
|
383
|
+
return self.submodel
|
|
109
384
|
|
|
110
385
|
def _plausibility_checks(self) -> None:
|
|
111
386
|
# TODO: Check for plausibility
|
|
@@ -113,70 +388,64 @@ class Effect(Element):
|
|
|
113
388
|
|
|
114
389
|
|
|
115
390
|
class EffectModel(ElementModel):
|
|
116
|
-
|
|
391
|
+
element: Effect # Type hint
|
|
392
|
+
|
|
393
|
+
def __init__(self, model: FlowSystemModel, element: Effect):
|
|
117
394
|
super().__init__(model, element)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
self.
|
|
395
|
+
|
|
396
|
+
def _do_modeling(self):
|
|
397
|
+
self.total: linopy.Variable | None = None
|
|
398
|
+
self.periodic: ShareAllocationModel = self.add_submodels(
|
|
121
399
|
ShareAllocationModel(
|
|
122
|
-
self._model,
|
|
123
|
-
|
|
124
|
-
self.label_of_element,
|
|
125
|
-
'
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
400
|
+
model=self._model,
|
|
401
|
+
dims=('period', 'scenario'),
|
|
402
|
+
label_of_element=self.label_of_element,
|
|
403
|
+
label_of_model=f'{self.label_of_model}(periodic)',
|
|
404
|
+
total_max=self.element.maximum_periodic,
|
|
405
|
+
total_min=self.element.minimum_periodic,
|
|
406
|
+
),
|
|
407
|
+
short_name='periodic',
|
|
130
408
|
)
|
|
131
409
|
|
|
132
|
-
self.
|
|
410
|
+
self.temporal: ShareAllocationModel = self.add_submodels(
|
|
133
411
|
ShareAllocationModel(
|
|
134
|
-
self._model,
|
|
135
|
-
|
|
136
|
-
self.label_of_element,
|
|
137
|
-
'
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
max_per_hour=self.element.maximum_operation_per_hour.active_data
|
|
145
|
-
if self.element.maximum_operation_per_hour is not None
|
|
146
|
-
else None,
|
|
147
|
-
)
|
|
412
|
+
model=self._model,
|
|
413
|
+
dims=('time', 'period', 'scenario'),
|
|
414
|
+
label_of_element=self.label_of_element,
|
|
415
|
+
label_of_model=f'{self.label_of_model}(temporal)',
|
|
416
|
+
total_max=self.element.maximum_temporal,
|
|
417
|
+
total_min=self.element.minimum_temporal,
|
|
418
|
+
min_per_hour=self.element.minimum_per_hour if self.element.minimum_per_hour is not None else None,
|
|
419
|
+
max_per_hour=self.element.maximum_per_hour if self.element.maximum_per_hour is not None else None,
|
|
420
|
+
),
|
|
421
|
+
short_name='temporal',
|
|
148
422
|
)
|
|
149
423
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
self._model.add_variables(
|
|
156
|
-
lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf,
|
|
157
|
-
upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf,
|
|
158
|
-
coords=None,
|
|
159
|
-
name=f'{self.label_full}|total',
|
|
160
|
-
),
|
|
161
|
-
'total',
|
|
424
|
+
self.total = self.add_variables(
|
|
425
|
+
lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf,
|
|
426
|
+
upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf,
|
|
427
|
+
coords=self._model.get_coords(['period', 'scenario']),
|
|
428
|
+
name=self.label_full,
|
|
162
429
|
)
|
|
163
430
|
|
|
164
|
-
self.
|
|
165
|
-
self.
|
|
166
|
-
self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total'
|
|
167
|
-
),
|
|
168
|
-
'total',
|
|
431
|
+
self.add_constraints(
|
|
432
|
+
self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total'
|
|
169
433
|
)
|
|
170
434
|
|
|
171
435
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
""" This datatype is used to define
|
|
436
|
+
TemporalEffectsUser = TemporalDataUser | dict[str, TemporalDataUser] # User-specified Shares to Effects
|
|
437
|
+
""" This datatype is used to define a temporal share to an effect by a certain attribute. """
|
|
438
|
+
|
|
439
|
+
PeriodicEffectsUser = PeriodicDataUser | dict[str, PeriodicDataUser] # User-specified Shares to Effects
|
|
440
|
+
""" This datatype is used to define a scalar share to an effect by a certain attribute. """
|
|
441
|
+
|
|
442
|
+
TemporalEffects = dict[str, TemporalData] # User-specified Shares to Effects
|
|
443
|
+
""" This datatype is used internally to handle temporal shares to an effect. """
|
|
177
444
|
|
|
178
|
-
|
|
179
|
-
""" This datatype is used to
|
|
445
|
+
PeriodicEffects = dict[str, Scalar]
|
|
446
|
+
""" This datatype is used internally to handle scalar shares to an effect. """
|
|
447
|
+
|
|
448
|
+
EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares
|
|
180
449
|
|
|
181
450
|
|
|
182
451
|
class EffectCollection:
|
|
@@ -184,18 +453,18 @@ class EffectCollection:
|
|
|
184
453
|
Handling all Effects
|
|
185
454
|
"""
|
|
186
455
|
|
|
187
|
-
def __init__(self, *effects:
|
|
456
|
+
def __init__(self, *effects: Effect):
|
|
188
457
|
self._effects = {}
|
|
189
|
-
self._standard_effect:
|
|
190
|
-
self._objective_effect:
|
|
458
|
+
self._standard_effect: Effect | None = None
|
|
459
|
+
self._objective_effect: Effect | None = None
|
|
191
460
|
|
|
192
|
-
self.
|
|
461
|
+
self.submodel: EffectCollectionModel | None = None
|
|
193
462
|
self.add_effects(*effects)
|
|
194
463
|
|
|
195
|
-
def create_model(self, model:
|
|
464
|
+
def create_model(self, model: FlowSystemModel) -> EffectCollectionModel:
|
|
196
465
|
self._plausibility_checks()
|
|
197
|
-
self.
|
|
198
|
-
return self.
|
|
466
|
+
self.submodel = EffectCollectionModel(model, self)
|
|
467
|
+
return self.submodel
|
|
199
468
|
|
|
200
469
|
def add_effects(self, *effects: Effect) -> None:
|
|
201
470
|
for effect in list(effects):
|
|
@@ -208,23 +477,27 @@ class EffectCollection:
|
|
|
208
477
|
self._effects[effect.label] = effect
|
|
209
478
|
logger.info(f'Registered new Effect: {effect.label}')
|
|
210
479
|
|
|
211
|
-
def create_effect_values_dict(
|
|
480
|
+
def create_effect_values_dict(
|
|
481
|
+
self, effect_values_user: PeriodicEffectsUser | TemporalEffectsUser
|
|
482
|
+
) -> dict[str, Scalar | TemporalDataUser] | None:
|
|
212
483
|
"""
|
|
213
484
|
Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
|
|
214
485
|
|
|
215
486
|
Examples
|
|
216
487
|
--------
|
|
217
|
-
effect_values_user = 20
|
|
218
|
-
effect_values_user = None
|
|
219
|
-
effect_values_user =
|
|
488
|
+
effect_values_user = 20 -> {'<standard_effect_label>': 20}
|
|
489
|
+
effect_values_user = {None: 20} -> {'<standard_effect_label>': 20}
|
|
490
|
+
effect_values_user = None -> None
|
|
491
|
+
effect_values_user = {'effect1': 20, 'effect2': 0.3} -> {'effect1': 20, 'effect2': 0.3}
|
|
220
492
|
|
|
221
493
|
Returns
|
|
222
494
|
-------
|
|
223
495
|
dict or None
|
|
224
|
-
A dictionary
|
|
496
|
+
A dictionary keyed by effect label, or None if input is None.
|
|
497
|
+
Note: a standard effect must be defined when passing scalars or None labels.
|
|
225
498
|
"""
|
|
226
499
|
|
|
227
|
-
def get_effect_label(eff:
|
|
500
|
+
def get_effect_label(eff: Effect | str) -> str:
|
|
228
501
|
"""Temporary function to get the label of an effect and warn for deprecation"""
|
|
229
502
|
if isinstance(eff, Effect):
|
|
230
503
|
warnings.warn(
|
|
@@ -233,7 +506,9 @@ class EffectCollection:
|
|
|
233
506
|
UserWarning,
|
|
234
507
|
stacklevel=2,
|
|
235
508
|
)
|
|
236
|
-
return eff.
|
|
509
|
+
return eff.label
|
|
510
|
+
elif eff is None:
|
|
511
|
+
return self.standard_effect.label
|
|
237
512
|
else:
|
|
238
513
|
return eff
|
|
239
514
|
|
|
@@ -241,32 +516,29 @@ class EffectCollection:
|
|
|
241
516
|
return None
|
|
242
517
|
if isinstance(effect_values_user, dict):
|
|
243
518
|
return {get_effect_label(effect): value for effect, value in effect_values_user.items()}
|
|
244
|
-
return {self.standard_effect.
|
|
519
|
+
return {self.standard_effect.label: effect_values_user}
|
|
245
520
|
|
|
246
521
|
def _plausibility_checks(self) -> None:
|
|
247
522
|
# Check circular loops in effects:
|
|
248
|
-
|
|
523
|
+
temporal, periodic = self.calculate_effect_share_factors()
|
|
249
524
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
525
|
+
# Validate all referenced sources exist
|
|
526
|
+
unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self.effects}
|
|
527
|
+
if unknown:
|
|
528
|
+
raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}')
|
|
255
529
|
|
|
256
|
-
for
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}'
|
|
267
|
-
)
|
|
530
|
+
temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal]))
|
|
531
|
+
periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic]))
|
|
532
|
+
|
|
533
|
+
if temporal_cycles:
|
|
534
|
+
cycle_str = '\n'.join([' -> '.join(cycle) for cycle in temporal_cycles])
|
|
535
|
+
raise ValueError(f'Error: circular temporal-shares detected:\n{cycle_str}')
|
|
536
|
+
|
|
537
|
+
if periodic_cycles:
|
|
538
|
+
cycle_str = '\n'.join([' -> '.join(cycle) for cycle in periodic_cycles])
|
|
539
|
+
raise ValueError(f'Error: circular periodic-shares detected:\n{cycle_str}')
|
|
268
540
|
|
|
269
|
-
def __getitem__(self, effect:
|
|
541
|
+
def __getitem__(self, effect: str | Effect | None) -> Effect:
|
|
270
542
|
"""
|
|
271
543
|
Get an effect by label, or return the standard effect if None is passed
|
|
272
544
|
|
|
@@ -292,22 +564,28 @@ class EffectCollection:
|
|
|
292
564
|
def __len__(self) -> int:
|
|
293
565
|
return len(self._effects)
|
|
294
566
|
|
|
295
|
-
def __contains__(self, item:
|
|
567
|
+
def __contains__(self, item: str | Effect) -> bool:
|
|
296
568
|
"""Check if the effect exists. Checks for label or object"""
|
|
297
569
|
if isinstance(item, str):
|
|
298
570
|
return item in self.effects # Check if the label exists
|
|
299
571
|
elif isinstance(item, Effect):
|
|
300
|
-
|
|
572
|
+
if item.label_full in self.effects:
|
|
573
|
+
return True
|
|
574
|
+
if item in self.effects.values(): # Check if the object exists
|
|
575
|
+
return True
|
|
301
576
|
return False
|
|
302
577
|
|
|
303
578
|
@property
|
|
304
|
-
def effects(self) ->
|
|
579
|
+
def effects(self) -> dict[str, Effect]:
|
|
305
580
|
return self._effects
|
|
306
581
|
|
|
307
582
|
@property
|
|
308
583
|
def standard_effect(self) -> Effect:
|
|
309
584
|
if self._standard_effect is None:
|
|
310
|
-
raise KeyError(
|
|
585
|
+
raise KeyError(
|
|
586
|
+
'No standard-effect specified! Either set an effect through is_standard=True '
|
|
587
|
+
'or pass a mapping when specifying effect values: {effect_label: value}.'
|
|
588
|
+
)
|
|
311
589
|
return self._standard_effect
|
|
312
590
|
|
|
313
591
|
@standard_effect.setter
|
|
@@ -328,60 +606,248 @@ class EffectCollection:
|
|
|
328
606
|
raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})')
|
|
329
607
|
self._objective_effect = value
|
|
330
608
|
|
|
331
|
-
|
|
332
|
-
|
|
609
|
+
def calculate_effect_share_factors(
|
|
610
|
+
self,
|
|
611
|
+
) -> tuple[
|
|
612
|
+
dict[tuple[str, str], xr.DataArray],
|
|
613
|
+
dict[tuple[str, str], xr.DataArray],
|
|
614
|
+
]:
|
|
615
|
+
shares_periodic = {}
|
|
616
|
+
for name, effect in self.effects.items():
|
|
617
|
+
if effect.share_from_periodic:
|
|
618
|
+
for source, data in effect.share_from_periodic.items():
|
|
619
|
+
if source not in shares_periodic:
|
|
620
|
+
shares_periodic[source] = {}
|
|
621
|
+
shares_periodic[source][name] = data
|
|
622
|
+
shares_periodic = calculate_all_conversion_paths(shares_periodic)
|
|
623
|
+
|
|
624
|
+
shares_temporal = {}
|
|
625
|
+
for name, effect in self.effects.items():
|
|
626
|
+
if effect.share_from_temporal:
|
|
627
|
+
for source, data in effect.share_from_temporal.items():
|
|
628
|
+
if source not in shares_temporal:
|
|
629
|
+
shares_temporal[source] = {}
|
|
630
|
+
shares_temporal[source][name] = data
|
|
631
|
+
shares_temporal = calculate_all_conversion_paths(shares_temporal)
|
|
632
|
+
|
|
633
|
+
return shares_temporal, shares_periodic
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class EffectCollectionModel(Submodel):
|
|
333
637
|
"""
|
|
334
638
|
Handling all Effects
|
|
335
639
|
"""
|
|
336
640
|
|
|
337
|
-
def __init__(self, model:
|
|
338
|
-
super().__init__(model, label_of_element='Effects')
|
|
641
|
+
def __init__(self, model: FlowSystemModel, effects: EffectCollection):
|
|
339
642
|
self.effects = effects
|
|
340
|
-
self.penalty:
|
|
643
|
+
self.penalty: ShareAllocationModel | None = None
|
|
644
|
+
super().__init__(model, label_of_element='Effects')
|
|
341
645
|
|
|
342
646
|
def add_share_to_effects(
|
|
343
647
|
self,
|
|
344
648
|
name: str,
|
|
345
|
-
expressions:
|
|
346
|
-
target: Literal['
|
|
649
|
+
expressions: EffectExpr,
|
|
650
|
+
target: Literal['temporal', 'periodic'],
|
|
347
651
|
) -> None:
|
|
348
652
|
for effect, expression in expressions.items():
|
|
349
|
-
if target == '
|
|
350
|
-
self.effects[effect].
|
|
351
|
-
|
|
352
|
-
|
|
653
|
+
if target == 'temporal':
|
|
654
|
+
self.effects[effect].submodel.temporal.add_share(
|
|
655
|
+
name,
|
|
656
|
+
expression,
|
|
657
|
+
dims=('time', 'period', 'scenario'),
|
|
658
|
+
)
|
|
659
|
+
elif target == 'periodic':
|
|
660
|
+
self.effects[effect].submodel.periodic.add_share(
|
|
661
|
+
name,
|
|
662
|
+
expression,
|
|
663
|
+
dims=('period', 'scenario'),
|
|
664
|
+
)
|
|
353
665
|
else:
|
|
354
666
|
raise ValueError(f'Target {target} not supported!')
|
|
355
667
|
|
|
356
668
|
def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None:
|
|
357
669
|
if expression.ndim != 0:
|
|
358
670
|
raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})')
|
|
359
|
-
self.penalty.add_share(name, expression)
|
|
671
|
+
self.penalty.add_share(name, expression, dims=())
|
|
360
672
|
|
|
361
|
-
def
|
|
673
|
+
def _do_modeling(self):
|
|
674
|
+
super()._do_modeling()
|
|
362
675
|
for effect in self.effects:
|
|
363
676
|
effect.create_model(self._model)
|
|
364
|
-
self.penalty = self.
|
|
365
|
-
ShareAllocationModel(self._model,
|
|
677
|
+
self.penalty = self.add_submodels(
|
|
678
|
+
ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'),
|
|
679
|
+
short_name='penalty',
|
|
366
680
|
)
|
|
367
|
-
for model in [effect.model for effect in self.effects] + [self.penalty]:
|
|
368
|
-
model.do_modeling()
|
|
369
681
|
|
|
370
682
|
self._add_share_between_effects()
|
|
371
683
|
|
|
372
|
-
self._model.add_objective(
|
|
684
|
+
self._model.add_objective(
|
|
685
|
+
(self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum()
|
|
686
|
+
)
|
|
373
687
|
|
|
374
688
|
def _add_share_between_effects(self):
|
|
375
|
-
for
|
|
376
|
-
# 1.
|
|
377
|
-
for
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
689
|
+
for target_effect in self.effects:
|
|
690
|
+
# 1. temporal: <- receiving temporal shares from other effects
|
|
691
|
+
for source_effect, time_series in target_effect.share_from_temporal.items():
|
|
692
|
+
target_effect.submodel.temporal.add_share(
|
|
693
|
+
self.effects[source_effect].submodel.temporal.label_full,
|
|
694
|
+
self.effects[source_effect].submodel.temporal.total_per_timestep * time_series,
|
|
695
|
+
dims=('time', 'period', 'scenario'),
|
|
381
696
|
)
|
|
382
|
-
# 2.
|
|
383
|
-
for
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
697
|
+
# 2. periodic: <- receiving periodic shares from other effects
|
|
698
|
+
for source_effect, factor in target_effect.share_from_periodic.items():
|
|
699
|
+
target_effect.submodel.periodic.add_share(
|
|
700
|
+
self.effects[source_effect].submodel.periodic.label_full,
|
|
701
|
+
self.effects[source_effect].submodel.periodic.total * factor,
|
|
702
|
+
dims=('period', 'scenario'),
|
|
387
703
|
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def calculate_all_conversion_paths(
|
|
707
|
+
conversion_dict: dict[str, dict[str, Scalar | xr.DataArray]],
|
|
708
|
+
) -> dict[tuple[str, str], xr.DataArray]:
|
|
709
|
+
"""
|
|
710
|
+
Calculates all possible direct and indirect conversion factors between units/domains.
|
|
711
|
+
This function uses Breadth-First Search (BFS) to find all possible conversion paths
|
|
712
|
+
between different units or domains in a conversion graph. It computes both direct
|
|
713
|
+
conversions (explicitly provided in the input) and indirect conversions (derived
|
|
714
|
+
through intermediate units).
|
|
715
|
+
Args:
|
|
716
|
+
conversion_dict: A nested dictionary where:
|
|
717
|
+
- Outer keys represent origin units/domains
|
|
718
|
+
- Inner dictionaries map target units/domains to their conversion factors
|
|
719
|
+
- Conversion factors can be integers, floats, or numpy arrays
|
|
720
|
+
Returns:
|
|
721
|
+
A dictionary mapping (origin, target) tuples to their respective conversion factors.
|
|
722
|
+
Each key is a tuple of strings representing the origin and target units/domains.
|
|
723
|
+
Each value is the conversion factor (int, float, or numpy array) from origin to target.
|
|
724
|
+
"""
|
|
725
|
+
# Initialize the result dictionary to accumulate all paths
|
|
726
|
+
result = {}
|
|
727
|
+
|
|
728
|
+
# Add direct connections to the result first
|
|
729
|
+
for origin, targets in conversion_dict.items():
|
|
730
|
+
for target, factor in targets.items():
|
|
731
|
+
result[(origin, target)] = factor
|
|
732
|
+
|
|
733
|
+
# Track all paths by keeping path history to avoid cycles
|
|
734
|
+
# Iterate over each domain in the dictionary
|
|
735
|
+
for origin in conversion_dict:
|
|
736
|
+
# Keep track of visited paths to avoid repeating calculations
|
|
737
|
+
processed_paths = set()
|
|
738
|
+
# Use a queue with (current_domain, factor, path_history)
|
|
739
|
+
queue = deque([(origin, 1, [origin])])
|
|
740
|
+
|
|
741
|
+
while queue:
|
|
742
|
+
current_domain, factor, path = queue.popleft()
|
|
743
|
+
|
|
744
|
+
# Skip if we've processed this exact path before
|
|
745
|
+
path_key = tuple(path)
|
|
746
|
+
if path_key in processed_paths:
|
|
747
|
+
continue
|
|
748
|
+
processed_paths.add(path_key)
|
|
749
|
+
|
|
750
|
+
# Iterate over the neighbors of the current domain
|
|
751
|
+
for target, conversion_factor in conversion_dict.get(current_domain, {}).items():
|
|
752
|
+
# Skip if target would create a cycle
|
|
753
|
+
if target in path:
|
|
754
|
+
continue
|
|
755
|
+
|
|
756
|
+
# Calculate the indirect conversion factor
|
|
757
|
+
indirect_factor = factor * conversion_factor
|
|
758
|
+
new_path = path + [target]
|
|
759
|
+
|
|
760
|
+
# Only consider paths starting at origin and ending at some target
|
|
761
|
+
if len(new_path) > 2 and new_path[0] == origin:
|
|
762
|
+
# Update the result dictionary - accumulate factors from different paths
|
|
763
|
+
if (origin, target) in result:
|
|
764
|
+
result[(origin, target)] = result[(origin, target)] + indirect_factor
|
|
765
|
+
else:
|
|
766
|
+
result[(origin, target)] = indirect_factor
|
|
767
|
+
|
|
768
|
+
# Add new path to queue for further exploration
|
|
769
|
+
queue.append((target, indirect_factor, new_path))
|
|
770
|
+
|
|
771
|
+
# Convert all values to DataArrays
|
|
772
|
+
result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value) for key, value in result.items()}
|
|
773
|
+
|
|
774
|
+
return result
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
778
|
+
"""
|
|
779
|
+
Detects cycles in a directed graph using DFS.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
graph: Adjacency list representation of the graph
|
|
783
|
+
|
|
784
|
+
Returns:
|
|
785
|
+
List of cycles found, where each cycle is a list of nodes
|
|
786
|
+
"""
|
|
787
|
+
# Track nodes in current recursion stack
|
|
788
|
+
visiting = set()
|
|
789
|
+
# Track nodes that have been fully explored
|
|
790
|
+
visited = set()
|
|
791
|
+
# Store all found cycles
|
|
792
|
+
cycles = []
|
|
793
|
+
|
|
794
|
+
def dfs_find_cycles(node, path=None):
|
|
795
|
+
if path is None:
|
|
796
|
+
path = []
|
|
797
|
+
|
|
798
|
+
# Current path to this node
|
|
799
|
+
current_path = path + [node]
|
|
800
|
+
# Add node to current recursion stack
|
|
801
|
+
visiting.add(node)
|
|
802
|
+
|
|
803
|
+
# Check all neighbors
|
|
804
|
+
for neighbor in graph.get(node, []):
|
|
805
|
+
# If neighbor is in current path, we found a cycle
|
|
806
|
+
if neighbor in visiting:
|
|
807
|
+
# Get the cycle by extracting the relevant portion of the path
|
|
808
|
+
cycle_start = current_path.index(neighbor)
|
|
809
|
+
cycle = current_path[cycle_start:] + [neighbor]
|
|
810
|
+
cycles.append(cycle)
|
|
811
|
+
# If neighbor hasn't been fully explored, check it
|
|
812
|
+
elif neighbor not in visited:
|
|
813
|
+
dfs_find_cycles(neighbor, current_path)
|
|
814
|
+
|
|
815
|
+
# Remove node from current path and mark as fully explored
|
|
816
|
+
visiting.remove(node)
|
|
817
|
+
visited.add(node)
|
|
818
|
+
|
|
819
|
+
# Check each unvisited node
|
|
820
|
+
for node in graph:
|
|
821
|
+
if node not in visited:
|
|
822
|
+
dfs_find_cycles(node)
|
|
823
|
+
|
|
824
|
+
return cycles
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def tuples_to_adjacency_list(edges: list[tuple[str, str]]) -> dict[str, list[str]]:
|
|
828
|
+
"""
|
|
829
|
+
Converts a list of edge tuples (source, target) to an adjacency list representation.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
edges: List of (source, target) tuples representing directed edges
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Dictionary mapping each source node to a list of its target nodes
|
|
836
|
+
"""
|
|
837
|
+
graph = {}
|
|
838
|
+
|
|
839
|
+
for source, target in edges:
|
|
840
|
+
if source not in graph:
|
|
841
|
+
graph[source] = []
|
|
842
|
+
graph[source].append(target)
|
|
843
|
+
|
|
844
|
+
# Ensure target nodes with no outgoing edges are in the graph
|
|
845
|
+
if target not in graph:
|
|
846
|
+
graph[target] = []
|
|
847
|
+
|
|
848
|
+
return graph
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# Backward compatibility aliases
|
|
852
|
+
NonTemporalEffectsUser = PeriodicEffectsUser
|
|
853
|
+
NonTemporalEffects = PeriodicEffects
|