flixopt 3.2.1__py3-none-any.whl → 3.4.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/elements.py CHANGED
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
  import numpy as np
12
12
  import xarray as xr
13
13
 
14
+ from . import io as fx_io
14
15
  from .config import CONFIG
15
16
  from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser
16
17
  from .features import InvestmentModel, OnOffModel
@@ -86,10 +87,12 @@ class Component(Element):
86
87
  super().__init__(label, meta_data=meta_data)
87
88
  self.inputs: list[Flow] = inputs or []
88
89
  self.outputs: list[Flow] = outputs or []
89
- self._check_unique_flow_labels()
90
90
  self.on_off_parameters = on_off_parameters
91
91
  self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or []
92
92
 
93
+ self._check_unique_flow_labels()
94
+ self._connect_flows()
95
+
93
96
  self.flows: dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs}
94
97
 
95
98
  def create_model(self, model: FlowSystemModel) -> ComponentModel:
@@ -115,6 +118,48 @@ class Component(Element):
115
118
  def _plausibility_checks(self) -> None:
116
119
  self._check_unique_flow_labels()
117
120
 
121
+ def _connect_flows(self):
122
+ # Inputs
123
+ for flow in self.inputs:
124
+ if flow.component not in ('UnknownComponent', self.label_full):
125
+ raise ValueError(
126
+ f'Flow "{flow.label}" already assigned to component "{flow.component}". '
127
+ f'Cannot attach to "{self.label_full}".'
128
+ )
129
+ flow.component = self.label_full
130
+ flow.is_input_in_component = True
131
+ # Outputs
132
+ for flow in self.outputs:
133
+ if flow.component not in ('UnknownComponent', self.label_full):
134
+ raise ValueError(
135
+ f'Flow "{flow.label}" already assigned to component "{flow.component}". '
136
+ f'Cannot attach to "{self.label_full}".'
137
+ )
138
+ flow.component = self.label_full
139
+ flow.is_input_in_component = False
140
+
141
+ # Validate prevent_simultaneous_flows: only allow local flows
142
+ if self.prevent_simultaneous_flows:
143
+ # Deduplicate while preserving order
144
+ seen = set()
145
+ self.prevent_simultaneous_flows = [
146
+ f for f in self.prevent_simultaneous_flows if id(f) not in seen and not seen.add(id(f))
147
+ ]
148
+ local = set(self.inputs + self.outputs)
149
+ foreign = [f for f in self.prevent_simultaneous_flows if f not in local]
150
+ if foreign:
151
+ names = ', '.join(f.label_full for f in foreign)
152
+ raise ValueError(
153
+ f'prevent_simultaneous_flows for "{self.label_full}" must reference its own flows. '
154
+ f'Foreign flows detected: {names}'
155
+ )
156
+
157
+ def __repr__(self) -> str:
158
+ """Return string representation with flow information."""
159
+ return fx_io.build_repr_from_init(
160
+ self, excluded_params={'self', 'label', 'inputs', 'outputs', 'kwargs'}, skip_default_size=True
161
+ ) + fx_io.format_flow_details(self)
162
+
118
163
 
119
164
  @register_class_for_io
120
165
  class Bus(Element):
@@ -178,6 +223,8 @@ class Bus(Element):
178
223
  by the FlowSystem during system setup.
179
224
  """
180
225
 
226
+ submodel: BusModel | None
227
+
181
228
  def __init__(
182
229
  self,
183
230
  label: str,
@@ -216,6 +263,10 @@ class Bus(Element):
216
263
  def with_excess(self) -> bool:
217
264
  return False if self.excess_penalty_per_flow_hour is None else True
218
265
 
266
+ def __repr__(self) -> str:
267
+ """Return string representation."""
268
+ return super().__repr__() + fx_io.format_flow_details(self)
269
+
219
270
 
220
271
  @register_class_for_io
221
272
  class Connection:
@@ -362,6 +413,8 @@ class Flow(Element):
362
413
 
363
414
  """
364
415
 
