From 9ac4bbb9aa44a7133e5cce489d02db88920545f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:24:14 +0100 Subject: [PATCH 01/86] Improve __str__ of FlowSystem --- flixopt/flow_system.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index fd0f6a98d..43cdfe7da 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -659,21 +659,6 @@ def _connect_network(self): ) def __repr__(self) -> str: - """Compact representation for debugging.""" - status = '✓' if self.connected_and_transformed else '⚠' - - # Build dimension info - dims = f'{len(self.timesteps)} timesteps [{self.timesteps[0].strftime("%Y-%m-%d")} to {self.timesteps[-1].strftime("%Y-%m-%d")}]' - if self.periods is not None: - dims += f', {len(self.periods)} periods' - if self.scenarios is not None: - dims += f', {len(self.scenarios)} scenarios' - - return f'FlowSystem({dims}, {len(self.components)} Components, {len(self.buses)} Buses, {len(self.effects)} Effects, {status})' - - def __str__(self) -> str: - """Structured summary for users.""" - def format_elements(element_names: list, label: str, alignment: int = 12): name_list = ', '.join(element_names[:3]) if len(element_names) > 3: @@ -683,7 +668,7 @@ def format_elements(element_names: list, label: str, alignment: int = 12): padding = alignment - len(label) - 1 # -1 for the colon return f'{label}:{"":<{padding}} {len(element_names)}{suffix}' - time_period = f'Time period: {self.timesteps[0].date()} to {self.timesteps[-1].date()}' + time_period = f'{self.timesteps[0].date()} to {self.timesteps[-1].date()}' freq_str = str(self.timesteps.freq).replace('<', '').replace('>', '') if self.timesteps.freq else 'irregular' lines = [ From a0289800412b7a4c999a21d567f13184765b6b48 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:32:44 +0100 Subject: [PATCH 02/86] Use ElementContainer class --- flixopt/commons.py | 2 + flixopt/flow_system.py | 17 +++-- flixopt/structure.py | 141 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..683bd8563 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -19,6 +19,7 @@ from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects +from .structure import ElementContainer __all__ = [ 'TimeSeriesData', @@ -34,6 +35,7 @@ 'LinearConverter', 'Transmission', 'FlowSystem', + 'ElementContainer', 'FullCalculation', 'SegmentedCalculation', 'AggregatedCalculation', diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 43cdfe7da..1867891de 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -32,7 +32,7 @@ TemporalEffectsUser, ) from .elements import Bus, Component, Flow -from .structure import Element, FlowSystemModel, Interface +from .structure import Element, ElementContainer, FlowSystemModel, Interface if TYPE_CHECKING: import pathlib @@ -104,8 +104,8 @@ def __init__( self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) # Element collections - self.components: dict[str, Component] = {} - self.buses: dict[str, Bus] = {} + self.components: ElementContainer[Component] = ElementContainer(element_type_name='components') + self.buses: ElementContainer[Bus] = ElementContainer(element_type_name='buses') self.effects: EffectCollection = EffectCollection() self.model: FlowSystemModel | None = None @@ -616,13 +616,13 @@ def _add_components(self, *components: Component) -> None: for new_component in list(components): logger.info(f'Registered new Component: {new_component.label_full}') self._check_if_element_is_unique(new_component) # check if already exists: - self.components[new_component.label_full] = new_component # Add to existing components + self.components.add(new_component) # Add to existing components def _add_buses(self, *buses: Bus): for new_bus in list(buses): logger.info(f'Registered new Bus: {new_bus.label_full}') self._check_if_element_is_unique(new_bus) # check if already exists: - self.buses[new_bus.label_full] = new_bus # Add to existing components + self.buses.add(new_bus) # Add to existing buses def _connect_network(self): """Connects the network of components and buses. Can be rerun without changes if no elements were added""" @@ -750,7 +750,12 @@ def flows(self) -> dict[str, Flow]: @property def all_elements(self) -> dict[str, Element]: - return {**self.components, **self.effects.effects, **self.flows, **self.buses} + return { + **dict(self.components.items()), + **self.effects.effects, + **self.flows, + **dict(self.buses.items()), + } @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: diff --git a/flixopt/structure.py b/flixopt/structure.py index 6ea618454..f99241e7d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -8,11 +8,14 @@ import inspect import logging from dataclasses import dataclass +from difflib import get_close_matches from io import StringIO from typing import ( TYPE_CHECKING, Any, + Generic, Literal, + TypeVar, ) import linopy @@ -895,6 +898,144 @@ def _valid_label(label: str) -> str: return label +# Type variable for ElementContainer +T = TypeVar('T', bound='Element') + + +class ElementContainer(Generic[T]): + """ + A container for Elements providing dict-like access patterns with nice repr. + + This container provides: + - String-based access via __getitem__ with helpful error messages + - Iteration over labels and values + - Nice __repr__ similar to FlowSystem's formatting + - Type safety through generic typing + + Example: + >>> components = ElementContainer[Component](element_type_name='components') + >>> components.add(my_component) + >>> comp = components['my_label'] + >>> for label in components: + ... print(label) + """ + + def __init__( + self, + elements: list[T] | dict[str, T] | None = None, + element_type_name: str = 'elements', + ): + """ + Args: + elements: Initial elements to add (list or dict). If dict, keys are ignored and element.label is used. + element_type_name: Name of the element type for repr (e.g., 'components', 'buses', 'flows') + """ + self._elements: dict[str, T] = {} + self._element_type_name = element_type_name + + if elements is not None: + if isinstance(elements, dict): + for element in elements.values(): + self.add(element) + else: + for element in elements: + self.add(element) + + def add(self, element: T) -> None: + """ + Add an element to the container. + + Args: + element: Element to add + + Raises: + ValueError: If an element with the same label already exists + """ + if element.label_full in self._elements: + raise ValueError( + f'Element with label "{element.label_full}" already exists in {self._element_type_name}. ' + f'Each element must have a unique label.' + ) + self._elements[element.label_full] = element + + def __getitem__(self, label: str) -> T: + """ + Get element by label with helpful error messages. + + Args: + label: Label of the element to retrieve + + Returns: + The element with the given label + + Raises: + KeyError: If element is not found, with suggestions for similar labels + """ + if label in self._elements: + return self._elements[label] + + # Provide helpful error with close matches suggestions + suggestions = get_close_matches(label, self._elements.keys(), n=3, cutoff=0.6) + error_msg = f'Element "{label}" not found in {self._element_type_name}.' + if suggestions: + error_msg += f' Did you mean: {", ".join(suggestions)}?' + raise KeyError(error_msg) + + def __contains__(self, label: str) -> bool: + """Check if element exists in the container by label.""" + return label in self._elements + + def __iter__(self): + """Iterate over element labels.""" + return iter(self._elements.keys()) + + def __len__(self) -> int: + """Return number of elements in the container.""" + return len(self._elements) + + def values(self): + """Return iterator over element values.""" + return self._elements.values() + + def items(self): + """Return iterator over (label, element) pairs.""" + return self._elements.items() + + def keys(self): + """Return iterator over element labels.""" + return self._elements.keys() + + def get(self, label: str, default=None) -> T | None: + """ + Get element by label, returning default if not found. + + Args: + label: Label of the element to retrieve + default: Value to return if element is not found + + Returns: + The element if found, otherwise default + """ + return self._elements.get(label, default) + + def __repr__(self) -> str: + """ + Return a nice string representation similar to FlowSystem's format_elements. + + Shows the count and first few element names. + """ + if not self._elements: + return f'{self._element_type_name.capitalize()}: 0' + + # Get first 3 element names + element_names = list(self._elements.keys()) + name_list = ', '.join(element_names[:3]) + if len(element_names) > 3: + name_list += f' ... (+{len(element_names) - 3} more)' + + return f'{self._element_type_name.capitalize()}: {len(self._elements)} ({name_list})' + + class Submodel(SubmodelsMixin): """Stores Variables and Constraints. Its a subset of a FlowSystemModel. Variables and constraints are stored in the main FlowSystemModel, and are referenced here. From a115d0e74eb6c805eedc5a053485d509992eb93d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:36:06 +0100 Subject: [PATCH 03/86] Inherrit from dict --- flixopt/flow_system.py | 7 +-- flixopt/structure.py | 96 +++++++++++++++++------------------------- 2 files changed, 40 insertions(+), 63 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 1867891de..5fe722f5a 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -750,12 +750,7 @@ def flows(self) -> dict[str, Flow]: @property def all_elements(self) -> dict[str, Element]: - return { - **dict(self.components.items()), - **self.effects.effects, - **self.flows, - **dict(self.buses.items()), - } + return {**self.components, **self.effects.effects, **self.flows, **self.buses} @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: diff --git a/flixopt/structure.py b/flixopt/structure.py index f99241e7d..9013e3e98 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -902,15 +902,16 @@ def _valid_label(label: str) -> str: T = TypeVar('T', bound='Element') -class ElementContainer(Generic[T]): +class ElementContainer(dict[str, T]): """ - A container for Elements providing dict-like access patterns with nice repr. + A dict-based container for Elements with helpful access patterns and nice repr. - This container provides: - - String-based access via __getitem__ with helpful error messages - - Iteration over labels and values + Inherits from dict for full compatibility with serialization and type checking, + while adding: + - Helpful error messages with fuzzy matching suggestions - Nice __repr__ similar to FlowSystem's formatting - Type safety through generic typing + - Validation on assignment Example: >>> components = ElementContainer[Component](element_type_name='components') @@ -927,10 +928,10 @@ def __init__( ): """ Args: - elements: Initial elements to add (list or dict). If dict, keys are ignored and element.label is used. + elements: Initial elements to add (list or dict). If dict, keys are ignored and element.label_full is used. element_type_name: Name of the element type for repr (e.g., 'components', 'buses', 'flows') """ - self._elements: dict[str, T] = {} + super().__init__() self._element_type_name = element_type_name if elements is not None: @@ -951,72 +952,53 @@ def add(self, element: T) -> None: Raises: ValueError: If an element with the same label already exists """ - if element.label_full in self._elements: + if element.label_full in self: raise ValueError( f'Element with label "{element.label_full}" already exists in {self._element_type_name}. ' f'Each element must have a unique label.' ) - self._elements[element.label_full] = element + self[element.label_full] = element - def __getitem__(self, label: str) -> T: + def __setitem__(self, label: str, element: T) -> None: """ - Get element by label with helpful error messages. + Set an element with validation. Args: - label: Label of the element to retrieve - - Returns: - The element with the given label + label: Label for the element (should match element.label_full) + element: Element to add Raises: - KeyError: If element is not found, with suggestions for similar labels + ValueError: If label doesn't match element.label_full """ - if label in self._elements: - return self._elements[label] - - # Provide helpful error with close matches suggestions - suggestions = get_close_matches(label, self._elements.keys(), n=3, cutoff=0.6) - error_msg = f'Element "{label}" not found in {self._element_type_name}.' - if suggestions: - error_msg += f' Did you mean: {", ".join(suggestions)}?' - raise KeyError(error_msg) - - def __contains__(self, label: str) -> bool: - """Check if element exists in the container by label.""" - return label in self._elements - - def __iter__(self): - """Iterate over element labels.""" - return iter(self._elements.keys()) - - def __len__(self) -> int: - """Return number of elements in the container.""" - return len(self._elements) - - def values(self): - """Return iterator over element values.""" - return self._elements.values() - - def items(self): - """Return iterator over (label, element) pairs.""" - return self._elements.items() - - def keys(self): - """Return iterator over element labels.""" - return self._elements.keys() + if label != element.label_full: + raise ValueError( + f'Key "{label}" does not match element.label_full "{element.label_full}". ' + f'Use element.label_full as the key or use .add() method.' + ) + super().__setitem__(label, element) - def get(self, label: str, default=None) -> T | None: + def __getitem__(self, label: str) -> T: """ - Get element by label, returning default if not found. + Get element by label with helpful error messages. Args: label: Label of the element to retrieve - default: Value to return if element is not found Returns: - The element if found, otherwise default + The element with the given label + + Raises: + KeyError: If element is not found, with suggestions for similar labels """ - return self._elements.get(label, default) + try: + return super().__getitem__(label) + except KeyError: + # Provide helpful error with close matches suggestions + suggestions = get_close_matches(label, self.keys(), n=3, cutoff=0.6) + error_msg = f'Element "{label}" not found in {self._element_type_name}.' + if suggestions: + error_msg += f' Did you mean: {", ".join(suggestions)}?' + raise KeyError(error_msg) from None def __repr__(self) -> str: """ @@ -1024,16 +1006,16 @@ def __repr__(self) -> str: Shows the count and first few element names. """ - if not self._elements: + if not self: return f'{self._element_type_name.capitalize()}: 0' # Get first 3 element names - element_names = list(self._elements.keys()) + element_names = list(self.keys()) name_list = ', '.join(element_names[:3]) if len(element_names) > 3: name_list += f' ... (+{len(element_names) - 3} more)' - return f'{self._element_type_name.capitalize()}: {len(self._elements)} ({name_list})' + return f'{self._element_type_name.capitalize()}: {len(self)} ({name_list})' class Submodel(SubmodelsMixin): From b04a2b3c691e9b12be5de2236213c640203454ef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:41:56 +0100 Subject: [PATCH 04/86] Improve repr --- flixopt/structure.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 9013e3e98..659c57677 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1002,20 +1002,20 @@ def __getitem__(self, label: str) -> T: def __repr__(self) -> str: """ - Return a nice string representation similar to FlowSystem's format_elements. - - Shows the count and first few element names. + Return a string representation similar to linopy.model.Variables. """ - if not self: - return f'{self._element_type_name.capitalize()}: 0' + title = self._element_type_name.capitalize() + line = '-' * len(title) + r = f'{title}\n{line}\n' + + for name, element in self.items(): + element_type = element.__class__.__name__ + r += f' * {name} ({element_type})\n' - # Get first 3 element names - element_names = list(self.keys()) - name_list = ', '.join(element_names[:3]) - if len(element_names) > 3: - name_list += f' ... (+{len(element_names) - 3} more)' + if not len(list(self)): + r += '\n' - return f'{self._element_type_name.capitalize()}: {len(self)} ({name_list})' + return r class Submodel(SubmodelsMixin): From 24b440dfccf720c70b21e75f33783f5854042d47 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:44:13 +0100 Subject: [PATCH 05/86] Assign flow.componet right away after the flow is passed to a Component --- flixopt/elements.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flixopt/elements.py b/flixopt/elements.py index a0fd306c0..2b4563121 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -87,6 +87,7 @@ def __init__( self.inputs: list[Flow] = inputs or [] self.outputs: list[Flow] = outputs or [] self._check_unique_flow_labels() + self._set_flow_labels() self.on_off_parameters = on_off_parameters self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or [] @@ -115,6 +116,10 @@ def _check_unique_flow_labels(self): def _plausibility_checks(self) -> None: self._check_unique_flow_labels() + def _set_flow_labels(self): + for flow in self.inputs + self.outputs: + flow.component = self.label_full + @register_class_for_io class Bus(Element): From 69e7ecf239733a8f282672127785fc15cd15a930 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:47:19 +0100 Subject: [PATCH 06/86] Add FLowContainer --- flixopt/flow_system.py | 5 +++-- flixopt/structure.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5fe722f5a..28bb7348b 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -744,9 +744,10 @@ def __iter__(self): return iter(self.all_elements.keys()) @property - def flows(self) -> dict[str, Flow]: + def flows(self) -> ElementContainer[Flow]: set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} - return {flow.label_full: flow for flow in set_of_flows} + flows_dict = {flow.label_full: flow for flow in set_of_flows} + return ElementContainer(elements=flows_dict, element_type_name='flows') @property def all_elements(self) -> dict[str, Element]: diff --git a/flixopt/structure.py b/flixopt/structure.py index 659c57677..bd470db91 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -998,6 +998,8 @@ def __getitem__(self, label: str) -> T: error_msg = f'Element "{label}" not found in {self._element_type_name}.' if suggestions: error_msg += f' Did you mean: {", ".join(suggestions)}?' + else: + error_msg += f' Got: {str(list(self))}' raise KeyError(error_msg) from None def __repr__(self) -> str: From a0f55abdc30c51015909f275c0f3c4bc6339da80 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:48:27 +0100 Subject: [PATCH 07/86] Improve Error Message --- flixopt/structure.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index bd470db91..e8ff35a52 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -999,7 +999,11 @@ def __getitem__(self, label: str) -> T: if suggestions: error_msg += f' Did you mean: {", ".join(suggestions)}?' else: - error_msg += f' Got: {str(list(self))}' + available = list(self.keys()) + if len(available) <= 5: + error_msg += f' Available: {", ".join(available)}' + else: + error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)' raise KeyError(error_msg) from None def __repr__(self) -> str: From f3181747b7da9e11c16317e6cbfdba905c4cc6b0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:28:54 +0100 Subject: [PATCH 08/86] Improve repr of FlowSystem --- flixopt/flow_system.py | 62 ++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 28bb7348b..6436f0e9b 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -659,47 +659,45 @@ def _connect_network(self): ) def __repr__(self) -> str: - def format_elements(element_names: list, label: str, alignment: int = 12): - name_list = ', '.join(element_names[:3]) - if len(element_names) > 3: - name_list += f' ... (+{len(element_names) - 3} more)' - - suffix = f' ({name_list})' if element_names else '' - padding = alignment - len(label) - 1 # -1 for the colon - return f'{label}:{"":<{padding}} {len(element_names)}{suffix}' + """Return a detailed string representation showing all containers.""" + title = 'FlowSystem' + line = '-' * len(title) + # Timestep info time_period = f'{self.timesteps[0].date()} to {self.timesteps[-1].date()}' freq_str = str(self.timesteps.freq).replace('<', '').replace('>', '') if self.timesteps.freq else 'irregular' - - lines = [ - f'Timesteps: {len(self.timesteps)} ({freq_str}) [{time_period}]', - ] + r = f'{title}\n{line}\n' + r += f'Timesteps: {len(self.timesteps)} ({freq_str}) [{time_period}]\n' # Add periods if present if self.periods is not None: period_names = ', '.join(str(p) for p in self.periods[:3]) if len(self.periods) > 3: period_names += f' ... (+{len(self.periods) - 3} more)' - lines.append(f'Periods: {len(self.periods)} ({period_names})') + r += f'Periods: {len(self.periods)} ({period_names})\n' # Add scenarios if present if self.scenarios is not None: scenario_names = ', '.join(str(s) for s in self.scenarios[:3]) if len(self.scenarios) > 3: scenario_names += f' ... (+{len(self.scenarios) - 3} more)' - lines.append(f'Scenarios: {len(self.scenarios)} ({scenario_names})') - - lines.extend( - [ - format_elements(list(self.components.keys()), 'Components'), - format_elements(list(self.buses.keys()), 'Buses'), - format_elements(list(self.effects.effects.keys()), 'Effects'), - f'Status: {"Connected & Transformed" if self.connected_and_transformed else "Not connected"}', - ] - ) - lines = ['FlowSystem:', f'{"─" * max(len(line) for line in lines)}'] + lines + r += f'Scenarios: {len(self.scenarios)} ({scenario_names})\n' + + r += f'Status: {"Connected & Transformed" if self.connected_and_transformed else "Not connected"}\n' - return '\n'.join(lines) + # Add containers (using their nice repr) + r += '\n' + repr(self.components) + '\n' + r += '\n' + repr(self.buses) + '\n' + + # Effects + r += '\nEffects\n-------\n' + for label, effect in self.effects.effects.items(): + effect_type = effect.__class__.__name__ + r += f' * {label} ({effect_type})\n' + if not self.effects.effects: + r += '\n' + + return r def __eq__(self, other: FlowSystem): """Check if two FlowSystems are equal by comparing their dataset representations.""" @@ -724,16 +722,22 @@ def __getitem__(self, item) -> Element: if item in self.all_elements: return self.all_elements[item] - # Provide helpful error with suggestions + # Provide helpful error with suggestions (matching ElementContainer style) from difflib import get_close_matches suggestions = get_close_matches(item, self.all_elements.keys(), n=3, cutoff=0.6) + error_msg = f'Element "{item}" not found in FlowSystem.' if suggestions: - suggestion_str = ', '.join(f"'{s}'" for s in suggestions) - raise KeyError(f"Element '{item}' not found. Did you mean: {suggestion_str}?") + error_msg += f' Did you mean: {", ".join(suggestions)}?' else: - raise KeyError(f"Element '{item}' not found in FlowSystem") + available = list(self.all_elements.keys()) + if len(available) <= 5: + error_msg += f' Available: {", ".join(available)}' + else: + error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)' + + raise KeyError(error_msg) def __contains__(self, item: str) -> bool: """Check if element exists in the FlowSystem.""" From e179682eeed0dd36c599342f3e635cdc0f474df6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:29:07 +0100 Subject: [PATCH 09/86] Use ElementContainer in results.py --- flixopt/results.py | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 950570df3..7e3ce273d 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -238,13 +238,20 @@ def __init__( self.name = name self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - self.components = { + + # Create ElementContainers for better access patterns + from .structure import ElementContainer + + components_dict = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() } + self.components = ElementContainer(elements=components_dict, element_type_name='components') - self.buses = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()} + buses_dict = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()} + self.buses = ElementContainer(elements=buses_dict, element_type_name='buses') - self.effects = {label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items()} + effects_dict = {label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items()} + self.effects = ElementContainer(elements=effects_dict, element_type_name='effects') if 'Flows' not in self.solution.attrs: warnings.warn( @@ -252,11 +259,12 @@ def __init__( 'is not availlable. We recommend to evaluate your results with a version <2.2.0.', stacklevel=2, ) - self.flows = {} + flows_dict = {} else: - self.flows = { + flows_dict = { label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items() } + self.flows = ElementContainer(elements=flows_dict, element_type_name='flows') self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.timesteps_extra) @@ -273,7 +281,8 @@ def __init__( self.colors: dict[str, str] = {} - def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults: + def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults | FlowResults: + """Get element results by label with helpful error messages.""" if key in self.components: return self.components[key] if key in self.buses: @@ -282,7 +291,24 @@ def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults return self.effects[key] if key in self.flows: return self.flows[key] - raise KeyError(f'No element with label {key} found.') + + # Provide helpful error with suggestions (matching ElementContainer style) + from difflib import get_close_matches + + all_elements = {**self.components, **self.buses, **self.effects, **self.flows} + suggestions = get_close_matches(key, all_elements.keys(), n=3, cutoff=0.6) + error_msg = f'Element "{key}" not found in results.' + + if suggestions: + error_msg += f' Did you mean: {", ".join(suggestions)}?' + else: + available = list(all_elements.keys()) + if len(available) <= 5: + error_msg += f' Available: {", ".join(available)}' + else: + error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)' + + raise KeyError(error_msg) @property def storages(self) -> list[ComponentResults]: From 7b9c786449579f3140b3b387d8298af1175f4855 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:35:14 +0100 Subject: [PATCH 10/86] Use a Mixin instead and use in results as well --- flixopt/commons.py | 4 +- flixopt/results.py | 12 +++--- flixopt/structure.py | 95 ++++++++++++++++++++++++-------------------- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/flixopt/commons.py b/flixopt/commons.py index 683bd8563..c47279f26 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -19,7 +19,7 @@ from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects -from .structure import ElementContainer +from .structure import ContainerMixin, ElementContainer, ResultsContainer __all__ = [ 'TimeSeriesData', @@ -35,7 +35,9 @@ 'LinearConverter', 'Transmission', 'FlowSystem', + 'ContainerMixin', 'ElementContainer', + 'ResultsContainer', 'FullCalculation', 'SegmentedCalculation', 'AggregatedCalculation', diff --git a/flixopt/results.py b/flixopt/results.py index 7e3ce273d..ec9a30824 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -239,19 +239,19 @@ def __init__( self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - # Create ElementContainers for better access patterns - from .structure import ElementContainer + # Create ResultsContainers for better access patterns + from .structure import ResultsContainer components_dict = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() } - self.components = ElementContainer(elements=components_dict, element_type_name='components') + self.components = ResultsContainer(elements=components_dict, element_type_name='components') buses_dict = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()} - self.buses = ElementContainer(elements=buses_dict, element_type_name='buses') + self.buses = ResultsContainer(elements=buses_dict, element_type_name='buses') effects_dict = {label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items()} - self.effects = ElementContainer(elements=effects_dict, element_type_name='effects') + self.effects = ResultsContainer(elements=effects_dict, element_type_name='effects') if 'Flows' not in self.solution.attrs: warnings.warn( @@ -264,7 +264,7 @@ def __init__( flows_dict = { label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items() } - self.flows = ElementContainer(elements=flows_dict, element_type_name='flows') + self.flows = ResultsContainer(elements=flows_dict, element_type_name='flows') self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.timesteps_extra) diff --git a/flixopt/structure.py b/flixopt/structure.py index e8ff35a52..841685bd3 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -898,27 +898,15 @@ def _valid_label(label: str) -> str: return label -# Type variable for ElementContainer -T = TypeVar('T', bound='Element') +# Type variable for containers +T = TypeVar('T') -class ElementContainer(dict[str, T]): +class ContainerMixin(dict[str, T]): """ - A dict-based container for Elements with helpful access patterns and nice repr. - - Inherits from dict for full compatibility with serialization and type checking, - while adding: - - Helpful error messages with fuzzy matching suggestions - - Nice __repr__ similar to FlowSystem's formatting - - Type safety through generic typing - - Validation on assignment - - Example: - >>> components = ElementContainer[Component](element_type_name='components') - >>> components.add(my_component) - >>> comp = components['my_label'] - >>> for label in components: - ... print(label) + Mixin providing shared container functionality with nice repr and error messages. + + Subclasses must implement _get_label() to extract the label from elements. """ def __init__( @@ -928,8 +916,8 @@ def __init__( ): """ Args: - elements: Initial elements to add (list or dict). If dict, keys are ignored and element.label_full is used. - element_type_name: Name of the element type for repr (e.g., 'components', 'buses', 'flows') + elements: Initial elements to add (list or dict) + element_type_name: Name for display (e.g., 'components', 'buses') """ super().__init__() self._element_type_name = element_type_name @@ -942,38 +930,35 @@ def __init__( for element in elements: self.add(element) - def add(self, element: T) -> None: + def _get_label(self, element: T) -> str: """ - Add an element to the container. + Extract label from element. Must be implemented by subclasses. Args: - element: Element to add + element: Element to get label from - Raises: - ValueError: If an element with the same label already exists + Returns: + Label string """ - if element.label_full in self: + raise NotImplementedError('Subclasses must implement _get_label()') + + def add(self, element: T) -> None: + """Add an element to the container.""" + label = self._get_label(element) + if label in self: raise ValueError( - f'Element with label "{element.label_full}" already exists in {self._element_type_name}. ' + f'Element with label "{label}" already exists in {self._element_type_name}. ' f'Each element must have a unique label.' ) - self[element.label_full] = element + self[label] = element def __setitem__(self, label: str, element: T) -> None: - """ - Set an element with validation. - - Args: - label: Label for the element (should match element.label_full) - element: Element to add - - Raises: - ValueError: If label doesn't match element.label_full - """ - if label != element.label_full: + """Set element with validation.""" + element_label = self._get_label(element) + if label != element_label: raise ValueError( - f'Key "{label}" does not match element.label_full "{element.label_full}". ' - f'Use element.label_full as the key or use .add() method.' + f'Key "{label}" does not match element label "{element_label}". ' + f'Use the correct label as key or use .add() method.' ) super().__setitem__(label, element) @@ -1007,9 +992,7 @@ def __getitem__(self, label: str) -> T: raise KeyError(error_msg) from None def __repr__(self) -> str: - """ - Return a string representation similar to linopy.model.Variables. - """ + """Return a string representation similar to linopy.model.Variables.""" title = self._element_type_name.capitalize() line = '-' * len(title) r = f'{title}\n{line}\n' @@ -1024,6 +1007,30 @@ def __repr__(self) -> str: return r +class ElementContainer(ContainerMixin[T]): + """ + Container for Element objects (Component, Bus, Flow, Effect). + + Uses element.label_full for keying. + """ + + def _get_label(self, element: T) -> str: + """Extract label_full from Element.""" + return element.label_full + + +class ResultsContainer(ContainerMixin[T]): + """ + Container for Results objects (ComponentResults, BusResults, etc). + + Uses element.label for keying. + """ + + def _get_label(self, element: T) -> str: + """Extract label from Results object.""" + return element.label + + class Submodel(SubmodelsMixin): """Stores Variables and Constraints. Its a subset of a FlowSystemModel. Variables and constraints are stored in the main FlowSystemModel, and are referenced here. From c1d2882d0d4139a70f306c205be233d05a494fa2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:39:23 +0100 Subject: [PATCH 11/86] Improve Results container usage --- flixopt/results.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index ec9a30824..ce4333661 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -245,13 +245,13 @@ def __init__( components_dict = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() } - self.components = ResultsContainer(elements=components_dict, element_type_name='components') + self.components = ResultsContainer(elements=components_dict, element_type_name='component results') buses_dict = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()} - self.buses = ResultsContainer(elements=buses_dict, element_type_name='buses') + self.buses = ResultsContainer(elements=buses_dict, element_type_name='bus results') effects_dict = {label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items()} - self.effects = ResultsContainer(elements=effects_dict, element_type_name='effects') + self.effects = ResultsContainer(elements=effects_dict, element_type_name='effect results') if 'Flows' not in self.solution.attrs: warnings.warn( @@ -264,7 +264,7 @@ def __init__( flows_dict = { label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items() } - self.flows = ResultsContainer(elements=flows_dict, element_type_name='flows') + self.flows = ResultsContainer(elements=flows_dict, element_type_name='flow results') self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.timesteps_extra) From 47fa30b3913adab64027cc37fd69dce4825cbe16 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:40:42 +0100 Subject: [PATCH 12/86] Simplify display --- flixopt/structure.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 841685bd3..e13c99ba1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -997,9 +997,8 @@ def __repr__(self) -> str: line = '-' * len(title) r = f'{title}\n{line}\n' - for name, element in self.items(): - element_type = element.__class__.__name__ - r += f' * {name} ({element_type})\n' + for name in self.keys(): + r += f' * {name}\n' if not len(list(self)): r += '\n' From 7b4dba00697218b637f37d90fb9014729110f59d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:47:34 +0100 Subject: [PATCH 13/86] Make CalculationResults iterable over sub containers --- flixopt/results.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index ce4333661..b6e91dd72 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -310,6 +310,73 @@ def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults raise KeyError(error_msg) + def __iter__(self): + """Iterate over all element labels (components, buses, effects, flows).""" + yield from self.components.keys() + yield from self.buses.keys() + yield from self.effects.keys() + yield from self.flows.keys() + + def __len__(self) -> int: + """Return total count of all elements.""" + return len(self.components) + len(self.buses) + len(self.effects) + len(self.flows) + + def __contains__(self, key: str) -> bool: + """Check if element exists in results.""" + return key in self.components or key in self.buses or key in self.effects or key in self.flows + + def keys(self): + """Return all element labels.""" + return list(self) + + def values(self): + """Return all element result objects.""" + return [self[key] for key in self] + + def items(self): + """Return (label, result) pairs for all elements.""" + return [(key, self[key]) for key in self] + + def __repr__(self) -> str: + """Return grouped representation of all results.""" + lines = [] + lines.append('Calculation Results') + lines.append('-' * len('Calculation Results')) + + # Components + if self.components: + lines.append('Components:') + for name in self.components.keys(): + lines.append(f' * {name}') + lines.append('') + + # Buses + if self.buses: + lines.append('Buses:') + for name in self.buses.keys(): + lines.append(f' * {name}') + lines.append('') + + # Effects + if self.effects: + lines.append('Effects:') + for name in self.effects.keys(): + lines.append(f' * {name}') + lines.append('') + + # Flows + if self.flows: + lines.append('Flows:') + for name in self.flows.keys(): + lines.append(f' * {name}') + lines.append('') + + # Remove trailing empty line if present + if lines and lines[-1] == '': + lines.pop() + + return '\n'.join(lines) + @property def storages(self) -> list[ComponentResults]: """Get all storage components in the results.""" From 4276f3aa1775a56131897e6175c23882076f56e5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:58:52 +0100 Subject: [PATCH 14/86] Create CompositeContainerMixin and use in FLowSystem and CalcualtionResults --- flixopt/flow_system.py | 48 +++++++---- flixopt/results.py | 104 +++--------------------- flixopt/structure.py | 176 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 108 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 6436f0e9b..3a466a142 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -32,7 +32,7 @@ TemporalEffectsUser, ) from .elements import Bus, Component, Flow -from .structure import Element, ElementContainer, FlowSystemModel, Interface +from .structure import CompositeContainerMixin, Element, ElementContainer, FlowSystemModel, Interface if TYPE_CHECKING: import pathlib @@ -43,7 +43,7 @@ logger = logging.getLogger('flixopt') -class FlowSystem(Interface): +class FlowSystem(Interface, CompositeContainerMixin): """ A FlowSystem organizes the high level Elements (Components, Buses & Effects). @@ -675,6 +675,8 @@ def __repr__(self) -> str: if len(self.periods) > 3: period_names += f' ... (+{len(self.periods) - 3} more)' r += f'Periods: {len(self.periods)} ({period_names})\n' + else: + r += 'Periods: None\n' # Add scenarios if present if self.scenarios is not None: @@ -682,20 +684,11 @@ def __repr__(self) -> str: if len(self.scenarios) > 3: scenario_names += f' ... (+{len(self.scenarios) - 3} more)' r += f'Scenarios: {len(self.scenarios)} ({scenario_names})\n' + else: + r += 'Scenarios: None\n' - r += f'Status: {"Connected & Transformed" if self.connected_and_transformed else "Not connected"}\n' - - # Add containers (using their nice repr) - r += '\n' + repr(self.components) + '\n' - r += '\n' + repr(self.buses) + '\n' - - # Effects - r += '\nEffects\n-------\n' - for label, effect in self.effects.effects.items(): - effect_type = effect.__class__.__name__ - r += f' * {label} ({effect_type})\n' - if not self.effects.effects: - r += '\n' + # Add grouped container view + r += '\n' + self._format_grouped_containers() return r @@ -747,6 +740,31 @@ def __iter__(self): """Iterate over element labels.""" return iter(self.all_elements.keys()) + def __len__(self) -> int: + """Return total count of all elements.""" + return len(self.all_elements) + + def keys(self): + """Return all element labels.""" + return list(self.all_elements.keys()) + + def values(self): + """Return all element objects.""" + return list(self.all_elements.values()) + + def items(self): + """Return (label, element) pairs for all elements.""" + return list(self.all_elements.items()) + + def _get_container_groups(self) -> dict[str, dict]: + """Return ordered container groups for CompositeContainerMixin.""" + return { + 'Components': dict(self.components), + 'Buses': dict(self.buses), + 'Effects': self.effects.effects, + 'Flows': dict(self.flows), + } + @property def flows(self) -> ElementContainer[Flow]: set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} diff --git a/flixopt/results.py b/flixopt/results.py index b6e91dd72..e7ec0e572 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -17,6 +17,7 @@ from .color_processing import process_colors from .config import CONFIG from .flow_system import FlowSystem +from .structure import CompositeContainerMixin if TYPE_CHECKING: import matplotlib.pyplot as plt @@ -53,7 +54,7 @@ class _FlowSystemRestorationError(Exception): pass -class CalculationResults: +class CalculationResults(CompositeContainerMixin): """Comprehensive container for optimization calculation results and analysis tools. This class provides unified access to all optimization results including flow rates, @@ -281,101 +282,18 @@ def __init__( self.colors: dict[str, str] = {} - def __getitem__(self, key: str) -> ComponentResults | BusResults | EffectResults | FlowResults: - """Get element results by label with helpful error messages.""" - if key in self.components: - return self.components[key] - if key in self.buses: - return self.buses[key] - if key in self.effects: - return self.effects[key] - if key in self.flows: - return self.flows[key] - - # Provide helpful error with suggestions (matching ElementContainer style) - from difflib import get_close_matches - - all_elements = {**self.components, **self.buses, **self.effects, **self.flows} - suggestions = get_close_matches(key, all_elements.keys(), n=3, cutoff=0.6) - error_msg = f'Element "{key}" not found in results.' - - if suggestions: - error_msg += f' Did you mean: {", ".join(suggestions)}?' - else: - available = list(all_elements.keys()) - if len(available) <= 5: - error_msg += f' Available: {", ".join(available)}' - else: - error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)' - - raise KeyError(error_msg) - - def __iter__(self): - """Iterate over all element labels (components, buses, effects, flows).""" - yield from self.components.keys() - yield from self.buses.keys() - yield from self.effects.keys() - yield from self.flows.keys() - - def __len__(self) -> int: - """Return total count of all elements.""" - return len(self.components) + len(self.buses) + len(self.effects) + len(self.flows) - - def __contains__(self, key: str) -> bool: - """Check if element exists in results.""" - return key in self.components or key in self.buses or key in self.effects or key in self.flows - - def keys(self): - """Return all element labels.""" - return list(self) - - def values(self): - """Return all element result objects.""" - return [self[key] for key in self] - - def items(self): - """Return (label, result) pairs for all elements.""" - return [(key, self[key]) for key in self] + def _get_container_groups(self) -> dict[str, dict]: + """Return ordered container groups for CompositeContainerMixin.""" + return { + 'Components': self.components, + 'Buses': self.buses, + 'Effects': self.effects, + 'Flows': self.flows, + } def __repr__(self) -> str: """Return grouped representation of all results.""" - lines = [] - lines.append('Calculation Results') - lines.append('-' * len('Calculation Results')) - - # Components - if self.components: - lines.append('Components:') - for name in self.components.keys(): - lines.append(f' * {name}') - lines.append('') - - # Buses - if self.buses: - lines.append('Buses:') - for name in self.buses.keys(): - lines.append(f' * {name}') - lines.append('') - - # Effects - if self.effects: - lines.append('Effects:') - for name in self.effects.keys(): - lines.append(f' * {name}') - lines.append('') - - # Flows - if self.flows: - lines.append('Flows:') - for name in self.flows.keys(): - lines.append(f' * {name}') - lines.append('') - - # Remove trailing empty line if present - if lines and lines[-1] == '': - lines.pop() - - return '\n'.join(lines) + return self._format_grouped_containers('Calculation Results') @property def storages(self) -> list[ComponentResults]: diff --git a/flixopt/structure.py b/flixopt/structure.py index e13c99ba1..8cd44858a 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1030,6 +1030,182 @@ def _get_label(self, element: T) -> str: return element.label +class CompositeContainerMixin: + """ + Mixin providing unified dict-like access across multiple typed containers. + + This mixin enables classes that manage multiple containers (e.g., components, + buses, effects, flows) to provide a unified interface for accessing elements + across all containers, as if they were a single collection. + + Key Features: + - Dict-like access: `obj['element_name']` searches all containers + - Iteration: `for label in obj:` iterates over all elements + - Membership: `'element' in obj` checks across all containers + - Standard dict methods: keys(), values(), items() + - Grouped display: Formatted repr showing elements by type + + Subclasses must implement: + _get_container_groups() -> dict[str, dict]: + Returns a dictionary mapping group names (e.g., 'Components', 'Buses') + to container dictionaries. Containers are displayed in the order returned. + + Example: + ```python + class MySystem(CompositeContainerMixin): + def __init__(self): + self.components = {'Boiler': ..., 'CHP': ...} + self.buses = {'Heat': ..., 'Power': ...} + + def _get_container_groups(self): + return { + 'Components': self.components, + 'Buses': self.buses, + } + + + system = MySystem() + system['Boiler'] # Returns Boiler component + 'Heat' in system # True + list(system) # ['Boiler', 'CHP', 'Heat', 'Power'] + ``` + + Integration with ContainerMixin: + This mixin is designed to work alongside ContainerMixin-based containers + (ElementContainer, ResultsContainer) by aggregating them into a unified + interface while preserving their individual functionality. + """ + + def _get_container_groups(self) -> dict[str, dict]: + """ + Return ordered dict of container groups to aggregate. + + Returns: + Dictionary mapping group names to container dicts. + Group names should be capitalized (e.g., 'Components', 'Buses'). + Order determines display order in __repr__. + + Example: + ```python + return { + 'Components': self.components, + 'Buses': self.buses, + 'Effects': self.effects, + } + ``` + """ + raise NotImplementedError('Subclasses must implement _get_container_groups()') + + def __getitem__(self, key: str): + """ + Get element by label, searching all containers. + + Args: + key: Element label to find + + Returns: + The element with the given label + + Raises: + KeyError: If element not found, with helpful suggestions + """ + # Search all containers in order + for container in self._get_container_groups().values(): + if key in container: + return container[key] + + # Element not found - provide helpful error + from difflib import get_close_matches + + all_elements = {} + for container in self._get_container_groups().values(): + all_elements.update(container) + + suggestions = get_close_matches(key, all_elements.keys(), n=3, cutoff=0.6) + error_msg = f'Element "{key}" not found.' + + if suggestions: + error_msg += f' Did you mean: {", ".join(suggestions)}?' + else: + available = list(all_elements.keys()) + if len(available) <= 5: + error_msg += f' Available: {", ".join(available)}' + else: + error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)' + + raise KeyError(error_msg) + + def __iter__(self): + """Iterate over all element labels across all containers.""" + for container in self._get_container_groups().values(): + yield from container.keys() + + def __len__(self) -> int: + """Return total count of elements across all containers.""" + return sum(len(container) for container in self._get_container_groups().values()) + + def __contains__(self, key: str) -> bool: + """Check if element exists in any container.""" + return any(key in container for container in self._get_container_groups().values()) + + def keys(self): + """Return all element labels across all containers.""" + return list(self) + + def values(self): + """Return all element objects across all containers.""" + return [self[key] for key in self] + + def items(self): + """Return (label, element) pairs for all elements.""" + return [(key, self[key]) for key in self] + + def _format_grouped_containers(self, title: str | None = None) -> str: + """ + Format containers as grouped string representation. + + Args: + title: Optional title for the representation. If None, no title is shown. + + Returns: + Formatted string with groups and their elements. + Empty groups are automatically hidden. + + Example output: + ``` + Components: + * Boiler + * CHP + + Buses: + * Heat + * Power + ``` + """ + lines = [] + + if title: + lines.append(title) + lines.append('-' * len(title)) + + container_groups = self._get_container_groups() + for group_name, container in container_groups.items(): + if container: # Only show non-empty groups + if lines and not title: # Add spacing between groups (but not before first) + lines.append('') + elif title and group_name == list(container_groups.keys())[0]: + # No spacing before first group when there's a title + pass + else: + lines.append('') + + lines.append(f'{group_name}:') + for name in container.keys(): + lines.append(f' * {name}') + + return '\n'.join(lines) + + class Submodel(SubmodelsMixin): """Stores Variables and Constraints. Its a subset of a FlowSystemModel. Variables and constraints are stored in the main FlowSystemModel, and are referenced here. From 11983e88e7d2fb931b64e6855f26a6fee83c3f87 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:01:08 +0100 Subject: [PATCH 15/86] Remove from commons.py --- flixopt/commons.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/flixopt/commons.py b/flixopt/commons.py index c47279f26..68412d6fe 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -19,7 +19,6 @@ from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects -from .structure import ContainerMixin, ElementContainer, ResultsContainer __all__ = [ 'TimeSeriesData', @@ -35,9 +34,6 @@ 'LinearConverter', 'Transmission', 'FlowSystem', - 'ContainerMixin', - 'ElementContainer', - 'ResultsContainer', 'FullCalculation', 'SegmentedCalculation', 'AggregatedCalculation', From d06a77474e0df2fd566543b9aafa08c249406d46 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:03:03 +0100 Subject: [PATCH 16/86] Re-add status --- flixopt/flow_system.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 3a466a142..e29931388 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -687,6 +687,10 @@ def __repr__(self) -> str: else: r += 'Scenarios: None\n' + # Add status + status = '✓' if self.connected_and_transformed else '⚠' + r += f'Status: {status}\n' + # Add grouped container view r += '\n' + self._format_grouped_containers() From 69c65dc1bdb68bd6b3441c598f3d026fb9821fda Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:07:55 +0100 Subject: [PATCH 17/86] Remove redundant stuff and add deprecation --- flixopt/flow_system.py | 69 +++++++++++++----------------------------- 1 file changed, 21 insertions(+), 48 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e29931388..d8efcc93d 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -603,10 +603,10 @@ def _check_if_element_is_unique(self, element: Element) -> None: Args: element: new element to check """ - if element in self.all_elements.values(): + if element in self.values(): raise ValueError(f'Element {element.label_full} already added to FlowSystem!') # check if name is already used: - if element.label_full in self.all_elements: + if element.label_full in self: raise ValueError(f'Label of Element {element.label_full} already used in another element!') def _add_effects(self, *args: Effect) -> None: @@ -714,52 +714,6 @@ def __eq__(self, other: FlowSystem): return True - def __getitem__(self, item) -> Element: - """Get element by exact label with helpful error messages.""" - if item in self.all_elements: - return self.all_elements[item] - - # Provide helpful error with suggestions (matching ElementContainer style) - from difflib import get_close_matches - - suggestions = get_close_matches(item, self.all_elements.keys(), n=3, cutoff=0.6) - error_msg = f'Element "{item}" not found in FlowSystem.' - - if suggestions: - error_msg += f' Did you mean: {", ".join(suggestions)}?' - else: - available = list(self.all_elements.keys()) - if len(available) <= 5: - error_msg += f' Available: {", ".join(available)}' - else: - error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)' - - raise KeyError(error_msg) - - def __contains__(self, item: str) -> bool: - """Check if element exists in the FlowSystem.""" - return item in self.all_elements - - def __iter__(self): - """Iterate over element labels.""" - return iter(self.all_elements.keys()) - - def __len__(self) -> int: - """Return total count of all elements.""" - return len(self.all_elements) - - def keys(self): - """Return all element labels.""" - return list(self.all_elements.keys()) - - def values(self): - """Return all element objects.""" - return list(self.all_elements.values()) - - def items(self): - """Return (label, element) pairs for all elements.""" - return list(self.all_elements.items()) - def _get_container_groups(self) -> dict[str, dict]: """Return ordered container groups for CompositeContainerMixin.""" return { @@ -777,6 +731,25 @@ def flows(self) -> ElementContainer[Flow]: @property def all_elements(self) -> dict[str, Element]: + """ + Get all elements as a dictionary. + + .. deprecated:: 3.2.0 + Use dict-like interface instead: `flow_system['element']`, `'element' in flow_system`, + `flow_system.keys()`, `flow_system.values()`, or `flow_system.items()`. + This property will be removed in v4.0.0. + + Returns: + Dictionary mapping element labels to element objects. + """ + warnings.warn( + "The 'all_elements' property is deprecated. Use dict-like interface instead: " + "flow_system['element'], 'element' in flow_system, flow_system.keys(), " + 'flow_system.values(), or flow_system.items(). ' + 'This property will be removed in v4.0.0.', + DeprecationWarning, + stacklevel=2, + ) return {**self.components, **self.effects.effects, **self.flows, **self.buses} @property From 457f31abc3c415ab4f74ae8a5d27b82c6fbb4707 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:10:37 +0100 Subject: [PATCH 18/86] Add type annotation to CompositeContainerMixin --- flixopt/flow_system.py | 2 +- flixopt/results.py | 2 +- flixopt/structure.py | 29 +++++++++++++++++++---------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index d8efcc93d..c19f5f749 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -43,7 +43,7 @@ logger = logging.getLogger('flixopt') -class FlowSystem(Interface, CompositeContainerMixin): +class FlowSystem(Interface, CompositeContainerMixin[Element]): """ A FlowSystem organizes the high level Elements (Components, Buses & Effects). diff --git a/flixopt/results.py b/flixopt/results.py index e7ec0e572..94d50861d 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -54,7 +54,7 @@ class _FlowSystemRestorationError(Exception): pass -class CalculationResults(CompositeContainerMixin): +class CalculationResults(CompositeContainerMixin['ComponentResults | BusResults | EffectResults | FlowResults']): """Comprehensive container for optimization calculation results and analysis tools. This class provides unified access to all optimization results including flow rates, diff --git a/flixopt/structure.py b/flixopt/structure.py index 8cd44858a..e42aa9cba 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1030,7 +1030,10 @@ def _get_label(self, element: T) -> str: return element.label -class CompositeContainerMixin: +T_element = TypeVar('T_element') + + +class CompositeContainerMixin(Generic[T_element]): """ Mixin providing unified dict-like access across multiple typed containers. @@ -1038,12 +1041,17 @@ class CompositeContainerMixin: buses, effects, flows) to provide a unified interface for accessing elements across all containers, as if they were a single collection. + Type Parameter: + T_element: The type of elements stored in the containers. Can be a union type + for containers holding multiple types (e.g., 'ComponentResults | BusResults'). + Key Features: - Dict-like access: `obj['element_name']` searches all containers - Iteration: `for label in obj:` iterates over all elements - Membership: `'element' in obj` checks across all containers - Standard dict methods: keys(), values(), items() - Grouped display: Formatted repr showing elements by type + - Type hints: Full IDE and type checker support Subclasses must implement: _get_container_groups() -> dict[str, dict]: @@ -1052,10 +1060,10 @@ class CompositeContainerMixin: Example: ```python - class MySystem(CompositeContainerMixin): + class MySystem(CompositeContainerMixin[Component | Bus]): def __init__(self): - self.components = {'Boiler': ..., 'CHP': ...} - self.buses = {'Heat': ..., 'Power': ...} + self.components = {'Boiler': Component(...), 'CHP': Component(...)} + self.buses = {'Heat': Bus(...), 'Power': Bus(...)} def _get_container_groups(self): return { @@ -1065,9 +1073,10 @@ def _get_container_groups(self): system = MySystem() - system['Boiler'] # Returns Boiler component + comp = system['Boiler'] # Type: Component | Bus (with proper IDE support) 'Heat' in system # True - list(system) # ['Boiler', 'CHP', 'Heat', 'Power'] + labels = system.keys() # Type: list[str] + elements = system.values() # Type: list[Component | Bus] ``` Integration with ContainerMixin: @@ -1096,7 +1105,7 @@ def _get_container_groups(self) -> dict[str, dict]: """ raise NotImplementedError('Subclasses must implement _get_container_groups()') - def __getitem__(self, key: str): + def __getitem__(self, key: str) -> T_element: """ Get element by label, searching all containers. @@ -1148,15 +1157,15 @@ def __contains__(self, key: str) -> bool: """Check if element exists in any container.""" return any(key in container for container in self._get_container_groups().values()) - def keys(self): + def keys(self) -> list[str]: """Return all element labels across all containers.""" return list(self) - def values(self): + def values(self) -> list[T_element]: """Return all element objects across all containers.""" return [self[key] for key in self] - def items(self): + def items(self) -> list[tuple[str, T_element]]: """Return (label, element) pairs for all elements.""" return [(key, self[key]) for key in self] From bc603ece8c4f5b03ba449b41a8d0a33ea19d1797 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:19:50 +0100 Subject: [PATCH 19/86] Use ContainerMixin in EffectsCollection --- flixopt/effects.py | 32 +++++++++++++++----------------- flixopt/flow_system.py | 9 +++++---- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 6225734fe..7ccddfcba 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -18,7 +18,7 @@ from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel -from .structure import Element, ElementModel, FlowSystemModel, Submodel, register_class_for_io +from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io if TYPE_CHECKING: from collections.abc import Iterator @@ -448,13 +448,13 @@ def _do_modeling(self): EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares -class EffectCollection: +class EffectCollection(ElementContainer[Effect]): """ Handling all Effects """ def __init__(self, *effects: Effect): - self._effects = {} + super().__init__(element_type_name='effects') self._standard_effect: Effect | None = None self._objective_effect: Effect | None = None @@ -474,7 +474,7 @@ def add_effects(self, *effects: Effect) -> None: self.standard_effect = effect if effect.is_objective: self.objective_effect = effect - self._effects[effect.label] = effect + self.add(effect) # Use the inherited add() method from ElementContainer logger.info(f'Registered new Effect: {effect.label}') def create_effect_values_dict( @@ -521,7 +521,7 @@ def _plausibility_checks(self) -> None: temporal, periodic = self.calculate_effect_share_factors() # Validate all referenced sources exist - unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self.effects} + unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self} if unknown: raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}') @@ -552,31 +552,29 @@ def __getitem__(self, effect: str | Effect | None) -> Effect: else: raise KeyError(f'Effect {effect} not found!') try: - return self.effects[effect] + return super().__getitem__(effect) # Use parent's __getitem__ for string keys except KeyError as e: raise KeyError(f'Effect "{effect}" not found! Add it to the FlowSystem first!') from e def __iter__(self) -> Iterator[Effect]: - return iter(self._effects.values()) + return iter(self.values()) # Iterate over Effect objects, not keys def __len__(self) -> int: - return len(self._effects) + return super().__len__() def __contains__(self, item: str | Effect) -> bool: """Check if the effect exists. Checks for label or object""" if isinstance(item, str): - return item in self.effects # Check if the label exists + return super().__contains__(item) # Check if the label exists elif isinstance(item, Effect): - if item.label_full in self.effects: + # First check by label and object identity (O(1)) + if item.label_full in self and self[item.label_full] is item: return True - if item in self.effects.values(): # Check if the object exists + # Fallback to full object search (O(n)) for objects with unexpected labels + if item in self.values(): return True return False - @property - def effects(self) -> dict[str, Effect]: - return self._effects - @property def standard_effect(self) -> Effect: if self._standard_effect is None: @@ -611,7 +609,7 @@ def calculate_effect_share_factors( dict[tuple[str, str], xr.DataArray], ]: shares_periodic = {} - for name, effect in self.effects.items(): + for name, effect in self.items(): if effect.share_from_periodic: for source, data in effect.share_from_periodic.items(): if source not in shares_periodic: @@ -620,7 +618,7 @@ def calculate_effect_share_factors( shares_periodic = calculate_all_conversion_paths(shares_periodic) shares_temporal = {} - for name, effect in self.effects.items(): + for name, effect in self.items(): if effect.share_from_temporal: for source, data in effect.share_from_temporal.items(): if source not in shares_temporal: diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index c19f5f749..d05c0321f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -6,6 +6,7 @@ import logging import warnings +from itertools import chain from typing import TYPE_CHECKING, Any, Literal, Optional import numpy as np @@ -433,7 +434,7 @@ def connect_and_transform(self): self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario']) self._connect_network() - for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): + for element in chain(self.components.values(), self.effects.values(), self.buses.values()): element.transform_data(self) self._connected_and_transformed = True @@ -581,7 +582,7 @@ def network_infos(self) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, 'class': 'Bus' if isinstance(node, Bus) else 'Component', 'infos': node.__str__(), } - for node in list(self.components.values()) + list(self.buses.values()) + for node in chain(self.components.values(), self.buses.values()) } edges = { @@ -719,7 +720,7 @@ def _get_container_groups(self) -> dict[str, dict]: return { 'Components': dict(self.components), 'Buses': dict(self.buses), - 'Effects': self.effects.effects, + 'Effects': dict(self.effects), 'Flows': dict(self.flows), } @@ -750,7 +751,7 @@ def all_elements(self) -> dict[str, Element]: DeprecationWarning, stacklevel=2, ) - return {**self.components, **self.effects.effects, **self.flows, **self.buses} + return {**self.components, **self.effects, **self.flows, **self.buses} @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: From 5839ebb7288c90fc73ee36147321766e3c2e350c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:20:03 +0100 Subject: [PATCH 20/86] Optimize acess patterns --- flixopt/flow_system.py | 2 +- flixopt/results.py | 7 ++++--- tests/test_examples.py | 2 +- tests/test_functional.py | 36 ++++++++++++++++++------------------ 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index d05c0321f..5568dd330 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -633,7 +633,7 @@ def _connect_network(self): flow.is_input_in_component = True if flow in component.inputs else False # Add Bus if not already added (deprecated) - if flow._bus_object is not None and flow._bus_object not in self.buses.values(): + if flow._bus_object is not None and flow._bus_object.label_full not in self.buses: warnings.warn( f'The Bus {flow._bus_object.label_full} was added to the FlowSystem from {flow.label_full}.' f'This is deprecated and will be removed in the future. ' diff --git a/flixopt/results.py b/flixopt/results.py index 94d50861d..3f03e49ce 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -631,11 +631,12 @@ def sizes( def _assign_flow_coords(self, da: xr.DataArray): # Add start and end coordinates + flows_list = list(self.flows.values()) da = da.assign_coords( { - 'start': ('flow', [flow.start for flow in self.flows.values()]), - 'end': ('flow', [flow.end for flow in self.flows.values()]), - 'component': ('flow', [flow.component for flow in self.flows.values()]), + 'start': ('flow', [flow.start for flow in flows_list]), + 'end': ('flow', [flow.end for flow in flows_list]), + 'component': ('flow', [flow.component for flow in flows_list]), } ) diff --git a/tests/test_examples.py b/tests/test_examples.py index eca79d7c7..ad600fbd5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -45,7 +45,7 @@ def test_independent_examples(example_script): This imitates behaviour of running the script directly. """ with working_directory(example_script.parent): - timeout = 600 + timeout = 1200 try: result = subprocess.run( [sys.executable, example_script.name], diff --git a/tests/test_functional.py b/tests/test_functional.py index 0f9fe02ef..a83bf112f 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -152,7 +152,7 @@ def test_fixed_size(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -193,7 +193,7 @@ def test_optimize_size(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -234,7 +234,7 @@ def test_size_bounds(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -289,8 +289,8 @@ def test_optional_invest(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] - boiler_optional = flow_system.all_elements['Boiler_optional'] + boiler = flow_system['Boiler'] + boiler_optional = flow_system['Boiler_optional'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -343,7 +343,7 @@ def test_on(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -387,7 +387,7 @@ def test_off(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -438,7 +438,7 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -502,7 +502,7 @@ def test_on_total_max(solver_fixture, time_steps_fixture): ) solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] + boiler = flow_system['Boiler'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -555,13 +555,13 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ), ), ) - flow_system.all_elements['Wärmelast'].inputs[0].fixed_relative_profile = np.array( + flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array( [0, 10, 20, 0, 12] ) # Else its non deterministic solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] - boiler_backup = flow_system.all_elements['Boiler_backup'] + boiler = flow_system['Boiler'] + boiler_backup = flow_system['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -624,12 +624,12 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100), ), ) - flow_system.all_elements['Wärmelast'].inputs[0].fixed_relative_profile = np.array([5, 10, 20, 18, 12]) + flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array([5, 10, 20, 18, 12]) # Else its non deterministic solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] - boiler_backup = flow_system.all_elements['Boiler_backup'] + boiler = flow_system['Boiler'] + boiler_backup = flow_system['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), @@ -686,13 +686,13 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ), ), ) - flow_system.all_elements['Wärmelast'].inputs[0].fixed_relative_profile = np.array( + flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array( [5, 0, 20, 18, 12] ) # Else its non deterministic solve_and_load(flow_system, solver_fixture) - boiler = flow_system.all_elements['Boiler'] - boiler_backup = flow_system.all_elements['Boiler_backup'] + boiler = flow_system['Boiler'] + boiler_backup = flow_system['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( costs.submodel.total.solution.item(), From a5081bec3821a21ddec3e4f810b5e2539605ed3c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:22:03 +0100 Subject: [PATCH 21/86] Remove unneded method --- flixopt/effects.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 7ccddfcba..d431ddcca 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -559,9 +559,6 @@ def __getitem__(self, effect: str | Effect | None) -> Effect: def __iter__(self) -> Iterator[Effect]: return iter(self.values()) # Iterate over Effect objects, not keys - def __len__(self) -> int: - return super().__len__() - def __contains__(self, item: str | Effect) -> bool: """Check if the effect exists. Checks for label or object""" if isinstance(item, str): From d8ead2dc7c4e460575d9c3da73e45d7809735af8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:26:44 +0100 Subject: [PATCH 22/86] Iterate group containers to collect values and pairs in one pass. --- flixopt/structure.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index e42aa9cba..2a814dad9 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1124,8 +1124,6 @@ def __getitem__(self, key: str) -> T_element: return container[key] # Element not found - provide helpful error - from difflib import get_close_matches - all_elements = {} for container in self._get_container_groups().values(): all_elements.update(container) @@ -1163,11 +1161,17 @@ def keys(self) -> list[str]: def values(self) -> list[T_element]: """Return all element objects across all containers.""" - return [self[key] for key in self] + vals = [] + for container in self._get_container_groups().values(): + vals.extend(container.values()) + return vals def items(self) -> list[tuple[str, T_element]]: """Return (label, element) pairs for all elements.""" - return [(key, self[key]) for key in self] + items = [] + for container in self._get_container_groups().values(): + items.extend(container.items()) + return items def _format_grouped_containers(self, title: str | None = None) -> str: """ From 3615ec7ea9999502e4dbf50baa7e12f762d0c801 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:36:11 +0100 Subject: [PATCH 23/86] Add sorting to containers --- flixopt/structure.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 2a814dad9..a6ab86006 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -898,6 +898,13 @@ def _valid_label(label: str) -> str: return label +def _natural_sort_key(text): + """Sort key for natural ordering (e.g., bus1, bus2, bus10 instead of bus1, bus10, bus2).""" + import re + + return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)] + + # Type variable for containers T = TypeVar('T') @@ -997,7 +1004,7 @@ def __repr__(self) -> str: line = '-' * len(title) r = f'{title}\n{line}\n' - for name in self.keys(): + for name in sorted(self.keys(), key=_natural_sort_key): r += f' * {name}\n' if not len(list(self)): @@ -1213,7 +1220,7 @@ def _format_grouped_containers(self, title: str | None = None) -> str: lines.append('') lines.append(f'{group_name}:') - for name in container.keys(): + for name in sorted(container.keys(), key=_natural_sort_key): lines.append(f' * {name}') return '\n'.join(lines) From 48772c8340f05a20a399e7571071556f104b3794 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 14:38:12 +0100 Subject: [PATCH 24/86] Reorder container groups --- flixopt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5568dd330..d13b38184 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -718,9 +718,9 @@ def __eq__(self, other: FlowSystem): def _get_container_groups(self) -> dict[str, dict]: """Return ordered container groups for CompositeContainerMixin.""" return { + 'Effects': dict(self.effects), 'Components': dict(self.components), 'Buses': dict(self.buses), - 'Effects': dict(self.effects), 'Flows': dict(self.flows), } From f668c9bfb8e69a74e53b69bb3995f3930cf34592 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:21:19 +0100 Subject: [PATCH 25/86] Add guardfs for flow results --- flixopt/results.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index 3f03e49ce..0c62eaecf 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -261,10 +261,12 @@ def __init__( stacklevel=2, ) flows_dict = {} + self._has_flow_data = False else: flows_dict = { label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items() } + self._has_flow_data = True self.flows = ResultsContainer(elements=flows_dict, element_type_name='flow results') self.timesteps_extra = self.solution.indexes['time'] @@ -558,6 +560,8 @@ def flow_rates( To recombine filtered dataarrays, use `xr.concat` with dim 'flow': >>>xr.concat([results.flow_rates(start='Fernwärme'), results.flow_rates(end='Fernwärme')], dim='flow') """ + if not self._has_flow_data: + raise ValueError('Flow data is not available in this results object (pre-v2.2.0).') if self._flow_rates is None: self._flow_rates = self._assign_flow_coords( xr.concat( @@ -619,6 +623,8 @@ def sizes( >>>xr.concat([results.sizes(start='Fernwärme'), results.sizes(end='Fernwärme')], dim='flow') """ + if not self._has_flow_data: + raise ValueError('Flow data is not available in this results object (pre-v2.2.0).') if self._sizes is None: self._sizes = self._assign_flow_coords( xr.concat( From 01a2796c0fc79bdf9e65ed90f577815ddde9cdca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:22:28 +0100 Subject: [PATCH 26/86] Rename _set_flow_labels and validate that the FLow is not already connected to another Component --- flixopt/elements.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 2b4563121..f20611028 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -87,7 +87,7 @@ def __init__( self.inputs: list[Flow] = inputs or [] self.outputs: list[Flow] = outputs or [] self._check_unique_flow_labels() - self._set_flow_labels() + self._connect_flows() self.on_off_parameters = on_off_parameters self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or [] @@ -116,9 +116,25 @@ def _check_unique_flow_labels(self): def _plausibility_checks(self) -> None: self._check_unique_flow_labels() - def _set_flow_labels(self): - for flow in self.inputs + self.outputs: + def _connect_flows(self): + # Inputs + for flow in self.inputs: + if flow.component not in ('UnknownComponent', self.label_full): + raise ValueError( + f'Flow "{flow.label}" already assigned to component "{flow.component}". ' + f'Cannot attach to "{self.label_full}".' + ) + flow.component = self.label_full + flow.is_input_in_component = True + # Outputs + for flow in self.outputs: + if flow.component not in ('UnknownComponent', self.label_full): + raise ValueError( + f'Flow "{flow.label}" already assigned to component "{flow.component}". ' + f'Cannot attach to "{self.label_full}".' + ) flow.component = self.label_full + flow.is_input_in_component = False @register_class_for_io From bb98bac8e3d0083b62351a27eb522e509f881fdf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:48:56 +0100 Subject: [PATCH 27/86] Add reprs --- flixopt/results.py | 373 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index 0c62eaecf..b3adbd460 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1124,6 +1124,83 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self._calculation_results.model.constraints[self._constraint_names] + def __repr__(self) -> str: + """Return detailed string representation with data preview.""" + class_name = self.__class__.__name__ + title = f'{class_name}: {self.label}' + line = '-' * len(title) + + lines = [title, line] + + # Variables section + var_count = len(self._variable_names) + lines.append(f'Variables: {var_count}') + if var_count > 0: + # Show first few variable names + display_vars = self._variable_names[:5] + if var_count > 5: + lines.append(f' • {", ".join(display_vars)}, ... ({var_count - 5} more)') + else: + lines.append(f' • {", ".join(display_vars)}') + + # Constraints section + const_count = len(self._constraint_names) + lines.append(f'Constraints: {const_count}') + if const_count > 0: + display_const = self._constraint_names[:3] + if const_count > 3: + lines.append(f' • {", ".join(display_const)}, ... ({const_count - 3} more)') + else: + lines.append(f' • {", ".join(display_const)}') + + # Solution dimensions + if hasattr(self.solution, 'dims') and self.solution.dims: + dim_strs = [f'{dim}[{len(self.solution[dim])}]' for dim in self.solution.dims] + lines.append(f'Solution: {", ".join(dim_strs)}') + else: + lines.append('Solution: scalar') + + # Data preview section + if var_count > 0: + lines.append('') + lines.append('Data Preview:') + preview_vars = list(self.solution.data_vars)[:3] # Show first 3 variables + + for var_name in preview_vars: + try: + var_data = self.solution[var_name] + + # Get preview values + if var_data.size == 1: + # Scalar variable + value = float(var_data.values) + lines.append(f' {var_name}: {value:.3f}') + elif 'time' in var_data.dims: + # Time-dependent variable - show first 5 timesteps + preview_slice = var_data.isel(time=slice(0, min(5, len(var_data.time)))) + if 'scenario' in var_data.dims and len(var_data.scenario) > 0: + # Show first scenario + preview_slice = preview_slice.isel(scenario=0) + scenario_label = str(var_data.scenario.values[0]) + values_str = ', '.join(f'{v:.3f}' for v in preview_slice.values[:5]) + lines.append(f' {var_name} (scenario={scenario_label}): [{values_str}...]') + else: + values_str = ', '.join(f'{v:.3f}' for v in preview_slice.values[:5]) + lines.append(f' {var_name}: [{values_str}...]') + else: + # Other dimensions + flat_values = var_data.values.flatten()[:5] + values_str = ', '.join(f'{v:.3f}' for v in flat_values) + lines.append(f' {var_name}: [{values_str}...]') + except Exception: + # If preview fails for any reason, skip this variable + lines.append(f' {var_name}: ') + + if len(list(self.solution.data_vars)) > 3: + lines.append(f' ... ({len(list(self.solution.data_vars)) - 3} more variables)') + + return '\n'.join(lines) + def filter_solution( self, variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, @@ -1615,10 +1692,112 @@ def node_balance( return ds + def __repr__(self) -> str: + """Return detailed string representation with node information.""" + # Get base representation from parent + base_repr = super().__repr__() + + lines = base_repr.split('\n') + + # Find where to insert node information (after Solution line, before Data Preview) + insert_idx = None + for i, line in enumerate(lines): + if line.startswith('Solution:'): + insert_idx = i + 1 + break + + if insert_idx is None: + insert_idx = len(lines) + + # Prepare node information + node_info = [] + + # Inputs section + input_count = len(self.inputs) + if input_count > 0: + node_info.append(f'Inputs: {input_count}') + display_inputs = self.inputs[:5] + if input_count > 5: + node_info.append(f' • {", ".join(display_inputs)}, ... ({input_count - 5} more)') + else: + node_info.append(f' • {", ".join(display_inputs)}') + else: + node_info.append('Inputs: 0') + + # Outputs section + output_count = len(self.outputs) + if output_count > 0: + node_info.append(f'Outputs: {output_count}') + display_outputs = self.outputs[:5] + if output_count > 5: + node_info.append(f' • {", ".join(display_outputs)}, ... ({output_count - 5} more)') + else: + node_info.append(f' • {", ".join(display_outputs)}') + else: + node_info.append('Outputs: 0') + + # Flows total + node_info.append(f'Total Flows: {len(self.flows)}') + + # Insert node information + lines[insert_idx:insert_idx] = node_info + + return '\n'.join(lines) + class BusResults(_NodeResults): """Results container for energy/material balance nodes in the system.""" + def __repr__(self) -> str: + """Return detailed string representation for bus node.""" + # Get base representation from parent (_NodeResults) + base_repr = super().__repr__() + + lines = base_repr.split('\n') + + # Find where to insert bus information (after Total Flows line, before Data Preview) + insert_idx = None + for i, line in enumerate(lines): + if line.startswith('Total Flows:'): + insert_idx = i + 1 + break + + if insert_idx is None: + # Fallback: insert before Data Preview + for i, line in enumerate(lines): + if line.startswith('Data Preview:'): + insert_idx = i + break + + if insert_idx is None: + insert_idx = len(lines) + + # Add bus-specific information + bus_info = ['Bus Type: Energy/Material Balance Node'] + + # Extract connected components from inputs and outputs + # Format is typically "Component(Flow)" or similar + connected_components = set() + for flow_label in self.inputs + self.outputs: + # Try to extract component name (before parenthesis if present) + if '(' in flow_label: + component = flow_label.split('(')[0] + connected_components.add(component) + + if connected_components: + comp_count = len(connected_components) + bus_info.append(f'Connected Components: {comp_count}') + display_comps = sorted(list(connected_components))[:5] + if comp_count > 5: + bus_info.append(f' • {", ".join(display_comps)}, ... ({comp_count - 5} more)') + else: + bus_info.append(f' • {", ".join(display_comps)}') + + # Insert bus information + lines[insert_idx:insert_idx] = bus_info + + return '\n'.join(lines) + class ComponentResults(_NodeResults): """Results container for individual system components with specialized analysis tools.""" @@ -1892,6 +2071,63 @@ def node_balance_with_charge_state( ), ) + def __repr__(self) -> str: + """Return detailed string representation with storage information.""" + # Get base representation from parent (_NodeResults) + base_repr = super().__repr__() + + lines = base_repr.split('\n') + + # Find where to insert component information (after Total Flows line, before Data Preview) + insert_idx = None + for i, line in enumerate(lines): + if line.startswith('Total Flows:'): + insert_idx = i + 1 + break + + if insert_idx is None: + # Fallback: insert before Data Preview + for i, line in enumerate(lines): + if line.startswith('Data Preview:'): + insert_idx = i + break + + if insert_idx is None: + insert_idx = len(lines) + + # Prepare component information + component_info = [] + + # Storage information + if self.is_storage: + component_info.append('Storage: Yes') + # Preview charge state + try: + charge_state = self.charge_state + if 'time' in charge_state.dims: + preview_vals = charge_state.isel(time=slice(0, min(5, len(charge_state.time)))) + if 'scenario' in charge_state.dims and len(charge_state.scenario) > 0: + preview_vals = preview_vals.isel(scenario=0) + scenario_label = str(charge_state.scenario.values[0]) + values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) + component_info.append(f' Charge State (scenario={scenario_label}): [{values_str}...]') + else: + values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) + component_info.append(f' Charge State: [{values_str}...]') + else: + value = float(charge_state.values) + component_info.append(f' Charge State: {value:.3f}') + except Exception: + component_info.append(' Charge State: ') + else: + component_info.append('Storage: No') + + # Insert component information + if component_info: + lines[insert_idx:insert_idx] = component_info + + return '\n'.join(lines) + class EffectResults(_ElementResults): """Results for an Effect""" @@ -1907,6 +2143,76 @@ def get_shares_from(self, element: str) -> xr.Dataset: """ return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] + def __repr__(self) -> str: + """Return detailed string representation with contribution information.""" + # Get base representation from parent (_ElementResults) + base_repr = super().__repr__() + + lines = base_repr.split('\n') + + # Find where to insert effect information (after Solution line, before Data Preview) + insert_idx = None + for i, line in enumerate(lines): + if line.startswith('Solution:'): + insert_idx = i + 1 + break + + if insert_idx is None: + # Fallback: insert before Data Preview + for i, line in enumerate(lines): + if line.startswith('Data Preview:'): + insert_idx = i + break + + if insert_idx is None: + insert_idx = len(lines) + + # Prepare effect information + effect_info = [] + + # Extract contributing elements from variable names + # Variable names are typically in format "element->effect_label" + contributing_elements = set() + for var_name in self._variable_names: + if '->' in var_name: + element = var_name.split('->')[0] + contributing_elements.add(element) + + if contributing_elements: + contrib_count = len(contributing_elements) + effect_info.append(f'Contributing Elements: {contrib_count}') + display_elements = sorted(list(contributing_elements))[:5] + if contrib_count > 5: + effect_info.append(f' • {", ".join(display_elements)}, ... ({contrib_count - 5} more)') + else: + effect_info.append(f' • {", ".join(display_elements)}') + + # Show total effect value if possible + try: + # Try to sum all contribution variables to get total effect + total_effect = sum(self.solution[var] for var in self._variable_names) + if 'time' in total_effect.dims: + preview_vals = total_effect.isel(time=slice(0, min(5, len(total_effect.time)))) + if 'scenario' in total_effect.dims and len(total_effect.scenario) > 0: + preview_vals = preview_vals.isel(scenario=0) + scenario_label = str(total_effect.scenario.values[0]) + values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) + effect_info.append(f'Total Effect (scenario={scenario_label}): [{values_str}...]') + else: + values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) + effect_info.append(f'Total Effect: [{values_str}...]') + else: + value = float(total_effect.values) + effect_info.append(f'Total Effect: {value:.3f}') + except Exception: + pass + + # Insert effect information + if effect_info: + lines[insert_idx:insert_idx] = effect_info + + return '\n'.join(lines) + class FlowResults(_ElementResults): def __init__( @@ -1943,6 +2249,73 @@ def size(self) -> xr.DataArray: logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN') return xr.DataArray(np.nan).rename(name) + def __repr__(self) -> str: + """Return detailed string representation with flow connection details.""" + # Get base representation from parent (_ElementResults) + base_repr = super().__repr__() + + lines = base_repr.split('\n') + + # Find where to insert flow information (after Solution line, before Data Preview) + insert_idx = None + for i, line in enumerate(lines): + if line.startswith('Solution:'): + insert_idx = i + 1 + break + + if insert_idx is None: + # Fallback: insert before Data Preview + for i, line in enumerate(lines): + if line.startswith('Data Preview:'): + insert_idx = i + break + + if insert_idx is None: + insert_idx = len(lines) + + # Prepare flow information + flow_info = [] + + # Connection details + flow_info.append(f'From: {self.start}') + flow_info.append(f'To: {self.end}') + if self.component: + flow_info.append(f'Component: {self.component}') + + # Check if size is available and show it + try: + size_var = self.size + if size_var.size == 1: + size_value = float(size_var.values) + if not (isinstance(size_value, float) and (size_value != size_value)): # Check for NaN + flow_info.append(f'Size: {size_value:.3f}') + except Exception: + pass + + # Flow rate preview + try: + flow_rate = self.flow_rate + if 'time' in flow_rate.dims: + preview_vals = flow_rate.isel(time=slice(0, min(5, len(flow_rate.time)))) + if 'scenario' in flow_rate.dims and len(flow_rate.scenario) > 0: + preview_vals = preview_vals.isel(scenario=0) + scenario_label = str(flow_rate.scenario.values[0]) + values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) + flow_info.append(f'Flow Rate (scenario={scenario_label}): [{values_str}...]') + else: + values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) + flow_info.append(f'Flow Rate: [{values_str}...]') + else: + value = float(flow_rate.values) + flow_info.append(f'Flow Rate: {value:.3f}') + except Exception: + flow_info.append('Flow Rate: ') + + # Insert flow information + lines[insert_idx:insert_idx] = flow_info + + return '\n'.join(lines) + class SegmentedCalculationResults: """Results container for segmented optimization calculations with temporal decomposition. From 519ee2c75d0560f8c7b1c7d7de9ef87d40a68b8e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:06:13 +0100 Subject: [PATCH 28/86] Add reprs to results classes --- flixopt/results.py | 385 +++------------------------------------------ 1 file changed, 23 insertions(+), 362 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b3adbd460..5028e492c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1125,81 +1125,10 @@ def constraints(self) -> linopy.Constraints: return self._calculation_results.model.constraints[self._constraint_names] def __repr__(self) -> str: - """Return detailed string representation with data preview.""" + """Return string representation with element info and dataset preview.""" class_name = self.__class__.__name__ - title = f'{class_name}: {self.label}' - line = '-' * len(title) - - lines = [title, line] - - # Variables section - var_count = len(self._variable_names) - lines.append(f'Variables: {var_count}') - if var_count > 0: - # Show first few variable names - display_vars = self._variable_names[:5] - if var_count > 5: - lines.append(f' • {", ".join(display_vars)}, ... ({var_count - 5} more)') - else: - lines.append(f' • {", ".join(display_vars)}') - - # Constraints section - const_count = len(self._constraint_names) - lines.append(f'Constraints: {const_count}') - if const_count > 0: - display_const = self._constraint_names[:3] - if const_count > 3: - lines.append(f' • {", ".join(display_const)}, ... ({const_count - 3} more)') - else: - lines.append(f' • {", ".join(display_const)}') - - # Solution dimensions - if hasattr(self.solution, 'dims') and self.solution.dims: - dim_strs = [f'{dim}[{len(self.solution[dim])}]' for dim in self.solution.dims] - lines.append(f'Solution: {", ".join(dim_strs)}') - else: - lines.append('Solution: scalar') - - # Data preview section - if var_count > 0: - lines.append('') - lines.append('Data Preview:') - preview_vars = list(self.solution.data_vars)[:3] # Show first 3 variables - - for var_name in preview_vars: - try: - var_data = self.solution[var_name] - - # Get preview values - if var_data.size == 1: - # Scalar variable - value = float(var_data.values) - lines.append(f' {var_name}: {value:.3f}') - elif 'time' in var_data.dims: - # Time-dependent variable - show first 5 timesteps - preview_slice = var_data.isel(time=slice(0, min(5, len(var_data.time)))) - if 'scenario' in var_data.dims and len(var_data.scenario) > 0: - # Show first scenario - preview_slice = preview_slice.isel(scenario=0) - scenario_label = str(var_data.scenario.values[0]) - values_str = ', '.join(f'{v:.3f}' for v in preview_slice.values[:5]) - lines.append(f' {var_name} (scenario={scenario_label}): [{values_str}...]') - else: - values_str = ', '.join(f'{v:.3f}' for v in preview_slice.values[:5]) - lines.append(f' {var_name}: [{values_str}...]') - else: - # Other dimensions - flat_values = var_data.values.flatten()[:5] - values_str = ', '.join(f'{v:.3f}' for v in flat_values) - lines.append(f' {var_name}: [{values_str}...]') - except Exception: - # If preview fails for any reason, skip this variable - lines.append(f' {var_name}: ') - - if len(list(self.solution.data_vars)) > 3: - lines.append(f' ... ({len(list(self.solution.data_vars)) - 3} more variables)') - - return '\n'.join(lines) + header = f'{class_name}: {self.label}' + return f'{header}\n{repr(self.solution)}' def filter_solution( self, @@ -1693,111 +1622,15 @@ def node_balance( return ds def __repr__(self) -> str: - """Return detailed string representation with node information.""" - # Get base representation from parent - base_repr = super().__repr__() - - lines = base_repr.split('\n') - - # Find where to insert node information (after Solution line, before Data Preview) - insert_idx = None - for i, line in enumerate(lines): - if line.startswith('Solution:'): - insert_idx = i + 1 - break - - if insert_idx is None: - insert_idx = len(lines) - - # Prepare node information - node_info = [] - - # Inputs section - input_count = len(self.inputs) - if input_count > 0: - node_info.append(f'Inputs: {input_count}') - display_inputs = self.inputs[:5] - if input_count > 5: - node_info.append(f' • {", ".join(display_inputs)}, ... ({input_count - 5} more)') - else: - node_info.append(f' • {", ".join(display_inputs)}') - else: - node_info.append('Inputs: 0') - - # Outputs section - output_count = len(self.outputs) - if output_count > 0: - node_info.append(f'Outputs: {output_count}') - display_outputs = self.outputs[:5] - if output_count > 5: - node_info.append(f' • {", ".join(display_outputs)}, ... ({output_count - 5} more)') - else: - node_info.append(f' • {", ".join(display_outputs)}') - else: - node_info.append('Outputs: 0') - - # Flows total - node_info.append(f'Total Flows: {len(self.flows)}') - - # Insert node information - lines[insert_idx:insert_idx] = node_info - - return '\n'.join(lines) + """Return string representation with node information.""" + class_name = self.__class__.__name__ + header = f'{class_name}: {self.label} | {len(self.inputs)} inputs, {len(self.outputs)} outputs, {len(self.flows)} flows' + return f'{header}\n{repr(self.solution)}' class BusResults(_NodeResults): """Results container for energy/material balance nodes in the system.""" - def __repr__(self) -> str: - """Return detailed string representation for bus node.""" - # Get base representation from parent (_NodeResults) - base_repr = super().__repr__() - - lines = base_repr.split('\n') - - # Find where to insert bus information (after Total Flows line, before Data Preview) - insert_idx = None - for i, line in enumerate(lines): - if line.startswith('Total Flows:'): - insert_idx = i + 1 - break - - if insert_idx is None: - # Fallback: insert before Data Preview - for i, line in enumerate(lines): - if line.startswith('Data Preview:'): - insert_idx = i - break - - if insert_idx is None: - insert_idx = len(lines) - - # Add bus-specific information - bus_info = ['Bus Type: Energy/Material Balance Node'] - - # Extract connected components from inputs and outputs - # Format is typically "Component(Flow)" or similar - connected_components = set() - for flow_label in self.inputs + self.outputs: - # Try to extract component name (before parenthesis if present) - if '(' in flow_label: - component = flow_label.split('(')[0] - connected_components.add(component) - - if connected_components: - comp_count = len(connected_components) - bus_info.append(f'Connected Components: {comp_count}') - display_comps = sorted(list(connected_components))[:5] - if comp_count > 5: - bus_info.append(f' • {", ".join(display_comps)}, ... ({comp_count - 5} more)') - else: - bus_info.append(f' • {", ".join(display_comps)}') - - # Insert bus information - lines[insert_idx:insert_idx] = bus_info - - return '\n'.join(lines) - class ComponentResults(_NodeResults): """Results container for individual system components with specialized analysis tools.""" @@ -2072,61 +1905,11 @@ def node_balance_with_charge_state( ) def __repr__(self) -> str: - """Return detailed string representation with storage information.""" - # Get base representation from parent (_NodeResults) - base_repr = super().__repr__() - - lines = base_repr.split('\n') - - # Find where to insert component information (after Total Flows line, before Data Preview) - insert_idx = None - for i, line in enumerate(lines): - if line.startswith('Total Flows:'): - insert_idx = i + 1 - break - - if insert_idx is None: - # Fallback: insert before Data Preview - for i, line in enumerate(lines): - if line.startswith('Data Preview:'): - insert_idx = i - break - - if insert_idx is None: - insert_idx = len(lines) - - # Prepare component information - component_info = [] - - # Storage information - if self.is_storage: - component_info.append('Storage: Yes') - # Preview charge state - try: - charge_state = self.charge_state - if 'time' in charge_state.dims: - preview_vals = charge_state.isel(time=slice(0, min(5, len(charge_state.time)))) - if 'scenario' in charge_state.dims and len(charge_state.scenario) > 0: - preview_vals = preview_vals.isel(scenario=0) - scenario_label = str(charge_state.scenario.values[0]) - values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) - component_info.append(f' Charge State (scenario={scenario_label}): [{values_str}...]') - else: - values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) - component_info.append(f' Charge State: [{values_str}...]') - else: - value = float(charge_state.values) - component_info.append(f' Charge State: {value:.3f}') - except Exception: - component_info.append(' Charge State: ') - else: - component_info.append('Storage: No') - - # Insert component information - if component_info: - lines[insert_idx:insert_idx] = component_info - - return '\n'.join(lines) + """Return string representation with storage indication.""" + class_name = self.__class__.__name__ + storage_tag = ' (Storage)' if self.is_storage else '' + header = f'{class_name}: {self.label}{storage_tag} | {len(self.inputs)} inputs, {len(self.outputs)} outputs, {len(self.flows)} flows' + return f'{header}\n{repr(self.solution)}' class EffectResults(_ElementResults): @@ -2144,74 +1927,13 @@ def get_shares_from(self, element: str) -> xr.Dataset: return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] def __repr__(self) -> str: - """Return detailed string representation with contribution information.""" - # Get base representation from parent (_ElementResults) - base_repr = super().__repr__() - - lines = base_repr.split('\n') - - # Find where to insert effect information (after Solution line, before Data Preview) - insert_idx = None - for i, line in enumerate(lines): - if line.startswith('Solution:'): - insert_idx = i + 1 - break - - if insert_idx is None: - # Fallback: insert before Data Preview - for i, line in enumerate(lines): - if line.startswith('Data Preview:'): - insert_idx = i - break - - if insert_idx is None: - insert_idx = len(lines) - - # Prepare effect information - effect_info = [] - - # Extract contributing elements from variable names - # Variable names are typically in format "element->effect_label" - contributing_elements = set() - for var_name in self._variable_names: - if '->' in var_name: - element = var_name.split('->')[0] - contributing_elements.add(element) - - if contributing_elements: - contrib_count = len(contributing_elements) - effect_info.append(f'Contributing Elements: {contrib_count}') - display_elements = sorted(list(contributing_elements))[:5] - if contrib_count > 5: - effect_info.append(f' • {", ".join(display_elements)}, ... ({contrib_count - 5} more)') - else: - effect_info.append(f' • {", ".join(display_elements)}') - - # Show total effect value if possible - try: - # Try to sum all contribution variables to get total effect - total_effect = sum(self.solution[var] for var in self._variable_names) - if 'time' in total_effect.dims: - preview_vals = total_effect.isel(time=slice(0, min(5, len(total_effect.time)))) - if 'scenario' in total_effect.dims and len(total_effect.scenario) > 0: - preview_vals = preview_vals.isel(scenario=0) - scenario_label = str(total_effect.scenario.values[0]) - values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) - effect_info.append(f'Total Effect (scenario={scenario_label}): [{values_str}...]') - else: - values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) - effect_info.append(f'Total Effect: [{values_str}...]') - else: - value = float(total_effect.values) - effect_info.append(f'Total Effect: {value:.3f}') - except Exception: - pass - - # Insert effect information - if effect_info: - lines[insert_idx:insert_idx] = effect_info - - return '\n'.join(lines) + """Return string representation with contribution information.""" + class_name = self.__class__.__name__ + # Extract contributing elements from variable names (format: "element->effect_label") + contributing_elements = {var_name.split('->')[0] for var_name in self._variable_names if '->' in var_name} + contrib_info = f' | {len(contributing_elements)} contributors' + header = f'{class_name}: {self.label}{contrib_info}' + return f'{header}\n{repr(self.solution)}' class FlowResults(_ElementResults): @@ -2250,71 +1972,10 @@ def size(self) -> xr.DataArray: return xr.DataArray(np.nan).rename(name) def __repr__(self) -> str: - """Return detailed string representation with flow connection details.""" - # Get base representation from parent (_ElementResults) - base_repr = super().__repr__() - - lines = base_repr.split('\n') - - # Find where to insert flow information (after Solution line, before Data Preview) - insert_idx = None - for i, line in enumerate(lines): - if line.startswith('Solution:'): - insert_idx = i + 1 - break - - if insert_idx is None: - # Fallback: insert before Data Preview - for i, line in enumerate(lines): - if line.startswith('Data Preview:'): - insert_idx = i - break - - if insert_idx is None: - insert_idx = len(lines) - - # Prepare flow information - flow_info = [] - - # Connection details - flow_info.append(f'From: {self.start}') - flow_info.append(f'To: {self.end}') - if self.component: - flow_info.append(f'Component: {self.component}') - - # Check if size is available and show it - try: - size_var = self.size - if size_var.size == 1: - size_value = float(size_var.values) - if not (isinstance(size_value, float) and (size_value != size_value)): # Check for NaN - flow_info.append(f'Size: {size_value:.3f}') - except Exception: - pass - - # Flow rate preview - try: - flow_rate = self.flow_rate - if 'time' in flow_rate.dims: - preview_vals = flow_rate.isel(time=slice(0, min(5, len(flow_rate.time)))) - if 'scenario' in flow_rate.dims and len(flow_rate.scenario) > 0: - preview_vals = preview_vals.isel(scenario=0) - scenario_label = str(flow_rate.scenario.values[0]) - values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) - flow_info.append(f'Flow Rate (scenario={scenario_label}): [{values_str}...]') - else: - values_str = ', '.join(f'{v:.3f}' for v in preview_vals.values[:5]) - flow_info.append(f'Flow Rate: [{values_str}...]') - else: - value = float(flow_rate.values) - flow_info.append(f'Flow Rate: {value:.3f}') - except Exception: - flow_info.append('Flow Rate: ') - - # Insert flow information - lines[insert_idx:insert_idx] = flow_info - - return '\n'.join(lines) + """Return string representation with flow connection details.""" + class_name = self.__class__.__name__ + header = f'{class_name}: {self.label} | {self.start} → {self.end}' + return f'{header}\n{repr(self.solution)}' class SegmentedCalculationResults: From d95baa8004917f553cfcfb11371830c7dc2f82c5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:15:51 +0100 Subject: [PATCH 29/86] Improve the reprs --- flixopt/results.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 5028e492c..4dfef0ebb 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1127,8 +1127,10 @@ def constraints(self) -> linopy.Constraints: def __repr__(self) -> str: """Return string representation with element info and dataset preview.""" class_name = self.__class__.__name__ - header = f'{class_name}: {self.label}' - return f'{header}\n{repr(self.solution)}' + header = f'{class_name}: "{self.label}"' + sol = self.solution + sol.attrs = {} + return f'{header}\n{repr(sol)}' def filter_solution( self, @@ -1624,8 +1626,12 @@ def node_balance( def __repr__(self) -> str: """Return string representation with node information.""" class_name = self.__class__.__name__ - header = f'{class_name}: {self.label} | {len(self.inputs)} inputs, {len(self.outputs)} outputs, {len(self.flows)} flows' - return f'{header}\n{repr(self.solution)}' + header = ( + f'{class_name}: "{self.label}" | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' + ) + sol = self.solution + sol.attrs = {} + return f'{header}\n{repr(sol)}' class BusResults(_NodeResults): @@ -1908,8 +1914,10 @@ def __repr__(self) -> str: """Return string representation with storage indication.""" class_name = self.__class__.__name__ storage_tag = ' (Storage)' if self.is_storage else '' - header = f'{class_name}: {self.label}{storage_tag} | {len(self.inputs)} inputs, {len(self.outputs)} outputs, {len(self.flows)} flows' - return f'{header}\n{repr(self.solution)}' + header = f'{class_name}: "{self.label}"{storage_tag} | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' + sol = self.solution + sol.attrs = {} + return f'{header}\n{repr(sol)}' class EffectResults(_ElementResults): @@ -1932,8 +1940,10 @@ def __repr__(self) -> str: # Extract contributing elements from variable names (format: "element->effect_label") contributing_elements = {var_name.split('->')[0] for var_name in self._variable_names if '->' in var_name} contrib_info = f' | {len(contributing_elements)} contributors' - header = f'{class_name}: {self.label}{contrib_info}' - return f'{header}\n{repr(self.solution)}' + header = f'{class_name}: "{self.label}"{contrib_info}' + sol = self.solution + sol.attrs = {} + return f'{header}\n{repr(sol)}' class FlowResults(_ElementResults): @@ -1974,8 +1984,10 @@ def size(self) -> xr.DataArray: def __repr__(self) -> str: """Return string representation with flow connection details.""" class_name = self.__class__.__name__ - header = f'{class_name}: {self.label} | {self.start} → {self.end}' - return f'{header}\n{repr(self.solution)}' + header = f'{class_name}: "{self.label}" | {self.start} → {self.end}' + sol = self.solution + sol.attrs = {} + return f'{header}\n{repr(sol)}' class SegmentedCalculationResults: From 9314aec417926177cac0cb458eb08668187d39df Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:18:19 +0100 Subject: [PATCH 30/86] Remove code duplication --- flixopt/results.py | 45 +++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 4dfef0ebb..2657d70ad 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1124,14 +1124,22 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self._calculation_results.model.constraints[self._constraint_names] - def __repr__(self) -> str: - """Return string representation with element info and dataset preview.""" + def _format_repr(self, info: str = '') -> str: + """Format repr with class name, label, optional info, and dataset. + + Args: + info: Optional additional information to append to header (e.g., ' | 3 inputs') + """ class_name = self.__class__.__name__ - header = f'{class_name}: "{self.label}"' + header = f'{class_name}: "{self.label}"{info}' sol = self.solution sol.attrs = {} return f'{header}\n{repr(sol)}' + def __repr__(self) -> str: + """Return string representation with element info and dataset preview.""" + return self._format_repr() + def filter_solution( self, variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, @@ -1625,13 +1633,8 @@ def node_balance( def __repr__(self) -> str: """Return string representation with node information.""" - class_name = self.__class__.__name__ - header = ( - f'{class_name}: "{self.label}" | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' - ) - sol = self.solution - sol.attrs = {} - return f'{header}\n{repr(sol)}' + info = f' | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' + return self._format_repr(info) class BusResults(_NodeResults): @@ -1912,12 +1915,9 @@ def node_balance_with_charge_state( def __repr__(self) -> str: """Return string representation with storage indication.""" - class_name = self.__class__.__name__ storage_tag = ' (Storage)' if self.is_storage else '' - header = f'{class_name}: "{self.label}"{storage_tag} | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' - sol = self.solution - sol.attrs = {} - return f'{header}\n{repr(sol)}' + info = f'{storage_tag} | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' + return self._format_repr(info) class EffectResults(_ElementResults): @@ -1936,14 +1936,10 @@ def get_shares_from(self, element: str) -> xr.Dataset: def __repr__(self) -> str: """Return string representation with contribution information.""" - class_name = self.__class__.__name__ # Extract contributing elements from variable names (format: "element->effect_label") contributing_elements = {var_name.split('->')[0] for var_name in self._variable_names if '->' in var_name} - contrib_info = f' | {len(contributing_elements)} contributors' - header = f'{class_name}: "{self.label}"{contrib_info}' - sol = self.solution - sol.attrs = {} - return f'{header}\n{repr(sol)}' + info = f' | {len(contributing_elements)} contributors' + return self._format_repr(info) class FlowResults(_ElementResults): @@ -1983,11 +1979,8 @@ def size(self) -> xr.DataArray: def __repr__(self) -> str: """Return string representation with flow connection details.""" - class_name = self.__class__.__name__ - header = f'{class_name}: "{self.label}" | {self.start} → {self.end}' - sol = self.solution - sol.attrs = {} - return f'{header}\n{repr(sol)}' + info = f' | {self.start} → {self.end}' + return self._format_repr(info) class SegmentedCalculationResults: From 0c02d1b1cf8876c5a1e82d668a48bb2c8677c701 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:28:28 +0100 Subject: [PATCH 31/86] Improve repr of ELements --- flixopt/components.py | 26 ++++++++++++++++++++++++++ flixopt/effects.py | 29 +++++++++++++++++++++++++++++ flixopt/elements.py | 43 +++++++++++++++++++++++++++++++++++++++++++ flixopt/structure.py | 13 +++++++++++++ 4 files changed, 111 insertions(+) diff --git a/flixopt/components.py b/flixopt/components.py index 09156e1dc..194ef9dd6 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -528,6 +528,32 @@ def _plausibility_checks(self) -> None: f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.' ) + def __repr__(self) -> str: + """Return string representation with storage capacity.""" + import xarray as xr + + in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 + out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 + total_flows = in_count + out_count + + # Try to get capacity value + capacity_info = '' + try: + cap = self.capacity_in_flow_hours + if isinstance(cap, InvestParameters) and cap.fixed_size is not None: + cap = cap.fixed_size + if isinstance(cap, xr.DataArray): + cap = float(cap.values) + elif isinstance(cap, xr.DataArray): + cap = float(cap.values) + + capacity_info = f' | capacity: {cap:.1f} flow-hours' + except Exception: + pass + + info = f' | {total_flows} flows ({in_count} in, {out_count} out){capacity_info}' + return self._format_repr(info) + @register_class_for_io class Transmission(Component): diff --git a/flixopt/effects.py b/flixopt/effects.py index d431ddcca..2d9fa48f8 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -386,6 +386,35 @@ def _plausibility_checks(self) -> None: # TODO: Check for plausibility pass + def __repr__(self) -> str: + """Return string representation with effect properties.""" + parts = [] + + # Unit + if hasattr(self, 'unit') and self.unit: + parts.insert(0, f'({self.unit})') + + # Objective + if hasattr(self, 'is_objective') and self.is_objective: + parts.append('objective') + + # Constraint types + constraint_types = [] + if hasattr(self, 'maximum_per_hour') and self.maximum_per_hour is not None: + constraint_types.append('per_hour') + if hasattr(self, 'maximum_temporal') and self.maximum_temporal is not None: + constraint_types.append('temporal') + if hasattr(self, 'maximum_periodic') and self.maximum_periodic is not None: + constraint_types.append('periodic') + if hasattr(self, 'maximum_total') and self.maximum_total is not None: + constraint_types.append('total') + + if constraint_types: + parts.append('constraints: ' + '+'.join(constraint_types)) + + info = ' ' + ' | '.join(parts) if parts else '' + return self._format_repr(info.replace(' |', '', 1) if info else '') + class EffectModel(ElementModel): element: Effect # Type hint diff --git a/flixopt/elements.py b/flixopt/elements.py index f20611028..36568a77f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -136,6 +136,15 @@ def _connect_flows(self): flow.component = self.label_full flow.is_input_in_component = False + def __repr__(self) -> str: + """Return string representation with flow information.""" + in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 + out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 + total_flows = in_count + out_count + + info = f' | {total_flows} flows ({in_count} in, {out_count} out)' + return self._format_repr(info) + @register_class_for_io class Bus(Element): @@ -237,6 +246,10 @@ def _plausibility_checks(self) -> None: def with_excess(self) -> bool: return False if self.excess_penalty_per_flow_hour is None else True + def __repr__(self) -> str: + """Return string representation.""" + return self._format_repr() + @register_class_for_io class Connection: @@ -514,6 +527,36 @@ def size_is_fixed(self) -> bool: # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True + def __repr__(self) -> str: + """Return string representation with bus and size.""" + import xarray as xr + + parts = [] + + # Bus connection + if self.bus is not None: + parts.append(f'bus: {self.bus}') + + try: + size_val = self.size + # Handle InvestParameters + if isinstance(size_val, InvestParameters): + if size_val.fixed_size is not None and size_val.mandatory: + size_val = size_val.fixed_size + else: + parts.append('size: investment decision') + size_val = None + + if size_val is not None: + if isinstance(size_val, xr.DataArray): + size_val = float(size_val.values) + parts.append(f'size: {size_val:.1f}') + except Exception: + pass + + info = ' | ' + ' | '.join(parts) if parts else '' + return self._format_repr(info) + class FlowModel(ElementModel): element: Flow # Type hint diff --git a/flixopt/structure.py b/flixopt/structure.py index a6ab86006..90be43e94 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -879,6 +879,19 @@ def create_model(self, model: FlowSystemModel) -> ElementModel: def label_full(self) -> str: return self.label + def _format_repr(self, info: str = '') -> str: + """Format repr with class name, label, and optional info. + + Args: + info: Optional additional information (e.g., ' | 2 flows') + """ + class_name = self.__class__.__name__ + return f'{class_name}: "{self.label_full}"{info}' + + def __repr__(self) -> str: + """Return string representation.""" + return self._format_repr() + @staticmethod def _valid_label(label: str) -> str: """Checks if the label is valid. If not, it is replaced by the default label. From 1c5b8aea3f863e5c87d0ce078dafba3d84f44c62 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:49:24 +0100 Subject: [PATCH 32/86] Improve Error message --- flixopt/effects.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 2d9fa48f8..ee3fb5845 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -581,9 +581,10 @@ def __getitem__(self, effect: str | Effect | None) -> Effect: else: raise KeyError(f'Effect {effect} not found!') try: - return super().__getitem__(effect) # Use parent's __getitem__ for string keys + return super().__getitem__(effect) # Leverage ContainerMixin suggestions except KeyError as e: - raise KeyError(f'Effect "{effect}" not found! Add it to the FlowSystem first!') from e + # Append context without discarding original message + raise KeyError(f'{e} Add the effect to the FlowSystem first.') from None def __iter__(self) -> Iterator[Effect]: return iter(self.values()) # Iterate over Effect objects, not keys From c4c532ca96327a9b3d1def7f3f63a674f6129348 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:50:16 +0100 Subject: [PATCH 33/86] Remove redundant sum('time') --- flixopt/results.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 2657d70ad..f32e45b7e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -761,8 +761,6 @@ def _compute_effect_total( temporal = temporal.sum('time') if periodic.isnull().all(): return temporal.rename(f'{element}->{effect}') - if 'time' in temporal.indexes: - temporal = temporal.sum('time') return periodic + temporal total = xr.DataArray(0) From df224e54fcc2148a8f3907b7a934bdb4471d7bc5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:50:39 +0100 Subject: [PATCH 34/86] Pre compile re pattern --- flixopt/structure.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 90be43e94..5879a5d2d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -911,11 +911,13 @@ def _valid_label(label: str) -> str: return label +# Precompiled regex pattern for natural sorting +_NATURAL_SPLIT = __import__('re').compile(r'(\d+)') + + def _natural_sort_key(text): """Sort key for natural ordering (e.g., bus1, bus2, bus10 instead of bus1, bus10, bus2).""" - import re - - return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)] + return [int(c) if c.isdigit() else c.lower() for c in _NATURAL_SPLIT.split(text)] # Type variable for containers From 29e9a985e80a058258cf8d317be20fd04dffd3bb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:50:53 +0100 Subject: [PATCH 35/86] Make code more concise --- flixopt/structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 5879a5d2d..2cf9463bf 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1022,7 +1022,7 @@ def __repr__(self) -> str: for name in sorted(self.keys(), key=_natural_sort_key): r += f' * {name}\n' - if not len(list(self)): + if not self: r += '\n' return r From 75a4e40f6f72008050b750e62f3568f5a1ae9638 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:51:23 +0100 Subject: [PATCH 36/86] Synchronise order of Containers in both FlowSystem and CalculationResults --- flixopt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index d13b38184..5568dd330 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -718,9 +718,9 @@ def __eq__(self, other: FlowSystem): def _get_container_groups(self) -> dict[str, dict]: """Return ordered container groups for CompositeContainerMixin.""" return { - 'Effects': dict(self.effects), 'Components': dict(self.components), 'Buses': dict(self.buses), + 'Effects': dict(self.effects), 'Flows': dict(self.flows), } From 3385e407ef035fdda48cc429812c4cb6e5f78aff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:51:40 +0100 Subject: [PATCH 37/86] Add note about caching --- flixopt/flow_system.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5568dd330..ab8631f9c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -726,6 +726,8 @@ def _get_container_groups(self) -> dict[str, dict]: @property def flows(self) -> ElementContainer[Flow]: + # NOTE: Creates new container on each access. Consider caching with invalidation + # on component changes if this property is called frequently in hot loops. set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} flows_dict = {flow.label_full: flow for flow in set_of_flows} return ElementContainer(elements=flows_dict, element_type_name='flows') From 512aaa68d62aa5d07203ab43e3352c732c45cc99 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:26:18 +0100 Subject: [PATCH 38/86] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eef465ad9..fd1d3a40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,9 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### 💥 Breaking Changes ### ♻️ Changed +**Improved repr methods:** +- **Results classes** (`ComponentResults`, `BusResults`, `FlowResults`, `EffectResults`) now show concise header with key metadata followed by xarray Dataset repr +- **Element classes** (`Component`, `Bus`, `Flow`, `Effect`, `Storage`) now show one-line summaries with essential information (connections, sizes, capacities, constraints) ### 🗑️ Deprecated From 552deaf1257eb48564a21937f489e998ce7b8f05 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:27:43 +0100 Subject: [PATCH 39/86] Changed from O(n) element in self.values() to O(1) label-based checking, avoiding expensive container merges. --- flixopt/effects.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index ee3fb5845..7884e20d0 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -594,12 +594,7 @@ def __contains__(self, item: str | Effect) -> bool: if isinstance(item, str): return super().__contains__(item) # Check if the label exists elif isinstance(item, Effect): - # First check by label and object identity (O(1)) - if item.label_full in self and self[item.label_full] is item: - return True - # Fallback to full object search (O(n)) for objects with unexpected labels - if item in self.values(): - return True + return item.label_full in self and self[item.label_full] is item return False @property From cfd73559c3541a1daba40e111356d282898a28cd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:28:27 +0100 Subject: [PATCH 40/86] Added early validation to ensure flows in prevent_simultaneous_flows belong to the component, preventing modeling order issues. --- flixopt/elements.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 36568a77f..c88e0d330 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -86,11 +86,12 @@ def __init__( super().__init__(label, meta_data=meta_data) self.inputs: list[Flow] = inputs or [] self.outputs: list[Flow] = outputs or [] - self._check_unique_flow_labels() - self._connect_flows() self.on_off_parameters = on_off_parameters self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or [] + self._check_unique_flow_labels() + self._connect_flows() + self.flows: dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} def create_model(self, model: FlowSystemModel) -> ComponentModel: @@ -136,6 +137,17 @@ def _connect_flows(self): flow.component = self.label_full flow.is_input_in_component = False + # Validate prevent_simultaneous_flows: only allow local flows + if self.prevent_simultaneous_flows: + local = set(self.inputs + self.outputs) + foreign = [f for f in self.prevent_simultaneous_flows if f not in local] + if foreign: + names = ', '.join(f.label_full for f in foreign) + raise ValueError( + f'prevent_simultaneous_flows for "{self.label_full}" must reference its own flows. ' + f'Foreign flows detected: {names}' + ) + def __repr__(self) -> str: """Return string representation with flow information.""" in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 From b86941c5f087113c5a0317502268c201cf737396 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:28:40 +0100 Subject: [PATCH 41/86] Import Statement Fix (structure.py:10, 916) --- flixopt/structure.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 2cf9463bf..78058878f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -7,6 +7,7 @@ import inspect import logging +import re from dataclasses import dataclass from difflib import get_close_matches from io import StringIO @@ -912,7 +913,7 @@ def _valid_label(label: str) -> str: # Precompiled regex pattern for natural sorting -_NATURAL_SPLIT = __import__('re').compile(r'(\d+)') +_NATURAL_SPLIT = re.compile(r'(\d+)') def _natural_sort_key(text): From cf13be4f3b7074a7b49050c948065bdd3c73342a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:29:16 +0100 Subject: [PATCH 42/86] Flow repr Improvement --- flixopt/elements.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index c88e0d330..aca6ce66e 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -553,8 +553,18 @@ def __repr__(self) -> str: size_val = self.size # Handle InvestParameters if isinstance(size_val, InvestParameters): - if size_val.fixed_size is not None and size_val.mandatory: - size_val = size_val.fixed_size + if size_val.fixed_size is not None: + # Show fixed size; annotate optional if not mandatory + ann = '' if size_val.mandatory else ' (optional)' + try: + if isinstance(size_val.fixed_size, xr.DataArray): + display_val = float(size_val.fixed_size.values) + else: + display_val = float(size_val.fixed_size) + parts.append(f'size: fixed {display_val:.1f}{ann}') + except Exception: + parts.append(f'size: fixed{ann}') + size_val = None else: parts.append('size: investment decision') size_val = None From 8e98bf3305ad84500f7f375dd6497acfd6b53819 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:30:59 +0100 Subject: [PATCH 43/86] Optimized Uniqueness Check --- flixopt/flow_system.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ab8631f9c..d032eb62f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -604,8 +604,6 @@ def _check_if_element_is_unique(self, element: Element) -> None: Args: element: new element to check """ - if element in self.values(): - raise ValueError(f'Element {element.label_full} already added to FlowSystem!') # check if name is already used: if element.label_full in self: raise ValueError(f'Label of Element {element.label_full} already used in another element!') From 298b2bfa619d2223145e24e058dfd56b967cdbe4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:31:24 +0100 Subject: [PATCH 44/86] Fixed separator mangling that produced strings like "(kg)objective" instead of "(kg) | objective" --- flixopt/effects.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 7884e20d0..d4e47aec0 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -412,8 +412,10 @@ def __repr__(self) -> str: if constraint_types: parts.append('constraints: ' + '+'.join(constraint_types)) - info = ' ' + ' | '.join(parts) if parts else '' - return self._format_repr(info.replace(' |', '', 1) if info else '') + info = ' | '.join(parts) + if info: + info = ' | ' + info + return self._format_repr(info) class EffectModel(ElementModel): From 1c7829e71de9ac9dbf10cf66c26fc3ff746835b4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:32:22 +0100 Subject: [PATCH 45/86] Flows Property Caching --- flixopt/flow_system.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index d032eb62f..d3ff2c52a 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -114,6 +114,7 @@ def __init__( self._used_in_calculation = False self._network_app = None + self._flows_cache: ElementContainer[Flow] | None = None # Use properties to validate and store scenario dimension settings self.scenario_independent_sizes = scenario_independent_sizes @@ -616,12 +617,14 @@ def _add_components(self, *components: Component) -> None: logger.info(f'Registered new Component: {new_component.label_full}') self._check_if_element_is_unique(new_component) # check if already exists: self.components.add(new_component) # Add to existing components + self._flows_cache = None # Invalidate flows cache def _add_buses(self, *buses: Bus): for new_bus in list(buses): logger.info(f'Registered new Bus: {new_bus.label_full}') self._check_if_element_is_unique(new_bus) # check if already exists: self.buses.add(new_bus) # Add to existing buses + self._flows_cache = None # Invalidate flows cache def _connect_network(self): """Connects the network of components and buses. Can be rerun without changes if no elements were added""" @@ -724,11 +727,10 @@ def _get_container_groups(self) -> dict[str, dict]: @property def flows(self) -> ElementContainer[Flow]: - # NOTE: Creates new container on each access. Consider caching with invalidation - # on component changes if this property is called frequently in hot loops. - set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} - flows_dict = {flow.label_full: flow for flow in set_of_flows} - return ElementContainer(elements=flows_dict, element_type_name='flows') + if self._flows_cache is None: + flows_set = {f for c in self.components.values() for f in c.inputs + c.outputs} + self._flows_cache = ElementContainer({f.label_full: f for f in flows_set}, element_type_name='flows') + return self._flows_cache @property def all_elements(self) -> dict[str, Element]: From ebbf4f4664028ee6ce426f51ad254d76e9f17572 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:38:34 +0100 Subject: [PATCH 46/86] Fixed __iter__ method (effects.py:591-592) Updated all code that relied on old behavior --- flixopt/calculation.py | 2 +- flixopt/effects.py | 8 ++++---- flixopt/flow_system.py | 2 +- flixopt/structure.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index f744c5247..d16e15885 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -112,7 +112,7 @@ def main_results(self) -> dict[str, Scalar | dict]: 'periodic': effect.submodel.periodic.total.solution.values, 'total': effect.submodel.total.solution.values, } - for effect in self.flow_system.effects + for effect in self.flow_system.effects.values() }, 'Invest-Decisions': { 'Invested': { diff --git a/flixopt/effects.py b/flixopt/effects.py index d4e47aec0..8679ddadc 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -588,8 +588,8 @@ def __getitem__(self, effect: str | Effect | None) -> Effect: # Append context without discarding original message raise KeyError(f'{e} Add the effect to the FlowSystem first.') from None - def __iter__(self) -> Iterator[Effect]: - return iter(self.values()) # Iterate over Effect objects, not keys + def __iter__(self) -> Iterator[str]: + return iter(self.keys()) # Iterate over keys like a normal dict def __contains__(self, item: str | Effect) -> bool: """Check if the effect exists. Checks for label or object""" @@ -692,7 +692,7 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - def _do_modeling(self): super()._do_modeling() - for effect in self.effects: + for effect in self.effects.values(): effect.create_model(self._model) self.penalty = self.add_submodels( ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), @@ -706,7 +706,7 @@ def _do_modeling(self): ) def _add_share_between_effects(self): - for target_effect in self.effects: + for target_effect in self.effects.values(): # 1. temporal: <- receiving temporal shares from other effects for source_effect, time_series in target_effect.share_from_temporal.items(): target_effect.submodel.temporal.add_share( diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index d3ff2c52a..635013cc2 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -234,7 +234,7 @@ def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: # Extract from effects effects_structure = {} - for effect in self.effects: + for effect in self.effects.values(): effect_structure, effect_arrays = effect._create_reference_structure() all_extracted_arrays.update(effect_arrays) effects_structure[effect.label] = effect_structure diff --git a/flixopt/structure.py b/flixopt/structure.py index 78058878f..9f1a8ef15 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -172,7 +172,7 @@ def solution(self): }, 'Effects': { effect.label_full: effect.submodel.results_structure() - for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) + for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) }, 'Flows': { flow.label_full: flow.submodel.results_structure() From 98d45bb8cb4d2f5f13d650c3030079932260a662 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:07:08 +0100 Subject: [PATCH 47/86] Deterministic Ordering for Effects --- flixopt/calculation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index d16e15885..5e919dbf5 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -112,7 +112,7 @@ def main_results(self) -> dict[str, Scalar | dict]: 'periodic': effect.submodel.periodic.total.solution.values, 'total': effect.submodel.total.solution.values, } - for effect in self.flow_system.effects.values() + for effect in sorted(self.flow_system.effects.values(), key=lambda e: e.label_full.upper()) }, 'Invest-Decisions': { 'Invested': { From 657bc54bb63463b08116566a1ebe61a971a93842 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:07:24 +0100 Subject: [PATCH 48/86] Removed Redundant Import --- flixopt/elements.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index aca6ce66e..e4e28fb6f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -541,8 +541,6 @@ def size_is_fixed(self) -> bool: def __repr__(self) -> str: """Return string representation with bus and size.""" - import xarray as xr - parts = [] # Bus connection From 8cc309340688da56c14c1ce90d033f5936aec720 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:09:04 +0100 Subject: [PATCH 49/86] Deterministic Ordering for Flows --- flixopt/flow_system.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 635013cc2..fb1933c6d 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -728,8 +728,10 @@ def _get_container_groups(self) -> dict[str, dict]: @property def flows(self) -> ElementContainer[Flow]: if self._flows_cache is None: - flows_set = {f for c in self.components.values() for f in c.inputs + c.outputs} - self._flows_cache = ElementContainer({f.label_full: f for f in flows_set}, element_type_name='flows') + flows = [f for c in self.components.values() for f in c.inputs + c.outputs] + # Deduplicate by id and sort for reproducibility + flows = sorted({id(f): f for f in flows}.values(), key=lambda f: f.label_full.lower()) + self._flows_cache = ElementContainer({f.label_full: f for f in flows}, element_type_name='flows') return self._flows_cache @property From 830e64c4a5b9b845c403b5f6c12552b9bff3fe19 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:09:41 +0100 Subject: [PATCH 50/86] Deduplicate prevent_simultaneous_flows --- flixopt/elements.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flixopt/elements.py b/flixopt/elements.py index e4e28fb6f..e0d748053 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -139,6 +139,11 @@ def _connect_flows(self): # Validate prevent_simultaneous_flows: only allow local flows if self.prevent_simultaneous_flows: + # Deduplicate while preserving order + seen = set() + self.prevent_simultaneous_flows = [ + f for f in self.prevent_simultaneous_flows if id(f) not in seen and not seen.add(id(f)) + ] local = set(self.inputs + self.outputs) foreign = [f for f in self.prevent_simultaneous_flows if f not in local] if foreign: From 5d94ce1fc1f2d3d84be59a2c2e621f887a64bfc4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:32:36 +0100 Subject: [PATCH 51/86] Improved DataArray Handling in repr --- flixopt/elements.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index e0d748053..745255449 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -561,10 +561,14 @@ def __repr__(self) -> str: ann = '' if size_val.mandatory else ' (optional)' try: if isinstance(size_val.fixed_size, xr.DataArray): - display_val = float(size_val.fixed_size.values) + if size_val.fixed_size.size == 1: + display_val = float(size_val.fixed_size.item()) + parts.append(f'size: fixed {display_val:.1f}{ann}') + else: + parts.append(f'size: fixed ({size_val.min().item()} - {size_val.max().item()}) {ann}') else: display_val = float(size_val.fixed_size) - parts.append(f'size: fixed {display_val:.1f}{ann}') + parts.append(f'size: fixed {display_val:.1f}{ann}') except Exception: parts.append(f'size: fixed{ann}') size_val = None @@ -574,8 +578,13 @@ def __repr__(self) -> str: if size_val is not None: if isinstance(size_val, xr.DataArray): - size_val = float(size_val.values) - parts.append(f'size: {size_val:.1f}') + if size_val.size == 1: + size_val = float(size_val.item()) + parts.append(f'size: {size_val:.1f}') + else: + parts.append(f'size: fixed ({size_val.min().item()} - {size_val.max().item()})') + else: + parts.append(f'size: {float(size_val):.1f}') except Exception: pass From cd2d809ddad0697f1630670d082fe92f2816dde4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:34:07 +0100 Subject: [PATCH 52/86] Enhanced Bus repr --- flixopt/elements.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 745255449..0d5c7d452 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -265,7 +265,22 @@ def with_excess(self) -> bool: def __repr__(self) -> str: """Return string representation.""" - return self._format_repr() + info = '' + if self.excess_penalty_per_flow_hour is not None: + # Try to extract scalar value for display + try: + if isinstance(self.excess_penalty_per_flow_hour, xr.DataArray): + if self.excess_penalty_per_flow_hour.size == 1: + penalty_val = float(self.excess_penalty_per_flow_hour.item()) + info = f' | excess_penalty: {penalty_val:.0f}' + else: + info = ' | excess_penalty: variable' + else: + penalty_val = float(self.excess_penalty_per_flow_hour) + info = f' | excess_penalty: {penalty_val:.0f}' + except Exception: + info = ' | excess_penalty: set' + return self._format_repr(info) @register_class_for_io From f38e095bb20900f2ffb46a1c9a6d54db1884fd58 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:34:18 +0100 Subject: [PATCH 53/86] Enhanced Component repr --- flixopt/elements.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 0d5c7d452..311de80e1 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -159,7 +159,17 @@ def __repr__(self) -> str: out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 total_flows = in_count + out_count - info = f' | {total_flows} flows ({in_count} in, {out_count} out)' + parts = [f'{total_flows} flows ({in_count} in, {out_count} out)'] + + # Add on_off indicator + if self.on_off_parameters is not None: + parts.append('on_off') + + # Add mutual exclusivity indicator + if self.prevent_simultaneous_flows: + parts.append(f'mutual_excl:{len(self.prevent_simultaneous_flows)}') + + info = ' | ' + ' | '.join(parts) return self._format_repr(info) From a4b18c68117b4d0c80658c1012a8f94ffeb4c309 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:34:48 +0100 Subject: [PATCH 54/86] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1d3a40f..eee8d2682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,13 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **Results classes** (`ComponentResults`, `BusResults`, `FlowResults`, `EffectResults`) now show concise header with key metadata followed by xarray Dataset repr - **Element classes** (`Component`, `Bus`, `Flow`, `Effect`, `Storage`) now show one-line summaries with essential information (connections, sizes, capacities, constraints) +**Container-based access:** +- **FlowSystem** now provides dict-like access patterns for all elements +- Use `flow_system['element_label']`, `flow_system.keys()`, `flow_system.values()`, and `flow_system.items()` for unified element access +- Specialized containers (`components`, `buses`, `effects`, `flows`) offer type-specific access with helpful error messages + ### 🗑️ Deprecated +- **`FlowSystem.all_elements`** property is deprecated in favor of dict-like interface (`flow_system['label']`, `.keys()`, `.values()`, `.items()`). Will be removed in v4.0.0. ### 🔥 Removed From ac3314ae36e641119c5c759104288f8a66e48f03 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:18:07 +0100 Subject: [PATCH 55/86] Simplify reprs --- flixopt/components.py | 27 ++++++++------------------- flixopt/elements.py | 41 +++++++---------------------------------- 2 files changed, 15 insertions(+), 53 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 194ef9dd6..d5ca02563 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -529,29 +529,18 @@ def _plausibility_checks(self) -> None: ) def __repr__(self) -> str: - """Return string representation with storage capacity.""" - import xarray as xr - - in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 - out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 - total_flows = in_count + out_count - - # Try to get capacity value - capacity_info = '' + """Return string representation with capacity.""" + info = ' | 2 flows (1 in, 1 out)' try: cap = self.capacity_in_flow_hours - if isinstance(cap, InvestParameters) and cap.fixed_size is not None: - cap = cap.fixed_size - if isinstance(cap, xr.DataArray): - cap = float(cap.values) - elif isinstance(cap, xr.DataArray): - cap = float(cap.values) - - capacity_info = f' | capacity: {cap:.1f} flow-hours' + if isinstance(cap, InvestParameters): + info += ' | capacity: invest' if cap.fixed_size is None else ' | capacity: fixed' + elif isinstance(cap, xr.DataArray) and cap.size == 1: + info += f' | capacity: {float(cap.item()):.1f}' + elif not isinstance(cap, xr.DataArray): + info += f' | capacity: {float(cap):.1f}' except Exception: pass - - info = f' | {total_flows} flows ({in_count} in, {out_count} out){capacity_info}' return self._format_repr(info) diff --git a/flixopt/elements.py b/flixopt/elements.py index 311de80e1..c26bd9e0b 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -572,44 +572,17 @@ def size_is_fixed(self) -> bool: def __repr__(self) -> str: """Return string representation with bus and size.""" parts = [] - - # Bus connection if self.bus is not None: parts.append(f'bus: {self.bus}') + # Simple size display try: - size_val = self.size - # Handle InvestParameters - if isinstance(size_val, InvestParameters): - if size_val.fixed_size is not None: - # Show fixed size; annotate optional if not mandatory - ann = '' if size_val.mandatory else ' (optional)' - try: - if isinstance(size_val.fixed_size, xr.DataArray): - if size_val.fixed_size.size == 1: - display_val = float(size_val.fixed_size.item()) - parts.append(f'size: fixed {display_val:.1f}{ann}') - else: - parts.append(f'size: fixed ({size_val.min().item()} - {size_val.max().item()}) {ann}') - else: - display_val = float(size_val.fixed_size) - parts.append(f'size: fixed {display_val:.1f}{ann}') - except Exception: - parts.append(f'size: fixed{ann}') - size_val = None - else: - parts.append('size: investment decision') - size_val = None - - if size_val is not None: - if isinstance(size_val, xr.DataArray): - if size_val.size == 1: - size_val = float(size_val.item()) - parts.append(f'size: {size_val:.1f}') - else: - parts.append(f'size: fixed ({size_val.min().item()} - {size_val.max().item()})') - else: - parts.append(f'size: {float(size_val):.1f}') + if isinstance(self.size, InvestParameters): + parts.append('size: invest' if self.size.fixed_size is None else 'size: fixed') + elif isinstance(self.size, xr.DataArray) and self.size.size == 1: + parts.append(f'size: {float(self.size.item()):.1f}') + elif not isinstance(self.size, xr.DataArray): + parts.append(f'size: {float(self.size):.1f}') except Exception: pass From 10e9860b6da3c1ab48968acd493b545973632db5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:48:04 +0100 Subject: [PATCH 56/86] Simplify reprs --- flixopt/components.py | 10 +++++----- flixopt/elements.py | 24 +++++++++++++++--------- flixopt/interface.py | 21 +++++++++++++++++++++ flixopt/io.py | 18 ++++++++++++++++++ 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index d5ca02563..3656666e9 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -530,15 +530,15 @@ def _plausibility_checks(self) -> None: def __repr__(self) -> str: """Return string representation with capacity.""" + from .io import _extract_scalar + info = ' | 2 flows (1 in, 1 out)' try: cap = self.capacity_in_flow_hours if isinstance(cap, InvestParameters): - info += ' | capacity: invest' if cap.fixed_size is None else ' | capacity: fixed' - elif isinstance(cap, xr.DataArray) and cap.size == 1: - info += f' | capacity: {float(cap.item()):.1f}' - elif not isinstance(cap, xr.DataArray): - info += f' | capacity: {float(cap):.1f}' + info += f' | capacity: {cap.format_for_repr()}' + else: + info += f' | capacity: {_extract_scalar(cap):.1f}' except Exception: pass return self._format_repr(info) diff --git a/flixopt/elements.py b/flixopt/elements.py index c26bd9e0b..6b349af5b 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -575,19 +575,25 @@ def __repr__(self) -> str: if self.bus is not None: parts.append(f'bus: {self.bus}') - # Simple size display + parts.append(self._format_size()) + + info = ' | ' + ' | '.join(parts) if parts else '' + return self._format_repr(info) + + def _format_size(self) -> str: + """Format size for display.""" + from .io import _extract_scalar + try: if isinstance(self.size, InvestParameters): - parts.append('size: invest' if self.size.fixed_size is None else 'size: fixed') - elif isinstance(self.size, xr.DataArray) and self.size.size == 1: - parts.append(f'size: {float(self.size.item()):.1f}') - elif not isinstance(self.size, xr.DataArray): - parts.append(f'size: {float(self.size):.1f}') + return self._format_invest_params(self.size) + return f'size: {_extract_scalar(self.size):.1f}' except Exception: - pass + return '?' - info = ' | ' + ' | '.join(parts) if parts else '' - return self._format_repr(info) + def _format_invest_params(self, params: InvestParameters) -> str: + """Format InvestParameters for display.""" + return f'size: {params.format_for_repr()}' class FlowModel(ElementModel): diff --git a/flixopt/interface.py b/flixopt/interface.py index 8264e2392..54e16a2f4 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -1051,6 +1051,27 @@ def minimum_or_fixed_size(self) -> PeriodicData: def maximum_or_fixed_size(self) -> PeriodicData: return self.fixed_size if self.fixed_size is not None else self.maximum_size + def format_for_repr(self) -> str: + """Format InvestParameters for display in repr methods. + + Returns: + Formatted string showing size information + """ + from .io import _extract_scalar + + if self.fixed_size is not None: + val = _extract_scalar(self.fixed_size) + status = 'mandatory' if self.mandatory else 'optional' + return f'{val:.1f} ({status})' + + # Show range if available + parts = [] + if self.minimum_size is not None: + parts.append(f'min: {_extract_scalar(self.minimum_size):.1f}') + if self.maximum_size is not None: + parts.append(f'max: {_extract_scalar(self.maximum_size):.1f}') + return ', '.join(parts) if parts else 'invest' + @staticmethod def compute_linked_periods(first_period: int, last_period: int, periods: pd.Index | list[int]) -> xr.DataArray: return xr.DataArray( diff --git a/flixopt/io.py b/flixopt/io.py index 059670ddd..93262b89d 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any import numpy as np +import pandas as pd import xarray as xr import yaml @@ -547,3 +548,20 @@ def update(self, new_name: str | None = None, new_folder: pathlib.Path | None = raise FileNotFoundError(f'Folder {new_folder} does not exist or is not a directory.') self.folder = new_folder self._update_paths() + + +def _extract_scalar(value) -> float: + """Extract scalar float from various data types.""" + if isinstance(value, (int, float, np.integer, np.floating)): + return float(value) + if isinstance(value, xr.DataArray): + if value.size == 1: + return float(value.item()) + return float(value.mean()) # Fallback: use mean for multi-value arrays + if isinstance(value, (np.ndarray, pd.Series)): + if value.size == 1: + return float(value.flat[0]) + return float(value.mean()) + if isinstance(value, pd.DataFrame): + return float(value.values.flat[0] if value.size == 1 else value.values.mean()) + return float(value) From bf368e67aefbab54dbe332cb2a20ca8451f6a321 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 08:38:02 +0100 Subject: [PATCH 57/86] Simplify repr formating of numbers --- flixopt/components.py | 4 +-- flixopt/elements.py | 4 +-- flixopt/interface.py | 10 +++--- flixopt/io.py | 74 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 3656666e9..8f038c0d7 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -530,7 +530,7 @@ def _plausibility_checks(self) -> None: def __repr__(self) -> str: """Return string representation with capacity.""" - from .io import _extract_scalar + from .io import numeric_to_str_for_repr info = ' | 2 flows (1 in, 1 out)' try: @@ -538,7 +538,7 @@ def __repr__(self) -> str: if isinstance(cap, InvestParameters): info += f' | capacity: {cap.format_for_repr()}' else: - info += f' | capacity: {_extract_scalar(cap):.1f}' + info += f' | capacity: {numeric_to_str_for_repr(cap)}' except Exception: pass return self._format_repr(info) diff --git a/flixopt/elements.py b/flixopt/elements.py index 6b349af5b..38ec3bfd5 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -582,12 +582,12 @@ def __repr__(self) -> str: def _format_size(self) -> str: """Format size for display.""" - from .io import _extract_scalar + from .io import numeric_to_str_for_repr try: if isinstance(self.size, InvestParameters): return self._format_invest_params(self.size) - return f'size: {_extract_scalar(self.size):.1f}' + return f'size: {numeric_to_str_for_repr(self.size)}' except Exception: return '?' diff --git a/flixopt/interface.py b/flixopt/interface.py index 54e16a2f4..eae0a8511 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -1057,19 +1057,19 @@ def format_for_repr(self) -> str: Returns: Formatted string showing size information """ - from .io import _extract_scalar + from .io import numeric_to_str_for_repr if self.fixed_size is not None: - val = _extract_scalar(self.fixed_size) + val = numeric_to_str_for_repr(self.fixed_size) status = 'mandatory' if self.mandatory else 'optional' - return f'{val:.1f} ({status})' + return f'{val} ({status})' # Show range if available parts = [] if self.minimum_size is not None: - parts.append(f'min: {_extract_scalar(self.minimum_size):.1f}') + parts.append(f'min: {numeric_to_str_for_repr(self.minimum_size)}') if self.maximum_size is not None: - parts.append(f'max: {_extract_scalar(self.maximum_size):.1f}') + parts.append(f'max: {numeric_to_str_for_repr(self.maximum_size)}') return ', '.join(parts) if parts else 'invest' @staticmethod diff --git a/flixopt/io.py b/flixopt/io.py index 93262b89d..3ee3e28cf 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -550,18 +550,66 @@ def update(self, new_name: str | None = None, new_folder: pathlib.Path | None = self._update_paths() -def _extract_scalar(value) -> float: - """Extract scalar float from various data types.""" +def numeric_to_str_for_repr( + value: int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray, + precision: int = 1, + atol: float = 1e-10, +) -> str: + """Format value for display in repr methods. + + For single values or uniform arrays, returns the formatted value. + For arrays with variation, returns a range showing min-max. + + Args: + value: Numeric value or container (DataArray, array, Series, DataFrame) + precision: Number of decimal places (default: 1) + atol: Absolute tolerance for considering values equal (default: 1e-10) + + Returns: + Formatted string representation: + - Single/uniform values: "100.0" + - Nearly uniform values: "~100.0" (values differ slightly but display similarly) + - Varying values: "50.0-150.0" (shows range from min to max) + + Raises: + TypeError: If value cannot be converted to numeric format + """ + # Handle simple scalar types if isinstance(value, (int, float, np.integer, np.floating)): - return float(value) + return f'{float(value):.{precision}f}' + + # Extract array data for variation checking + arr = None if isinstance(value, xr.DataArray): - if value.size == 1: - return float(value.item()) - return float(value.mean()) # Fallback: use mean for multi-value arrays - if isinstance(value, (np.ndarray, pd.Series)): - if value.size == 1: - return float(value.flat[0]) - return float(value.mean()) - if isinstance(value, pd.DataFrame): - return float(value.values.flat[0] if value.size == 1 else value.values.mean()) - return float(value) + arr = value.values.flatten() + elif isinstance(value, (np.ndarray, pd.Series)): + arr = np.asarray(value).flatten() + elif isinstance(value, pd.DataFrame): + arr = value.values.flatten() + else: + # Fallback for unknown types + try: + return f'{float(value):.{precision}f}' + except (TypeError, ValueError) as e: + raise TypeError(f'Cannot format value of type {type(value).__name__} for repr') from e + + # Check for single value + if arr.size == 1: + return f'{float(arr[0]):.{precision}f}' + + # Check if all values are the same or very close + min_val = float(np.min(arr)) + max_val = float(np.max(arr)) + + # First check: values are essentially identical + if np.allclose(min_val, max_val, atol=atol): + return f'{float(np.mean(arr)):.{precision}f}' + + # Second check: display values are the same but actual values differ slightly + min_str = f'{min_val:.{precision}f}' + max_str = f'{max_val:.{precision}f}' + if min_str == max_str: + return f'~{min_str}' + + # Values vary significantly - show range + return f'{min_str}-{max_str}' From 08709ecdc3b9afcbe653e00ccdeca2fe1f31352f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:27:02 +0100 Subject: [PATCH 58/86] Reformat reprs --- flixopt/structure.py | 122 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 5 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 9f1a8ef15..8b86e83fa 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -802,19 +802,70 @@ def __repr__(self): init_signature = inspect.signature(self.__init__) init_args = init_signature.parameters + # Check if this has a 'label' parameter - if so, show it first as positional + has_label = 'label' in init_args + # Create a dictionary with argument names and their values, with better formatting args_parts = [] - for name in init_args: + label_value = None + + for name, param in init_args.items(): if name == 'self': continue + # Skip *args and **kwargs + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + if name == 'label' and has_label: + # Save label for later to show as positional arg + label_value = getattr(self, name, None) + continue + value = getattr(self, name, None) + + # Skip if value matches default or is an empty container with None default + if param.default != inspect.Parameter.empty: + # Special handling for empty containers (even if default was None) + if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: + if param.default is None or ( + isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 + ): + continue + # Handle array comparisons (xarray, numpy) + elif isinstance(value, (xr.DataArray, np.ndarray)): + try: + if isinstance(param.default, (xr.DataArray, np.ndarray)): + # Compare arrays element-wise + if isinstance(value, xr.DataArray) and isinstance(param.default, xr.DataArray): + if value.equals(param.default): + continue + elif np.array_equal(value, param.default): + continue + except Exception: + pass # If comparison fails, include in repr + elif value == param.default: + continue + + # Skip None values if default is None + if value is None and param.default is None: + continue + # Truncate long representations value_repr = repr(value) if len(value_repr) > 50: value_repr = value_repr[:47] + '...' args_parts.append(f'{name}={value_repr}') - args_str = ', '.join(args_parts) + # Build args string with label first as positional if present + if has_label and label_value is not None: + label_repr = repr(label_value) + if len(label_repr) > 50: + label_repr = label_repr[:47] + '...' + args_str = label_repr + if args_parts: + args_str += ', ' + ', '.join(args_parts) + else: + args_str = ', '.join(args_parts) + return f'{self.__class__.__name__}({args_str})' except Exception: # Fallback if introspection fails @@ -887,7 +938,65 @@ def _format_repr(self, info: str = '') -> str: info: Optional additional information (e.g., ' | 2 flows') """ class_name = self.__class__.__name__ - return f'{class_name}: "{self.label_full}"{info}' + + # Get constructor parameters (excluding 'self' and 'label') + init_signature = inspect.signature(self.__init__) + init_params = init_signature.parameters + + # Build kwargs for non-default parameters (excluding label which goes first) + kwargs_parts = [] + for param_name, param in init_params.items(): + if param_name in ('self', 'label'): + continue + + # Get current value + value = getattr(self, param_name, None) + + # Skip if value matches default + if param.default != inspect.Parameter.empty: + # Special handling for empty containers (even if default was None) + if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: + if param.default is None or ( + isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 + ): + continue + # Handle array comparisons (xarray, numpy) + elif isinstance(value, (xr.DataArray, np.ndarray)): + try: + if isinstance(param.default, (xr.DataArray, np.ndarray)): + # Compare arrays element-wise + if isinstance(value, xr.DataArray) and isinstance(param.default, xr.DataArray): + if value.equals(param.default): + continue + elif np.array_equal(value, param.default): + continue + except Exception: + pass # If comparison fails, include in repr + elif value == param.default: + continue + + # Skip None values if default is None + if value is None and param.default is None: + continue + + # Format value + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + + kwargs_parts.append(f'{param_name}={value_repr}') + + # Build the constructor-style representation + args_str = f'"{self.label_full}"' + if kwargs_parts: + args_str += ', ' + ', '.join(kwargs_parts) + + # Add derived info as a comment if provided + if info: + # Remove leading ' | ' if present (from old format) and format as comment + info_clean = info.lstrip(' |').strip() + return f'{class_name}({args_str}) # {info_clean}' + return f'{class_name}({args_str})' def __repr__(self) -> str: """Return string representation.""" @@ -1016,7 +1125,8 @@ def __getitem__(self, label: str) -> T: def __repr__(self) -> str: """Return a string representation similar to linopy.model.Variables.""" - title = self._element_type_name.capitalize() + count = len(self) + title = f'{self._element_type_name.capitalize()} ({count} item{"s" if count != 1 else ""})' line = '-' * len(title) r = f'{title}\n{line}\n' @@ -1024,7 +1134,9 @@ def __repr__(self) -> str: r += f' * {name}\n' if not self: - r += '\n' + title = f'{self._element_type_name.capitalize()} (0 items)' + line = '-' * len(title) + r = f'{title}\n{line}\n\n' return r From 131e9fac333dd3609d43c429a50fdbcecce94894 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:30:33 +0100 Subject: [PATCH 59/86] Reformat reprs --- flixopt/structure.py | 50 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 8b86e83fa..e87743320 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -815,6 +815,9 @@ def __repr__(self): # Skip *args and **kwargs if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): continue + # Skip 'kwargs' attribute explicitly (even if stored on object) + if name == 'kwargs': + continue if name == 'label' and has_label: # Save label for later to show as positional arg label_value = getattr(self, name, None) @@ -849,10 +852,23 @@ def __repr__(self): if value is None and param.default is None: continue - # Truncate long representations - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' + # Format value - use numeric formatter for numbers + from . import io as fx_io + + if isinstance( + value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) + ): + try: + value_repr = fx_io.numeric_to_str_for_repr(value) + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + else: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + args_parts.append(f'{name}={value_repr}') # Build args string with label first as positional if present @@ -948,6 +964,12 @@ def _format_repr(self, info: str = '') -> str: for param_name, param in init_params.items(): if param_name in ('self', 'label'): continue + # Skip *args and **kwargs + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + # Skip 'kwargs' attribute explicitly (even if stored on object) + if param_name == 'kwargs': + continue # Get current value value = getattr(self, param_name, None) @@ -979,10 +1001,22 @@ def _format_repr(self, info: str = '') -> str: if value is None and param.default is None: continue - # Format value - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' + # Format value - use numeric formatter for numbers + from . import io as fx_io + + if isinstance( + value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) + ): + try: + value_repr = fx_io.numeric_to_str_for_repr(value) + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + else: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' kwargs_parts.append(f'{param_name}={value_repr}') From d9c1f7080d2baea26cf9edbdeeca1b0d1970f961 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:40:34 +0100 Subject: [PATCH 60/86] Reformat reprs --- flixopt/elements.py | 74 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 38ec3bfd5..3a2b7ae6d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -155,22 +155,84 @@ def _connect_flows(self): def __repr__(self) -> str: """Return string representation with flow information.""" + import inspect + in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 total_flows = in_count + out_count + # Build first line manually (excluding inputs/outputs which are shown below) + class_name = self.__class__.__name__ + + # Get constructor parameters (excluding 'self', 'label', 'inputs', 'outputs') + init_signature = inspect.signature(self.__init__) + init_params = init_signature.parameters + + # Build kwargs for non-default parameters (excluding label, inputs, outputs) + kwargs_parts = [] + for param_name, param in init_params.items(): + if param_name in ('self', 'label', 'inputs', 'outputs'): + continue + # Skip *args and **kwargs + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + + value = getattr(self, param_name, None) + + # Skip if value matches default or is empty + if param.default != inspect.Parameter.empty: + if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: + if param.default is None or ( + isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 + ): + continue + elif value == param.default: + continue + + if value is None and param.default is None: + continue + + # Format value + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + kwargs_parts.append(f'{param_name}={value_repr}') + + # Build the first line + args_str = f'"{self.label_full}"' + if kwargs_parts: + args_str += ', ' + ', '.join(kwargs_parts) + + # Add flow summary as comment parts = [f'{total_flows} flows ({in_count} in, {out_count} out)'] - - # Add on_off indicator if self.on_off_parameters is not None: parts.append('on_off') - - # Add mutual exclusivity indicator if self.prevent_simultaneous_flows: parts.append(f'mutual_excl:{len(self.prevent_simultaneous_flows)}') - info = ' | ' + ' | '.join(parts) - return self._format_repr(info) + info_clean = ' | '.join(parts) + result = f'{class_name}({args_str}) # {info_clean}' + + # Add multi-line flow details if there are any flows + if total_flows > 0: + flow_lines = [] + + # Add inputs section + if in_count > 0: + flow_lines.append(' inputs:') + for flow in self.inputs: + flow_lines.append(f' * {repr(flow)}') + + # Add outputs section + if out_count > 0: + flow_lines.append(' outputs:') + for flow in self.outputs: + flow_lines.append(f' * {repr(flow)}') + + if flow_lines: + result += '\n' + '\n'.join(flow_lines) + + return result @register_class_for_io From 30baa4bdfb25fcf44350d1c003602086b9a43570 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:41:54 +0100 Subject: [PATCH 61/86] Reformat reprs --- flixopt/elements.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 3a2b7ae6d..a33cc9f0f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -203,15 +203,18 @@ def __repr__(self) -> str: if kwargs_parts: args_str += ', ' + ', '.join(kwargs_parts) - # Add flow summary as comment - parts = [f'{total_flows} flows ({in_count} in, {out_count} out)'] + # Add metadata indicators (not flow count, since flows are shown below) + parts = [] if self.on_off_parameters is not None: parts.append('on_off') if self.prevent_simultaneous_flows: parts.append(f'mutual_excl:{len(self.prevent_simultaneous_flows)}') - info_clean = ' | '.join(parts) - result = f'{class_name}({args_str}) # {info_clean}' + if parts: + info_clean = ' | '.join(parts) + result = f'{class_name}({args_str}) # {info_clean}' + else: + result = f'{class_name}({args_str})' # Add multi-line flow details if there are any flows if total_flows > 0: From 44417d9c20ade2d5c8a5323326a0d3f0ad6547c5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:43:42 +0100 Subject: [PATCH 62/86] Reformat reprs --- flixopt/structure.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/flixopt/structure.py b/flixopt/structure.py index e87743320..6a53118bd 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -845,6 +845,15 @@ def __repr__(self): continue except Exception: pass # If comparison fails, include in repr + # Handle numeric comparisons (deals with 0 vs 0.0, int vs float) + elif isinstance(value, (int, float, np.integer, np.floating)) and isinstance( + param.default, (int, float, np.integer, np.floating) + ): + try: + if float(value) == float(param.default): + continue + except (ValueError, TypeError): + pass elif value == param.default: continue @@ -864,6 +873,37 @@ def __repr__(self): value_repr = repr(value) if len(value_repr) > 50: value_repr = value_repr[:47] + '...' + elif isinstance(value, dict): + # Format dicts with numeric/array values nicely + try: + formatted_items = [] + for k, v in value.items(): + if isinstance( + v, + ( + int, + float, + np.integer, + np.floating, + np.ndarray, + pd.Series, + pd.DataFrame, + xr.DataArray, + ), + ): + v_str = fx_io.numeric_to_str_for_repr(v) + else: + v_str = repr(v) + if len(v_str) > 30: + v_str = v_str[:27] + '...' + formatted_items.append(f'{repr(k)}: {v_str}') + value_repr = '{' + ', '.join(formatted_items) + '}' + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' else: value_repr = repr(value) if len(value_repr) > 50: @@ -994,6 +1034,15 @@ def _format_repr(self, info: str = '') -> str: continue except Exception: pass # If comparison fails, include in repr + # Handle numeric comparisons (deals with 0 vs 0.0, int vs float) + elif isinstance(value, (int, float, np.integer, np.floating)) and isinstance( + param.default, (int, float, np.integer, np.floating) + ): + try: + if float(value) == float(param.default): + continue + except (ValueError, TypeError): + pass elif value == param.default: continue @@ -1001,6 +1050,17 @@ def _format_repr(self, info: str = '') -> str: if value is None and param.default is None: continue + # Special case: hide CONFIG.Modeling.big for size parameter + if param_name == 'size': + from .config import CONFIG + + try: + if isinstance(value, (int, float, np.integer, np.floating)): + if float(value) == CONFIG.Modeling.big: + continue + except Exception: + pass + # Format value - use numeric formatter for numbers from . import io as fx_io @@ -1013,6 +1073,27 @@ def _format_repr(self, info: str = '') -> str: value_repr = repr(value) if len(value_repr) > 50: value_repr = value_repr[:47] + '...' + elif isinstance(value, dict): + # Format dicts with numeric/array values nicely + try: + formatted_items = [] + for k, v in value.items(): + if isinstance( + v, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) + ): + v_str = fx_io.numeric_to_str_for_repr(v) + else: + v_str = repr(v) + if len(v_str) > 30: + v_str = v_str[:27] + '...' + formatted_items.append(f'{repr(k)}: {v_str}') + value_repr = '{' + ', '.join(formatted_items) + '}' + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' else: value_repr = repr(value) if len(value_repr) > 50: From b738723ee065007e27ef4375a81bb39cf5d31e71 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:45:10 +0100 Subject: [PATCH 63/86] Reformat reprs --- flixopt/elements.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index a33cc9f0f..bae4b4140 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -640,16 +640,27 @@ def __repr__(self) -> str: if self.bus is not None: parts.append(f'bus: {self.bus}') - parts.append(self._format_size()) + size_str = self._format_size() + if size_str: # Only add if not None/empty + parts.append(size_str) info = ' | ' + ' | '.join(parts) if parts else '' return self._format_repr(info) - def _format_size(self) -> str: - """Format size for display.""" + def _format_size(self) -> str | None: + """Format size for display. Returns None if size is default CONFIG.big.""" + import numpy as np + + from .config import CONFIG from .io import numeric_to_str_for_repr try: + # Hide default CONFIG.big size + if not isinstance(self.size, InvestParameters): + if isinstance(self.size, (int, float, np.integer, np.floating)): + if float(self.size) == CONFIG.Modeling.big: + return None + if isinstance(self.size, InvestParameters): return self._format_invest_params(self.size) return f'size: {numeric_to_str_for_repr(self.size)}' From e0142c9aa8bbf68b10fda888fe985c9b8c42f951 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:56:14 +0100 Subject: [PATCH 64/86] Reformat reprs --- flixopt/elements.py | 57 ++-------- flixopt/io.py | 178 ++++++++++++++++++++++++++++++ flixopt/structure.py | 252 ++----------------------------------------- 3 files changed, 192 insertions(+), 295 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index bae4b4140..5500d52a0 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -155,66 +155,25 @@ def _connect_flows(self): def __repr__(self) -> str: """Return string representation with flow information.""" - import inspect + from . import io as fx_io in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 total_flows = in_count + out_count - # Build first line manually (excluding inputs/outputs which are shown below) - class_name = self.__class__.__name__ - - # Get constructor parameters (excluding 'self', 'label', 'inputs', 'outputs') - init_signature = inspect.signature(self.__init__) - init_params = init_signature.parameters - - # Build kwargs for non-default parameters (excluding label, inputs, outputs) - kwargs_parts = [] - for param_name, param in init_params.items(): - if param_name in ('self', 'label', 'inputs', 'outputs'): - continue - # Skip *args and **kwargs - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - continue - - value = getattr(self, param_name, None) - - # Skip if value matches default or is empty - if param.default != inspect.Parameter.empty: - if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: - if param.default is None or ( - isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 - ): - continue - elif value == param.default: - continue - - if value is None and param.default is None: - continue - - # Format value - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - kwargs_parts.append(f'{param_name}={value_repr}') - - # Build the first line - args_str = f'"{self.label_full}"' - if kwargs_parts: - args_str += ', ' + ', '.join(kwargs_parts) - - # Add metadata indicators (not flow count, since flows are shown below) + # Build metadata indicators (not flow count, since flows are shown below) parts = [] if self.on_off_parameters is not None: parts.append('on_off') if self.prevent_simultaneous_flows: parts.append(f'mutual_excl:{len(self.prevent_simultaneous_flows)}') - if parts: - info_clean = ' | '.join(parts) - result = f'{class_name}({args_str}) # {info_clean}' - else: - result = f'{class_name}({args_str})' + info = ' | '.join(parts) if parts else '' + + # Build first line (excluding inputs/outputs which are shown below) + result = fx_io.build_repr_from_init( + self, excluded_params={'self', 'label', 'inputs', 'outputs', 'kwargs'}, info=info, skip_default_size=True + ) # Add multi-line flow details if there are any flows if total_flows > 0: diff --git a/flixopt/io.py b/flixopt/io.py index 3ee3e28cf..d70cb7572 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import json import logging import pathlib @@ -613,3 +614,180 @@ def numeric_to_str_for_repr( # Values vary significantly - show range return f'{min_str}-{max_str}' + + +def build_repr_from_init( + obj: object, + excluded_params: set[str] | None = None, + info: str = '', + label_as_positional: bool = True, + skip_default_size: bool = False, +) -> str: + """Build a repr string from __init__ signature, showing non-default parameter values. + + This utility function extracts common repr logic used across flixopt classes. + It introspects the __init__ method to build a constructor-style repr showing + only parameters that differ from their defaults. + + Args: + obj: The object to create repr for + excluded_params: Set of parameter names to exclude (e.g., {'self', 'inputs', 'outputs'}) + Default excludes 'self', 'label', and 'kwargs' + info: Optional comment to append (e.g., '2 flows (1 in, 1 out)') + label_as_positional: If True and 'label' param exists, show it as first positional arg + skip_default_size: If True, skip 'size' parameter when it equals CONFIG.Modeling.big + + Returns: + Formatted repr string like: ClassName("label", param=value) # info + """ + if excluded_params is None: + excluded_params = {'self', 'label', 'kwargs'} + else: + # Always exclude 'self' + excluded_params = excluded_params | {'self'} + + try: + # Get the constructor arguments and their current values + init_signature = inspect.signature(obj.__init__) + init_params = init_signature.parameters + + # Check if this has a 'label' parameter - if so, show it first as positional + has_label = 'label' in init_params and label_as_positional + + # Build kwargs for non-default parameters + kwargs_parts = [] + label_value = None + + for param_name, param in init_params.items(): + if param_name in excluded_params: + continue + + # Skip *args and **kwargs + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + + # Handle label separately if showing as positional + if param_name == 'label' and has_label: + label_value = getattr(obj, param_name, None) + continue + + # Get current value + value = getattr(obj, param_name, None) + + # Skip if value matches default + if param.default != inspect.Parameter.empty: + # Special handling for empty containers (even if default was None) + if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: + if param.default is None or ( + isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 + ): + continue + + # Handle array comparisons (xarray, numpy) + elif isinstance(value, (xr.DataArray, np.ndarray)): + try: + if isinstance(param.default, (xr.DataArray, np.ndarray)): + # Compare arrays element-wise + if isinstance(value, xr.DataArray) and isinstance(param.default, xr.DataArray): + if value.equals(param.default): + continue + elif np.array_equal(value, param.default): + continue + except Exception: + pass # If comparison fails, include in repr + + # Handle numeric comparisons (deals with 0 vs 0.0, int vs float) + elif isinstance(value, (int, float, np.integer, np.floating)) and isinstance( + param.default, (int, float, np.integer, np.floating) + ): + try: + if float(value) == float(param.default): + continue + except (ValueError, TypeError): + pass + + elif value == param.default: + continue + + # Skip None values if default is None + if value is None and param.default is None: + continue + + # Special case: hide CONFIG.Modeling.big for size parameter + if skip_default_size and param_name == 'size': + from .config import CONFIG + + try: + if isinstance(value, (int, float, np.integer, np.floating)): + if float(value) == CONFIG.Modeling.big: + continue + except Exception: + pass + + # Format value - use numeric formatter for numbers + if isinstance( + value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) + ): + try: + value_repr = numeric_to_str_for_repr(value) + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + + elif isinstance(value, dict): + # Format dicts with numeric/array values nicely + try: + formatted_items = [] + for k, v in value.items(): + if isinstance( + v, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) + ): + v_str = numeric_to_str_for_repr(v) + else: + v_str = repr(v) + if len(v_str) > 30: + v_str = v_str[:27] + '...' + formatted_items.append(f'{repr(k)}: {v_str}') + value_repr = '{' + ', '.join(formatted_items) + '}' + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + + else: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + + kwargs_parts.append(f'{param_name}={value_repr}') + + # Build args string with label first as positional if present + if has_label and label_value is not None: + # Use label_full if available, otherwise label + if hasattr(obj, 'label_full'): + label_repr = repr(obj.label_full) + else: + label_repr = repr(label_value) + + if len(label_repr) > 50: + label_repr = label_repr[:47] + '...' + args_str = label_repr + if kwargs_parts: + args_str += ', ' + ', '.join(kwargs_parts) + else: + args_str = ', '.join(kwargs_parts) + + # Build final repr + class_name = obj.__class__.__name__ + if info: + # Remove leading ' | ' if present (from old format) and format as comment + info_clean = info.lstrip(' |').strip() + return f'{class_name}({args_str}) # {info_clean}' + return f'{class_name}({args_str})' + + except Exception: + # Fallback if introspection fails + return f'{obj.__class__.__name__}()' diff --git a/flixopt/structure.py b/flixopt/structure.py index 6a53118bd..325bd0327 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -797,135 +797,9 @@ def to_json(self, path: str | pathlib.Path): def __repr__(self): """Return a detailed string representation for debugging.""" - try: - # Get the constructor arguments and their current values - init_signature = inspect.signature(self.__init__) - init_args = init_signature.parameters - - # Check if this has a 'label' parameter - if so, show it first as positional - has_label = 'label' in init_args - - # Create a dictionary with argument names and their values, with better formatting - args_parts = [] - label_value = None - - for name, param in init_args.items(): - if name == 'self': - continue - # Skip *args and **kwargs - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - continue - # Skip 'kwargs' attribute explicitly (even if stored on object) - if name == 'kwargs': - continue - if name == 'label' and has_label: - # Save label for later to show as positional arg - label_value = getattr(self, name, None) - continue - - value = getattr(self, name, None) - - # Skip if value matches default or is an empty container with None default - if param.default != inspect.Parameter.empty: - # Special handling for empty containers (even if default was None) - if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: - if param.default is None or ( - isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 - ): - continue - # Handle array comparisons (xarray, numpy) - elif isinstance(value, (xr.DataArray, np.ndarray)): - try: - if isinstance(param.default, (xr.DataArray, np.ndarray)): - # Compare arrays element-wise - if isinstance(value, xr.DataArray) and isinstance(param.default, xr.DataArray): - if value.equals(param.default): - continue - elif np.array_equal(value, param.default): - continue - except Exception: - pass # If comparison fails, include in repr - # Handle numeric comparisons (deals with 0 vs 0.0, int vs float) - elif isinstance(value, (int, float, np.integer, np.floating)) and isinstance( - param.default, (int, float, np.integer, np.floating) - ): - try: - if float(value) == float(param.default): - continue - except (ValueError, TypeError): - pass - elif value == param.default: - continue - - # Skip None values if default is None - if value is None and param.default is None: - continue - - # Format value - use numeric formatter for numbers - from . import io as fx_io - - if isinstance( - value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) - ): - try: - value_repr = fx_io.numeric_to_str_for_repr(value) - except Exception: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - elif isinstance(value, dict): - # Format dicts with numeric/array values nicely - try: - formatted_items = [] - for k, v in value.items(): - if isinstance( - v, - ( - int, - float, - np.integer, - np.floating, - np.ndarray, - pd.Series, - pd.DataFrame, - xr.DataArray, - ), - ): - v_str = fx_io.numeric_to_str_for_repr(v) - else: - v_str = repr(v) - if len(v_str) > 30: - v_str = v_str[:27] + '...' - formatted_items.append(f'{repr(k)}: {v_str}') - value_repr = '{' + ', '.join(formatted_items) + '}' - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - except Exception: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - else: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - - args_parts.append(f'{name}={value_repr}') - - # Build args string with label first as positional if present - if has_label and label_value is not None: - label_repr = repr(label_value) - if len(label_repr) > 50: - label_repr = label_repr[:47] + '...' - args_str = label_repr - if args_parts: - args_str += ', ' + ', '.join(args_parts) - else: - args_str = ', '.join(args_parts) + from . import io as fx_io - return f'{self.__class__.__name__}({args_str})' - except Exception: - # Fallback if introspection fails - return f'{self.__class__.__name__}()' + return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'}) def __str__(self): """Return a user-friendly string representation.""" @@ -993,125 +867,11 @@ def _format_repr(self, info: str = '') -> str: Args: info: Optional additional information (e.g., ' | 2 flows') """ - class_name = self.__class__.__name__ - - # Get constructor parameters (excluding 'self' and 'label') - init_signature = inspect.signature(self.__init__) - init_params = init_signature.parameters - - # Build kwargs for non-default parameters (excluding label which goes first) - kwargs_parts = [] - for param_name, param in init_params.items(): - if param_name in ('self', 'label'): - continue - # Skip *args and **kwargs - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - continue - # Skip 'kwargs' attribute explicitly (even if stored on object) - if param_name == 'kwargs': - continue - - # Get current value - value = getattr(self, param_name, None) - - # Skip if value matches default - if param.default != inspect.Parameter.empty: - # Special handling for empty containers (even if default was None) - if isinstance(value, (dict, list, tuple, set)) and len(value) == 0: - if param.default is None or ( - isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0 - ): - continue - # Handle array comparisons (xarray, numpy) - elif isinstance(value, (xr.DataArray, np.ndarray)): - try: - if isinstance(param.default, (xr.DataArray, np.ndarray)): - # Compare arrays element-wise - if isinstance(value, xr.DataArray) and isinstance(param.default, xr.DataArray): - if value.equals(param.default): - continue - elif np.array_equal(value, param.default): - continue - except Exception: - pass # If comparison fails, include in repr - # Handle numeric comparisons (deals with 0 vs 0.0, int vs float) - elif isinstance(value, (int, float, np.integer, np.floating)) and isinstance( - param.default, (int, float, np.integer, np.floating) - ): - try: - if float(value) == float(param.default): - continue - except (ValueError, TypeError): - pass - elif value == param.default: - continue - - # Skip None values if default is None - if value is None and param.default is None: - continue - - # Special case: hide CONFIG.Modeling.big for size parameter - if param_name == 'size': - from .config import CONFIG - - try: - if isinstance(value, (int, float, np.integer, np.floating)): - if float(value) == CONFIG.Modeling.big: - continue - except Exception: - pass + from . import io as fx_io - # Format value - use numeric formatter for numbers - from . import io as fx_io - - if isinstance( - value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) - ): - try: - value_repr = fx_io.numeric_to_str_for_repr(value) - except Exception: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - elif isinstance(value, dict): - # Format dicts with numeric/array values nicely - try: - formatted_items = [] - for k, v in value.items(): - if isinstance( - v, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) - ): - v_str = fx_io.numeric_to_str_for_repr(v) - else: - v_str = repr(v) - if len(v_str) > 30: - v_str = v_str[:27] + '...' - formatted_items.append(f'{repr(k)}: {v_str}') - value_repr = '{' + ', '.join(formatted_items) + '}' - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - except Exception: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - else: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - - kwargs_parts.append(f'{param_name}={value_repr}') - - # Build the constructor-style representation - args_str = f'"{self.label_full}"' - if kwargs_parts: - args_str += ', ' + ', '.join(kwargs_parts) - - # Add derived info as a comment if provided - if info: - # Remove leading ' | ' if present (from old format) and format as comment - info_clean = info.lstrip(' |').strip() - return f'{class_name}({args_str}) # {info_clean}' - return f'{class_name}({args_str})' + return fx_io.build_repr_from_init( + self, excluded_params={'self', 'label', 'kwargs'}, info=info, skip_default_size=True + ) def __repr__(self) -> str: """Return string representation.""" From 7de85768e09409aecf6607b52816928219f55d5a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:03:52 +0100 Subject: [PATCH 65/86] Reformat reprs --- flixopt/components.py | 36 +++++++++++++++++++++++++++++++----- flixopt/io.py | 9 +++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 8f038c0d7..40b580561 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -530,18 +530,44 @@ def _plausibility_checks(self) -> None: def __repr__(self) -> str: """Return string representation with capacity.""" - from .io import numeric_to_str_for_repr + from . import io as fx_io - info = ' | 2 flows (1 in, 1 out)' + # Build info with capacity + parts = ['2 flows (1 in, 1 out)'] try: cap = self.capacity_in_flow_hours if isinstance(cap, InvestParameters): - info += f' | capacity: {cap.format_for_repr()}' + parts.append(f'capacity: {cap.format_for_repr()}') else: - info += f' | capacity: {numeric_to_str_for_repr(cap)}' + parts.append(f'capacity: {fx_io.numeric_to_str_for_repr(cap)}') except Exception: pass - return self._format_repr(info) + + info = ' | '.join(parts) + + # Use build_repr_from_init directly to exclude charging and discharging + result = fx_io.build_repr_from_init( + self, + excluded_params={'self', 'label', 'charging', 'discharging', 'kwargs'}, + info=info, + skip_default_size=True, + ) + + # Add multi-line flow details + flow_lines = [] + if hasattr(self, 'inputs') and self.inputs: + flow_lines.append(' inputs:') + for flow in self.inputs: + flow_lines.append(f' * {repr(flow)}') + if hasattr(self, 'outputs') and self.outputs: + flow_lines.append(' outputs:') + for flow in self.outputs: + flow_lines.append(f' * {repr(flow)}') + + if flow_lines: + result += '\n' + '\n'.join(flow_lines) + + return result @register_class_for_io diff --git a/flixopt/io.py b/flixopt/io.py index d70cb7572..38dea262f 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -659,18 +659,19 @@ def build_repr_from_init( label_value = None for param_name, param in init_params.items(): - if param_name in excluded_params: - continue - # Skip *args and **kwargs if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): continue - # Handle label separately if showing as positional + # Handle label separately if showing as positional (check BEFORE excluded_params) if param_name == 'label' and has_label: label_value = getattr(obj, param_name, None) continue + # Now check if parameter should be excluded + if param_name in excluded_params: + continue + # Get current value value = getattr(obj, param_name, None) From 907e4d3f24770d56a60ef385bd7304163f6d5bee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:10:35 +0100 Subject: [PATCH 66/86] Reformat reprs --- flixopt/components.py | 16 +--------------- flixopt/elements.py | 31 ++++--------------------------- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 40b580561..91f0ef672 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -529,27 +529,13 @@ def _plausibility_checks(self) -> None: ) def __repr__(self) -> str: - """Return string representation with capacity.""" + """Return string representation.""" from . import io as fx_io - # Build info with capacity - parts = ['2 flows (1 in, 1 out)'] - try: - cap = self.capacity_in_flow_hours - if isinstance(cap, InvestParameters): - parts.append(f'capacity: {cap.format_for_repr()}') - else: - parts.append(f'capacity: {fx_io.numeric_to_str_for_repr(cap)}') - except Exception: - pass - - info = ' | '.join(parts) - # Use build_repr_from_init directly to exclude charging and discharging result = fx_io.build_repr_from_init( self, excluded_params={'self', 'label', 'charging', 'discharging', 'kwargs'}, - info=info, skip_default_size=True, ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 5500d52a0..6a313c12d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -299,22 +299,7 @@ def with_excess(self) -> bool: def __repr__(self) -> str: """Return string representation.""" - info = '' - if self.excess_penalty_per_flow_hour is not None: - # Try to extract scalar value for display - try: - if isinstance(self.excess_penalty_per_flow_hour, xr.DataArray): - if self.excess_penalty_per_flow_hour.size == 1: - penalty_val = float(self.excess_penalty_per_flow_hour.item()) - info = f' | excess_penalty: {penalty_val:.0f}' - else: - info = ' | excess_penalty: variable' - else: - penalty_val = float(self.excess_penalty_per_flow_hour) - info = f' | excess_penalty: {penalty_val:.0f}' - except Exception: - info = ' | excess_penalty: set' - return self._format_repr(info) + return self._format_repr() @register_class_for_io @@ -594,17 +579,9 @@ def size_is_fixed(self) -> bool: return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True def __repr__(self) -> str: - """Return string representation with bus and size.""" - parts = [] - if self.bus is not None: - parts.append(f'bus: {self.bus}') - - size_str = self._format_size() - if size_str: # Only add if not None/empty - parts.append(size_str) - - info = ' | ' + ' | '.join(parts) if parts else '' - return self._format_repr(info) + """Return string representation.""" + # No need for info comment since bus and size are already in constructor params + return self._format_repr() def _format_size(self) -> str | None: """Format size for display. Returns None if size is default CONFIG.big.""" From aac74fb9d58a46f6612e1895b32aea660da9a11a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:18:16 +0100 Subject: [PATCH 67/86] Reformat reprs --- flixopt/components.py | 3 +-- flixopt/elements.py | 31 ++++++++++++++++++++++++++++--- flixopt/structure.py | 4 ---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 91f0ef672..140f0fe59 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -11,6 +11,7 @@ import numpy as np import xarray as xr +from . import io as fx_io from .core import PeriodicDataUser, PlausibilityError, TemporalData, TemporalDataUser from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, PiecewiseModel @@ -530,8 +531,6 @@ def _plausibility_checks(self) -> None: def __repr__(self) -> str: """Return string representation.""" - from . import io as fx_io - # Use build_repr_from_init directly to exclude charging and discharging result = fx_io.build_repr_from_init( self, diff --git a/flixopt/elements.py b/flixopt/elements.py index 6a313c12d..615fa4062 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -11,6 +11,7 @@ import numpy as np import xarray as xr +from . import io as fx_io from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .features import InvestmentModel, OnOffModel @@ -155,8 +156,6 @@ def _connect_flows(self): def __repr__(self) -> str: """Return string representation with flow information.""" - from . import io as fx_io - in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 total_flows = in_count + out_count @@ -299,7 +298,33 @@ def with_excess(self) -> bool: def __repr__(self) -> str: """Return string representation.""" - return self._format_repr() + # Build first line + result = self._format_repr() + + # Add multi-line flow details + in_count = len(self.inputs) if self.inputs else 0 + out_count = len(self.outputs) if self.outputs else 0 + total_flows = in_count + out_count + + if total_flows > 0: + flow_lines = [] + + # Add inputs section + if in_count > 0: + flow_lines.append(' inputs:') + for flow in self.inputs: + flow_lines.append(f' * {repr(flow)}') + + # Add outputs section + if out_count > 0: + flow_lines.append(' outputs:') + for flow in self.outputs: + flow_lines.append(f' * {repr(flow)}') + + if flow_lines: + result += '\n' + '\n'.join(flow_lines) + + return result @register_class_for_io diff --git a/flixopt/structure.py b/flixopt/structure.py index 325bd0327..791e743c6 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -797,8 +797,6 @@ def to_json(self, path: str | pathlib.Path): def __repr__(self): """Return a detailed string representation for debugging.""" - from . import io as fx_io - return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'}) def __str__(self): @@ -867,8 +865,6 @@ def _format_repr(self, info: str = '') -> str: Args: info: Optional additional information (e.g., ' | 2 flows') """ - from . import io as fx_io - return fx_io.build_repr_from_init( self, excluded_params={'self', 'label', 'kwargs'}, info=info, skip_default_size=True ) From b6c7c97d38775757f5ebc2954b730874f0daa1a8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:34:36 +0100 Subject: [PATCH 68/86] Use helper formats to create the reprs --- flixopt/components.py | 16 +-------- flixopt/effects.py | 7 ++-- flixopt/elements.py | 53 ++---------------------------- flixopt/flow_system.py | 5 ++- flixopt/io.py | 74 ++++++++++++++++++++++++++++++++++++++++++ flixopt/structure.py | 33 +++++++------------ 6 files changed, 95 insertions(+), 93 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 140f0fe59..4304fea2d 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -537,21 +537,7 @@ def __repr__(self) -> str: excluded_params={'self', 'label', 'charging', 'discharging', 'kwargs'}, skip_default_size=True, ) - - # Add multi-line flow details - flow_lines = [] - if hasattr(self, 'inputs') and self.inputs: - flow_lines.append(' inputs:') - for flow in self.inputs: - flow_lines.append(f' * {repr(flow)}') - if hasattr(self, 'outputs') and self.outputs: - flow_lines.append(' outputs:') - for flow in self.outputs: - flow_lines.append(f' * {repr(flow)}') - - if flow_lines: - result += '\n' + '\n'.join(flow_lines) - + result += fx_io.format_flow_details(self) return result diff --git a/flixopt/effects.py b/flixopt/effects.py index 8679ddadc..a574e6399 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -16,6 +16,7 @@ import numpy as np import xarray as xr +from . import io as fx_io from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io @@ -392,7 +393,7 @@ def __repr__(self) -> str: # Unit if hasattr(self, 'unit') and self.unit: - parts.insert(0, f'({self.unit})') + parts.append(f'({self.unit})') # Objective if hasattr(self, 'is_objective') and self.is_objective: @@ -412,9 +413,7 @@ def __repr__(self) -> str: if constraint_types: parts.append('constraints: ' + '+'.join(constraint_types)) - info = ' | '.join(parts) - if info: - info = ' | ' + info + info = fx_io.build_metadata_info(parts) return self._format_repr(info) diff --git a/flixopt/elements.py b/flixopt/elements.py index 615fa4062..b44147617 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -156,10 +156,6 @@ def _connect_flows(self): def __repr__(self) -> str: """Return string representation with flow information.""" - in_count = len(self.inputs) if hasattr(self, 'inputs') and self.inputs else 0 - out_count = len(self.outputs) if hasattr(self, 'outputs') and self.outputs else 0 - total_flows = in_count + out_count - # Build metadata indicators (not flow count, since flows are shown below) parts = [] if self.on_off_parameters is not None: @@ -167,32 +163,13 @@ def __repr__(self) -> str: if self.prevent_simultaneous_flows: parts.append(f'mutual_excl:{len(self.prevent_simultaneous_flows)}') - info = ' | '.join(parts) if parts else '' + info = fx_io.build_metadata_info(parts) # Build first line (excluding inputs/outputs which are shown below) result = fx_io.build_repr_from_init( self, excluded_params={'self', 'label', 'inputs', 'outputs', 'kwargs'}, info=info, skip_default_size=True ) - - # Add multi-line flow details if there are any flows - if total_flows > 0: - flow_lines = [] - - # Add inputs section - if in_count > 0: - flow_lines.append(' inputs:') - for flow in self.inputs: - flow_lines.append(f' * {repr(flow)}') - - # Add outputs section - if out_count > 0: - flow_lines.append(' outputs:') - for flow in self.outputs: - flow_lines.append(f' * {repr(flow)}') - - if flow_lines: - result += '\n' + '\n'.join(flow_lines) - + result += fx_io.format_flow_details(self) return result @@ -298,32 +275,8 @@ def with_excess(self) -> bool: def __repr__(self) -> str: """Return string representation.""" - # Build first line result = self._format_repr() - - # Add multi-line flow details - in_count = len(self.inputs) if self.inputs else 0 - out_count = len(self.outputs) if self.outputs else 0 - total_flows = in_count + out_count - - if total_flows > 0: - flow_lines = [] - - # Add inputs section - if in_count > 0: - flow_lines.append(' inputs:') - for flow in self.inputs: - flow_lines.append(f' * {repr(flow)}') - - # Add outputs section - if out_count > 0: - flow_lines.append(' outputs:') - for flow in self.outputs: - flow_lines.append(f' * {repr(flow)}') - - if flow_lines: - result += '\n' + '\n'.join(flow_lines) - + result += fx_io.format_flow_details(self) return result diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index fb1933c6d..ec67b73f1 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -13,6 +13,7 @@ import pandas as pd import xarray as xr +from . import io as fx_io from .config import CONFIG from .core import ( ConversionError, @@ -662,13 +663,11 @@ def _connect_network(self): def __repr__(self) -> str: """Return a detailed string representation showing all containers.""" - title = 'FlowSystem' - line = '-' * len(title) + r = fx_io.format_title_with_underline('FlowSystem') # Timestep info time_period = f'{self.timesteps[0].date()} to {self.timesteps[-1].date()}' freq_str = str(self.timesteps.freq).replace('<', '').replace('>', '') if self.timesteps.freq else 'irregular' - r = f'{title}\n{line}\n' r += f'Timesteps: {len(self.timesteps)} ({freq_str}) [{time_period}]\n' # Add periods if present diff --git a/flixopt/io.py b/flixopt/io.py index 38dea262f..fe5750dc3 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -792,3 +792,77 @@ def build_repr_from_init( except Exception: # Fallback if introspection fails return f'{obj.__class__.__name__}()' + + +def format_flow_details(obj, has_inputs: bool = True, has_outputs: bool = True) -> str: + """Format inputs and outputs as indented bullet list. + + Args: + obj: Object with 'inputs' and/or 'outputs' attributes + has_inputs: Whether to check for inputs + has_outputs: Whether to check for outputs + + Returns: + Formatted string with flow details (including leading newline), or empty string if no flows + """ + flow_lines = [] + + if has_inputs and hasattr(obj, 'inputs') and obj.inputs: + flow_lines.append(' inputs:') + for flow in obj.inputs: + flow_lines.append(f' * {repr(flow)}') + + if has_outputs and hasattr(obj, 'outputs') and obj.outputs: + flow_lines.append(' outputs:') + for flow in obj.outputs: + flow_lines.append(f' * {repr(flow)}') + + return '\n' + '\n'.join(flow_lines) if flow_lines else '' + + +def format_title_with_underline(title: str, underline_char: str = '-') -> str: + """Format a title with underline of matching length. + + Args: + title: The title text + underline_char: Character to use for underline (default: '-') + + Returns: + Formatted string: "Title\\n-----\\n" + """ + return f'{title}\n{underline_char * len(title)}\n' + + +def format_sections_with_headers(sections: dict[str, str], underline_char: str = '-') -> list[str]: + """Format sections with underlined headers. + + Args: + sections: Dict mapping section headers to content + underline_char: Character for underlining headers + + Returns: + List of formatted section strings + """ + formatted_sections = [] + for section_header, section_content in sections.items(): + underline = underline_char * len(section_header) + formatted_sections.append(f'{section_header}\n{underline}\n{section_content}') + return formatted_sections + + +def build_metadata_info(parts: list[str], prefix: str = ' | ') -> str: + """Build metadata info string from parts. + + Args: + parts: List of metadata strings (empty strings are filtered out) + prefix: Prefix to add if parts is non-empty + + Returns: + Formatted info string or empty string + """ + # Filter out empty strings + parts = [p for p in parts if p] + if not parts: + return '' + info = ' | '.join(parts) + return prefix + info if prefix else info diff --git a/flixopt/structure.py b/flixopt/structure.py index 791e743c6..3c59c11fd 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -246,9 +246,7 @@ def __repr__(self) -> str: } # Format sections with headers and underlines - formatted_sections = [] - for section_header, section_content in sections.items(): - formatted_sections.append(f'{section_header}\n{"-" * len(section_header)}\n{section_content}') + formatted_sections = fx_io.format_sections_with_headers(sections) title = f'FlowSystemModel ({self.type})' all_sections = '\n'.join(formatted_sections) @@ -998,16 +996,14 @@ def __repr__(self) -> str: """Return a string representation similar to linopy.model.Variables.""" count = len(self) title = f'{self._element_type_name.capitalize()} ({count} item{"s" if count != 1 else ""})' - line = '-' * len(title) - r = f'{title}\n{line}\n' - - for name in sorted(self.keys(), key=_natural_sort_key): - r += f' * {name}\n' if not self: - title = f'{self._element_type_name.capitalize()} (0 items)' - line = '-' * len(title) - r = f'{title}\n{line}\n\n' + r = fx_io.format_title_with_underline(title) + r += '\n' + else: + r = fx_io.format_title_with_underline(title) + for name in sorted(self.keys(), key=_natural_sort_key): + r += f' * {name}\n' return r @@ -1386,9 +1382,7 @@ def __repr__(self) -> str: } # Format sections with headers and underlines - formatted_sections = [] - for section_header, section_content in sections.items(): - formatted_sections.append(f'{section_header}\n{"-" * len(section_header)}\n{section_content}') + formatted_sections = fx_io.format_sections_with_headers(sections) model_string = f'Submodel "{self.label_of_model}":' all_sections = '\n'.join(formatted_sections) @@ -1432,7 +1426,7 @@ def __contains__(self, name: str) -> bool: def __repr__(self) -> str: """Simple representation of the submodels collection.""" if not self.data: - return 'flixopt.structure.Submodels:\n----------------------------\n \n' + return fx_io.format_title_with_underline('flixopt.structure.Submodels') + ' \n' total_vars = sum(len(submodel.variables) for submodel in self.data.values()) total_cons = sum(len(submodel.constraints) for submodel in self.data.values()) @@ -1440,18 +1434,15 @@ def __repr__(self) -> str: title = ( f'flixopt.structure.Submodels ({total_vars} vars, {total_cons} constraints, {len(self.data)} submodels):' ) - underline = '-' * len(title) - if not self.data: - return f'{title}\n{underline}\n \n' - sub_models_string = '' + result = fx_io.format_title_with_underline(title) for name, submodel in self.data.items(): type_name = submodel.__class__.__name__ var_count = len(submodel.variables) con_count = len(submodel.constraints) - sub_models_string += f'\n * {name} [{type_name}] ({var_count}v/{con_count}c)' + result += f' * {name} [{type_name}] ({var_count}v/{con_count}c)\n' - return f'{title}\n{underline}{sub_models_string}\n' + return result def items(self) -> ItemsView[str, Submodel]: return self.data.items() From 6705011cd921ec26b229ccc63fc6b6e5c7be52c7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:08:53 +0100 Subject: [PATCH 69/86] Remove str from Interface class --- flixopt/structure.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 3c59c11fd..6334c3ad7 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -797,18 +797,6 @@ def __repr__(self): """Return a detailed string representation for debugging.""" return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'}) - def __str__(self): - """Return a user-friendly string representation.""" - try: - data = self.get_structure(clean=True, stats=True) - with StringIO() as output_buffer: - console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(data, expand_all=True, indent_guides=True)) - return output_buffer.getvalue() - except Exception: - # Fallback if structure generation fails - return f'{self.__class__.__name__} instance' - def copy(self) -> Interface: """ Create a copy of the Interface object. From f1d9677f449f444b0b6f053ed89c9a36ef4833b9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:37:10 +0100 Subject: [PATCH 70/86] Improve FlowSystem repr --- flixopt/flow_system.py | 2 +- flixopt/structure.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ec67b73f1..d61f55061 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -663,7 +663,7 @@ def _connect_network(self): def __repr__(self) -> str: """Return a detailed string representation showing all containers.""" - r = fx_io.format_title_with_underline('FlowSystem') + r = fx_io.format_title_with_underline('FlowSystem', '=') # Timestep info time_period = f'{self.timesteps[0].date()} to {self.timesteps[-1].date()}' diff --git a/flixopt/structure.py b/flixopt/structure.py index 6334c3ad7..7334dcfa5 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1163,12 +1163,13 @@ def items(self) -> list[tuple[str, T_element]]: items.extend(container.items()) return items - def _format_grouped_containers(self, title: str | None = None) -> str: + def _format_grouped_containers(self, title: str | None = None, group_underline_char: str = '-') -> str: """ Format containers as grouped string representation. Args: title: Optional title for the representation. If None, no title is shown. + group_underline_char: Character to use for underlining group headers Returns: Formatted string with groups and their elements. @@ -1177,12 +1178,14 @@ def _format_grouped_containers(self, title: str | None = None) -> str: Example output: ``` Components: + ----------- * Boiler * CHP Buses: - * Heat - * Power + ------ + * Bus("Heat", ...) + * Bus("Power", ...) ``` """ lines = [] @@ -1203,6 +1206,7 @@ def _format_grouped_containers(self, title: str | None = None) -> str: lines.append('') lines.append(f'{group_name}:') + lines.append(group_underline_char * len(group_name + ':')) for name in sorted(container.keys(), key=_natural_sort_key): lines.append(f' * {name}') From 1a16e3a7b49e28d71a318c363147651abffae532 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:50:23 +0100 Subject: [PATCH 71/86] Improve Reprs of Containers --- flixopt/flow_system.py | 10 ++++----- flixopt/results.py | 6 +++++- flixopt/structure.py | 46 ++++++++++++++++-------------------------- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index d61f55061..12678f55c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -715,13 +715,13 @@ def __eq__(self, other: FlowSystem): return True - def _get_container_groups(self) -> dict[str, dict]: + def _get_container_groups(self) -> dict[str, ElementContainer]: """Return ordered container groups for CompositeContainerMixin.""" return { - 'Components': dict(self.components), - 'Buses': dict(self.buses), - 'Effects': dict(self.effects), - 'Flows': dict(self.flows), + 'Components': self.components, + 'Buses': self.buses, + 'Effects': self.effects, + 'Flows': self.flows, } @property diff --git a/flixopt/results.py b/flixopt/results.py index f32e45b7e..a26cbe4ad 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -295,7 +295,11 @@ def _get_container_groups(self) -> dict[str, dict]: def __repr__(self) -> str: """Return grouped representation of all results.""" - return self._format_grouped_containers('Calculation Results') + r = fx_io.format_title_with_underline(self.__class__.__name__, '=') + r += f'Name: "{self.name}"\nFolder: {self.folder}\n' + # Add grouped container view + r += '\n' + self._format_grouped_containers() + return r @property def storages(self) -> list[ComponentResults]: diff --git a/flixopt/structure.py b/flixopt/structure.py index 7334dcfa5..5a7b85b32 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1075,12 +1075,12 @@ def _get_container_groups(self): interface while preserving their individual functionality. """ - def _get_container_groups(self) -> dict[str, dict]: + def _get_container_groups(self) -> dict[str, Any]: """ Return ordered dict of container groups to aggregate. Returns: - Dictionary mapping group names to container dicts. + Dictionary mapping group names to container objects (e.g., ElementContainer, ResultsContainer). Group names should be capitalized (e.g., 'Components', 'Buses'). Order determines display order in __repr__. @@ -1163,13 +1163,12 @@ def items(self) -> list[tuple[str, T_element]]: items.extend(container.items()) return items - def _format_grouped_containers(self, title: str | None = None, group_underline_char: str = '-') -> str: + def _format_grouped_containers(self, title: str | None = None) -> str: """ - Format containers as grouped string representation. + Format containers as grouped string representation using each container's repr. Args: title: Optional title for the representation. If None, no title is shown. - group_underline_char: Character to use for underlining group headers Returns: Formatted string with groups and their elements. @@ -1177,40 +1176,29 @@ def _format_grouped_containers(self, title: str | None = None, group_underline_c Example output: ``` - Components: - ----------- + Components (1 item) + ------------------- * Boiler - * CHP - Buses: - ------ - * Bus("Heat", ...) - * Bus("Power", ...) + Buses (2 items) + --------------- + * Heat + * Power ``` """ - lines = [] + parts = [] if title: - lines.append(title) - lines.append('-' * len(title)) + parts.append(fx_io.format_title_with_underline(title)) container_groups = self._get_container_groups() - for group_name, container in container_groups.items(): + for container in container_groups.values(): if container: # Only show non-empty groups - if lines and not title: # Add spacing between groups (but not before first) - lines.append('') - elif title and group_name == list(container_groups.keys())[0]: - # No spacing before first group when there's a title - pass - else: - lines.append('') - - lines.append(f'{group_name}:') - lines.append(group_underline_char * len(group_name + ':')) - for name in sorted(container.keys(), key=_natural_sort_key): - lines.append(f' * {name}') + if parts: # Add spacing between sections + parts.append('') + parts.append(repr(container).rstrip('\n')) - return '\n'.join(lines) + return '\n'.join(parts) class Submodel(SubmodelsMixin): From 5b65505b449e70fd5490a6cda36dccd597e3eef4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:09:14 +0100 Subject: [PATCH 72/86] Numeric formatter handles NaN/Inf/empty inputs --- flixopt/io.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index fe5750dc3..a8075ae18 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -594,17 +594,27 @@ def numeric_to_str_for_repr( except (TypeError, ValueError) as e: raise TypeError(f'Cannot format value of type {type(value).__name__} for repr') from e + # Normalize dtype and handle empties + arr = arr.astype(float, copy=False) + if arr.size == 0: + return '?' + + # Filter non-finite values + finite = arr[np.isfinite(arr)] + if finite.size == 0: + return 'nan' + # Check for single value - if arr.size == 1: - return f'{float(arr[0]):.{precision}f}' + if finite.size == 1: + return f'{float(finite[0]):.{precision}f}' # Check if all values are the same or very close - min_val = float(np.min(arr)) - max_val = float(np.max(arr)) + min_val = float(np.nanmin(finite)) + max_val = float(np.nanmax(finite)) # First check: values are essentially identical if np.allclose(min_val, max_val, atol=atol): - return f'{float(np.mean(arr)):.{precision}f}' + return f'{float(np.mean(finite)):.{precision}f}' # Second check: display values are the same but actual values differ slightly min_str = f'{min_val:.{precision}f}' From ca3415915c7d809d5db98a5bcbb9413a0917cffe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:10:00 +0100 Subject: [PATCH 73/86] Effect repr shows constraints for both min and max bounds --- flixopt/effects.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index a574e6399..4f87f15b2 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -392,22 +392,22 @@ def __repr__(self) -> str: parts = [] # Unit - if hasattr(self, 'unit') and self.unit: + if self.unit: parts.append(f'({self.unit})') # Objective - if hasattr(self, 'is_objective') and self.is_objective: + if self.is_objective: parts.append('objective') - # Constraint types + # Constraint types (flag if either min or max bound is set) constraint_types = [] - if hasattr(self, 'maximum_per_hour') and self.maximum_per_hour is not None: + if any([self.minimum_per_hour is not None, self.minimum_per_hour is not None]): constraint_types.append('per_hour') - if hasattr(self, 'maximum_temporal') and self.maximum_temporal is not None: + if any([self.minimum_temporal is not None, self.maximum_temporal is not None]): constraint_types.append('temporal') - if hasattr(self, 'maximum_periodic') and self.maximum_periodic is not None: + if any([self.minimum_periodic is not None, self.maximum_periodic is not None]): constraint_types.append('periodic') - if hasattr(self, 'maximum_total') and self.maximum_total is not None: + if any([self.minimum_total is not None, self.maximum_total is not None]): constraint_types.append('total') if constraint_types: From 60df4c5ca28b7ebc26f6db3fc687858f63a6c08b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:24:57 +0100 Subject: [PATCH 74/86] Improve repr code --- flixopt/components.py | 6 ++---- flixopt/effects.py | 38 ++++++-------------------------------- flixopt/elements.py | 27 ++++----------------------- flixopt/io.py | 9 ++------- flixopt/results.py | 39 ++++----------------------------------- flixopt/structure.py | 12 +----------- 6 files changed, 19 insertions(+), 112 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 4304fea2d..8f89378ae 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -532,13 +532,11 @@ def _plausibility_checks(self) -> None: def __repr__(self) -> str: """Return string representation.""" # Use build_repr_from_init directly to exclude charging and discharging - result = fx_io.build_repr_from_init( + return fx_io.build_repr_from_init( self, excluded_params={'self', 'label', 'charging', 'discharging', 'kwargs'}, skip_default_size=True, - ) - result += fx_io.format_flow_details(self) - return result + ) + fx_io.format_flow_details(self) @register_class_for_io diff --git a/flixopt/effects.py b/flixopt/effects.py index 4f87f15b2..067d5ac5b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -387,35 +387,6 @@ def _plausibility_checks(self) -> None: # TODO: Check for plausibility pass - def __repr__(self) -> str: - """Return string representation with effect properties.""" - parts = [] - - # Unit - if self.unit: - parts.append(f'({self.unit})') - - # Objective - if self.is_objective: - parts.append('objective') - - # Constraint types (flag if either min or max bound is set) - constraint_types = [] - if any([self.minimum_per_hour is not None, self.minimum_per_hour is not None]): - constraint_types.append('per_hour') - if any([self.minimum_temporal is not None, self.maximum_temporal is not None]): - constraint_types.append('temporal') - if any([self.minimum_periodic is not None, self.maximum_periodic is not None]): - constraint_types.append('periodic') - if any([self.minimum_total is not None, self.maximum_total is not None]): - constraint_types.append('total') - - if constraint_types: - parts.append('constraints: ' + '+'.join(constraint_types)) - - info = fx_io.build_metadata_info(parts) - return self._format_repr(info) - class EffectModel(ElementModel): element: Effect # Type hint @@ -550,10 +521,13 @@ def _plausibility_checks(self) -> None: # Check circular loops in effects: temporal, periodic = self.calculate_effect_share_factors() - # Validate all referenced sources exist - unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self} + # Validate all referenced effects (both sources and targets) exist + edges = list(temporal.keys()) + list(periodic.keys()) + unknown_sources = {src for src, _ in edges if src not in self} + unknown_targets = {tgt for _, tgt in edges if tgt not in self} + unknown = unknown_sources | unknown_targets if unknown: - raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}') + raise KeyError(f'Unknown effects used in effect share mappings: {sorted(unknown)}') temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal])) periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic])) diff --git a/flixopt/elements.py b/flixopt/elements.py index b44147617..799ccc24d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -156,21 +156,9 @@ def _connect_flows(self): def __repr__(self) -> str: """Return string representation with flow information.""" - # Build metadata indicators (not flow count, since flows are shown below) - parts = [] - if self.on_off_parameters is not None: - parts.append('on_off') - if self.prevent_simultaneous_flows: - parts.append(f'mutual_excl:{len(self.prevent_simultaneous_flows)}') - - info = fx_io.build_metadata_info(parts) - - # Build first line (excluding inputs/outputs which are shown below) - result = fx_io.build_repr_from_init( - self, excluded_params={'self', 'label', 'inputs', 'outputs', 'kwargs'}, info=info, skip_default_size=True - ) - result += fx_io.format_flow_details(self) - return result + return fx_io.build_repr_from_init( + self, excluded_params={'self', 'label', 'inputs', 'outputs', 'kwargs'}, skip_default_size=True + ) + fx_io.format_flow_details(self) @register_class_for_io @@ -275,9 +263,7 @@ def with_excess(self) -> bool: def __repr__(self) -> str: """Return string representation.""" - result = self._format_repr() - result += fx_io.format_flow_details(self) - return result + return super().__repr__() + fx_io.format_flow_details(self) @register_class_for_io @@ -556,11 +542,6 @@ def size_is_fixed(self) -> bool: # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True - def __repr__(self) -> str: - """Return string representation.""" - # No need for info comment since bus and size are already in constructor params - return self._format_repr() - def _format_size(self) -> str | None: """Format size for display. Returns None if size is default CONFIG.big.""" import numpy as np diff --git a/flixopt/io.py b/flixopt/io.py index a8075ae18..3bf61c37b 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -629,7 +629,6 @@ def numeric_to_str_for_repr( def build_repr_from_init( obj: object, excluded_params: set[str] | None = None, - info: str = '', label_as_positional: bool = True, skip_default_size: bool = False, ) -> str: @@ -643,12 +642,11 @@ def build_repr_from_init( obj: The object to create repr for excluded_params: Set of parameter names to exclude (e.g., {'self', 'inputs', 'outputs'}) Default excludes 'self', 'label', and 'kwargs' - info: Optional comment to append (e.g., '2 flows (1 in, 1 out)') label_as_positional: If True and 'label' param exists, show it as first positional arg skip_default_size: If True, skip 'size' parameter when it equals CONFIG.Modeling.big Returns: - Formatted repr string like: ClassName("label", param=value) # info + Formatted repr string like: ClassName("label", param=value) """ if excluded_params is None: excluded_params = {'self', 'label', 'kwargs'} @@ -793,10 +791,7 @@ def build_repr_from_init( # Build final repr class_name = obj.__class__.__name__ - if info: - # Remove leading ' | ' if present (from old format) and format as comment - info_clean = info.lstrip(' |').strip() - return f'{class_name}({args_str}) # {info_clean}' + return f'{class_name}({args_str})' except Exception: diff --git a/flixopt/results.py b/flixopt/results.py index a26cbe4ad..8902f43ac 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1126,21 +1126,13 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self._calculation_results.model.constraints[self._constraint_names] - def _format_repr(self, info: str = '') -> str: - """Format repr with class name, label, optional info, and dataset. - - Args: - info: Optional additional information to append to header (e.g., ' | 3 inputs') - """ + def __repr__(self) -> str: + """Return string representation with element info and dataset preview.""" class_name = self.__class__.__name__ - header = f'{class_name}: "{self.label}"{info}' + header = f'{class_name}: "{self.label}"' sol = self.solution sol.attrs = {} - return f'{header}\n{repr(sol)}' - - def __repr__(self) -> str: - """Return string representation with element info and dataset preview.""" - return self._format_repr() + return f'{header}\n{"-" * len(header)}\n{repr(sol)}' def filter_solution( self, @@ -1633,11 +1625,6 @@ def node_balance( return ds - def __repr__(self) -> str: - """Return string representation with node information.""" - info = f' | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' - return self._format_repr(info) - class BusResults(_NodeResults): """Results container for energy/material balance nodes in the system.""" @@ -1915,12 +1902,6 @@ def node_balance_with_charge_state( ), ) - def __repr__(self) -> str: - """Return string representation with storage indication.""" - storage_tag = ' (Storage)' if self.is_storage else '' - info = f'{storage_tag} | {len(self.flows)} flows ({len(self.inputs)} in, {len(self.outputs)} out)' - return self._format_repr(info) - class EffectResults(_ElementResults): """Results for an Effect""" @@ -1936,13 +1917,6 @@ def get_shares_from(self, element: str) -> xr.Dataset: """ return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] - def __repr__(self) -> str: - """Return string representation with contribution information.""" - # Extract contributing elements from variable names (format: "element->effect_label") - contributing_elements = {var_name.split('->')[0] for var_name in self._variable_names if '->' in var_name} - info = f' | {len(contributing_elements)} contributors' - return self._format_repr(info) - class FlowResults(_ElementResults): def __init__( @@ -1979,11 +1953,6 @@ def size(self) -> xr.DataArray: logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN') return xr.DataArray(np.nan).rename(name) - def __repr__(self) -> str: - """Return string representation with flow connection details.""" - info = f' | {self.start} → {self.end}' - return self._format_repr(info) - class SegmentedCalculationResults: """Results container for segmented optimization calculations with temporal decomposition. diff --git a/flixopt/structure.py b/flixopt/structure.py index 5a7b85b32..820e9a5fe 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -845,19 +845,9 @@ def create_model(self, model: FlowSystemModel) -> ElementModel: def label_full(self) -> str: return self.label - def _format_repr(self, info: str = '') -> str: - """Format repr with class name, label, and optional info. - - Args: - info: Optional additional information (e.g., ' | 2 flows') - """ - return fx_io.build_repr_from_init( - self, excluded_params={'self', 'label', 'kwargs'}, info=info, skip_default_size=True - ) - def __repr__(self) -> str: """Return string representation.""" - return self._format_repr() + return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'}, skip_default_size=True) @staticmethod def _valid_label(label: str) -> str: From 7de0a88c208f7127e3583df961bb9970e5d3c2fb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:43:27 +0100 Subject: [PATCH 75/86] Improve detection of defaults --- flixopt/io.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flixopt/io.py b/flixopt/io.py index 3bf61c37b..40a4129c4 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -702,6 +702,14 @@ def build_repr_from_init( continue elif np.array_equal(value, param.default): continue + elif isinstance(param.default, (int, float, np.integer, np.floating)): + # Compare array to scalar (e.g., after transform_data converts scalar to DataArray) + if isinstance(value, xr.DataArray): + if np.all(value.values == float(param.default)): + continue + elif isinstance(value, np.ndarray): + if np.all(value == float(param.default)): + continue except Exception: pass # If comparison fails, include in repr From 2b8622c686a23666b129b91b5ab6f039cb736e6e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:08:08 +0100 Subject: [PATCH 76/86] Removed the unused _format_size method (dead code with no call sites --- flixopt/elements.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 799ccc24d..2a9a2cf4f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -542,26 +542,6 @@ def size_is_fixed(self) -> bool: # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True - def _format_size(self) -> str | None: - """Format size for display. Returns None if size is default CONFIG.big.""" - import numpy as np - - from .config import CONFIG - from .io import numeric_to_str_for_repr - - try: - # Hide default CONFIG.big size - if not isinstance(self.size, InvestParameters): - if isinstance(self.size, (int, float, np.integer, np.floating)): - if float(self.size) == CONFIG.Modeling.big: - return None - - if isinstance(self.size, InvestParameters): - return self._format_invest_params(self.size) - return f'size: {numeric_to_str_for_repr(self.size)}' - except Exception: - return '?' - def _format_invest_params(self, params: InvestParameters) -> str: """Format InvestParameters for display.""" return f'size: {params.format_for_repr()}' From f1150174b23923c54047185c0c25809e2a37f61e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:08:24 +0100 Subject: [PATCH 77/86] Simplified flows container construction by passing the list directly instead of converting to dict first --- flixopt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 12678f55c..9c910fa79 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -730,7 +730,7 @@ def flows(self) -> ElementContainer[Flow]: flows = [f for c in self.components.values() for f in c.inputs + c.outputs] # Deduplicate by id and sort for reproducibility flows = sorted({id(f): f for f in flows}.values(), key=lambda f: f.label_full.lower()) - self._flows_cache = ElementContainer({f.label_full: f for f in flows}, element_type_name='flows') + self._flows_cache = ElementContainer(flows, element_type_name='flows') return self._flows_cache @property From 2a7ca74e0e6df523f7ab03dc907f0d5464b41788 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:08:38 +0100 Subject: [PATCH 78/86] Moved ResultsContainer import to module level for consistency --- flixopt/results.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 8902f43ac..eee4de3e9 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -17,7 +17,7 @@ from .color_processing import process_colors from .config import CONFIG from .flow_system import FlowSystem -from .structure import CompositeContainerMixin +from .structure import CompositeContainerMixin, ResultsContainer if TYPE_CHECKING: import matplotlib.pyplot as plt @@ -241,8 +241,6 @@ def __init__( self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' # Create ResultsContainers for better access patterns - from .structure import ResultsContainer - components_dict = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() } From cfb9f9293749348e40476dba2a5c8b7eba1fc60f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:14:40 +0100 Subject: [PATCH 79/86] prevents mutation of the original self.solution.attrs --- flixopt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index eee4de3e9..415892fe0 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1128,7 +1128,7 @@ def __repr__(self) -> str: """Return string representation with element info and dataset preview.""" class_name = self.__class__.__name__ header = f'{class_name}: "{self.label}"' - sol = self.solution + sol = self.solution.copy(deep=False) sol.attrs = {} return f'{header}\n{"-" * len(header)}\n{repr(sol)}' From 5bf9ad6459d49a13e2e92439481501e12b2d6396 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:16:18 +0100 Subject: [PATCH 80/86] Fix return type annotation to match base class. --- flixopt/results.py | 4 ++-- flixopt/structure.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 415892fe0..5268bb179 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -17,7 +17,7 @@ from .color_processing import process_colors from .config import CONFIG from .flow_system import FlowSystem -from .structure import CompositeContainerMixin, ResultsContainer +from .structure import CompositeContainerMixin, ElementContainer, ResultsContainer if TYPE_CHECKING: import matplotlib.pyplot as plt @@ -282,7 +282,7 @@ def __init__( self.colors: dict[str, str] = {} - def _get_container_groups(self) -> dict[str, dict]: + def _get_container_groups(self) -> dict[str, ElementContainer]: """Return ordered container groups for CompositeContainerMixin.""" return { 'Components': self.components, diff --git a/flixopt/structure.py b/flixopt/structure.py index 820e9a5fe..2cd46eac5 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1065,7 +1065,7 @@ def _get_container_groups(self): interface while preserving their individual functionality. """ - def _get_container_groups(self) -> dict[str, Any]: + def _get_container_groups(self) -> dict[str, ElementContainer]: """ Return ordered dict of container groups to aggregate. From e78a55e2dffc785d22c8ce0351b7e41f54ecb35e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:16:47 +0100 Subject: [PATCH 81/86] Extract _format_value_for_repr() as dedicated method --- flixopt/io.py | 91 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index 40a4129c4..8df03401c 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -626,6 +626,57 @@ def numeric_to_str_for_repr( return f'{min_str}-{max_str}' +def _format_value_for_repr(value) -> str: + """Format a single value for display in repr. + + Args: + value: The value to format + + Returns: + Formatted string representation of the value + """ + # Format numeric types using specialized formatter + if isinstance(value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray)): + try: + return numeric_to_str_for_repr(value) + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + return value_repr + + # Format dicts with numeric/array values nicely + elif isinstance(value, dict): + try: + formatted_items = [] + for k, v in value.items(): + if isinstance( + v, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) + ): + v_str = numeric_to_str_for_repr(v) + else: + v_str = repr(v) + if len(v_str) > 30: + v_str = v_str[:27] + '...' + formatted_items.append(f'{repr(k)}: {v_str}') + value_repr = '{' + ', '.join(formatted_items) + '}' + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + return value_repr + except Exception: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + return value_repr + + # Default repr with truncation + else: + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + return value_repr + + def build_repr_from_init( obj: object, excluded_params: set[str] | None = None, @@ -741,44 +792,8 @@ def build_repr_from_init( except Exception: pass - # Format value - use numeric formatter for numbers - if isinstance( - value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) - ): - try: - value_repr = numeric_to_str_for_repr(value) - except Exception: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - - elif isinstance(value, dict): - # Format dicts with numeric/array values nicely - try: - formatted_items = [] - for k, v in value.items(): - if isinstance( - v, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray) - ): - v_str = numeric_to_str_for_repr(v) - else: - v_str = repr(v) - if len(v_str) > 30: - v_str = v_str[:27] + '...' - formatted_items.append(f'{repr(k)}: {v_str}') - value_repr = '{' + ', '.join(formatted_items) + '}' - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - except Exception: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - - else: - value_repr = repr(value) - if len(value_repr) > 50: - value_repr = value_repr[:47] + '...' - + # Format value using helper function + value_repr = _format_value_for_repr(value) kwargs_parts.append(f'{param_name}={value_repr}') # Build args string with label first as positional if present From e4c20d84b30a7ebae762bd149eb4a68dd8e565fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:35:49 +0100 Subject: [PATCH 82/86] FIx type hint --- flixopt/results.py | 2 +- flixopt/structure.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 5268bb179..9232f0175 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -282,7 +282,7 @@ def __init__( self.colors: dict[str, str] = {} - def _get_container_groups(self) -> dict[str, ElementContainer]: + def _get_container_groups(self) -> dict[str, ResultsContainer]: """Return ordered container groups for CompositeContainerMixin.""" return { 'Components': self.components, diff --git a/flixopt/structure.py b/flixopt/structure.py index 2cd46eac5..065769cd2 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -1065,7 +1065,7 @@ def _get_container_groups(self): interface while preserving their individual functionality. """ - def _get_container_groups(self) -> dict[str, ElementContainer]: + def _get_container_groups(self) -> dict[str, ContainerMixin[Any]]: """ Return ordered dict of container groups to aggregate. From 5f9d589f0cfc399e430735f8d5c692c9886afca4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:34:29 +0100 Subject: [PATCH 83/86] Improve Error message --- flixopt/effects.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 067d5ac5b..757549223 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -558,8 +558,9 @@ def __getitem__(self, effect: str | Effect | None) -> Effect: try: return super().__getitem__(effect) # Leverage ContainerMixin suggestions except KeyError as e: - # Append context without discarding original message - raise KeyError(f'{e} Add the effect to the FlowSystem first.') from None + # Extract the original message and append context for cleaner output + original_msg = str(e).strip('\'"') + raise KeyError(f'{original_msg} Add the effect to the FlowSystem first.') from None def __iter__(self) -> Iterator[str]: return iter(self.keys()) # Iterate over keys like a normal dict From 9507cd0c5d3966ba79017e083b353a55f3fd5aff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:38:18 +0100 Subject: [PATCH 84/86] Typo in docs --- docs/user-guide/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index df97bf768..7c631bf4b 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -19,11 +19,11 @@ Every FlixOpt model starts with creating a FlowSystem. It: [`Flow`][flixopt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. -- Have a `size` which, generally speaking, defines how fast energy or material can be moved. Usually measured in MW, kW, m³/h, etc. -- Have a `flow_rate`, which is defines how fast energy or material is transported. Usually measured in MW, kW, m³/h, etc. +- Have a `size` which, generally speaking, defines how much energy or material can be moved. Usually measured in MW, kW, m³/h, etc. +- Have a `flow_rate`, which defines how fast energy or material is transported. Usually measured in MW, kW, m³/h, etc. - Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) - Can have fixed profiles (for demands or renewable generation) -- Can have [Effects](#effects) associated by their use (operation, investment, on/off, ...) +- Can have [Effects](#effects) associated by their use (costs, emissions, labour, ...) #### Flow Hours While the **Flow Rate** defines the rate in which energy or material is transported, the **Flow Hours** define the amount of energy or material that is transported. From e93693b90b8a0ff1b1a1338f9c646993d3e24614 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:42:21 +0100 Subject: [PATCH 85/86] Fix indents in docs and CHANGELOG.md --- CHANGELOG.md | 62 ++++++++++++++++++++-------------------- docs/user-guide/index.md | 24 ++++++++-------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eee8d2682..e129e434c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,21 +100,21 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp **Color management:** - **`setup_colors()` method** for `CalculationResults` and `SegmentedCalculationResults` to configure consistent colors across all plots - - Group components by colorscales: `results.setup_colors({'CHP': 'reds', 'Storage': 'blues', 'Greys': ['Grid', 'Demand']})` - - Automatically propagates to all segments in segmented calculations - - Colors persist across all plot calls unless explicitly overridden + - Group components by colorscales: `results.setup_colors({'CHP': 'reds', 'Storage': 'blues', 'Greys': ['Grid', 'Demand']})` + - Automatically propagates to all segments in segmented calculations + - Colors persist across all plot calls unless explicitly overridden - **Flexible color inputs**: Supports colorscale names (e.g., 'turbo', 'plasma'), color lists, or label-to-color dictionaries - **Cross-backend compatibility**: Seamless color handling for both Plotly and Matplotlib **Plotting customization:** - **Plotting kwargs support**: Pass additional arguments to plotting backends via `px_kwargs`, `plot_kwargs`, and `backend_kwargs` parameters - **New `CONFIG.Plotting` configuration section**: - - `default_show`: Control default plot visibility - - `default_engine`: Choose 'plotly' or 'matplotlib' - - `default_dpi`: Set resolution for saved plots - - `default_facet_cols`: Configure default faceting columns - - `default_sequential_colorscale`: Default for heatmaps (now 'turbo') - - `default_qualitative_colorscale`: Default for categorical plots (now 'plotly') + - `default_show`: Control default plot visibility + - `default_engine`: Choose 'plotly' or 'matplotlib' + - `default_dpi`: Set resolution for saved plots + - `default_facet_cols`: Configure default faceting columns + - `default_sequential_colorscale`: Default for heatmaps (now 'turbo') + - `default_qualitative_colorscale`: Default for categorical plots (now 'plotly') **I/O improvements:** - Centralized JSON/YAML I/O with auto-format detection @@ -281,12 +281,12 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir **API and Behavior Changes:** - **Effect system redesigned** (no deprecation): - - **Terminology changes**: Effect domains renamed for clarity: `operation` → `temporal`, `invest`/`investment` → `periodic` - - **Sharing system**: The old `specific_share_to_other_effects_*` parameters were completely replaced with the new `share_from_temporal` and `share_from_periodic` syntax (see 🔥 Removed section) + - **Terminology changes**: Effect domains renamed for clarity: `operation` → `temporal`, `invest`/`investment` → `periodic` + - **Sharing system**: The old `specific_share_to_other_effects_*` parameters were completely replaced with the new `share_from_temporal` and `share_from_periodic` syntax (see 🔥 Removed section) - **FlowSystem independence**: FlowSystems cannot be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent. Each Subcalculation in `SegmentedCalculation` now has its own distinct `FlowSystem` object - **Bus and Effect object assignment**: Direct assignment of Bus/Effect objects is no longer supported. Use labels (strings) instead: - - `Flow.bus` must receive a string label, not a Bus object - - Effect shares must use effect labels (strings) in dictionaries, not Effect objects + - `Flow.bus` must receive a string label, not a Bus object + - Effect shares must use effect labels (strings) in dictionaries, not Effect objects - **Logging defaults** (from v2.2.0): Console and file logging are now disabled by default. Enable explicitly with `CONFIG.Logging.console = True` and `CONFIG.apply()` **Class and Method Renaming:** @@ -300,14 +300,14 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir - Investment binary variable: `is_invested` → `invested` in `InvestmentModel` - Switch tracking variables in `OnOffModel`: - - `switch_on` → `switch|on` - - `switch_off` → `switch|off` - - `switch_on_nr` → `switch|count` + - `switch_on` → `switch|on` + - `switch_off` → `switch|off` + - `switch_on_nr` → `switch|count` - Effect submodel variables (following terminology changes): - - `Effect(invest)|total` → `Effect(periodic)` - - `Effect(operation)|total` → `Effect(temporal)` - - `Effect(operation)|total_per_timestep` → `Effect(temporal)|per_timestep` - - `Effect|total` → `Effect` + - `Effect(invest)|total` → `Effect(periodic)` + - `Effect(operation)|total` → `Effect(temporal)` + - `Effect(operation)|total_per_timestep` → `Effect(temporal)|per_timestep` + - `Effect|total` → `Effect` **Data Structure Changes:** @@ -528,7 +528,7 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir ### ✨ Added - **Network Visualization**: Added `FlowSystem.start_network_app()` and `FlowSystem.stop_network_app()` to easily visualize the network structure of a flow system in an interactive Dash web app - - *Note: This is still experimental and might change in the future* + - *Note: This is still experimental and might change in the future* ### ♻️ Changed - **Multi-Flow Support**: `Sink`, `Source`, and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables modeling more use cases with these classes @@ -570,8 +570,8 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir ### 🐛 Fixed - Storage losses per hour were not calculated correctly, as mentioned by @brokenwings01. This might have led to issues when modeling large losses and long timesteps. - - Old implementation: $c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i)) \cdot \Delta \text{t}_{i}$ - - Correct implementation: $c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i)) ^{\Delta \text{t}_{i}}$ + - Old implementation: $c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i)) \cdot \Delta \text{t}_{i}$ + - Correct implementation: $c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i)) ^{\Delta \text{t}_{i}}$ ### 🚧 Known Issues - Just to mention: Plotly >= 6 may raise errors if "nbformat" is not installed. We pinned plotly to <6, but this may be fixed in the future. @@ -596,10 +596,10 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir ### 💥 Breaking Changes - Restructured the modeling of the On/Off state of Flows or Components - - Variable renaming: `...|consecutive_on_hours` → `...|ConsecutiveOn|hours` - - Variable renaming: `...|consecutive_off_hours` → `...|ConsecutiveOff|hours` - - Constraint renaming: `...|consecutive_on_hours_con1` → `...|ConsecutiveOn|con1` - - Similar pattern for all consecutive on/off constraints + - Variable renaming: `...|consecutive_on_hours` → `...|ConsecutiveOn|hours` + - Variable renaming: `...|consecutive_off_hours` → `...|ConsecutiveOff|hours` + - Constraint renaming: `...|consecutive_on_hours_con1` → `...|ConsecutiveOn|con1` + - Similar pattern for all consecutive on/off constraints ### 🐛 Fixed - Fixed the lower bound of `flow_rate` when using optional investments without OnOffParameters @@ -645,10 +645,10 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir **Variable Structure:** - Restructured the modeling of the On/Off state of Flows or Components - - Variable renaming: `...|consecutive_on_hours` → `...|ConsecutiveOn|hours` - - Variable renaming: `...|consecutive_off_hours` → `...|ConsecutiveOff|hours` - - Constraint renaming: `...|consecutive_on_hours_con1` → `...|ConsecutiveOn|con1` - - Similar pattern for all consecutive on/off constraints + - Variable renaming: `...|consecutive_on_hours` → `...|ConsecutiveOn|hours` + - Variable renaming: `...|consecutive_off_hours` → `...|ConsecutiveOff|hours` + - Constraint renaming: `...|consecutive_on_hours_con1` → `...|ConsecutiveOn|con1` + - Similar pattern for all consecutive on/off constraints ### 🔥 Removed - **Pyomo dependency** (replaced by linopy) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 7c631bf4b..30ad15c89 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -50,21 +50,21 @@ Examples: [`Component`][flixopt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixopt.elements.Flow]. The generic component types work across all domains: - [`LinearConverters`][flixopt.components.LinearConverter] - Converts input flows to output flows with (piecewise) linear relationships - - *Energy: boilers, heat pumps, turbines* - - *Manufacturing: assembly lines, processing equipment* - - *Chemistry: reactors, separators* + - *Energy: boilers, heat pumps, turbines* + - *Manufacturing: assembly lines, processing equipment* + - *Chemistry: reactors, separators* - [`Storages`][flixopt.components.Storage] - Stores energy or material over time - - *Energy: batteries, thermal storage, gas storage* - - *Logistics: warehouses, buffer inventory* - - *Water: reservoirs, tanks* + - *Energy: batteries, thermal storage, gas storage* + - *Logistics: warehouses, buffer inventory* + - *Water: reservoirs, tanks* - [`Sources`][flixopt.components.Source] / [`Sinks`][flixopt.components.Sink] / [`SourceAndSinks`][flixopt.components.SourceAndSink] - Produce or consume flows - - *Energy: demands, renewable generation* - - *Manufacturing: raw material supply, product demand* - - *Supply chain: suppliers, customers* + - *Energy: demands, renewable generation* + - *Manufacturing: raw material supply, product demand* + - *Supply chain: suppliers, customers* - [`Transmissions`][flixopt.components.Transmission] - Moves flows between locations with possible losses - - *Energy: pipelines, power lines* - - *Logistics: transport routes* - - *Water: distribution networks* + - *Energy: pipelines, power lines* + - *Logistics: transport routes* + - *Water: distribution networks* **Pre-built specialized components** for energy systems include [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. These can serve as blueprints for custom domain-specific components. From dff3534938a98827705e75ff65772cbc5d392bb2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:48:44 +0100 Subject: [PATCH 86/86] Improve documentation of the FLowSystem access --- docs/user-guide/index.md | 9 ++++- flixopt/flow_system.py | 72 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 30ad15c89..b20d15263 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -15,6 +15,13 @@ Every FlixOpt model starts with creating a FlowSystem. It: - Contains and connects [components](#components), [buses](#buses), and [flows](#flows) - Manages the [effects](#effects) (objectives and constraints) +FlowSystem provides two ways to access elements: + +- **Dict-like interface**: Access any element by label: `flow_system['Boiler']`, `'Boiler' in flow_system`, `flow_system.keys()` +- **Direct containers**: Access type-specific containers: `flow_system.components`, `flow_system.buses`, `flow_system.effects`, `flow_system.flows` + +Element labels must be unique across all types. See the [`FlowSystem` API reference][flixopt.flow_system.FlowSystem] for detailed examples and usage patterns. + ### Flows [`Flow`][flixopt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. @@ -105,7 +112,7 @@ FlixOpt offers different calculation modes: The results of a calculation are stored in a [`CalculationResults`][flixopt.results.CalculationResults] object. This object contains the solutions of the optimization as well as all information about the [`Calculation`][flixopt.calculation.Calculation] and the [`FlowSystem`][flixopt.flow_system.FlowSystem] it was created from. -The solutions is stored as an `xarray.Dataset`, but can be accessed through their assotiated Component, Bus or Effect. +The solution is stored as an `xarray.Dataset`, but can be accessed through their assotiated Component, Bus or Effect. This [`CalculationResults`][flixopt.results.CalculationResults] object can be saved to file and reloaded from file, allowing you to analyze the results anytime after the solve. diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9c910fa79..26364c4b4 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -47,9 +47,11 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): """ - A FlowSystem organizes the high level Elements (Components, Buses & Effects). + A FlowSystem organizes the high level Elements (Components, Buses, Effects & Flows). - This is the main container class that users work with to build and manage their System. + This is the main container class that users work with to build and manage their energy or material flow system. + FlowSystem provides both direct container access (via .components, .buses, .effects, .flows) and a unified + dict-like interface for accessing any element by label across all container types. Args: timesteps: The timesteps of the model. @@ -71,10 +73,74 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): - False: All flow rates are optimized separately per scenario - list[str]: Only specified flows (by label_full) are equalized across scenarios + Examples: + Creating a FlowSystem and accessing elements: + + >>> import flixopt as fx + >>> import pandas as pd + >>> timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + >>> flow_system = fx.FlowSystem(timesteps) + >>> + >>> # Add elements to the system + >>> boiler = fx.Component('Boiler', inputs=[heat_flow], on_off_parameters=...) + >>> heat_bus = fx.Bus('Heat', excess_penalty_per_flow_hour=1e4) + >>> costs = fx.Effect('costs', is_objective=True, is_standard=True) + >>> flow_system.add_elements(boiler, heat_bus, costs) + + Unified dict-like access (recommended for most cases): + + >>> # Access any element by label, regardless of type + >>> boiler = flow_system['Boiler'] # Returns Component + >>> heat_bus = flow_system['Heat'] # Returns Bus + >>> costs = flow_system['costs'] # Returns Effect + >>> + >>> # Check if element exists + >>> if 'Boiler' in flow_system: + ... print('Boiler found in system') + >>> + >>> # Iterate over all elements + >>> for label in flow_system.keys(): + ... element = flow_system[label] + ... print(f'{label}: {type(element).__name__}') + >>> + >>> # Get all element labels and objects + >>> all_labels = list(flow_system.keys()) + >>> all_elements = list(flow_system.values()) + >>> for label, element in flow_system.items(): + ... print(f'{label}: {element}') + + Direct container access for type-specific operations: + + >>> # Access specific container when you need type filtering + >>> for component in flow_system.components.values(): + ... print(f'{component.label}: {len(component.inputs)} inputs') + >>> + >>> # Access buses directly + >>> for bus in flow_system.buses.values(): + ... print(f'{bus.label}') + >>> + >>> # Flows are automatically collected from all components + >>> for flow in flow_system.flows.values(): + ... print(f'{flow.label_full}: {flow.size}') + >>> + >>> # Access effects + >>> for effect in flow_system.effects.values(): + ... print(f'{effect.label}') + Notes: + - The dict-like interface (`flow_system['element']`) searches across all containers + (components, buses, effects, flows) to find the element with the matching label. + - Element labels must be unique across all container types. Attempting to add + elements with duplicate labels will raise an error, ensuring each label maps to exactly one element. + - The `.all_elements` property is deprecated. Use the dict-like interface instead: + `flow_system['element']`, `'element' in flow_system`, `flow_system.keys()`, + `flow_system.values()`, or `flow_system.items()`. + - Direct container access (`.components`, `.buses`, `.effects`, `.flows`) is useful + when you need type-specific filtering or operations. + - The `.flows` container is automatically populated from all component inputs and outputs. - Creates an empty registry for components and buses, an empty EffectCollection, and a placeholder for a SystemModel. - The instance starts disconnected (self._connected_and_transformed == False) and will be - connected_and_transformed automatically when trying to solve a calculation. + connected_and_transformed automatically when trying to solve a calculation. """ def __init__(