flixopt 2.1.0__py3-none-any.whl → 2.2.0b0__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/release-notes/v2.2.0.md +55 -0
- docs/user-guide/Mathematical Notation/Investment.md +115 -0
- flixopt/calculation.py +65 -37
- flixopt/components.py +119 -74
- flixopt/core.py +966 -451
- flixopt/effects.py +269 -65
- flixopt/elements.py +83 -52
- flixopt/features.py +134 -85
- flixopt/flow_system.py +99 -16
- flixopt/interface.py +142 -51
- flixopt/io.py +56 -27
- flixopt/linear_converters.py +3 -3
- flixopt/plotting.py +34 -16
- flixopt/results.py +807 -109
- flixopt/structure.py +64 -10
- flixopt/utils.py +6 -9
- {flixopt-2.1.0.dist-info → flixopt-2.2.0b0.dist-info}/METADATA +1 -1
- {flixopt-2.1.0.dist-info → flixopt-2.2.0b0.dist-info}/RECORD +21 -20
- {flixopt-2.1.0.dist-info → flixopt-2.2.0b0.dist-info}/WHEEL +1 -1
- {flixopt-2.1.0.dist-info → flixopt-2.2.0b0.dist-info}/top_level.txt +0 -1
- site/release-notes/_template.txt +0 -32
- {flixopt-2.1.0.dist-info → flixopt-2.2.0b0.dist-info}/licenses/LICENSE +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, Union
|
|
10
|
+
from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Set, Tuple, Union
|
|
11
11
|
|
|
12
12
|
import linopy
|
|
13
13
|
import numpy as np
|
|
14
|
-
import
|
|
14
|
+
import xarray as xr
|
|
15
15
|
|
|
16
|
-
from .core import
|
|
16
|
+
from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData, extract_data
|
|
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['EffectValuesUserTimestep'] = None,
|
|
42
|
+
specific_share_to_other_effects_invest: Optional['EffectValuesUserScenario'] = None,
|
|
43
|
+
minimum_operation: Optional[ScenarioData] = None,
|
|
44
|
+
maximum_operation: Optional[ScenarioData] = None,
|
|
45
|
+
minimum_invest: Optional[ScenarioData] = None,
|
|
46
|
+
maximum_invest: Optional[ScenarioData] = 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[ScenarioData] = None,
|
|
50
|
+
maximum_total: Optional[ScenarioData] = None,
|
|
51
51
|
):
|
|
52
52
|
"""
|
|
53
53
|
Args:
|
|
@@ -76,10 +76,12 @@ 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: EffectValuesUserTimestep = (
|
|
80
80
|
specific_share_to_other_effects_operation or {}
|
|
81
81
|
)
|
|
82
|
-
self.specific_share_to_other_effects_invest:
|
|
82
|
+
self.specific_share_to_other_effects_invest: EffectValuesUserTimestep = (
|
|
83
|
+
specific_share_to_other_effects_invest or {}
|
|
84
|
+
)
|
|
83
85
|
self.minimum_operation = minimum_operation
|
|
84
86
|
self.maximum_operation = maximum_operation
|
|
85
87
|
self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour
|
|
@@ -96,11 +98,33 @@ class Effect(Element):
|
|
|
96
98
|
self.maximum_operation_per_hour = flow_system.create_time_series(
|
|
97
99
|
f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system
|
|
98
100
|
)
|
|
99
|
-
|
|
100
101
|
self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series(
|
|
101
102
|
f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation'
|
|
102
103
|
)
|
|
103
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
|
+
|
|
104
128
|
def create_model(self, model: SystemModel) -> 'EffectModel':
|
|
105
129
|
self._plausibility_checks()
|
|
106
130
|
self.model = EffectModel(model, self)
|
|
@@ -118,31 +142,29 @@ class EffectModel(ElementModel):
|
|
|
118
142
|
self.total: Optional[linopy.Variable] = None
|
|
119
143
|
self.invest: ShareAllocationModel = self.add(
|
|
120
144
|
ShareAllocationModel(
|
|
121
|
-
self._model,
|
|
122
|
-
False,
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
model=self._model,
|
|
146
|
+
has_time_dim=False,
|
|
147
|
+
has_scenario_dim=True,
|
|
148
|
+
label_of_element=self.label_of_element,
|
|
149
|
+
label='invest',
|
|
125
150
|
label_full=f'{self.label_full}(invest)',
|
|
126
|
-
total_max=self.element.maximum_invest,
|
|
127
|
-
total_min=self.element.minimum_invest,
|
|
151
|
+
total_max=extract_data(self.element.maximum_invest),
|
|
152
|
+
total_min=extract_data(self.element.minimum_invest),
|
|
128
153
|
)
|
|
129
154
|
)
|
|
130
155
|
|
|
131
156
|
self.operation: ShareAllocationModel = self.add(
|
|
132
157
|
ShareAllocationModel(
|
|
133
|
-
self._model,
|
|
134
|
-
True,
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
model=self._model,
|
|
159
|
+
has_time_dim=True,
|
|
160
|
+
has_scenario_dim=True,
|
|
161
|
+
label_of_element=self.label_of_element,
|
|
162
|
+
label='operation',
|
|
137
163
|
label_full=f'{self.label_full}(operation)',
|
|
138
|
-
total_max=self.element.maximum_operation,
|
|
139
|
-
total_min=self.element.minimum_operation,
|
|
140
|
-
min_per_hour=self.element.minimum_operation_per_hour
|
|
141
|
-
|
|
142
|
-
else None,
|
|
143
|
-
max_per_hour=self.element.maximum_operation_per_hour.active_data
|
|
144
|
-
if self.element.maximum_operation_per_hour is not None
|
|
145
|
-
else None,
|
|
164
|
+
total_max=extract_data(self.element.maximum_operation),
|
|
165
|
+
total_min=extract_data(self.element.minimum_operation),
|
|
166
|
+
min_per_hour=extract_data(self.element.minimum_operation_per_hour),
|
|
167
|
+
max_per_hour=extract_data(self.element.maximum_operation_per_hour),
|
|
146
168
|
)
|
|
147
169
|
)
|
|
148
170
|
|
|
@@ -152,9 +174,9 @@ class EffectModel(ElementModel):
|
|
|
152
174
|
|
|
153
175
|
self.total = self.add(
|
|
154
176
|
self._model.add_variables(
|
|
155
|
-
lower=self.element.minimum_total
|
|
156
|
-
upper=self.element.maximum_total
|
|
157
|
-
coords=
|
|
177
|
+
lower=extract_data(self.element.minimum_total, if_none=-np.inf),
|
|
178
|
+
upper=extract_data(self.element.maximum_total, if_none=np.inf),
|
|
179
|
+
coords=self._model.get_coords(time_dim=False),
|
|
158
180
|
name=f'{self.label_full}|total',
|
|
159
181
|
),
|
|
160
182
|
'total',
|
|
@@ -162,7 +184,7 @@ class EffectModel(ElementModel):
|
|
|
162
184
|
|
|
163
185
|
self.add(
|
|
164
186
|
self._model.add_constraints(
|
|
165
|
-
self.total == self.operation.total
|
|
187
|
+
self.total == self.operation.total + self.invest.total, name=f'{self.label_full}|total'
|
|
166
188
|
),
|
|
167
189
|
'total',
|
|
168
190
|
)
|
|
@@ -171,11 +193,12 @@ class EffectModel(ElementModel):
|
|
|
171
193
|
EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares
|
|
172
194
|
EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values
|
|
173
195
|
EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored
|
|
174
|
-
EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects
|
|
175
|
-
""" This datatype is used to define the share to an effect by a certain attribute. """
|
|
176
196
|
|
|
177
|
-
|
|
178
|
-
""" This datatype is used to define the share to an effect
|
|
197
|
+
EffectValuesUserScenario = Union[ScenarioData, Dict[str, ScenarioData]]
|
|
198
|
+
""" This datatype is used to define the share to an effect for every scenario. """
|
|
199
|
+
|
|
200
|
+
EffectValuesUserTimestep = Union[TimestepData, Dict[str, TimestepData]]
|
|
201
|
+
""" This datatype is used to define the share to an effect for every timestep. """
|
|
179
202
|
|
|
180
203
|
|
|
181
204
|
class EffectCollection:
|
|
@@ -207,7 +230,9 @@ class EffectCollection:
|
|
|
207
230
|
self._effects[effect.label] = effect
|
|
208
231
|
logger.info(f'Registered new Effect: {effect.label}')
|
|
209
232
|
|
|
210
|
-
def create_effect_values_dict(
|
|
233
|
+
def create_effect_values_dict(
|
|
234
|
+
self, effect_values_user: Union[EffectValuesUserScenario, EffectValuesUserTimestep]
|
|
235
|
+
) -> Optional[EffectValuesDict]:
|
|
211
236
|
"""
|
|
212
237
|
Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
|
|
213
238
|
|
|
@@ -244,26 +269,18 @@ class EffectCollection:
|
|
|
244
269
|
|
|
245
270
|
def _plausibility_checks(self) -> None:
|
|
246
271
|
# Check circular loops in effects:
|
|
247
|
-
|
|
272
|
+
operation, invest = self.calculate_effect_share_factors()
|
|
248
273
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
f' {effect_label} -> has share in: {share_ffect_label}\n'
|
|
252
|
-
f' {share_ffect_label} -> has share in: {effect_label}'
|
|
253
|
-
)
|
|
274
|
+
operation_cycles = detect_cycles(tuples_to_adjacency_list([key for key in operation]))
|
|
275
|
+
invest_cycles = detect_cycles(tuples_to_adjacency_list([key for key in invest]))
|
|
254
276
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
# invest:
|
|
263
|
-
for target_effect in effect.specific_share_to_other_effects_invest.keys():
|
|
264
|
-
assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), (
|
|
265
|
-
f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}'
|
|
266
|
-
)
|
|
277
|
+
if operation_cycles:
|
|
278
|
+
cycle_str = "\n".join([" -> ".join(cycle) for cycle in operation_cycles])
|
|
279
|
+
raise ValueError(f'Error: circular operation-shares detected:\n{cycle_str}')
|
|
280
|
+
|
|
281
|
+
if invest_cycles:
|
|
282
|
+
cycle_str = "\n".join([" -> ".join(cycle) for cycle in invest_cycles])
|
|
283
|
+
raise ValueError(f'Error: circular invest-shares detected:\n{cycle_str}')
|
|
267
284
|
|
|
268
285
|
def __getitem__(self, effect: Union[str, Effect]) -> 'Effect':
|
|
269
286
|
"""
|
|
@@ -327,6 +344,30 @@ class EffectCollection:
|
|
|
327
344
|
raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})')
|
|
328
345
|
self._objective_effect = value
|
|
329
346
|
|
|
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
|
+
|
|
330
371
|
|
|
331
372
|
class EffectCollectionModel(Model):
|
|
332
373
|
"""
|
|
@@ -346,29 +387,42 @@ class EffectCollectionModel(Model):
|
|
|
346
387
|
) -> None:
|
|
347
388
|
for effect, expression in expressions.items():
|
|
348
389
|
if target == 'operation':
|
|
349
|
-
self.effects[effect].model.operation.add_share(
|
|
390
|
+
self.effects[effect].model.operation.add_share(
|
|
391
|
+
name,
|
|
392
|
+
expression,
|
|
393
|
+
has_time_dim=True,
|
|
394
|
+
has_scenario_dim=True,
|
|
395
|
+
)
|
|
350
396
|
elif target == 'invest':
|
|
351
|
-
self.effects[effect].model.invest.add_share(
|
|
397
|
+
self.effects[effect].model.invest.add_share(
|
|
398
|
+
name,
|
|
399
|
+
expression,
|
|
400
|
+
has_time_dim=False,
|
|
401
|
+
has_scenario_dim=True,
|
|
402
|
+
)
|
|
352
403
|
else:
|
|
353
404
|
raise ValueError(f'Target {target} not supported!')
|
|
354
405
|
|
|
355
406
|
def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None:
|
|
356
407
|
if expression.ndim != 0:
|
|
357
408
|
raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})')
|
|
358
|
-
self.penalty.add_share(name, expression)
|
|
409
|
+
self.penalty.add_share(name, expression, has_time_dim=False, has_scenario_dim=False)
|
|
359
410
|
|
|
360
411
|
def do_modeling(self):
|
|
361
412
|
for effect in self.effects:
|
|
362
413
|
effect.create_model(self._model)
|
|
363
414
|
self.penalty = self.add(
|
|
364
|
-
ShareAllocationModel(self._model,
|
|
415
|
+
ShareAllocationModel(self._model, has_time_dim=False, has_scenario_dim=False, label_of_element='Penalty')
|
|
365
416
|
)
|
|
366
417
|
for model in [effect.model for effect in self.effects] + [self.penalty]:
|
|
367
418
|
model.do_modeling()
|
|
368
419
|
|
|
369
420
|
self._add_share_between_effects()
|
|
370
421
|
|
|
371
|
-
self._model.add_objective(
|
|
422
|
+
self._model.add_objective(
|
|
423
|
+
(self.effects.objective_effect.model.total * self._model.scenario_weights).sum()
|
|
424
|
+
+ self.penalty.total.sum()
|
|
425
|
+
)
|
|
372
426
|
|
|
373
427
|
def _add_share_between_effects(self):
|
|
374
428
|
for origin_effect in self.effects:
|
|
@@ -376,11 +430,161 @@ class EffectCollectionModel(Model):
|
|
|
376
430
|
for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items():
|
|
377
431
|
self.effects[target_effect].model.operation.add_share(
|
|
378
432
|
origin_effect.model.operation.label_full,
|
|
379
|
-
origin_effect.model.operation.total_per_timestep * time_series.
|
|
433
|
+
origin_effect.model.operation.total_per_timestep * time_series.selected_data,
|
|
434
|
+
has_time_dim=True,
|
|
435
|
+
has_scenario_dim=True,
|
|
380
436
|
)
|
|
381
437
|
# 2. invest: -> hier ist es Scalar (share)
|
|
382
438
|
for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items():
|
|
383
439
|
self.effects[target_effect].model.invest.add_share(
|
|
384
440
|
origin_effect.model.invest.label_full,
|
|
385
441
|
origin_effect.model.invest.total * factor,
|
|
442
|
+
has_time_dim=False,
|
|
443
|
+
has_scenario_dim=True,
|
|
386
444
|
)
|
|
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
|