Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,18 @@ Use find-and-replace to update your code with the mappings above. The functional

A partial backwards compatibility wrapper would be misleading, so we opted for a clean breaking change.

- `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
- Renamed `Bus.excess_penalty_per_flow_hour` → `imbalance_penalty_per_flow_hour`
- Renamed `Bus.with_excess` → `allows_imbalance`

### 🗑️ Deprecated

- `Bus.excess_penalty_per_flow_hour` → use `imbalance_penalty_per_flow_hour`

### 🔥 Removed

**Modules removed:**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,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')`
Expand Down
8 changes: 4 additions & 4 deletions docs/user-guide/mathematical-notation/elements/Bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `excess_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

Expand All @@ -27,10 +27,10 @@ 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`)
- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`imbalance_penalty_per_flow_hour`)

---

Expand Down
8 changes: 4 additions & 4 deletions examples/02_Complex/complex_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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=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 ---
Expand Down
10 changes: 5 additions & 5 deletions examples/03_Optimization_modes/example_optimization_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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=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
Expand Down
76 changes: 40 additions & 36 deletions flixopt/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,9 @@ 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.
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).
imbalance_penalty_per_flow_hour: Penalty costs for bus balance violations.
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.

Expand All @@ -207,7 +207,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
)
```

Expand All @@ -216,7 +216,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
)
```

Expand All @@ -225,14 +225,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
The bus balance equation enforced is: Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand

When excess_penalty_per_flow_hour is None, excess and deficit 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.
Expand All @@ -246,11 +246,16 @@ 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 = None,
meta_data: dict | None = None,
**kwargs,
):
super().__init__(label, meta_data=meta_data)
self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour
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] = []

Expand All @@ -267,25 +272,25 @@ 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(
f'Bus "{self.label_full}" has no Flows connected to it. Please remove it from the FlowSystem'
)

@property
def with_excess(self) -> bool:
return False if self.excess_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."""
Expand Down Expand Up @@ -856,8 +861,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):
Expand All @@ -870,39 +875,38 @@ 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:
excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour)
# 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.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
# Σ(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.excess_input * 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: total_imbalance_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,
Expand Down
2 changes: 1 addition & 1 deletion flixopt/flow_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]):
>>>
>>> # Add elements to the system
>>> boiler = fx.Component('Boiler', inputs=[heat_flow], status_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)

Expand Down
10 changes: 5 additions & 5 deletions flixopt/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,15 +309,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
if bus.allows_imbalance
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
)
],
}
Expand Down
25 changes: 12 additions & 13 deletions tests/test_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')]),
Expand All @@ -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')]),
Expand All @@ -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,
)

Expand All @@ -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
Expand All @@ -75,14 +74,14 @@ 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):
"""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')]),
Expand Down
4 changes: 2 additions & 2 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down