416
+ submodel: FlowModel | None
417
+
365
418
  def __init__(
366
419
  self,
367
420
  label: str,
@@ -493,6 +546,10 @@ class Flow(Element):
493
546
  # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen
494
547
  return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True
495
548
 
549
+ def _format_invest_params(self, params: InvestParameters) -> str:
550
+ """Format InvestParameters for display."""
551
+ return f'size: {params.format_for_repr()}'
552
+
496
553
 
497
554
  class FlowModel(ElementModel):
498
555
  element: Flow # Type hint
flixopt/flow_system.py CHANGED
@@ -6,12 +6,14 @@ from __future__ import annotations
6
6
 
7
7
  import logging
8
8
  import warnings
9
+ from itertools import chain
9
10
  from typing import TYPE_CHECKING, Any, Literal, Optional
10
11
 
11
12
  import numpy as np
12
13
  import pandas as pd
13
14
  import xarray as xr
14
15
 
16
+ from . import io as fx_io
15
17
  from .config import CONFIG
16
18
  from .core import (
17
19
  ConversionError,
@@ -32,7 +34,7 @@ from .effects import (
32
34
  TemporalEffectsUser,
33
35
  )
34
36
  from .elements import Bus, Component, Flow
35
- from .structure import Element, FlowSystemModel, Interface
37
+ from .structure import CompositeContainerMixin, Element, ElementContainer, FlowSystemModel, Interface
36
38
 
37
39
  if TYPE_CHECKING:
38
40
  import pathlib
@@ -43,11 +45,13 @@ if TYPE_CHECKING:
43
45
  logger = logging.getLogger('flixopt')
44
46
 
45
47
 
46
- class FlowSystem(Interface):
48
+ class FlowSystem(Interface, CompositeContainerMixin[Element]):
47
49
  """
48
- A FlowSystem organizes the high level Elements (Components, Buses & Effects).
50
+ A FlowSystem organizes the high level Elements (Components, Buses, Effects & Flows).
49
51
 
50
- This is the main container class that users work with to build and manage their System.
52
+ This is the main container class that users work with to build and manage their energy or material flow system.
53
+ FlowSystem provides both direct container access (via .components, .buses, .effects, .flows) and a unified
54
+ dict-like interface for accessing any element by label across all container types.
51
55
 
52
56
  Args:
53
57
  timesteps: The timesteps of the model.
@@ -69,12 +73,78 @@ class FlowSystem(Interface):
69
73
  - False: All flow rates are optimized separately per scenario
70
74
  - list[str]: Only specified flows (by label_full) are equalized across scenarios
71
75
 
76
+ Examples:
77
+ Creating a FlowSystem and accessing elements:
78
+
79
+ >>> import flixopt as fx
80
+ >>> import pandas as pd
81
+ >>> timesteps = pd.date_range('2023-01-01', periods=24, freq='h')
82
+ >>> flow_system = fx.FlowSystem(timesteps)
83
+ >>>
84
+ >>> # Add elements to the system
85
+ >>> boiler = fx.Component('Boiler', inputs=[heat_flow], on_off_parameters=...)
86
+ >>> heat_bus = fx.Bus('Heat', excess_penalty_per_flow_hour=1e4)
87
+ >>> costs = fx.Effect('costs', is_objective=True, is_standard=True)
88
+ >>> flow_system.add_elements(boiler, heat_bus, costs)
89
+
90
+ Unified dict-like access (recommended for most cases):
91
+
92
+ >>> # Access any element by label, regardless of type
93
+ >>> boiler = flow_system['Boiler'] # Returns Component
94
+ >>> heat_bus = flow_system['Heat'] # Returns Bus
95
+ >>> costs = flow_system['costs'] # Returns Effect
96
+ >>>
97
+ >>> # Check if element exists
98
+ >>> if 'Boiler' in flow_system:
99
+ ... print('Boiler found in system')
100
+ >>>
101
+ >>> # Iterate over all elements
102
+ >>> for label in flow_system.keys():
103
+ ... element = flow_system[label]
104
+ ... print(f'{label}: {type(element).__name__}')
105
+ >>>
106
+ >>> # Get all element labels and objects
107
+ >>> all_labels = list(flow_system.keys())
108
+ >>> all_elements = list(flow_system.values())
109
+ >>> for label, element in flow_system.items():
110
+ ... print(f'{label}: {element}')
111
+
112
+ Direct container access for type-specific operations:
113
+
114
+ >>> # Access specific container when you need type filtering
115
+ >>> for component in flow_system.components.values():
116
+ ... print(f'{component.label}: {len(component.inputs)} inputs')
117
+ >>>
118
+ >>> # Access buses directly
119
+ >>> for bus in flow_system.buses.values():
120
+ ... print(f'{bus.label}')
121
+ >>>
122
+ >>> # Flows are automatically collected from all components
123
+ >>> for flow in flow_system.flows.values():
124
+ ... print(f'{flow.label_full}: {flow.size}')
125
+ >>>
126
+ >>> # Access effects
127
+ >>> for effect in flow_system.effects.values():
128
+ ... print(f'{effect.label}')
129
+
72
130
  Notes:
131
+ - The dict-like interface (`flow_system['element']`) searches across all containers
132
+ (components, buses, effects, flows) to find the element with the matching label.
133
+ - Element labels must be unique across all container types. Attempting to add
134
+ elements with duplicate labels will raise an error, ensuring each label maps to exactly one element.
135
+ - The `.all_elements` property is deprecated. Use the dict-like interface instead:
136
+ `flow_system['element']`, `'element' in flow_system`, `flow_system.keys()`,
137
+ `flow_system.values()`, or `flow_system.items()`.
138
+ - Direct container access (`.components`, `.buses`, `.effects`, `.flows`) is useful
139
+ when you need type-specific filtering or operations.
140
+ - The `.flows` container is automatically populated from all component inputs and outputs.
73
141
  - Creates an empty registry for components and buses, an empty EffectCollection, and a placeholder for a SystemModel.
74
142
  - The instance starts disconnected (self._connected_and_transformed == False) and will be
75
- connected_and_transformed automatically when trying to solve a calculation.
143
+ connected_and_transformed automatically when trying to solve a calculation.
76
144
  """
77
145
 
146
+ model: FlowSystemModel | None
147
+
78
148
  def __init__(
79
149
  self,
80
150
  timesteps: pd.DatetimeIndex,
@@ -104,8 +174,8 @@ class FlowSystem(Interface):
104
174
  self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep)
105
175
 
106
176
  # Element collections
107
- self.components: dict[str, Component] = {}
108
- self.buses: dict[str, Bus] = {}
177
+ self.components: ElementContainer[Component] = ElementContainer(element_type_name='components')
178
+ self.buses: ElementContainer[Bus] = ElementContainer(element_type_name='buses')
109
179
  self.effects: EffectCollection = EffectCollection()
110
180
  self.model: FlowSystemModel | None = None
111
181
 
@@ -113,6 +183,7 @@ class FlowSystem(Interface):
113
183
  self._used_in_calculation = False
114
184
 
115
185
  self._network_app = None
186
+ self._flows_cache: ElementContainer[Flow] | None = None
116
187
 
117
188
  # Use properties to validate and store scenario dimension settings
118
189
  self.scenario_independent_sizes = scenario_independent_sizes
@@ -232,7 +303,7 @@ class FlowSystem(Interface):
232
303
 
233
304
  # Extract from effects
234
305
  effects_structure = {}
235
- for effect in self.effects:
306
+ for effect in self.effects.values():
236
307
  effect_structure, effect_arrays = effect._create_reference_structure()
237
308
  all_extracted_arrays.update(effect_arrays)
238
309
  effects_structure[effect.label] = effect_structure
@@ -433,7 +504,7 @@ class FlowSystem(Interface):
433
504
  self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario'])
434
505
 
435
506
  self._connect_network()
436
- for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()):
507
+ for element in chain(self.components.values(), self.effects.values(), self.buses.values()):
437
508
  element.transform_data(self)
438
509
  self._connected_and_transformed = True
439
510
 
@@ -581,7 +652,7 @@ class FlowSystem(Interface):
581
652
  'class': 'Bus' if isinstance(node, Bus) else 'Component',
582
653
  'infos': node.__str__(),
583
654
  }
