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.

Files changed (48) hide show
  1. docs/examples/00-Minimal Example.md +1 -1
  2. docs/examples/01-Basic Example.md +1 -1
  3. docs/examples/02-Complex Example.md +1 -1
  4. docs/examples/index.md +1 -1
  5. docs/faq/contribute.md +26 -14
  6. docs/faq/index.md +1 -1
  7. docs/javascripts/mathjax.js +1 -1
  8. docs/user-guide/Mathematical Notation/Bus.md +1 -1
  9. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +13 -13
  10. docs/user-guide/Mathematical Notation/Flow.md +1 -1
  11. docs/user-guide/Mathematical Notation/LinearConverter.md +2 -2
  12. docs/user-guide/Mathematical Notation/Piecewise.md +1 -1
  13. docs/user-guide/Mathematical Notation/Storage.md +1 -1
  14. docs/user-guide/Mathematical Notation/index.md +1 -1
  15. docs/user-guide/Mathematical Notation/others.md +1 -1
  16. docs/user-guide/index.md +2 -2
  17. flixopt/__init__.py +5 -0
  18. flixopt/aggregation.py +0 -1
  19. flixopt/calculation.py +40 -72
  20. flixopt/commons.py +10 -1
  21. flixopt/components.py +326 -154
  22. flixopt/core.py +459 -966
  23. flixopt/effects.py +67 -270
  24. flixopt/elements.py +76 -84
  25. flixopt/features.py +172 -154
  26. flixopt/flow_system.py +70 -99
  27. flixopt/interface.py +315 -147
  28. flixopt/io.py +27 -56
  29. flixopt/linear_converters.py +3 -3
  30. flixopt/network_app.py +755 -0
  31. flixopt/plotting.py +16 -34
  32. flixopt/results.py +108 -806
  33. flixopt/structure.py +11 -67
  34. flixopt/utils.py +9 -6
  35. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/METADATA +63 -42
  36. flixopt-2.2.0rc2.dist-info/RECORD +54 -0
  37. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/WHEEL +1 -1
  38. scripts/extract_release_notes.py +45 -0
  39. docs/release-notes/_template.txt +0 -32
  40. docs/release-notes/index.md +0 -7
  41. docs/release-notes/v2.0.0.md +0 -93
  42. docs/release-notes/v2.0.1.md +0 -12
  43. docs/release-notes/v2.1.0.md +0 -31
  44. docs/release-notes/v2.2.0.md +0 -55
  45. docs/user-guide/Mathematical Notation/Investment.md +0 -115
  46. flixopt-2.2.0b0.dist-info/RECORD +0 -59
  47. {flixopt-2.2.0b0.dist-info → flixopt-2.2.0rc2.dist-info}/licenses/LICENSE +0 -0
  48. {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, Set, Tuple, Union
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 xarray as xr
14
+ import pandas as pd
15
15
 
16
- from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData, extract_data
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['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,
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[ScenarioData] = None,
50
- maximum_total: Optional[ScenarioData] = None,
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: EffectValuesUserTimestep = (
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: EffectValuesUserTimestep = (
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', self.maximum_operation_per_hour, flow_system
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
- model=self._model,
146
- has_time_dim=False,
147
- has_scenario_dim=True,
148
- label_of_element=self.label_of_element,
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=extract_data(self.element.maximum_invest),
152
- total_min=extract_data(self.element.minimum_invest),
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
- model=self._model,
159
- has_time_dim=True,
160
- has_scenario_dim=True,
161
- label_of_element=self.label_of_element,
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=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),
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=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),
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
- 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. """
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
- operation, invest = self.calculate_effect_share_factors()
248
+ # TODO: Improve checks!! Only most basic case covered...
273
249
 
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]))
276
-
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}')
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
- 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}')
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, has_time_dim=False, has_scenario_dim=False)
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, has_time_dim=False, has_scenario_dim=False, label_of_element='Penalty')
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.selected_data,
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