flixopt 3.2.0__py3-none-any.whl → 3.3.1__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/calculation.py CHANGED
@@ -112,7 +112,7 @@ class Calculation:
112
112
  'periodic': effect.submodel.periodic.total.solution.values,
113
113
  'total': effect.submodel.total.solution.values,
114
114
  }
115
- for effect in self.flow_system.effects
115
+ for effect in sorted(self.flow_system.effects.values(), key=lambda e: e.label_full.upper())
116
116
  },
117
117
  'Invest-Decisions': {
118
118
  'Invested': {
flixopt/components.py CHANGED
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Literal
11
11
  import numpy as np
12
12
  import xarray as xr
13
13
 
14
+ from . import io as fx_io
14
15
  from .core import PeriodicDataUser, PlausibilityError, TemporalData, TemporalDataUser
15
16
  from .elements import Component, ComponentModel, Flow
16
17
  from .features import InvestmentModel, PiecewiseModel
@@ -528,6 +529,15 @@ class Storage(Component):
528
529
  f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.'
529
530
  )
530
531
 
532
+ def __repr__(self) -> str:
533
+ """Return string representation."""
534
+ # Use build_repr_from_init directly to exclude charging and discharging
535
+ return fx_io.build_repr_from_init(
536
+ self,
537
+ excluded_params={'self', 'label', 'charging', 'discharging', 'kwargs'},
538
+ skip_default_size=True,
539
+ ) + fx_io.format_flow_details(self)
540
+
531
541
 
532
542
  @register_class_for_io
533
543
  class Transmission(Component):
flixopt/effects.py CHANGED
@@ -16,9 +16,10 @@ import linopy
16
16
  import numpy as np
17
17
  import xarray as xr
18
18
 
19
+ from . import io as fx_io
19
20
  from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser
20
21
  from .features import ShareAllocationModel
21
- from .structure import Element, ElementModel, FlowSystemModel, Submodel, register_class_for_io
22
+ from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io
22
23
 
23
24
  if TYPE_CHECKING:
24
25
  from collections.abc import Iterator
@@ -448,13 +449,13 @@ PeriodicEffects = dict[str, Scalar]
448
449
  EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares
449
450
 
450
451
 
451
- class EffectCollection:
452
+ class EffectCollection(ElementContainer[Effect]):
452
453
  """
453
454
  Handling all Effects
454
455
  """
455
456
 
456
457
  def __init__(self, *effects: Effect):
457
- self._effects = {}
458
+ super().__init__(element_type_name='effects')
458
459
  self._standard_effect: Effect | None = None
459
460
  self._objective_effect: Effect | None = None
460
461
 
@@ -474,7 +475,7 @@ class EffectCollection:
474
475
  self.standard_effect = effect
475
476
  if effect.is_objective:
476
477
  self.objective_effect = effect
477
- self._effects[effect.label] = effect
478
+ self.add(effect) # Use the inherited add() method from ElementContainer
478
479
  logger.info(f'Registered new Effect: {effect.label}')
479
480
 
480
481
  def create_effect_values_dict(
@@ -520,10 +521,13 @@ class EffectCollection:
520
521
  # Check circular loops in effects:
521
522
  temporal, periodic = self.calculate_effect_share_factors()
522
523
 
523
- # Validate all referenced sources exist
524
- unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self.effects}
524
+ # Validate all referenced effects (both sources and targets) exist
525
+ edges = list(temporal.keys()) + list(periodic.keys())
526
+ unknown_sources = {src for src, _ in edges if src not in self}
527
+ unknown_targets = {tgt for _, tgt in edges if tgt not in self}
528
+ unknown = unknown_sources | unknown_targets
525
529
  if unknown:
526
- raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}')
530
+ raise KeyError(f'Unknown effects used in effect share mappings: {sorted(unknown)}')
527
531
 
528
532
  temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal]))
529
533
  periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic]))
@@ -552,31 +556,23 @@ class EffectCollection:
552
556
  else:
553
557
  raise KeyError(f'Effect {effect} not found!')
554
558
  try:
555
- return self.effects[effect]
559
+ return super().__getitem__(effect) # Leverage ContainerMixin suggestions
556
560
  except KeyError as e:
557
- raise KeyError(f'Effect "{effect}" not found! Add it to the FlowSystem first!') from e
561
+ # Extract the original message and append context for cleaner output
562
+ original_msg = str(e).strip('\'"')
563
+ raise KeyError(f'{original_msg} Add the effect to the FlowSystem first.') from None
558
564
 
559
- def __iter__(self) -> Iterator[Effect]:
560
- return iter(self._effects.values())
561
-
562
- def __len__(self) -> int:
563
- return len(self._effects)
565
+ def __iter__(self) -> Iterator[str]:
566
+ return iter(self.keys()) # Iterate over keys like a normal dict
564
567
 