584
- for node in list(self.components.values()) + list(self.buses.values())
655
+ for node in chain(self.components.values(), self.buses.values())
585
656
  }
586
657
 
587
658
  edges = {
@@ -603,10 +674,8 @@ class FlowSystem(Interface):
603
674
  Args:
604
675
  element: new element to check
605
676
  """
606
- if element in self.all_elements.values():
607
- raise ValueError(f'Element {element.label_full} already added to FlowSystem!')
608
677
  # check if name is already used:
609
- if element.label_full in self.all_elements:
678
+ if element.label_full in self:
610
679
  raise ValueError(f'Label of Element {element.label_full} already used in another element!')
611
680
 
612
681
  def _add_effects(self, *args: Effect) -> None:
@@ -616,13 +685,15 @@ class FlowSystem(Interface):
616
685
  for new_component in list(components):
617
686
  logger.info(f'Registered new Component: {new_component.label_full}')
618
687
  self._check_if_element_is_unique(new_component) # check if already exists:
619
- self.components[new_component.label_full] = new_component # Add to existing components
688
+ self.components.add(new_component) # Add to existing components
689
+ self._flows_cache = None # Invalidate flows cache
620
690
 
621
691
  def _add_buses(self, *buses: Bus):
622
692
  for new_bus in list(buses):
623
693
  logger.info(f'Registered new Bus: {new_bus.label_full}')
624
694
  self._check_if_element_is_unique(new_bus) # check if already exists:
625
- self.buses[new_bus.label_full] = new_bus # Add to existing components
695
+ self.buses.add(new_bus) # Add to existing buses
696
+ self._flows_cache = None # Invalidate flows cache
626
697
 
627
698
  def _connect_network(self):
628
699
  """Connects the network of components and buses. Can be rerun without changes if no elements were added"""
@@ -632,7 +703,7 @@ class FlowSystem(Interface):
632
703
  flow.is_input_in_component = True if flow in component.inputs else False
633
704
 
634
705
  # Add Bus if not already added (deprecated)
635
- if flow._bus_object is not None and flow._bus_object not in self.buses.values():
706
+ if flow._bus_object is not None and flow._bus_object.label_full not in self.buses:
636
707
  warnings.warn(
637
708
  f'The Bus {flow._bus_object.label_full} was added to the FlowSystem from {flow.label_full}.'
638
709
  f'This is deprecated and will be removed in the future. '
@@ -659,62 +730,40 @@ class FlowSystem(Interface):
659
730
  )
660
731
 
661
732
  def __repr__(self) -> str:
662
- """Compact representation for debugging."""
663
- status = '' if self.connected_and_transformed else ''
664
-
665
- # Build dimension info
666
- dims = f'{len(self.timesteps)} timesteps [{self.timesteps[0].strftime("%Y-%m-%d")} to {self.timesteps[-1].strftime("%Y-%m-%d")}]'
667
- if self.periods is not None:
668
- dims += f', {len(self.periods)} periods'
669
- if self.scenarios is not None:
670
- dims += f', {len(self.scenarios)} scenarios'
671
-
672
- return f'FlowSystem({dims}, {len(self.components)} Components, {len(self.buses)} Buses, {len(self.effects)} Effects, {status})'
673
-
674
- def __str__(self) -> str:
675
- """Structured summary for users."""
676
-
677
- def format_elements(element_names: list, label: str, alignment: int = 12):
678
- name_list = ', '.join(element_names[:3])
679
- if len(element_names) > 3:
680
- name_list += f' ... (+{len(element_names) - 3} more)'
733
+ """Return a detailed string representation showing all containers."""
734
+ r = fx_io.format_title_with_underline('FlowSystem', '=')
681
735
 
682
- suffix = f' ({name_list})' if element_names else ''
683
- padding = alignment - len(label) - 1 # -1 for the colon
684
- return f'{label}:{"":<{padding}} {len(element_names)}{suffix}'
685
-
686
- time_period = f'Time period: {self.timesteps[0].date()} to {self.timesteps[-1].date()}'
736
+ # Timestep info
737
+ time_period = f'{self.timesteps[0].date()} to {self.timesteps[-1].date()}'
687
738
  freq_str = str(self.timesteps.freq).replace('<', '').replace('>', '') if self.timesteps.freq else 'irregular'
688
-
689
- lines = [
690
- f'Timesteps: {len(self.timesteps)} ({freq_str}) [{time_period}]',
691
- ]
739
+ r += f'Timesteps: {len(self.timesteps)} ({freq_str}) [{time_period}]\n'
692
740
 
693
741
  # Add periods if present
694
742
  if self.periods is not None:
695
743
  period_names = ', '.join(str(p) for p in self.periods[:3])
696
744
  if len(self.periods) > 3:
697
745
  period_names += f' ... (+{len(self.periods) - 3} more)'
698
- lines.append(f'Periods: {len(self.periods)} ({period_names})')
746
+ r += f'Periods: {len(self.periods)} ({period_names})\n'
747
+ else:
748
+ r += 'Periods: None\n'
699
749
 
700
750
  # Add scenarios if present
701
751
  if self.scenarios is not None:
702
752
  scenario_names = ', '.join(str(s) for s in self.scenarios[:3])
703
753
  if len(self.scenarios) > 3:
704
754
  scenario_names += f' ... (+{len(self.scenarios) - 3} more)'
705
- lines.append(f'Scenarios: {len(self.scenarios)} ({scenario_names})')
706
-
707
- lines.extend(
708
- [
709
- format_elements(list(self.components.keys()), 'Components'),
710
- format_elements(list(self.buses.keys()), 'Buses'),
711
- format_elements(list(self.effects.effects.keys()), 'Effects'),
712
- f'Status: {"Connected & Transformed" if self.connected_and_transformed else "Not connected"}',
713
- ]
714
- )
715
- lines = ['FlowSystem:', f'{"─" * max(len(line) for line in lines)}'] + lines
755
+ r += f'Scenarios: {len(self.scenarios)} ({scenario_names})\n'
756
+ else:
757
+ r += 'Scenarios: None\n'
758
+
759
+ # Add status
760
+ status = '✓' if self.connected_and_transformed else ''
761
+ r += f'Status: {status}\n'
762
+
763
+ # Add grouped container view
764
+ r += '\n' + self._format_grouped_containers()
716
765
 
717
- return '\n'.join(lines)
766
+ return r
718
767
 
719
768
  def __eq__(self, other: FlowSystem):
720
769
  """Check if two FlowSystems are equal by comparing their dataset representations."""
@@ -734,38 +783,46 @@ class FlowSystem(Interface):
734
783
 
735
784
  return True
736
785
 
737
- def __getitem__(self, item) -> Element:
738
- """Get element by exact label with helpful error messages."""
739
- if item in self.all_elements:
740
- return self.all_elements[item]
741
-
742
- # Provide helpful error with suggestions
743
- from difflib import get_close_matches
744
-
745
- suggestions = get_close_matches(item, self.all_elements.keys(), n=3, cutoff=0.6)
746
-
747
- if suggestions:
748
- suggestion_str = ', '.join(f"'{s}'" for s in suggestions)
749
- raise KeyError(f"Element '{item}' not found. Did you mean: {suggestion_str}?")
750
- else:
751
- raise KeyError(f"Element '{item}' not found in FlowSystem")
752
-
753
- def __contains__(self, item: str) -> bool:
754
- """Check if element exists in the FlowSystem."""
755
- return item in self.all_elements
756
-
757
- def __iter__(self):
758
- """Iterate over element labels."""
759
- return iter(self.all_elements.keys())
786
+ def _get_container_groups(self) -> dict[str, ElementContainer]:
787
+ """Return ordered container groups for CompositeContainerMixin."""
788
+ return {
789
+ 'Components': self.components,
790
+ 'Buses': self.buses,
791
+ 'Effects': self.effects,
792
+ 'Flows': self.flows,
793
+ }
760
794
 
761
795
  @property
762
- def flows(self) -> dict[str, Flow]:
763
- set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs}
764
- return {flow.label_full: flow for flow in set_of_flows}
796
+ def flows(self) -> ElementContainer[Flow]:
797
+ if self._flows_cache is None:
798
+ flows = [f for c in self.components.values() for f in c.inputs + c.outputs]
799
+ # Deduplicate by id and sort for reproducibility
800
+ flows = sorted({id(f): f for f in flows}.values(), key=lambda f: f.label_full.lower())
801
+ self._flows_cache = ElementContainer(flows, element_type_name='flows')
802
+ return self._flows_cache
765
803
 
766
804
  @property
767
805
  def all_elements(self) -> dict[str, Element]:
768
- return {**self.components, **self.effects.effects, **self.flows, **self.buses}
806
+ """
807
+ Get all elements as a dictionary.
808
+
809
+ .. deprecated:: 3.2.0
810
+ Use dict-like interface instead: `flow_system['element']`, `'element' in flow_system`,
811
+ `flow_system.keys()`, `flow_system.values()`, or `flow_system.items()`.
812
+ This property will be removed in v4.0.0.
813
+
814
+ Returns:
815
+ Dictionary mapping element labels to element objects.
816
+ """
817
+ warnings.warn(
818
+ "The 'all_elements' property is deprecated. Use dict-like interface instead: "
819
+ "flow_system['element'], 'element' in flow_system, flow_system.keys(), "
820
+ 'flow_system.values(), or flow_system.items(). '
821
+ 'This property will be removed in v4.0.0.',
822
+ DeprecationWarning,
823
+ stacklevel=2,
824
+ )
825
+ return {**self.components, **self.effects, **self.flows, **self.buses}
769
826
 
770
827
  @property
771
828
  def coords(self) -> dict[FlowSystemDimensions, pd.Index]:
flixopt/interface.py CHANGED
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import logging
9
9
  import warnings
10
- from typing import TYPE_CHECKING, Literal, Optional
10
+ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  import numpy as np
13
13
  import pandas as pd
@@ -1051,6 +1051,27 @@ class InvestParameters(Interface):
1051
1051
  def maximum_or_fixed_size(self) -> PeriodicData:
1052
1052
  return self.fixed_size if self.fixed_size is not None else self.maximum_size
1053
1053
 
1054
+ def format_for_repr(self) -> str:
1055
+ """Format InvestParameters for display in repr methods.
1056
+
1057
+ Returns:
1058
+ Formatted string showing size information
1059
+ """
1060
+ from .io import numeric_to_str_for_repr
1061
+
1062
+ if self.fixed_size is not None:
1063
+ val = numeric_to_str_for_repr(self.fixed_size)
1064
+ status = 'mandatory' if self.mandatory else 'optional'
1065
+ return f'{val} ({status})'
1066
+
1067
+ # Show range if available
1068
+ parts = []
1069
+ if self.minimum_size is not None:
1070
+ parts.append(f'min: {numeric_to_str_for_repr(self.minimum_size)}')
1071
+ if self.maximum_size is not None:
1072
+ parts.append(f'max: {numeric_to_str_for_repr(self.maximum_size)}')
1073
+ return ', '.join(parts) if parts else 'invest'
1074
+
1054
1075
  @staticmethod
1055
1076
  def compute_linked_periods(first_period: int, last_period: int, periods: pd.Index | list[int]) -> xr.DataArray:
1056
1077
  return xr.DataArray(
@@ -1324,7 +1345,7 @@ class OnOffParameters(Interface):
1324
1345
  return True
1325
1346
 
1326
1347
  return any(
1327
- param is not None and param != {}
1348
+ self._has_value(param)
1328
1349
  for param in [
1329
1350
  self.effects_per_switch_on,
1330
1351
  self.switch_on_total_max,