flixopt 2.2.0b0__py3-none-any.whl → 2.2.0rc2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- docs/examples/00-Minimal Example.md +1 -1
- docs/examples/01-Basic Example.md +1 -1
- docs/examples/02-Complex Example.md +1 -1
- docs/examples/index.md +1 -1
- docs/faq/contribute.md +26 -14
- docs/faq/index.md +1 -1
- docs/javascripts/mathjax.js +1 -1
- docs/user-guide/Mathematical Notation/Bus.md +1 -1
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +13 -13
- docs/user-guide/Mathematical Notation/Flow.md +1 -1
- docs/user-guide/Mathematical Notation/LinearConverter.md +2 -2
- docs/user-guide/Mathematical Notation/Piecewise.md +1 -1
- docs/user-guide/Mathematical Notation/Storage.md +1 -1
- docs/user-guide/Mathematical Notation/index.md +1 -1
- docs/user-guide/Mathematical Notation/others.md +1 -1
- docs/user-guide/index.md +2 -2
- flixopt/__init__.py +5 -0
- flixopt/aggregation.py +0 -1
- flixopt/calculation.py +40 -72
- flixopt/commons.py +10 -1
- flixopt/components.py +326 -154
- flixopt/core.py +459 -966
- flixopt/effects.py +67 -270
- flixopt/elements.py +76 -84
- flixopt/features.py +172 -154
- flixopt/flow_system.py +70 -99
- flixopt/interface.py +315 -147
- flixopt/io.py +27 -56
- flixopt/linear_converters.py +3 -3
- flixopt/network_app.py +755 -0
- flixopt/plotting.py +16 -34
- flixopt/results.py +108 -806
- flixopt/structure.py +11 -67
- flixopt/utils.py +9 -6
- {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/METADATA +63 -42
- flixopt-2.2.0rc2.dist-info/RECORD +54 -0
- {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/WHEEL +1 -1
- scripts/extract_release_notes.py +45 -0
- 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/Investment.md +0 -115
- flixopt-2.2.0b0.dist-info/RECORD +0 -59
- {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/licenses/LICENSE +0 -0
- {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/top_level.txt +0 -0
flixopt/effects.py
CHANGED
|
@@ -7,13 +7,13 @@ which are then transformed into the internal data structure.
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import warnings
|
|
10
|
-
from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional,
|
|
10
|
+
from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union
|
|
11
11
|
|
|
12
12
|
import linopy
|
|
13
13
|
import numpy as np
|
|
14
|
-
import
|
|
14
|
+
import pandas as pd
|
|
15
15
|
|
|
16
|
-
from .core import NumericDataTS,
|
|
16
|
+
from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection
|
|
17
17
|
from .features import ShareAllocationModel
|
|
18
18
|
from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io
|
|
19
19
|
|
|
@@ -38,16 +38,16 @@ class Effect(Element):
|
|
|
38
38
|
meta_data: Optional[Dict] = None,
|
|
39
39
|
is_standard: bool = False,
|
|
40
40
|
is_objective: bool = False,
|
|
41
|
-
specific_share_to_other_effects_operation: Optional['
|
|
42
|
-
specific_share_to_other_effects_invest: Optional['
|
|
43
|
-
minimum_operation: Optional[
|
|
44
|
-
maximum_operation: Optional[
|
|
45
|
-
minimum_invest: Optional[
|
|
46
|
-
maximum_invest: Optional[
|
|
41
|
+
specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None,
|
|
42
|
+
specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None,
|
|
43
|
+
minimum_operation: Optional[Scalar] = None,
|
|
44
|
+
maximum_operation: Optional[Scalar] = None,
|
|
45
|
+
minimum_invest: Optional[Scalar] = None,
|
|
46
|
+
maximum_invest: Optional[Scalar] = None,
|
|
47
47
|
minimum_operation_per_hour: Optional[NumericDataTS] = None,
|
|
48
48
|
maximum_operation_per_hour: Optional[NumericDataTS] = None,
|
|
49
|
-
minimum_total: Optional[
|
|
50
|
-
maximum_total: Optional[
|
|
49
|
+
minimum_total: Optional[Scalar] = None,
|
|
50
|
+
maximum_total: Optional[Scalar] = None,
|
|
51
51
|
):
|
|
52
52
|
"""
|
|
53
53
|
Args:
|
|
@@ -76,12 +76,10 @@ class Effect(Element):
|
|
|
76
76
|
self.description = description
|
|
77
77
|
self.is_standard = is_standard
|
|
78
78
|
self.is_objective = is_objective
|
|
79
|
-
self.specific_share_to_other_effects_operation:
|
|
79
|
+
self.specific_share_to_other_effects_operation: EffectValuesUser = (
|
|
80
80
|
specific_share_to_other_effects_operation or {}
|
|
81
81
|
)
|
|
82
|
-
self.specific_share_to_other_effects_invest:
|
|
83
|
-
specific_share_to_other_effects_invest or {}
|
|
84
|
-
)
|
|
82
|
+
self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {}
|
|
85
83
|
self.minimum_operation = minimum_operation
|
|
86
84
|
self.maximum_operation = maximum_operation
|
|
87
85
|
self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour
|
|
@@ -96,35 +94,14 @@ class Effect(Element):
|
|
|
96
94
|
f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour
|
|
97
95
|
)
|
|
98
96
|
self.maximum_operation_per_hour = flow_system.create_time_series(
|
|
99
|
-
f'{self.label_full}|maximum_operation_per_hour',
|
|
97
|
+
f'{self.label_full}|maximum_operation_per_hour',
|
|
98
|
+
self.maximum_operation_per_hour,
|
|
100
99
|
)
|
|
100
|
+
|
|
101
101
|
self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series(
|
|
102
102
|
f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation'
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
-
self.minimum_operation = flow_system.create_time_series(
|
|
106
|
-
f'{self.label_full}|minimum_operation', self.minimum_operation, has_time_dim=False
|
|
107
|
-
)
|
|
108
|
-
self.maximum_operation = flow_system.create_time_series(
|
|
109
|
-
f'{self.label_full}|maximum_operation', self.maximum_operation, has_time_dim=False
|
|
110
|
-
)
|
|
111
|
-
self.minimum_invest = flow_system.create_time_series(
|
|
112
|
-
f'{self.label_full}|minimum_invest', self.minimum_invest, has_time_dim=False
|
|
113
|
-
)
|
|
114
|
-
self.maximum_invest = flow_system.create_time_series(
|
|
115
|
-
f'{self.label_full}|maximum_invest', self.maximum_invest, has_time_dim=False
|
|
116
|
-
)
|
|
117
|
-
self.minimum_total = flow_system.create_time_series(
|
|
118
|
-
f'{self.label_full}|minimum_total', self.minimum_total, has_time_dim=False,
|
|
119
|
-
)
|
|
120
|
-
self.maximum_total = flow_system.create_time_series(
|
|
121
|
-
f'{self.label_full}|maximum_total', self.maximum_total, has_time_dim=False
|
|
122
|
-
)
|
|
123
|
-
self.specific_share_to_other_effects_invest = flow_system.create_effect_time_series(
|
|
124
|
-
f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest',
|
|
125
|
-
has_time_dim=False
|
|
126
|
-
)
|
|
127
|
-
|
|
128
105
|
def create_model(self, model: SystemModel) -> 'EffectModel':
|
|
129
106
|
self._plausibility_checks()
|
|
130
107
|
self.model = EffectModel(model, self)
|
|
@@ -142,29 +119,31 @@ class EffectModel(ElementModel):
|
|
|
142
119
|
self.total: Optional[linopy.Variable] = None
|
|
143
120
|
self.invest: ShareAllocationModel = self.add(
|
|
144
121
|
ShareAllocationModel(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
label='invest',
|
|
122
|
+
self._model,
|
|
123
|
+
False,
|
|
124
|
+
self.label_of_element,
|
|
125
|
+
'invest',
|
|
150
126
|
label_full=f'{self.label_full}(invest)',
|
|
151
|
-
total_max=
|
|
152
|
-
total_min=
|
|
127
|
+
total_max=self.element.maximum_invest,
|
|
128
|
+
total_min=self.element.minimum_invest,
|
|
153
129
|
)
|
|
154
130
|
)
|
|
155
131
|
|
|
156
132
|
self.operation: ShareAllocationModel = self.add(
|
|
157
133
|
ShareAllocationModel(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
label='operation',
|
|
134
|
+
self._model,
|
|
135
|
+
True,
|
|
136
|
+
self.label_of_element,
|
|
137
|
+
'operation',
|
|
163
138
|
label_full=f'{self.label_full}(operation)',
|
|
164
|
-
total_max=
|
|
165
|
-
total_min=
|
|
166
|
-
min_per_hour=
|
|
167
|
-
|
|
139
|
+
total_max=self.element.maximum_operation,
|
|
140
|
+
total_min=self.element.minimum_operation,
|
|
141
|
+
min_per_hour=self.element.minimum_operation_per_hour.active_data
|
|
142
|
+
if self.element.minimum_operation_per_hour is not None
|
|
143
|
+
else None,
|
|
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,
|
|
168
147
|
)
|
|
169
148
|
)
|
|
170
149
|
|
|
@@ -174,9 +153,9 @@ class EffectModel(ElementModel):
|
|
|
174
153
|
|
|
175
154
|
self.total = self.add(
|
|
176
155
|
self._model.add_variables(
|
|
177
|
-
lower=
|
|
178
|
-
upper=
|
|
179
|
-
coords=
|
|
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,
|
|
180
159
|
name=f'{self.label_full}|total',
|
|
181
160
|
),
|
|
182
161
|
'total',
|
|
@@ -184,7 +163,7 @@ class EffectModel(ElementModel):
|
|
|
184
163
|
|
|
185
164
|
self.add(
|
|
186
165
|
self._model.add_constraints(
|
|
187
|
-
self.total == self.operation.total + self.invest.total, name=f'{self.label_full}|total'
|
|
166
|
+
self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total'
|
|
188
167
|
),
|
|
189
168
|
'total',
|
|
190
169
|
)
|
|
@@ -193,12 +172,11 @@ class EffectModel(ElementModel):
|
|
|
193
172
|
EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares
|
|
194
173
|
EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values
|
|
195
174
|
EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored
|
|
175
|
+
EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects
|
|
176
|
+
""" This datatype is used to define the share to an effect by a certain attribute. """
|
|
196
177
|
|
|
197
|
-
|
|
198
|
-
""" This datatype is used to define the share to an effect
|
|
199
|
-
|
|
200
|
-
EffectValuesUserTimestep = Union[TimestepData, Dict[str, TimestepData]]
|
|
201
|
-
""" This datatype is used to define the share to an effect for every timestep. """
|
|
178
|
+
EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects
|
|
179
|
+
""" This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """
|
|
202
180
|
|
|
203
181
|
|
|
204
182
|
class EffectCollection:
|
|
@@ -230,9 +208,7 @@ class EffectCollection:
|
|
|
230
208
|
self._effects[effect.label] = effect
|
|
231
209
|
logger.info(f'Registered new Effect: {effect.label}')
|
|
232
210
|
|
|
233
|
-
def create_effect_values_dict(
|
|
234
|
-
self, effect_values_user: Union[EffectValuesUserScenario, EffectValuesUserTimestep]
|
|
235
|
-
) -> Optional[EffectValuesDict]:
|
|
211
|
+
def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]:
|
|
236
212
|
"""
|
|
237
213
|
Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
|
|
238
214
|
|
|
@@ -269,18 +245,26 @@ class EffectCollection:
|
|
|
269
245
|
|
|
270
246
|
def _plausibility_checks(self) -> None:
|
|
271
247
|
# Check circular loops in effects:
|
|
272
|
-
|
|
248
|
+
# TODO: Improve checks!! Only most basic case covered...
|
|
273
249
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
raise ValueError(f'Error: circular operation-shares detected:\n{cycle_str}')
|
|
250
|
+
def error_str(effect_label: str, share_ffect_label: str):
|
|
251
|
+
return (
|
|
252
|
+
f' {effect_label} -> has share in: {share_ffect_label}\n'
|
|
253
|
+
f' {share_ffect_label} -> has share in: {effect_label}'
|
|
254
|
+
)
|
|
280
255
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
256
|
+
for effect in self.effects.values():
|
|
257
|
+
# Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen:
|
|
258
|
+
# operation:
|
|
259
|
+
for target_effect in effect.specific_share_to_other_effects_operation.keys():
|
|
260
|
+
assert effect not in self[target_effect].specific_share_to_other_effects_operation.keys(), (
|
|
261
|
+
f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}'
|
|
262
|
+
)
|
|
263
|
+
# invest:
|
|
264
|
+
for target_effect in effect.specific_share_to_other_effects_invest.keys():
|
|
265
|
+
assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), (
|
|
266
|
+
f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}'
|
|
267
|
+
)
|
|
284
268
|
|
|
285
269
|
def __getitem__(self, effect: Union[str, Effect]) -> 'Effect':
|
|
286
270
|
"""
|
|
@@ -344,30 +328,6 @@ class EffectCollection:
|
|
|
344
328
|
raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})')
|
|
345
329
|
self._objective_effect = value
|
|
346
330
|
|
|
347
|
-
def calculate_effect_share_factors(self) -> Tuple[
|
|
348
|
-
Dict[Tuple[str, str], xr.DataArray],
|
|
349
|
-
Dict[Tuple[str, str], xr.DataArray],
|
|
350
|
-
]:
|
|
351
|
-
shares_invest = {}
|
|
352
|
-
for name, effect in self.effects.items():
|
|
353
|
-
if effect.specific_share_to_other_effects_invest:
|
|
354
|
-
shares_invest[name] = {
|
|
355
|
-
target: extract_data(data)
|
|
356
|
-
for target, data in effect.specific_share_to_other_effects_invest.items()
|
|
357
|
-
}
|
|
358
|
-
shares_invest = calculate_all_conversion_paths(shares_invest)
|
|
359
|
-
|
|
360
|
-
shares_operation = {}
|
|
361
|
-
for name, effect in self.effects.items():
|
|
362
|
-
if effect.specific_share_to_other_effects_operation:
|
|
363
|
-
shares_operation[name] = {
|
|
364
|
-
target: extract_data(data)
|
|
365
|
-
for target, data in effect.specific_share_to_other_effects_operation.items()
|
|
366
|
-
}
|
|
367
|
-
shares_operation = calculate_all_conversion_paths(shares_operation)
|
|
368
|
-
|
|
369
|
-
return shares_operation, shares_invest
|
|
370
|
-
|
|
371
331
|
|
|
372
332
|
class EffectCollectionModel(Model):
|
|
373
333
|
"""
|
|
@@ -387,42 +347,29 @@ class EffectCollectionModel(Model):
|
|
|
387
347
|
) -> None:
|
|
388
348
|
for effect, expression in expressions.items():
|
|
389
349
|
if target == 'operation':
|
|
390
|
-
self.effects[effect].model.operation.add_share(
|
|
391
|
-
name,
|
|
392
|
-
expression,
|
|
393
|
-
has_time_dim=True,
|
|
394
|
-
has_scenario_dim=True,
|
|
395
|
-
)
|
|
350
|
+
self.effects[effect].model.operation.add_share(name, expression)
|
|
396
351
|
elif target == 'invest':
|
|
397
|
-
self.effects[effect].model.invest.add_share(
|
|
398
|
-
name,
|
|
399
|
-
expression,
|
|
400
|
-
has_time_dim=False,
|
|
401
|
-
has_scenario_dim=True,
|
|
402
|
-
)
|
|
352
|
+
self.effects[effect].model.invest.add_share(name, expression)
|
|
403
353
|
else:
|
|
404
354
|
raise ValueError(f'Target {target} not supported!')
|
|
405
355
|
|
|
406
356
|
def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None:
|
|
407
357
|
if expression.ndim != 0:
|
|
408
358
|
raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})')
|
|
409
|
-
self.penalty.add_share(name, expression
|
|
359
|
+
self.penalty.add_share(name, expression)
|
|
410
360
|
|
|
411
361
|
def do_modeling(self):
|
|
412
362
|
for effect in self.effects:
|
|
413
363
|
effect.create_model(self._model)
|
|
414
364
|
self.penalty = self.add(
|
|
415
|
-
ShareAllocationModel(self._model,
|
|
365
|
+
ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')
|
|
416
366
|
)
|
|
417
367
|
for model in [effect.model for effect in self.effects] + [self.penalty]:
|
|
418
368
|
model.do_modeling()
|
|
419
369
|
|
|
420
370
|
self._add_share_between_effects()
|
|
421
371
|
|
|
422
|
-
self._model.add_objective(
|
|
423
|
-
(self.effects.objective_effect.model.total * self._model.scenario_weights).sum()
|
|
424
|
-
+ self.penalty.total.sum()
|
|
425
|
-
)
|
|
372
|
+
self._model.add_objective(self.effects.objective_effect.model.total + self.penalty.total)
|
|
426
373
|
|
|
427
374
|
def _add_share_between_effects(self):
|
|
428
375
|
for origin_effect in self.effects:
|
|
@@ -430,161 +377,11 @@ class EffectCollectionModel(Model):
|
|
|
430
377
|
for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items():
|
|
431
378
|
self.effects[target_effect].model.operation.add_share(
|
|
432
379
|
origin_effect.model.operation.label_full,
|
|
433
|
-
origin_effect.model.operation.total_per_timestep * time_series.
|
|
434
|
-
has_time_dim=True,
|
|
435
|
-
has_scenario_dim=True,
|
|
380
|
+
origin_effect.model.operation.total_per_timestep * time_series.active_data,
|
|
436
381
|
)
|
|
437
382
|
# 2. invest: -> hier ist es Scalar (share)
|
|
438
383
|
for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items():
|
|
439
384
|
self.effects[target_effect].model.invest.add_share(
|
|
440
385
|
origin_effect.model.invest.label_full,
|
|
441
386
|
origin_effect.model.invest.total * factor,
|
|
442
|
-
has_time_dim=False,
|
|
443
|
-
has_scenario_dim=True,
|
|
444
387
|
)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def calculate_all_conversion_paths(
|
|
448
|
-
conversion_dict: Dict[str, Dict[str, xr.DataArray]],
|
|
449
|
-
) -> Dict[Tuple[str, str], xr.DataArray]:
|
|
450
|
-
"""
|
|
451
|
-
Calculates all possible direct and indirect conversion factors between units/domains.
|
|
452
|
-
This function uses Breadth-First Search (BFS) to find all possible conversion paths
|
|
453
|
-
between different units or domains in a conversion graph. It computes both direct
|
|
454
|
-
conversions (explicitly provided in the input) and indirect conversions (derived
|
|
455
|
-
through intermediate units).
|
|
456
|
-
Args:
|
|
457
|
-
conversion_dict: A nested dictionary where:
|
|
458
|
-
- Outer keys represent origin units/domains
|
|
459
|
-
- Inner dictionaries map target units/domains to their conversion factors
|
|
460
|
-
- Conversion factors can be integers, floats, or numpy arrays
|
|
461
|
-
Returns:
|
|
462
|
-
A dictionary mapping (origin, target) tuples to their respective conversion factors.
|
|
463
|
-
Each key is a tuple of strings representing the origin and target units/domains.
|
|
464
|
-
Each value is the conversion factor (int, float, or numpy array) from origin to target.
|
|
465
|
-
"""
|
|
466
|
-
# Initialize the result dictionary to accumulate all paths
|
|
467
|
-
result = {}
|
|
468
|
-
|
|
469
|
-
# Add direct connections to the result first
|
|
470
|
-
for origin, targets in conversion_dict.items():
|
|
471
|
-
for target, factor in targets.items():
|
|
472
|
-
result[(origin, target)] = factor
|
|
473
|
-
|
|
474
|
-
# Track all paths by keeping path history to avoid cycles
|
|
475
|
-
# Iterate over each domain in the dictionary
|
|
476
|
-
for origin in conversion_dict:
|
|
477
|
-
# Keep track of visited paths to avoid repeating calculations
|
|
478
|
-
processed_paths = set()
|
|
479
|
-
# Use a queue with (current_domain, factor, path_history)
|
|
480
|
-
queue = [(origin, 1, [origin])]
|
|
481
|
-
|
|
482
|
-
while queue:
|
|
483
|
-
current_domain, factor, path = queue.pop(0)
|
|
484
|
-
|
|
485
|
-
# Skip if we've processed this exact path before
|
|
486
|
-
path_key = tuple(path)
|
|
487
|
-
if path_key in processed_paths:
|
|
488
|
-
continue
|
|
489
|
-
processed_paths.add(path_key)
|
|
490
|
-
|
|
491
|
-
# Iterate over the neighbors of the current domain
|
|
492
|
-
for target, conversion_factor in conversion_dict.get(current_domain, {}).items():
|
|
493
|
-
# Skip if target would create a cycle
|
|
494
|
-
if target in path:
|
|
495
|
-
continue
|
|
496
|
-
|
|
497
|
-
# Calculate the indirect conversion factor
|
|
498
|
-
indirect_factor = factor * conversion_factor
|
|
499
|
-
new_path = path + [target]
|
|
500
|
-
|
|
501
|
-
# Only consider paths starting at origin and ending at some target
|
|
502
|
-
if len(new_path) > 2 and new_path[0] == origin:
|
|
503
|
-
# Update the result dictionary - accumulate factors from different paths
|
|
504
|
-
if (origin, target) in result:
|
|
505
|
-
result[(origin, target)] = result[(origin, target)] + indirect_factor
|
|
506
|
-
else:
|
|
507
|
-
result[(origin, target)] = indirect_factor
|
|
508
|
-
|
|
509
|
-
# Add new path to queue for further exploration
|
|
510
|
-
queue.append((target, indirect_factor, new_path))
|
|
511
|
-
|
|
512
|
-
# Convert all values to DataArrays
|
|
513
|
-
result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value)
|
|
514
|
-
for key, value in result.items()}
|
|
515
|
-
|
|
516
|
-
return result
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
def detect_cycles(graph: Dict[str, List[str]]) -> List[List[str]]:
|
|
520
|
-
"""
|
|
521
|
-
Detects cycles in a directed graph using DFS.
|
|
522
|
-
|
|
523
|
-
Args:
|
|
524
|
-
graph: Adjacency list representation of the graph
|
|
525
|
-
|
|
526
|
-
Returns:
|
|
527
|
-
List of cycles found, where each cycle is a list of nodes
|
|
528
|
-
"""
|
|
529
|
-
# Track nodes in current recursion stack
|
|
530
|
-
visiting = set()
|
|
531
|
-
# Track nodes that have been fully explored
|
|
532
|
-
visited = set()
|
|
533
|
-
# Store all found cycles
|
|
534
|
-
cycles = []
|
|
535
|
-
|
|
536
|
-
def dfs_find_cycles(node, path=None):
|
|
537
|
-
if path is None:
|
|
538
|
-
path = []
|
|
539
|
-
|
|
540
|
-
# Current path to this node
|
|
541
|
-
current_path = path + [node]
|
|
542
|
-
# Add node to current recursion stack
|
|
543
|
-
visiting.add(node)
|
|
544
|
-
|
|
545
|
-
# Check all neighbors
|
|
546
|
-
for neighbor in graph.get(node, []):
|
|
547
|
-
# If neighbor is in current path, we found a cycle
|
|
548
|
-
if neighbor in visiting:
|
|
549
|
-
# Get the cycle by extracting the relevant portion of the path
|
|
550
|
-
cycle_start = current_path.index(neighbor)
|
|
551
|
-
cycle = current_path[cycle_start:] + [neighbor]
|
|
552
|
-
cycles.append(cycle)
|
|
553
|
-
# If neighbor hasn't been fully explored, check it
|
|
554
|
-
elif neighbor not in visited:
|
|
555
|
-
dfs_find_cycles(neighbor, current_path)
|
|
556
|
-
|
|
557
|
-
# Remove node from current path and mark as fully explored
|
|
558
|
-
visiting.remove(node)
|
|
559
|
-
visited.add(node)
|
|
560
|
-
|
|
561
|
-
# Check each unvisited node
|
|
562
|
-
for node in graph:
|
|
563
|
-
if node not in visited:
|
|
564
|
-
dfs_find_cycles(node)
|
|
565
|
-
|
|
566
|
-
return cycles
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
def tuples_to_adjacency_list(edges: List[Tuple[str, str]]) -> Dict[str, List[str]]:
|
|
570
|
-
"""
|
|
571
|
-
Converts a list of edge tuples (source, target) to an adjacency list representation.
|
|
572
|
-
|
|
573
|
-
Args:
|
|
574
|
-
edges: List of (source, target) tuples representing directed edges
|
|
575
|
-
|
|
576
|
-
Returns:
|
|
577
|
-
Dictionary mapping each source node to a list of its target nodes
|
|
578
|
-
"""
|
|
579
|
-
graph = {}
|
|
580
|
-
|
|
581
|
-
for source, target in edges:
|
|
582
|
-
if source not in graph:
|
|
583
|
-
graph[source] = []
|
|
584
|
-
graph[source].append(target)
|
|
585
|
-
|
|
586
|
-
# Ensure target nodes with no outgoing edges are in the graph
|
|
587
|
-
if target not in graph:
|
|
588
|
-
graph[target] = []
|
|
589
|
-
|
|
590
|
-
return graph
|