565
568
  def __contains__(self, item: str | Effect) -> bool:
566
569
  """Check if the effect exists. Checks for label or object"""
567
570
  if isinstance(item, str):
568
- return item in self.effects # Check if the label exists
571
+ return super().__contains__(item) # Check if the label exists
569
572
  elif isinstance(item, Effect):
570
- if item.label_full in self.effects:
571
- return True
572
- if item in self.effects.values(): # Check if the object exists
573
- return True
573
+ return item.label_full in self and self[item.label_full] is item
574
574
  return False
575
575
 
576
- @property
577
- def effects(self) -> dict[str, Effect]:
578
- return self._effects
579
-
580
576
  @property
581
577
  def standard_effect(self) -> Effect:
582
578
  if self._standard_effect is None:
@@ -611,7 +607,7 @@ class EffectCollection:
611
607
  dict[tuple[str, str], xr.DataArray],
612
608
  ]:
613
609
  shares_periodic = {}
614
- for name, effect in self.effects.items():
610
+ for name, effect in self.items():
615
611
  if effect.share_from_periodic:
616
612
  for source, data in effect.share_from_periodic.items():
617
613
  if source not in shares_periodic:
@@ -620,7 +616,7 @@ class EffectCollection:
620
616
  shares_periodic = calculate_all_conversion_paths(shares_periodic)
621
617
 
622
618
  shares_temporal = {}
623
- for name, effect in self.effects.items():
619
+ for name, effect in self.items():
624
620
  if effect.share_from_temporal:
625
621
  for source, data in effect.share_from_temporal.items():
626
622
  if source not in shares_temporal:
@@ -670,7 +666,7 @@ class EffectCollectionModel(Submodel):
670
666
 
671
667
  def _do_modeling(self):
672
668
  super()._do_modeling()
673
- for effect in self.effects:
669
+ for effect in self.effects.values():
674
670
  effect.create_model(self._model)
675
671
  self.penalty = self.add_submodels(
676
672
  ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'),
@@ -684,7 +680,7 @@ class EffectCollectionModel(Submodel):
684
680
  )
685
681
 
686
682
  def _add_share_between_effects(self):
687
- for target_effect in self.effects:
683
+ for target_effect in self.effects.values():
688
684
  # 1. temporal: <- receiving temporal shares from other effects
689
685
  for source_effect, time_series in target_effect.share_from_temporal.items():
690
686
  target_effect.submodel.temporal.add_share(
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):
@@ -216,6 +261,10 @@ class Bus(Element):
216
261
  def with_excess(self) -> bool:
217
262
  return False if self.excess_penalty_per_flow_hour is None else True
218
263
 
264
+ def __repr__(self) -> str:
265
+ """Return string representation."""
266
+ return super().__repr__() + fx_io.format_flow_details(self)
267
+
219
268
 
220
269
  @register_class_for_io
221
270
  class Connection:
@@ -493,6 +542,10 @@ class Flow(Element):
493
542
  # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen
494
543
  return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True
495
544
 
545
+ def _format_invest_params(self, params: InvestParameters) -> str:
546
+ """Format InvestParameters for display."""
547
+ return f'size: {params.format_for_repr()}'
548
+
496
549
 
497
550
  class FlowModel(ElementModel):
498
551
  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,10 +73,74 @@ 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
 
78
146
  def __init__(
@@ -80,7 +148,7 @@ class FlowSystem(Interface):
80
148
  timesteps: pd.DatetimeIndex,
81
149
  periods: pd.Index | None = None,
82
150
  scenarios: pd.Index | None = None,
83
- hours_of_last_timestep: float | None = None,
151
+ hours_of_last_timestep: int | float | None = None,
84
152
  hours_of_previous_timesteps: int | float | np.ndarray | None = None,
85
153
  weights: PeriodicDataUser | None = None,
86
154
  scenario_independent_sizes: bool | list[str] = True,
@@ -104,8 +172,8 @@ class FlowSystem(Interface):
104
172
  self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep)
105
173
 
106
174
  # Element collections
107
- self.components: dict[str, Component] = {}
108
- self.buses: dict[str, Bus] = {}
175
+ self.components: ElementContainer[Component] = ElementContainer(element_type_name='components')
176
+ self.buses: ElementContainer[Bus] = ElementContainer(element_type_name='buses')
109
177
  self.effects: EffectCollection = EffectCollection()
110
178
  self.model: FlowSystemModel | None = None
111
179
 
@@ -113,6 +181,7 @@ class FlowSystem(Interface):
113
181
  self._used_in_calculation = False
114
182
 
115
183
  self._network_app = None
