Skip to content

Commit 8e8fe5c

Browse files
authored
Add FlowContainer for inputs/outputs (#587)
* FlowContainer implementation is complete. Here's a summary of what was done: Summary 1. Created FlowContainer Class (structure.py) - Added FlowContainer class that extends ContainerMixin - Uses flow.label_full as the key (e.g., 'Boiler(Q_th)') - Supports index-based access: inputs[0] - Supports label-based access: inputs['Boiler(Q_th)'] - Added __add__ method to ContainerMixin for concatenation 2. Updated Component Class (elements.py) - Changed inputs and outputs from list[Flow] to FlowContainer - Modified __init__ to accept both list[Flow] and dict[str, Flow] (for deserialization) - Updated _check_unique_flow_labels() and _connect_flows() to accept lists as parameters - FlowContainers are created after connecting flows (so label_full is correct) 3. Updated Bus Class (elements.py) - Changed inputs and outputs from list[Flow] to FlowContainer 4. Updated Iteration Patterns All iteration patterns were updated from: - for flow in self.inputs: → for flow in self.inputs.values(): - for flow in self.inputs + self.outputs: → for flow in (self.inputs + self.outputs).values(): Files updated: - elements.py (Component, Bus, BusModel, ComponentModel) - components.py (TransmissionModel, LinearConverterModel) - flow_system.py (flow population and network connection) - io.py (format_flow_details) - statistics_accessor.py (various plotting methods) - tests/deprecated/test_integration.py Usage Examples boiler = Boiler(label='Boiler', inputs=[Flow('Q_th', bus=heat_bus)]) # Both access methods work assert boiler.inputs[0] == boiler.inputs['Boiler(Q_th)'] assert len(boiler.inputs) == 1 # Iteration requires .values() for flow in boiler.inputs.values(): print(flow.label_full) # Concatenation works all_flows = boiler.inputs + boiler.outputs for flow in all_flows.values(): print(flow.label) All 1570 tests passed. * Summary Added all_flows property to Component and Bus classes using itertools.chain: @Property def all_flows(self) -> Iterator[Flow]: """Iterate over all flows (inputs and outputs) without creating intermediate containers.""" return chain(self.inputs.values(), self.outputs.values()) Performance Improvement ┌─────────┬──────────────────────────────┬───────────────────────┐ │ Pattern │ Before │ After │ ├─────────┼──────────────────────────────┼───────────────────────┤ │ Memory │ O(n) - creates new container │ O(1) - iterator only │ ├─────────┼──────────────────────────────┼───────────────────────┤ │ Time │ O(n) - copies all elements │ O(1) - lazy iteration │ └─────────┴──────────────────────────────┴───────────────────────┘ Usage # Efficient iteration (no allocation) for flow in component.all_flows: ... # When you need a list (e.g., reusing multiple times) all_flows = list(component.all_flows) # Container concatenation still available when needed combined = component.inputs + component.outputs # Creates new FlowContainer Files Updated - elements.py - Added property, updated 8 iteration patterns - components.py - Updated 1 pattern - flow_system.py - Updated 3 patterns - statistics_accessor.py - Updated 1 pattern All 1570 tests pass. * Summary 1. FlowContainer now supports short-label access When all flows in a container belong to the same component, you can access by short label: # Both work for component.inputs, component.outputs, and component.flows: component.inputs['Boiler(Q_th)'] # Full label component.inputs['Q_th'] # Short label ✓ # The `in` operator also supports short labels: if 'Q_th' in component.flows: # Works! ... 2. Component.flows is now a FlowContainer (cached property) # Before (dict keyed by short label): component.flows: dict[str, Flow] # After (FlowContainer with full/short label access): component.flows: FlowContainer # cached_property API Summary ┌─────────────────────┬──────────────────────────────────────┐ │ Access Pattern │ Example │ ├─────────────────────┼──────────────────────────────────────┤ │ Full label │ component.flows['Boiler(Q_th)'] │ ├─────────────────────┼──────────────────────────────────────┤ │ Short label │ component.flows['Q_th'] │ ├─────────────────────┼──────────────────────────────────────┤ │ Index │ component.flows[0] │ ├─────────────────────┼──────────────────────────────────────┤ │ Membership │ 'Q_th' in component.flows │ ├─────────────────────┼──────────────────────────────────────┤ │ Iteration │ for flow in component.flows.values() │ ├─────────────────────┼──────────────────────────────────────┤ │ Efficient iteration │ for flow in component.all_flows │ └─────────────────────┴──────────────────────────────────────┘ Note: Short-label access only works when all flows in the container share the same component (which is always true for component.inputs, component.outputs, and component.flows). * Remove all_flows and make bus.flows non cahced! * Udpate Changelo
1 parent 8128530 commit 8e8fe5c

8 files changed

Lines changed: 213 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,10 @@ flow_system.topology.set_component_colors({'Oranges': ['Solar1', 'Solar2']}) #
258258
flow_system.topology.set_component_colors('turbo', overwrite=False) # Only unset colors
259259
```
260260

261+
#### FlowContainer for Component Flows (#587)
262+
263+
`Component.inputs`, `Component.outputs`, and `Component.flows` now use `FlowContainer` (dict-like) with dual access by index or label: `inputs[0]` or `inputs['Q_th']`.
264+
261265
### 💥 Breaking Changes
262266

263267
#### tsam v3 Migration
@@ -296,6 +300,7 @@ fs.transform.cluster(
296300
#### Other Breaking Changes
297301

298302
- `FlowSystem.scenario_weights` are now always normalized to sum to 1 when set (including after `.sel()` subsetting)
303+
- `Component.inputs`/`outputs` and `Bus.inputs`/`outputs` are now `FlowContainer` (dict-like). Use `.values()` to iterate flows.
299304

300305
### ♻️ Changed
301306

flixopt/components.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,7 @@ class TransmissionModel(ComponentModel):
811811

812812
def __init__(self, model: FlowSystemModel, element: Transmission):
813813
if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0):
814-
for flow in element.inputs + element.outputs:
814+
for flow in element.flows.values():
815815
if flow.status_parameters is None:
816816
flow.status_parameters = StatusParameters()
817817
flow.status_parameters.link_to_flow_system(
@@ -877,8 +877,8 @@ def _do_modeling(self):
877877

878878
# Create conversion factor constraints if specified
879879
if self.element.conversion_factors:
880-
all_input_flows = set(self.element.inputs)
881-
all_output_flows = set(self.element.outputs)
880+
all_input_flows = set(self.element.inputs.values())
881+
all_output_flows = set(self.element.outputs.values())
882882

883883
# für alle linearen Gleichungen:
884884
for i, conv_factors in enumerate(self.element.conversion_factors):

flixopt/elements.py

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .structure import (
2121
Element,
2222
ElementModel,
23+
FlowContainer,
2324
FlowSystemModel,
2425
VariableCategory,
2526
register_class_for_io,
@@ -89,23 +90,44 @@ class Component(Element):
8990
def __init__(
9091
self,
9192
label: str,
92-
inputs: list[Flow] | None = None,
93-
outputs: list[Flow] | None = None,
93+
inputs: list[Flow] | dict[str, Flow] | None = None,
94+
outputs: list[Flow] | dict[str, Flow] | None = None,
9495
status_parameters: StatusParameters | None = None,
9596
prevent_simultaneous_flows: list[Flow] | None = None,
9697
meta_data: dict | None = None,
9798
color: str | None = None,
9899
):
99100
super().__init__(label, meta_data=meta_data, color=color)
100-
self.inputs: list[Flow] = inputs or []
101-
self.outputs: list[Flow] = outputs or []
102101
self.status_parameters = status_parameters
103102
self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or []
104103

105-
self._check_unique_flow_labels()
106-
self._connect_flows()
104+
# Convert dict to list (for deserialization compatibility)
105+
# FlowContainers serialize as dicts, but constructor expects lists
106+
if isinstance(inputs, dict):
107+
inputs = list(inputs.values())
108+
if isinstance(outputs, dict):
109+
outputs = list(outputs.values())
110+
111+
# Use temporary lists, connect flows first (sets component name on flows),
112+
# then create FlowContainers (which use label_full as key)
113+
_inputs = inputs or []
114+
_outputs = outputs or []
115+
self._check_unique_flow_labels(_inputs, _outputs)
116+
self._connect_flows(_inputs, _outputs)
107117

108-
self.flows: dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs}
118+
# Create FlowContainers after connecting (so label_full is correct)
119+
self.inputs: FlowContainer = FlowContainer(_inputs, element_type_name='inputs')
120+
self.outputs: FlowContainer = FlowContainer(_outputs, element_type_name='outputs')
121+
122+
@functools.cached_property
123+
def flows(self) -> FlowContainer:
124+
"""All flows (inputs and outputs) as a FlowContainer.
125+
126+
Supports access by label_full or short label:
127+
component.flows['Boiler(Q_th)'] # Full label
128+
component.flows['Q_th'] # Short label
129+
"""
130+
return self.inputs + self.outputs
109131

110132
def create_model(self, model: FlowSystemModel) -> ComponentModel:
111133
self._plausibility_checks()
@@ -120,18 +142,29 @@ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
120142
super().link_to_flow_system(flow_system, self.label_full)
121143
if self.status_parameters is not None:
122144
self.status_parameters.link_to_flow_system(flow_system, self._sub_prefix('status_parameters'))
123-
for flow in self.inputs + self.outputs:
145+
for flow in self.flows.values():
124146
flow.link_to_flow_system(flow_system)
125147

126148
def transform_data(self) -> None:
127149
if self.status_parameters is not None:
128150
self.status_parameters.transform_data()
129151

130-
for flow in self.inputs + self.outputs:
152+
for flow in self.flows.values():
131153
flow.transform_data()
132154

133-
def _check_unique_flow_labels(self):
134-
all_flow_labels = [flow.label for flow in self.inputs + self.outputs]
155+
def _check_unique_flow_labels(self, inputs: list[Flow] = None, outputs: list[Flow] = None):
156+
"""Check that all flow labels within a component are unique.
157+
158+
Args:
159+
inputs: List of input flows (optional, defaults to self.inputs)
160+
outputs: List of output flows (optional, defaults to self.outputs)
161+
"""
162+
if inputs is None:
163+
inputs = list(self.inputs.values())
164+
if outputs is None:
165+
outputs = list(self.outputs.values())
166+
167+
all_flow_labels = [flow.label for flow in inputs + outputs]
135168

136169
if len(set(all_flow_labels)) != len(all_flow_labels):
137170
duplicates = {label for label in all_flow_labels if all_flow_labels.count(label) > 1}
@@ -143,17 +176,28 @@ def _plausibility_checks(self) -> None:
143176
# Component with status_parameters requires all flows to have sizes set
144177
# (status_parameters are propagated to flows in _do_modeling, which need sizes for big-M constraints)
145178
if self.status_parameters is not None:
146-
flows_without_size = [flow.label for flow in self.inputs + self.outputs if flow.size is None]
179+
flows_without_size = [flow.label for flow in self.flows.values() if flow.size is None]
147180
if flows_without_size:
148181
raise PlausibilityError(
149182
f'Component "{self.label_full}" has status_parameters, but the following flows have no size: '
150183
f'{flows_without_size}. All flows need explicit sizes when the component uses status_parameters '
151184
f'(required for big-M constraints).'
152185
)
153186

154-
def _connect_flows(self):
187+
def _connect_flows(self, inputs: list[Flow] = None, outputs: list[Flow] = None):
188+
"""Connect flows to this component by setting component name and direction.
189+
190+
Args:
191+
inputs: List of input flows (optional, defaults to self.inputs)
192+
outputs: List of output flows (optional, defaults to self.outputs)
193+
"""
194+
if inputs is None:
195+
inputs = list(self.inputs.values())
196+
if outputs is None:
197+
outputs = list(self.outputs.values())
198+
155199
# Inputs
156-
for flow in self.inputs:
200+
for flow in inputs:
157201
if flow.component not in ('UnknownComponent', self.label_full):
158202
raise ValueError(
159203
f'Flow "{flow.label}" already assigned to component "{flow.component}". '
@@ -162,7 +206,7 @@ def _connect_flows(self):
162206
flow.component = self.label_full
163207
flow.is_input_in_component = True
164208
# Outputs
165-
for flow in self.outputs:
209+
for flow in outputs:
166210
if flow.component not in ('UnknownComponent', self.label_full):
167211
raise ValueError(
168212
f'Flow "{flow.label}" already assigned to component "{flow.component}". '
@@ -178,7 +222,7 @@ def _connect_flows(self):
178222
self.prevent_simultaneous_flows = [
179223
f for f in self.prevent_simultaneous_flows if id(f) not in seen and not seen.add(id(f))
180224
]
181-
local = set(self.inputs + self.outputs)
225+
local = set(inputs + outputs)
182226
foreign = [f for f in self.prevent_simultaneous_flows if f not in local]
183227
if foreign:
184228
names = ', '.join(f.label_full for f in foreign)
@@ -275,8 +319,13 @@ def __init__(
275319
self._validate_kwargs(kwargs)
276320
self.carrier = carrier.lower() if carrier else None # Store as lowercase string
277321
self.imbalance_penalty_per_flow_hour = imbalance_penalty_per_flow_hour
278-
self.inputs: list[Flow] = []
279-
self.outputs: list[Flow] = []
322+
self.inputs: FlowContainer = FlowContainer(element_type_name='inputs')
323+
self.outputs: FlowContainer = FlowContainer(element_type_name='outputs')
324+
325+
@property
326+
def flows(self) -> FlowContainer:
327+
"""All flows (inputs and outputs) as a FlowContainer."""
328+
return self.inputs + self.outputs
280329

281330
def create_model(self, model: FlowSystemModel) -> BusModel:
282331
self._plausibility_checks()
@@ -289,7 +338,7 @@ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
289338
Elements use their label_full as prefix by default, ignoring the passed prefix.
290339
"""
291340
super().link_to_flow_system(flow_system, self.label_full)
292-
for flow in self.inputs + self.outputs:
341+
for flow in self.flows.values():
293342
flow.link_to_flow_system(flow_system)
294343

295344
def transform_data(self) -> None:
@@ -959,10 +1008,10 @@ def _do_modeling(self):
9591008
"""Create variables, constraints, and nested submodels"""
9601009
super()._do_modeling()
9611010
# inputs == outputs
962-
for flow in self.element.inputs + self.element.outputs:
1011+
for flow in self.element.flows.values():
9631012
self.register_variable(flow.submodel.flow_rate, flow.label_full)
964-
inputs = sum([flow.submodel.flow_rate for flow in self.element.inputs])
965-
outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs])
1013+
inputs = sum([flow.submodel.flow_rate for flow in self.element.inputs.values()])
1014+
outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs.values()])
9661015
eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance')
9671016

9681017
# Add virtual supply/demand to balance and penalty if needed
@@ -997,8 +1046,8 @@ def _do_modeling(self):
9971046
)
9981047

9991048
def results_structure(self):
1000-
inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs]
1001-
outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs]
1049+
inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs.values()]
1050+
outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs.values()]
10021051
if self.virtual_supply is not None:
10031052
inputs.append(self.virtual_supply.name)
10041053
if self.virtual_demand is not None:
@@ -1007,7 +1056,7 @@ def results_structure(self):
10071056
**super().results_structure(),
10081057
'inputs': inputs,
10091058
'outputs': outputs,
1010-
'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs],
1059+
'flows': [flow.label_full for flow in self.element.flows.values()],
10111060
}
10121061

10131062

@@ -1022,7 +1071,7 @@ def _do_modeling(self):
10221071
"""Create variables, constraints, and nested submodels"""
10231072
super()._do_modeling()
10241073

1025-
all_flows = self.element.inputs + self.element.outputs
1074+
all_flows = list(self.element.flows.values())
10261075

10271076
# Set status_parameters on flows if needed
10281077
if self.element.status_parameters:
@@ -1087,9 +1136,9 @@ def _do_modeling(self):
10871136
def results_structure(self):
10881137
return {
10891138
**super().results_structure(),
1090-
'inputs': [flow.submodel.flow_rate.name for flow in self.element.inputs],
1091-
'outputs': [flow.submodel.flow_rate.name for flow in self.element.outputs],
1092-
'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs],
1139+
'inputs': [flow.submodel.flow_rate.name for flow in self.element.inputs.values()],
1140+
'outputs': [flow.submodel.flow_rate.name for flow in self.element.outputs.values()],
1141+
'flows': [flow.label_full for flow in self.element.flows.values()],
10931142
}
10941143

10951144
@property
@@ -1098,7 +1147,7 @@ def previous_status(self) -> xr.DataArray | None:
10981147
if self.element.status_parameters is None:
10991148
raise ValueError(f'StatusModel not present in \n{self}\nCant access previous_status')
11001149

1101-
previous_status = [flow.submodel.status._previous_status for flow in self.element.inputs + self.element.outputs]
1150+
previous_status = [flow.submodel.status._previous_status for flow in self.element.flows.values()]
11021151
previous_status = [da for da in previous_status if da is not None]
11031152

11041153
if not previous_status: # Empty list

flixopt/flow_system.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,7 @@ def from_old_dataset(cls, path: str | pathlib.Path) -> FlowSystem:
968968
# Now previous_flow_rate=None means relaxed (no constraint at t=0)
969969
for comp in flow_system.components.values():
970970
if getattr(comp, 'status_parameters', None) is not None:
971-
for flow in comp.inputs + comp.outputs:
971+
for flow in comp.flows.values():
972972
if flow.previous_flow_rate is None:
973973
flow.previous_flow_rate = 0
974974

@@ -1912,9 +1912,9 @@ def _add_buses(self, *buses: Bus):
19121912
def _connect_network(self):
19131913
"""Connects the network of components and buses. Can be rerun without changes if no elements were added"""
19141914
for component in self.components.values():
1915-
for flow in component.inputs + component.outputs:
1915+
for flow in component.flows.values():
19161916
flow.component = component.label_full
1917-
flow.is_input_in_component = True if flow in component.inputs else False
1917+
flow.is_input_in_component = flow.label_full in component.inputs
19181918

19191919
# Connect Buses
19201920
bus = self.buses.get(flow.bus)
@@ -1923,10 +1923,10 @@ def _connect_network(self):
19231923
f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". '
19241924
f'Please add it first.'
19251925
)
1926-
if flow.is_input_in_component and flow not in bus.outputs:
1927-
bus.outputs.append(flow)
1928-
elif not flow.is_input_in_component and flow not in bus.inputs:
1929-
bus.inputs.append(flow)
1926+
if flow.is_input_in_component and flow.label_full not in bus.outputs:
1927+
bus.outputs.add(flow)
1928+
elif not flow.is_input_in_component and flow.label_full not in bus.inputs:
1929+
bus.inputs.add(flow)
19301930

19311931
# Count flows manually to avoid triggering cache rebuild
19321932
flow_count = sum(len(c.inputs) + len(c.outputs) for c in self.components.values())
@@ -2010,7 +2010,7 @@ def _get_container_groups(self) -> dict[str, ElementContainer]:
20102010
@property
20112011
def flows(self) -> ElementContainer[Flow]:
20122012
if self._flows_cache is None:
2013-
flows = [f for c in self.components.values() for f in c.inputs + c.outputs]
2013+
flows = [f for c in self.components.values() for f in c.flows.values()]
20142014
# Deduplicate by id and sort for reproducibility
20152015
flows = sorted({id(f): f for f in flows}.values(), key=lambda f: f.label_full.lower())
20162016
self._flows_cache = ElementContainer(flows, element_type_name='flows', truncate_repr=10)

flixopt/io.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,12 +1392,12 @@ def format_flow_details(obj: Any, has_inputs: bool = True, has_outputs: bool = T
13921392

13931393
if has_inputs and hasattr(obj, 'inputs') and obj.inputs:
13941394
flow_lines.append(' inputs:')
1395-
for flow in obj.inputs:
1395+
for flow in obj.inputs.values():
13961396
flow_lines.append(f' * {repr(flow)}')
13971397

13981398
if has_outputs and hasattr(obj, 'outputs') and obj.outputs:
13991399
flow_lines.append(' outputs:')
1400-
for flow in obj.outputs:
1400+
for flow in obj.outputs.values():
14011401
flow_lines.append(f' * {repr(flow)}')
14021402

14031403
return '\n' + '\n'.join(flow_lines) if flow_lines else ''

flixopt/statistics_accessor.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ def get_effect_shares(
779779
if element not in self._fs.components:
780780
raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}')
781781
comp = self._fs.components[element]
782-
flows = [f.label_full.split('|')[0] for f in comp.inputs + comp.outputs]
782+
flows = [flow.split('|')[0] for flow in comp.flows]
783783
return xr.merge(
784784
[ds]
785785
+ [
@@ -1509,8 +1509,8 @@ def balance(
15091509
else:
15101510
raise KeyError(f"'{node}' not found in buses or components")
15111511

1512-
input_labels = [f.label_full for f in element.inputs]
1513-
output_labels = [f.label_full for f in element.outputs]
1512+
input_labels = [f.label_full for f in element.inputs.values()]
1513+
output_labels = [f.label_full for f in element.outputs.values()]
15141514
all_labels = input_labels + output_labels
15151515

15161516
filtered_labels = _filter_by_pattern(all_labels, include, exclude)
@@ -1617,9 +1617,9 @@ def carrier_balance(
16171617
output_labels: list[str] = [] # Outputs from buses = consumption
16181618

16191619
for bus in carrier_buses:
1620-
for flow in bus.inputs:
1620+
for flow in bus.inputs.values():
16211621
input_labels.append(flow.label_full)
1622-
for flow in bus.outputs:
1622+
for flow in bus.outputs.values():
16231623
output_labels.append(flow.label_full)
16241624

16251625
all_labels = input_labels + output_labels
@@ -2230,8 +2230,8 @@ def storage(
22302230
raise ValueError(f"'{storage}' is not a storage (no charge_state variable found)")
22312231

22322232
# Get flow data
2233-
input_labels = [f.label_full for f in component.inputs]
2234-
output_labels = [f.label_full for f in component.outputs]
2233+
input_labels = [f.label_full for f in component.inputs.values()]
2234+
output_labels = [f.label_full for f in component.outputs.values()]
22352235
all_labels = input_labels + output_labels
22362236

22372237
if unit == 'flow_rate':

0 commit comments

Comments
 (0)