From 4ec2c628e2ad90732adc41b7a820b5e25e35ce13 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:16:21 +0100 Subject: [PATCH 1/8] =?UTF-8?q?=20=20|=20File=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20|=20Changes=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20|=20=20=20|-------------------------|-----------?= =?UTF-8?q?---------------------------------------------------------------?= =?UTF-8?q?--------|=20=20=20|=20flixopt/elements.py=20=20=20=20=20|=20Ren?= =?UTF-8?q?amed=20attributes=20excess=5Finput=20=E2=86=92=20virtual=5Fsupp?= =?UTF-8?q?ly,=20excess=5Foutput=20=E2=86=92=20virtual=5Fdemand=20|=20=20?= =?UTF-8?q?=20|=20flixopt/optimization.py=20|=20Updated=20attribute=20acce?= =?UTF-8?q?ss=20and=20result=20keys=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20|=20=20=20|=20tests/test=5Fbus.py=20=20=20?= =?UTF-8?q?=20=20=20=20|=20Updated=20variable=20name=20strings=20in=20asse?= =?UTF-8?q?rtions=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20|=20?= =?UTF-8?q?=20=20|=20docs/.../Bus.md=20=20=20=20=20=20=20=20=20|=20Updated?= =?UTF-8?q?=20description=20of=20=CF=86=20symbols=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20|?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variable names in the optimization model are now: - {BusName}|virtual_supply (was excess_input) - {BusName}|virtual_demand (was excess_output) --- .../mathematical-notation/elements/Bus.md | 2 +- flixopt/elements.py | 26 ++++++++++--------- flixopt/optimization.py | 8 +++--- tests/test_bus.py | 16 ++++++------ 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/user-guide/mathematical-notation/elements/Bus.md b/docs/user-guide/mathematical-notation/elements/Bus.md index bfe57d234..6b9b184b9 100644 --- a/docs/user-guide/mathematical-notation/elements/Bus.md +++ b/docs/user-guide/mathematical-notation/elements/Bus.md @@ -27,7 +27,7 @@ With: - $\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$ being the set of all incoming and outgoing flows - $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively -- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively +- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the virtual supply and virtual demand at time $\text{t}_i$, respectively - $\text{t}_i$ being the time step - $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term - $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) diff --git a/flixopt/elements.py b/flixopt/elements.py index 5c13f17c5..dffeca0dc 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -930,8 +930,8 @@ class BusModel(ElementModel): element: Bus # Type hint def __init__(self, model: FlowSystemModel, element: Bus): - self.excess_input: linopy.Variable | None = None - self.excess_output: linopy.Variable | None = None + self.virtual_supply: linopy.Variable | None = None + self.virtual_demand: linopy.Variable | None = None super().__init__(model, element) def _do_modeling(self): @@ -948,35 +948,37 @@ def _do_modeling(self): if self.element.with_excess: excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour) - self.excess_input = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_input') + self.virtual_supply = self.add_variables( + lower=0, coords=self._model.get_coords(), short_name='virtual_supply' + ) - self.excess_output = self.add_variables( - lower=0, coords=self._model.get_coords(), short_name='excess_output' + self.virtual_demand = self.add_variables( + lower=0, coords=self._model.get_coords(), short_name='virtual_demand' ) - eq_bus_balance.lhs -= -self.excess_input + self.excess_output + eq_bus_balance.lhs -= -self.virtual_supply + self.virtual_demand # Add penalty shares as temporal effects (time-dependent) from .effects import PENALTY_EFFECT_LABEL self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.excess_input * excess_penalty}, + expressions={PENALTY_EFFECT_LABEL: self.virtual_supply * excess_penalty}, target='temporal', ) self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.excess_output * excess_penalty}, + expressions={PENALTY_EFFECT_LABEL: self.virtual_demand * excess_penalty}, target='temporal', ) def results_structure(self): inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs] outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs] - if self.excess_input is not None: - inputs.append(self.excess_input.name) - if self.excess_output is not None: - outputs.append(self.excess_output.name) + if self.virtual_supply is not None: + inputs.append(self.virtual_supply.name) + if self.virtual_demand is not None: + outputs.append(self.virtual_demand.name) return { **super().results_structure(), 'inputs': inputs, diff --git a/flixopt/optimization.py b/flixopt/optimization.py index e537029d7..53cfc253a 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -329,15 +329,15 @@ def main_results(self) -> dict[str, int | float | dict]: 'Buses with excess': [ { bus.label_full: { - 'input': bus.submodel.excess_input.solution.sum('time'), - 'output': bus.submodel.excess_output.solution.sum('time'), + 'virtual_supply': bus.submodel.virtual_supply.solution.sum('time'), + 'virtual_demand': bus.submodel.virtual_demand.solution.sum('time'), } } for bus in self.flow_system.buses.values() if bus.with_excess and ( - bus.submodel.excess_input.solution.sum().item() > 1e-3 - or bus.submodel.excess_output.solution.sum().item() > 1e-3 + bus.submodel.virtual_supply.solution.sum().item() > 1e-3 + or bus.submodel.virtual_demand.solution.sum().item() > 1e-3 ) ], } diff --git a/tests/test_bus.py b/tests/test_bus.py index f1497a0ec..3596486d8 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -37,26 +37,26 @@ def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): model = create_linopy_model(flow_system) assert set(bus.submodel.variables) == { - 'TestBus|excess_input', - 'TestBus|excess_output', + 'TestBus|virtual_supply', + 'TestBus|virtual_demand', 'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate', } assert set(bus.submodel.constraints) == {'TestBus|balance'} assert_var_equal( - model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords=model.get_coords()) + model.variables['TestBus|virtual_supply'], model.add_variables(lower=0, coords=model.get_coords()) ) assert_var_equal( - model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=model.get_coords()) + model.variables['TestBus|virtual_demand'], model.add_variables(lower=0, coords=model.get_coords()) ) assert_conequal( model.constraints['TestBus|balance'], model.variables['GastarifTest(Q_Gas)|flow_rate'] - model.variables['WärmelastTest(Q_th_Last)|flow_rate'] - + model.variables['TestBus|excess_input'] - - model.variables['TestBus|excess_output'] + + model.variables['TestBus|virtual_supply'] + - model.variables['TestBus|virtual_demand'] == 0, ) @@ -75,8 +75,8 @@ def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): assert_conequal( model.constraints['TestBus->Penalty(temporal)'], model.variables['TestBus->Penalty(temporal)'] - == model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step - + model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step, + == model.variables['TestBus|virtual_supply'] * 1e5 * model.hours_per_step + + model.variables['TestBus|virtual_demand'] * 1e5 * model.hours_per_step, ) def test_bus_with_coords(self, basic_flow_system_linopy_coords, coords_config): From ffb5348f7a88bfed7200305b909b2d378b54844f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:26:03 +0100 Subject: [PATCH 2/8] =?UTF-8?q?Renamed=20excess=5Fpenalty=5Fper=5Fflow=5Fh?= =?UTF-8?q?our=20=E2=86=92=20imbalance=5Fpenalty=5Fper=5Fflow=5Fhour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++ .../effects-penalty-objective.md | 2 +- .../mathematical-notation/elements/Bus.md | 4 +-- examples/02_Complex/complex_example.py | 6 ++-- .../example_optimization_modes.py | 8 +++--- flixopt/elements.py | 28 +++++++++---------- flixopt/flow_system.py | 2 +- tests/test_bus.py | 4 +-- tests/test_functional.py | 4 +-- 9 files changed, 32 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7544375ff..1a89dd820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### ♻️ Changed +- Renamed `BusModel.excess_input` → `virtual_supply` and `BusModel.excess_output` → `virtual_demand` for clearer semantics +- Renamed `Bus.excess_penalty_per_flow_hour` → `imbalance_penalty_per_flow_hour` + ### 🗑️ Deprecated ### 🔥 Removed diff --git a/docs/user-guide/mathematical-notation/effects-penalty-objective.md b/docs/user-guide/mathematical-notation/effects-penalty-objective.md index 1c96f3613..1a5d9c455 100644 --- a/docs/user-guide/mathematical-notation/effects-penalty-objective.md +++ b/docs/user-guide/mathematical-notation/effects-penalty-objective.md @@ -196,7 +196,7 @@ Where: - $s_{l \rightarrow \Phi, \text{per}}$ is the periodic penalty share from element $l$ - $s_{l \rightarrow \Phi, \text{temp}}(\text{t}_i)$ is the temporal penalty share from element $l$ at timestep $\text{t}_i$ -**Primary usage:** Penalties occur in [Buses](elements/Bus.md) via the `excess_penalty_per_flow_hour` parameter, which allows nodal imbalances at a high cost, and in time series aggregation to allow period flexibility. +**Primary usage:** Penalties occur in [Buses](elements/Bus.md) via the `imbalance_penalty_per_flow_hour` parameter, which allows nodal imbalances at a high cost, and in time series aggregation to allow period flexibility. **Key properties:** - Penalty shares are added via `add_share_to_effects(name, expressions={fx.PENALTY_EFFECT_LABEL: ...}, target='temporal'/'periodic')` diff --git a/docs/user-guide/mathematical-notation/elements/Bus.md b/docs/user-guide/mathematical-notation/elements/Bus.md index 6b9b184b9..b3c7acb4f 100644 --- a/docs/user-guide/mathematical-notation/elements/Bus.md +++ b/docs/user-guide/mathematical-notation/elements/Bus.md @@ -5,7 +5,7 @@ $$ \label{eq:bus_balance} \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) $$ -Optionally, a Bus can have a `excess_penalty_per_flow_hour` parameter, which allows to penaltize the balance for missing or excess flow-rates. +Optionally, a Bus can have a `imbalance_penalty_per_flow_hour` parameter, which allows to penaltize the balance for missing or excess flow-rates. This is usefull as it handles a possible ifeasiblity gently. This changes the balance to @@ -30,7 +30,7 @@ With: - $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the virtual supply and virtual demand at time $\text{t}_i$, respectively - $\text{t}_i$ being the time step - $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term -- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) +- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`imbalance_penalty_per_flow_hour`) --- diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 2913f643f..3ab610410 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -34,9 +34,9 @@ # --- Define Energy Buses --- # Represent node balances (inputs=outputs) for the different energy carriers (electricity, heat, gas) in the system flow_system.add_elements( - fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Strom', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Gas', imbalance_penalty_per_flow_hour=excess_penalty), ) # --- Define Effects --- diff --git a/examples/03_Optimization_modes/example_optimization_modes.py b/examples/03_Optimization_modes/example_optimization_modes.py index d3ae566e4..c7114852a 100644 --- a/examples/03_Optimization_modes/example_optimization_modes.py +++ b/examples/03_Optimization_modes/example_optimization_modes.py @@ -67,10 +67,10 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: flow_system = fx.FlowSystem(timesteps) flow_system.add_elements( - fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Kohle', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Strom', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Gas', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Kohle', imbalance_penalty_per_flow_hour=excess_penalty), ) # Effects diff --git a/flixopt/elements.py b/flixopt/elements.py index 6b859a18d..c6b57ee5d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -196,7 +196,7 @@ class Bus(Element): Args: label: The label of the Element. Used to identify it in the FlowSystem. - excess_penalty_per_flow_hour: Penalty costs for bus balance violations. + imbalance_penalty_per_flow_hour: Penalty costs for bus balance violations. When None, no excess/deficit is allowed (hard constraint). When set to a value > 0, allows bus imbalances at penalty cost. Default is 1e5 (high penalty). meta_data: Used to store additional information. Not used internally but saved @@ -208,7 +208,7 @@ class Bus(Element): ```python electricity_bus = Bus( label='main_electrical_bus', - excess_penalty_per_flow_hour=None, # No imbalance allowed + imbalance_penalty_per_flow_hour=None, # No imbalance allowed ) ``` @@ -217,7 +217,7 @@ class Bus(Element): ```python heat_network = Bus( label='district_heating_network', - excess_penalty_per_flow_hour=1000, # €1000/MWh penalty for imbalance + imbalance_penalty_per_flow_hour=1000, # €1000/MWh penalty for imbalance ) ``` @@ -226,14 +226,14 @@ class Bus(Element): ```python material_hub = Bus( label='material_processing_hub', - excess_penalty_per_flow_hour=waste_disposal_costs, # Time series + imbalance_penalty_per_flow_hour=waste_disposal_costs, # Time series ) ``` Note: The bus balance equation enforced is: Σ(inflows) = Σ(outflows) + excess - deficit - When excess_penalty_per_flow_hour is None, excess and deficit are forced to zero. + When imbalance_penalty_per_flow_hour is None, virtual demand and supply are forced to zero. When a penalty cost is specified, the optimization can choose to violate the balance if economically beneficial, paying the penalty. The penalty is added to the objective directly. @@ -247,11 +247,11 @@ class Bus(Element): def __init__( self, label: str, - excess_penalty_per_flow_hour: Numeric_TPS | None = 1e5, + imbalance_penalty_per_flow_hour: Numeric_TPS | None = 1e5, meta_data: dict | None = None, ): super().__init__(label, meta_data=meta_data) - self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour + self.imbalance_penalty_per_flow_hour = imbalance_penalty_per_flow_hour self.inputs: list[Flow] = [] self.outputs: list[Flow] = [] @@ -268,16 +268,16 @@ def _set_flow_system(self, flow_system) -> None: def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - self.excess_penalty_per_flow_hour = self._fit_coords( - f'{prefix}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour + self.imbalance_penalty_per_flow_hour = self._fit_coords( + f'{prefix}|imbalance_penalty_per_flow_hour', self.imbalance_penalty_per_flow_hour ) def _plausibility_checks(self) -> None: - if self.excess_penalty_per_flow_hour is not None: - zero_penalty = np.all(np.equal(self.excess_penalty_per_flow_hour, 0)) + if self.imbalance_penalty_per_flow_hour is not None: + zero_penalty = np.all(np.equal(self.imbalance_penalty_per_flow_hour, 0)) if zero_penalty: logger.warning( - f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.' + f'In Bus {self.label_full}, the imbalance_penalty_per_flow_hour is 0. Use "None" or a value > 0.' ) if len(self.inputs) == 0 and len(self.outputs) == 0: raise ValueError( @@ -286,7 +286,7 @@ def _plausibility_checks(self) -> None: @property def with_excess(self) -> bool: - return False if self.excess_penalty_per_flow_hour is None else True + return False if self.imbalance_penalty_per_flow_hour is None else True def __repr__(self) -> str: """Return string representation.""" @@ -880,7 +880,7 @@ def _do_modeling(self): # Add excess to balance and penalty if needed if self.element.with_excess: - excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour) + excess_penalty = np.multiply(self._model.hours_per_step, self.element.imbalance_penalty_per_flow_hour) self.virtual_supply = self.add_variables( lower=0, coords=self._model.get_coords(), short_name='virtual_supply' diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 52c403396..ec2e350a2 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -77,7 +77,7 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): >>> >>> # 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) + >>> heat_bus = fx.Bus('Heat', imbalance_penalty_per_flow_hour=1e4) >>> costs = fx.Effect('costs', is_objective=True, is_standard=True) >>> flow_system.add_elements(boiler, heat_bus, costs) diff --git a/tests/test_bus.py b/tests/test_bus.py index 3596486d8..ebef6f13a 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -9,7 +9,7 @@ class TestBusModel: def test_bus(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) + bus = fx.Bus('TestBus', imbalance_penalty_per_flow_hour=None) flow_system.add_elements( bus, fx.Sink('WärmelastTest', inputs=[fx.Flow('Q_th_Last', 'TestBus')]), @@ -82,7 +82,7 @@ def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): def test_bus_with_coords(self, basic_flow_system_linopy_coords, coords_config): """Test bus behavior across different coordinate configurations.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) + bus = fx.Bus('TestBus', imbalance_penalty_per_flow_hour=None) flow_system.add_elements( bus, fx.Sink('WärmelastTest', inputs=[fx.Flow('Q_th_Last', 'TestBus')]), diff --git a/tests/test_functional.py b/tests/test_functional.py index ae01a44f2..c7f4e5fee 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -66,8 +66,8 @@ def flow_system_base(timesteps: pd.DatetimeIndex) -> fx.FlowSystem: flow_system = fx.FlowSystem(timesteps) flow_system.add_elements( - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=None), - fx.Bus('Gas', excess_penalty_per_flow_hour=None), + fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=None), + fx.Bus('Gas', imbalance_penalty_per_flow_hour=None), ) flow_system.add_elements(fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True)) flow_system.add_elements( From 1cd703b00c2d0d16ec0b0a5d064d11a7c45872e9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:28:29 +0100 Subject: [PATCH 3/8] rename excess_penalty to imbalance_penalty --- examples/02_Complex/complex_example.py | 8 ++++---- .../example_optimization_modes.py | 10 +++++----- flixopt/elements.py | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 3ab610410..2ce272368 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -13,7 +13,7 @@ # --- Experiment Options --- # Configure options for testing various parameters and behaviors check_penalty = False - excess_penalty = 1e5 + imbalance_penalty = 1e5 use_chp_with_piecewise_conversion = True time_indices = None # Define specific time steps for custom optimizations, or use the entire series @@ -34,9 +34,9 @@ # --- Define Energy Buses --- # Represent node balances (inputs=outputs) for the different energy carriers (electricity, heat, gas) in the system flow_system.add_elements( - fx.Bus('Strom', imbalance_penalty_per_flow_hour=excess_penalty), - fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=excess_penalty), - fx.Bus('Gas', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Strom', imbalance_penalty_per_flow_hour=imbalance_penalty), + fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=imbalance_penalty), + fx.Bus('Gas', imbalance_penalty_per_flow_hour=imbalance_penalty), ) # --- Define Effects --- diff --git a/examples/03_Optimization_modes/example_optimization_modes.py b/examples/03_Optimization_modes/example_optimization_modes.py index c7114852a..b2d3ce0a1 100644 --- a/examples/03_Optimization_modes/example_optimization_modes.py +++ b/examples/03_Optimization_modes/example_optimization_modes.py @@ -41,7 +41,7 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: penalty_of_period_freedom=0, ) keep_extreme_periods = True - excess_penalty = 1e5 # or set to None if not needed + imbalance_penalty = 1e5 # or set to None if not needed # Data Import data_import = pd.read_csv( @@ -67,10 +67,10 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: flow_system = fx.FlowSystem(timesteps) flow_system.add_elements( - fx.Bus('Strom', imbalance_penalty_per_flow_hour=excess_penalty), - fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=excess_penalty), - fx.Bus('Gas', imbalance_penalty_per_flow_hour=excess_penalty), - fx.Bus('Kohle', imbalance_penalty_per_flow_hour=excess_penalty), + fx.Bus('Strom', imbalance_penalty_per_flow_hour=imbalance_penalty), + fx.Bus('Fernwärme', imbalance_penalty_per_flow_hour=imbalance_penalty), + fx.Bus('Gas', imbalance_penalty_per_flow_hour=imbalance_penalty), + fx.Bus('Kohle', imbalance_penalty_per_flow_hour=imbalance_penalty), ) # Effects diff --git a/flixopt/elements.py b/flixopt/elements.py index c6b57ee5d..3acbb77e2 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -880,7 +880,7 @@ def _do_modeling(self): # Add excess to balance and penalty if needed if self.element.with_excess: - excess_penalty = np.multiply(self._model.hours_per_step, self.element.imbalance_penalty_per_flow_hour) + imbalance_penalty = np.multiply(self._model.hours_per_step, self.element.imbalance_penalty_per_flow_hour) self.virtual_supply = self.add_variables( lower=0, coords=self._model.get_coords(), short_name='virtual_supply' @@ -897,12 +897,12 @@ def _do_modeling(self): self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.virtual_supply * excess_penalty}, + expressions={PENALTY_EFFECT_LABEL: self.virtual_supply * imbalance_penalty}, target='temporal', ) self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.virtual_demand * excess_penalty}, + expressions={PENALTY_EFFECT_LABEL: self.virtual_demand * imbalance_penalty}, target='temporal', ) From 7417b6994bc5706eabd529f6c4210a62abd77958 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:32:38 +0100 Subject: [PATCH 4/8] Change default to None --- CHANGELOG.md | 4 ++++ flixopt/elements.py | 10 +++++++--- tests/test_bus.py | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a89dd820..e7ea7288a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### 💥 Breaking Changes +- `Bus.imbalance_penalty_per_flow_hour` now defaults to `None` (strict balance) instead of `1e5` + ### ♻️ Changed - Renamed `BusModel.excess_input` → `virtual_supply` and `BusModel.excess_output` → `virtual_demand` for clearer semantics @@ -66,6 +68,8 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ### 🗑️ Deprecated +- `Bus.excess_penalty_per_flow_hour` → use `imbalance_penalty_per_flow_hour` + ### 🔥 Removed **Deprecated parameters removed** (all were deprecated in v4.0.0 or earlier): diff --git a/flixopt/elements.py b/flixopt/elements.py index 3acbb77e2..3b77467fe 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -197,8 +197,8 @@ class Bus(Element): Args: label: The label of the Element. Used to identify it in the FlowSystem. imbalance_penalty_per_flow_hour: Penalty costs for bus balance violations. - When None, no excess/deficit is allowed (hard constraint). When set to a - value > 0, allows bus imbalances at penalty cost. Default is 1e5 (high penalty). + When None (default), no imbalance is allowed (hard constraint). When set to a + value > 0, allows bus imbalances at penalty cost. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -247,10 +247,14 @@ class Bus(Element): def __init__( self, label: str, - imbalance_penalty_per_flow_hour: Numeric_TPS | None = 1e5, + imbalance_penalty_per_flow_hour: Numeric_TPS | None = None, meta_data: dict | None = None, + **kwargs, ): super().__init__(label, meta_data=meta_data) + imbalance_penalty_per_flow_hour = self._handle_deprecated_kwarg( + kwargs, 'excess_penalty_per_flow_hour', 'imbalance_penalty_per_flow_hour', imbalance_penalty_per_flow_hour + ) self.imbalance_penalty_per_flow_hour = imbalance_penalty_per_flow_hour self.inputs: list[Flow] = [] self.outputs: list[Flow] = [] diff --git a/tests/test_bus.py b/tests/test_bus.py index ebef6f13a..eb55a93ac 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -28,7 +28,7 @@ def test_bus(self, basic_flow_system_linopy_coords, coords_config): def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - bus = fx.Bus('TestBus') + bus = fx.Bus('TestBus', imbalance_penalty_per_flow_hour=1e5) flow_system.add_elements( bus, fx.Sink('WärmelastTest', inputs=[fx.Flow('Q_th_Last', 'TestBus')]), From 7aa93e5aaae26510020f92a59dc6b7b9308ed4d7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 03:00:46 +0100 Subject: [PATCH 5/8] Added self._validate_kwargs(kwargs) to catch typos and unexpected arguments --- flixopt/elements.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/elements.py b/flixopt/elements.py index 3b77467fe..5a14353a0 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -255,6 +255,7 @@ def __init__( imbalance_penalty_per_flow_hour = self._handle_deprecated_kwarg( kwargs, 'excess_penalty_per_flow_hour', 'imbalance_penalty_per_flow_hour', imbalance_penalty_per_flow_hour ) + self._validate_kwargs(kwargs) self.imbalance_penalty_per_flow_hour = imbalance_penalty_per_flow_hour self.inputs: list[Flow] = [] self.outputs: list[Flow] = [] From cec7d721f0525c5bc3aa5c3f0081c5897c27a169 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 03:04:20 +0100 Subject: [PATCH 6/8] =?UTF-8?q?Renamed=20with=5Fexcess=20=E2=86=92=20allow?= =?UTF-8?q?s=5Fimbalance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + flixopt/elements.py | 8 ++++---- flixopt/optimization.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ea7288a..b3fa5137f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - Renamed `BusModel.excess_input` → `virtual_supply` and `BusModel.excess_output` → `virtual_demand` for clearer semantics - Renamed `Bus.excess_penalty_per_flow_hour` → `imbalance_penalty_per_flow_hour` +- Renamed `Bus.with_excess` → `allows_imbalance` ### 🗑️ Deprecated diff --git a/flixopt/elements.py b/flixopt/elements.py index 5a14353a0..0eceb5fa4 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -290,8 +290,8 @@ def _plausibility_checks(self) -> None: ) @property - def with_excess(self) -> bool: - return False if self.imbalance_penalty_per_flow_hour is None else True + def allows_imbalance(self) -> bool: + return self.imbalance_penalty_per_flow_hour is not None def __repr__(self) -> str: """Return string representation.""" @@ -883,8 +883,8 @@ def _do_modeling(self): outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs]) eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance') - # Add excess to balance and penalty if needed - if self.element.with_excess: + # Add virtual supply/demand to balance and penalty if needed + if self.element.allows_imbalance: imbalance_penalty = np.multiply(self._model.hours_per_step, self.element.imbalance_penalty_per_flow_hour) self.virtual_supply = self.add_variables( diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 53cfc253a..323f590ef 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -334,7 +334,7 @@ def main_results(self) -> dict[str, int | float | dict]: } } for bus in self.flow_system.buses.values() - if bus.with_excess + if bus.allows_imbalance and ( bus.submodel.virtual_supply.solution.sum().item() > 1e-3 or bus.submodel.virtual_demand.solution.sum().item() > 1e-3 From 8881d297f93dfdf0b88f920812ee3583500d43b2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 04:02:28 +0100 Subject: [PATCH 7/8] Fix docstring --- flixopt/elements.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 0eceb5fa4..4112d24f2 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -231,9 +231,9 @@ class Bus(Element): ``` Note: - The bus balance equation enforced is: Σ(inflows) = Σ(outflows) + excess - deficit + The bus balance equation enforced is: Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand - When imbalance_penalty_per_flow_hour is None, virtual demand and supply are forced to zero. + When imbalance_penalty_per_flow_hour is None, virtual_supply and virtual_demand are forced to zero. When a penalty cost is specified, the optimization can choose to violate the balance if economically beneficial, paying the penalty. The penalty is added to the objective directly. From d2a5eb237f1519c17af7fa8ed6a888839905934e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 05:36:51 +0100 Subject: [PATCH 8/8] =?UTF-8?q?=20=201.=20docs/user-guide/mathematical-not?= =?UTF-8?q?ation/elements/Bus.md=20-=20Fixed=20three=20typos:=20=20=20=20?= =?UTF-8?q?=20-=20"a=20imbalance=5Fpenalty=5Fper=5Fflow=5Fhour"=20?= =?UTF-8?q?=E2=86=92=20"an=20imbalance=5Fpenalty=5Fper=5Fflow=5Fhour"=20?= =?UTF-8?q?=20=20=20=20-=20"usefull"=20=E2=86=92=20"useful"=20=20=20=20=20?= =?UTF-8?q?-=20"ifeasiblity"=20=E2=86=92=20"infeasibility"=20=20=202.=20te?= =?UTF-8?q?sts/test=5Fbus.py=20-=20Updated=20comments=20to=20use=20the=20n?= =?UTF-8?q?ew=20imbalance=20terminology=20instead=20of=20the=20old=20"exce?= =?UTF-8?q?ss"=20terminology=20=20=203.=20flixopt/elements.py=20(BusModel)?= =?UTF-8?q?=20-=20Improved=20code=20clarity:=20=20=20=20=20-=20Changed=20e?= =?UTF-8?q?q=5Fbus=5Fbalance.lhs=20-=3D=20-self.virtual=5Fsupply=20+=20sel?= =?UTF-8?q?f.virtual=5Fdemand=20to=20the=20more=20readable=20eq=5Fbus=5Fba?= =?UTF-8?q?lance.lhs=20+=3D=20self.virtual=5Fsupply=20-=20=20=20self.virtu?= =?UTF-8?q?al=5Fdemand=20=20=20=20=20-=20Added=20a=20comment=20explaining?= =?UTF-8?q?=20the=20equation:=20#=20=CE=A3(inflows)=20+=20virtual=5Fsupply?= =?UTF-8?q?=20=3D=20=CE=A3(outflows)=20+=20virtual=5Fdemand=20=20=20=20=20?= =?UTF-8?q?-=20Combined=20the=20two=20separate=20add=5Fshare=5Fto=5Feffect?= =?UTF-8?q?s=20calls=20into=20a=20single=20call=20with=20the=20combined=20?= =?UTF-8?q?expression=20(self.virtual=5Fsupply=20+=20=20=20self.virtual=5F?= =?UTF-8?q?demand)=20*=20imbalance=5Fpenalty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 12 bus tests pass with these changes. --- docs/user-guide/mathematical-notation/elements/Bus.md | 4 ++-- flixopt/elements.py | 11 ++++------- tests/test_bus.py | 3 +-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/user-guide/mathematical-notation/elements/Bus.md b/docs/user-guide/mathematical-notation/elements/Bus.md index b3c7acb4f..5028e8ef7 100644 --- a/docs/user-guide/mathematical-notation/elements/Bus.md +++ b/docs/user-guide/mathematical-notation/elements/Bus.md @@ -5,8 +5,8 @@ $$ \label{eq:bus_balance} \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) $$ -Optionally, a Bus can have a `imbalance_penalty_per_flow_hour` parameter, which allows to penaltize the balance for missing or excess flow-rates. -This is usefull as it handles a possible ifeasiblity gently. +Optionally, a Bus can have an `imbalance_penalty_per_flow_hour` parameter, which allows to penalize the balance for missing or excess flow-rates. +This is useful as it handles a possible infeasibility gently. This changes the balance to diff --git a/flixopt/elements.py b/flixopt/elements.py index 472817a61..9ca938b62 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -887,19 +887,16 @@ def _do_modeling(self): lower=0, coords=self._model.get_coords(), short_name='virtual_demand' ) - eq_bus_balance.lhs -= -self.virtual_supply + self.virtual_demand + # Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand + eq_bus_balance.lhs += self.virtual_supply - self.virtual_demand # Add penalty shares as temporal effects (time-dependent) from .effects import PENALTY_EFFECT_LABEL + total_imbalance_penalty = (self.virtual_supply + self.virtual_demand) * imbalance_penalty self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.virtual_supply * imbalance_penalty}, - target='temporal', - ) - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.virtual_demand * imbalance_penalty}, + expressions={PENALTY_EFFECT_LABEL: total_imbalance_penalty}, target='temporal', ) diff --git a/tests/test_bus.py b/tests/test_bus.py index eb55a93ac..cc49a2073 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -65,8 +65,7 @@ def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): assert 'TestBus->Penalty(temporal)' in model.constraints assert 'TestBus->Penalty(temporal)' in model.variables - # The penalty share should equal the excess times the penalty cost - # Note: Each excess (input and output) creates its own share constraint, so we have two + # The penalty share should equal the imbalance (virtual_supply + virtual_demand) times the penalty cost # Let's verify the total penalty contribution by checking the effect's temporal model penalty_effect = flow_system.effects.penalty_effect assert penalty_effect.submodel is not None