184
+ self._flows_cache: ElementContainer[Flow] | None = None
116
185
 
117
186
  # Use properties to validate and store scenario dimension settings
118
187
  self.scenario_independent_sizes = scenario_independent_sizes
@@ -232,7 +301,7 @@ class FlowSystem(Interface):
232
301
 
233
302
  # Extract from effects
234
303
  effects_structure = {}
235
- for effect in self.effects:
304
+ for effect in self.effects.values():
236
305
  effect_structure, effect_arrays = effect._create_reference_structure()
237
306
  all_extracted_arrays.update(effect_arrays)
238
307
  effects_structure[effect.label] = effect_structure
@@ -433,7 +502,7 @@ class FlowSystem(Interface):
433
502
  self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario'])
434
503
 
435
504
  self._connect_network()
436
- for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()):
505
+ for element in chain(self.components.values(), self.effects.values(), self.buses.values()):
437
506
  element.transform_data(self)
438
507
  self._connected_and_transformed = True
439
508
 
@@ -581,7 +650,7 @@ class FlowSystem(Interface):
581
650
  'class': 'Bus' if isinstance(node, Bus) else 'Component',
582
651
  'infos': node.__str__(),
583
652
  }
584
- for node in list(self.components.values()) + list(self.buses.values())
653
+ for node in chain(self.components.values(), self.buses.values())
585
654
  }
586
655
 
587
656
  edges = {
@@ -603,10 +672,8 @@ class FlowSystem(Interface):
603
672
  Args:
604
673
  element: new element to check
605
674
  """
606
- if element in self.all_elements.values():
607
- raise ValueError(f'Element {element.label_full} already added to FlowSystem!')
608
675
  # check if name is already used:
609
- if element.label_full in self.all_elements:
676
+ if element.label_full in self:
610
677
  raise ValueError(f'Label of Element {element.label_full} already used in another element!')
611
678
 
612
679
  def _add_effects(self, *args: Effect) -> None:
@@ -616,13 +683,15 @@ class FlowSystem(Interface):
616
683
  for new_component in list(components):
617
684
  logger.info(f'Registered new Component: {new_component.label_full}')
618
685
  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
686
+ self.components.add(new_component) # Add to existing components
687
+ self._flows_cache = None # Invalidate flows cache
620
688
 
621
689
  def _add_buses(self, *buses: Bus):
622
690
  for new_bus in list(buses):
623
691
  logger.info(f'Registered new Bus: {new_bus.label_full}')
624
692
  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
693
+ self.buses.add(new_bus) # Add to existing buses
694
+ self._flows_cache = None # Invalidate flows cache
626
695
 
627
696
  def _connect_network(self):
628
697
  """Connects the network of components and buses. Can be rerun without changes if no elements were added"""
@@ -632,7 +701,7 @@ class FlowSystem(Interface):
632
701
  flow.is_input_in_component = True if flow in component.inputs else False
633
702
 
634
703
  # Add Bus if not already added (deprecated)
635
- if flow._bus_object is not None and flow._bus_object not in self.buses.values():
704
+ if flow._bus_object is not None and flow._bus_object.label_full not in self.buses:
636
705
  warnings.warn(
637
706
  f'The Bus {flow._bus_object.label_full} was added to the FlowSystem from {flow.label_full}.'
638
707
  f'This is deprecated and will be removed in the future. '
@@ -659,62 +728,40 @@ class FlowSystem(Interface):
659
728
  )
660
729
 
661
730
  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)'
681
-
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}'
731
+ """Return a detailed string representation showing all containers."""
732
+ r = fx_io.format_title_with_underline('FlowSystem', '=')
685
733
 
686
- time_period = f'Time period: {self.timesteps[0].date()} to {self.timesteps[-1].date()}'
734
+ # Timestep info
735
+ time_period = f'{self.timesteps[0].date()} to {self.timesteps[-1].date()}'
687
736
  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
- ]
737
+ r += f'Timesteps: {len(self.timesteps)} ({freq_str}) [{time_period}]\n'
692
738
 
693
739
  # Add periods if present
694
740
  if self.periods is not None:
695
741
  period_names = ', '.join(str(p) for p in self.periods[:3])
696
742
  if len(self.periods) > 3:
697
743
  period_names += f' ... (+{len(self.periods) - 3} more)'
698
- lines.append(f'Periods: {len(self.periods)} ({period_names})')
744
+ r += f'Periods: {len(self.periods)} ({period_names})\n'
745
+ else:
746
+ r += 'Periods: None\n'
699
747
 
700
748
  # Add scenarios if present
701
749
  if self.scenarios is not None:
702
750
  scenario_names = ', '.join(str(s) for s in self.scenarios[:3])
703
751
  if len(self.scenarios) > 3:
704
752
  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
753
+ r += f'Scenarios: {len(self.scenarios)} ({scenario_names})\n'
754
+ else:
755
+ r += 'Scenarios: None\n'
716
756
 
717
- return '\n'.join(lines)
757
+ # Add status
758
+ status = '✓' if self.connected_and_transformed else '⚠'
759
+ r += f'Status: {status}\n'
760
+
761
+ # Add grouped container view
762
+ r += '\n' + self._format_grouped_containers()
763
+
764
+ return r
718
765
 
719
766
  def __eq__(self, other: FlowSystem):
720
767
  """Check if two FlowSystems are equal by comparing their dataset representations."""
@@ -734,38 +781,46 @@ class FlowSystem(Interface):
734
781
 
735
782
  return True
736
783
 
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())
784
+ def _get_container_groups(self) -> dict[str, ElementContainer]:
785
+ """Return ordered container groups for CompositeContainerMixin."""
786
+ return {
787
+ 'Components': self.components,
788
+ 'Buses': self.buses,
789
+ 'Effects': self.effects,
790
+ 'Flows': self.flows,
791
+ }
760
792
 
761
793
  @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}
794
+ def flows(self) -> ElementContainer[Flow]:
795
+ if self._flows_cache is None:
796
+ flows = [f for c in self.components.values() for f in c.inputs + c.outputs]
797
+ # Deduplicate by id and sort for reproducibility
798
+ flows = sorted({id(f): f for f in flows}.values(), key=lambda f: f.label_full.lower())
799
+ self._flows_cache = ElementContainer(flows, element_type_name='flows')
800
+ return self._flows_cache
765
801
 
766
802
  @property
767
803
  def all_elements(self) -> dict[str, Element]:
768
- return {**self.components, **self.effects.effects, **self.flows, **self.buses}
804
+ """
805
+ Get all elements as a dictionary.
806
+
807
+ .. deprecated:: 3.2.0
808
+ Use dict-like interface instead: `flow_system['element']`, `'element' in flow_system`,
809
+ `flow_system.keys()`, `flow_system.values()`, or `flow_system.items()`.
810
+ This property will be removed in v4.0.0.
811
+
812
+ Returns:
813
+ Dictionary mapping element labels to element objects.
814
+ """
815
+ warnings.warn(
816
+ "The 'all_elements' property is deprecated. Use dict-like interface instead: "
817
+ "flow_system['element'], 'element' in flow_system, flow_system.keys(), "
818
+ 'flow_system.values(), or flow_system.items(). '
819
+ 'This property will be removed in v4.0.0.',
820
+ DeprecationWarning,
821
+ stacklevel=2,
822
+ )
823
+ return {**self.components, **self.effects, **self.flows, **self.buses}
769
824
 
770
825
  @property
771
826
  def coords(self) -> dict[FlowSystemDimensions, pd.Index]:
@@ -929,6 +984,8 @@ class FlowSystem(Interface):
929
984
  self,
930
985
  time: str,
931
986
  method: Literal['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] = 'mean',
987
+ hours_of_last_timestep: int | float | None = None,
988
+ hours_of_previous_timesteps: int | float | np.ndarray | None = None,
932
989
  **kwargs: Any,
933
990
  ) -> FlowSystem:
934
991
  """
@@ -938,10 +995,12 @@ class FlowSystem(Interface):
938
995
  Args:
939
996
  time: Resampling frequency (e.g., '3h', '2D', '1M')
940
997
  method: Resampling method. Recommended: 'mean', 'first', 'last', 'max', 'min'
998
+ hours_of_last_timestep: New duration of the last time step. Defaults to the last time interval of the new timesteps
999
+ hours_of_previous_timesteps: New duration of the previous timestep. Defaults to the first time increment of the new timesteps
941
1000
  **kwargs: Additional arguments passed to xarray.resample()
942
1001
 
943
1002
  Returns:
944
- FlowSystem: New FlowSystem with resampled data
1003
+ FlowSystem: New resampled FlowSystem
945
1004
  """
946
1005
  if not self.connected_and_transformed:
947
1006
  self.connect_and_transform()
@@ -975,6 +1034,10 @@ class FlowSystem(Interface):
975
1034
  else:
976
1035
  resampled_dataset = resampled_time_data
977
1036
 
1037
+ # Let FlowSystem recalculate or use explicitly set value
1038
+ resampled_dataset.attrs['hours_of_last_timestep'] = hours_of_last_timestep
1039
+ resampled_dataset.attrs['hours_of_previous_timesteps'] = hours_of_previous_timesteps
1040
+
978
1041
  return self.__class__.from_dataset(resampled_dataset)
979
1042
 
980
1043
  @property