Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5cbe9a7
add BadPointPressureLiftController tutorial notebook for district hea…
JonasPfeiffer123 May 22, 2025
abe6a37
Merge branch 'e2nIEE:develop' into Tutorials-Examples-District-Heatin…
JonasPfeiffer123 Aug 28, 2025
a5269ab
Create MinimumSupplyTemperatureController.ipynb
JonasPfeiffer123 Aug 28, 2025
a595d70
The previous commit was an accident in this branch.
JonasPfeiffer123 Aug 28, 2025
5e6281a
Added BadPointPressureController reference in the documentation
JonasPfeiffer123 Sep 2, 2025
a6913ca
Merge branch 'e2nIEE:develop' into Tutorials-Examples-District-Heatin…
JonasPfeiffer123 Nov 4, 2025
7a7813f
feat: Add BadPointPressureLiftController as standalone controller module
JonasPfeiffer123 Nov 4, 2025
b7361cc
style: Remove trailing whitespace in BadPointPressureLiftController
JonasPfeiffer123 Nov 4, 2025
a1ada19
fix: Remove unnecessary whitespace in BadPointPressureLiftController
JonasPfeiffer123 Nov 4, 2025
2ec057f
Merge branch 'develop' into Tutorials-Examples-District-Heating-Contr…
EPrade Nov 5, 2025
03f323f
Merge branch 'develop' into Tutorials-Examples-District-Heating-Contr…
SimonRubenDrauz Nov 7, 2025
6b750f5
test: Add test suite for BadPointPressureLiftController
JonasPfeiffer123 Nov 7, 2025
21fad29
test: Organize controller test in control subdirectory
JonasPfeiffer123 Nov 7, 2025
4c3de27
Merge branch 'develop' into Tutorials-Examples-District-Heating-Contr…
EPrade Nov 7, 2025
8f99a6f
refactor: Improve BadPointPressureLiftController implementation
JonasPfeiffer123 Nov 7, 2025
51c0b0c
Merge branch 'Tutorials-Examples-District-Heating-Controllers-' of ht…
JonasPfeiffer123 Nov 7, 2025
286731c
docs: Convert controller docstrings to Sphinx reST format
JonasPfeiffer123 Nov 7, 2025
f35bf41
refactor: Clean up whitespace in BadPointPressureLiftController press…
JonasPfeiffer123 Nov 7, 2025
bd56886
Add dual-mode operation to BadPointPressureLiftController
JonasPfeiffer123 Nov 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion doc/source/controller/controller_classes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,13 @@ This is used to read the data from a DataSource and write it to a network.

.. _ConstControl:
.. autoclass:: pandapower.control.controller.const_control.ConstControl
:members:
:members:

Custom Controller Example: BadPointPressureLiftController
==========================================================

A practical example of a custom controller implementation is provided in the Jupyter Notebook
`BadPointPressureLiftController.ipynb <https://github.com/e2nIEE/pandapipes/tree/develop/tutorials/BadPointPressureLiftController.ipynb>`_.
This notebook demonstrates how to create a controller that maintains a minimum pressure difference at the worst point in a district heating network, which is a common requirement in district heating systems.

The BadPointPressureLiftController is now available as a standalone controller class in :code:`pandapipes.control` and serves as both a template for user-defined controllers and as an important tool for operating thermal grids with pandapipes.
1 change: 1 addition & 0 deletions src/pandapipes/control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.

from pandapipes.control.run_control import run_control
from pandapipes.control.controller import BadPointPressureLiftController
1 change: 1 addition & 0 deletions src/pandapipes/control/controller/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from pandapipes.control.controller.bad_point_pressure_lift_controller import BadPointPressureLiftController
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from pandapower.control.basic_controller import BasicCtrl

class BadPointPressureLiftController(BasicCtrl):
"""
A controller for maintaining the pressure difference at the worst point
(German: Differenzdruckregelung im Schlechtpunkt) in the network.

The BadPointPressureLiftController is a custom controller designed for district heating networks
modeled with pandapipes. Its main purpose is to maintain a minimum pressure difference at the
network's "worst point"—the heat exchanger with the lowest pressure difference (Schlechtpunktregelung).

Key Features:

- **Automatic Worst Point Detection:** Identifies the heat exchanger with the lowest pressure difference where heat flow is present.
- **Pressure Regulation:** Adjusts the circulation pump's lift and flow pressures to ensure the pressure difference at the worst point meets a specified minimum target.
- **Proportional Control:** Uses a proportional gain to determine the adjustment magnitude based on the deviation from the target pressure difference.
- **Control Modes:** Supports two operating modes - fixed flow pressure (adjusts only plift) or fixed return pressure (adjusts both plift and pflow).
- **Standby Mode:** If no heat flow is detected, the controller switches the pump to a standby mode with minimum lift and flow pressures.
- **Convergence Check:** Determines if the pressure difference is within a specified tolerance of the target, signaling convergence.

:param net: The pandapipes network
:type net: pandapipesNet
:param circ_pump_pressure_idx: Index of the circulation pump, defaults to 0
:type circ_pump_pressure_idx: int, optional
:param target_dp_min_bar: Target minimum pressure difference in bar, defaults to 1
:type target_dp_min_bar: float, optional
:param tolerance: Tolerance for pressure difference, defaults to 0.2
:type tolerance: float, optional
:param proportional_gain: Proportional gain for the controller, defaults to 0.2
:type proportional_gain: float, optional
:param mode: Control mode - 'fixed_pflow' (adjusts only plift, keeps p_flow constant) or 'fixed_preturn' (adjusts both plift and pflow, keeps p_return constant), defaults to 'fixed_preturn'
:type mode: str, optional
:param min_plift: Minimum lift pressure in bar, defaults to 1.5
:type min_plift: float, optional
:param min_pflow: Minimum flow pressure in bar, defaults to 3.5
:type min_pflow: float, optional
:param min_preturn: Minimum return pressure in bar (for fixed_pflow mode), defaults to 2.0
:type min_preturn: float, optional
:param kwargs: Additional keyword arguments
:type kwargs: dict, optional
"""
def __init__(self, net, circ_pump_pressure_idx=0, target_dp_min_bar=1, tolerance=0.2,
proportional_gain=0.2, mode='fixed_preturn', min_plift=1.5, min_pflow=3.5,
min_preturn=2.0, **kwargs):
super(BadPointPressureLiftController, self).__init__(net, **kwargs)
self.circ_pump_pressure_idx = circ_pump_pressure_idx
self.target_dp_min_bar = target_dp_min_bar
self.tolerance = tolerance
self.proportional_gain = proportional_gain

if mode not in ['fixed_pflow', 'fixed_preturn']:
raise ValueError("mode must be either 'fixed_pflow' or 'fixed_preturn'")
self.mode = mode

self.min_plift = min_plift # Minimum lift pressure in bar
self.min_pflow = min_pflow # Minimum flow pressure in bar
self.min_preturn = min_preturn # Minimum return pressure in bar

self.iteration = 0 # Add iteration counter

self.dp_min, self.heat_consumer_idx = self.calculate_worst_point(net)

def calculate_worst_point(self, net):
"""
Calculate the worst point in the heating network, defined as the heat exchanger
with the lowest pressure difference.

:param net: The pandapipes network
:type net: pandapipesNet
:return: Tuple of (minimum pressure difference, index of worst point)
:rtype: tuple(float, int)
"""
# Calculate pressure difference for all heat consumers with heat flow
diff = net.res_heat_consumer["p_from_bar"] - net.res_heat_consumer["p_to_bar"]

# Filter out heat consumers with no heat flow
qext = net.heat_consumer["qext_w"]
active_consumers = qext != 0

if not active_consumers.any():
return 0, -1

# Find the minimum delta p where the heat flow is not zero
diff_active = diff[active_consumers]
dp_min = diff_active.min()
idx_min = diff_active.idxmin()

return dp_min, idx_min

def time_step(self, net, time_step):
"""
Reset the iteration counter at the start of each time step.

:param net: The pandapipes network
:type net: pandapipesNet
:param time_step: The current time step
:type time_step: int
:return: The current time step
:rtype: int
"""
self.iteration = 0 # reset iteration counter
self.dp_min, self.heat_consumer_idx = self.calculate_worst_point(net)

return time_step

def is_converged(self, net):
"""
Check if the controller has converged.

:param net: The pandapipes network
:type net: pandapipesNet
:return: True if converged, False otherwise
:rtype: bool
"""

if all(net.heat_consumer["qext_w"] == 0):
return True

current_dp_bar = net.res_heat_consumer["p_from_bar"].at[self.heat_consumer_idx] - \
net.res_heat_consumer["p_to_bar"].at[self.heat_consumer_idx]

# Check if the pressure difference is within tolerance
dp_within_tolerance = abs(current_dp_bar - self.target_dp_min_bar) < self.tolerance

if dp_within_tolerance:
return dp_within_tolerance

def control_step(self, net):
"""
Adjust the pump pressure to maintain the target pressure difference.

:param net: The pandapipes network
:type net: pandapipesNet
"""
# Increment iteration counter
self.iteration += 1

# Adjust the pump pressure or switch to standby mode when heat flow is zero
if all(net.heat_consumer["qext_w"] == 0):
# Switch to standby mode
print("No heat flow detected. Switching to standby mode.")
net.circ_pump_pressure.at[self.circ_pump_pressure_idx, "plift_bar"] = self.min_plift # Minimum lift pressure
net.circ_pump_pressure.at[self.circ_pump_pressure_idx, "p_flow_bar"] = self.min_pflow # Minimum flow pressure
return super(BadPointPressureLiftController, self).control_step(net)

# Check whether the heat flow in the heat exchanger is zero
current_dp_bar = net.res_heat_consumer["p_from_bar"].at[self.heat_consumer_idx] - \
net.res_heat_consumer["p_to_bar"].at[self.heat_consumer_idx]
current_plift_bar = net.circ_pump_pressure["plift_bar"].at[self.circ_pump_pressure_idx]
current_pflow_bar = net.circ_pump_pressure["p_flow_bar"].at[self.circ_pump_pressure_idx]

dp_error = self.target_dp_min_bar - current_dp_bar

if self.mode == 'fixed_pflow':
# Mode 1: Keep p_flow constant, adjust only plift
plift_adjustment = dp_error * self.proportional_gain
new_plift = current_plift_bar + plift_adjustment

# Check if p_return would fall below minimum
new_preturn = current_pflow_bar - new_plift
if new_preturn < self.min_preturn:
new_plift = current_pflow_bar - self.min_preturn

net.circ_pump_pressure["plift_bar"].at[self.circ_pump_pressure_idx] = new_plift
else: # fixed_preturn
# Mode 2: Keep p_return constant, adjust both plift and pflow
plift_adjustment = dp_error * self.proportional_gain
pflow_adjustment = dp_error * self.proportional_gain
new_plift = current_plift_bar + plift_adjustment
new_pflow = current_pflow_bar + pflow_adjustment
net.circ_pump_pressure["plift_bar"].at[self.circ_pump_pressure_idx] = new_plift
net.circ_pump_pressure["p_flow_bar"].at[self.circ_pump_pressure_idx] = new_pflow

return super(BadPointPressureLiftController, self).control_step(net)
126 changes: 126 additions & 0 deletions src/pandapipes/test/control/test_bad_point_pressure_lift_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import numpy as np
import pytest
import pandapipes as pp
from pandapipes.control import BadPointPressureLiftController
from pandapipes.control.run_control import run_control

@pytest.fixture
def district_heating_net():
"""Create a simple district heating network with pump and heat consumers."""
net = pp.create_empty_network(fluid="water")

supply_temperature_k = 85 + 273.15
pipetype = "110/202 PLUS"

# Create ring network
j1 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(0, 10))
j2 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(0, 0))
j3 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(10, 0))
j4 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(60, 0))
j5 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(85, 0))
j6 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(85, 10))
j7 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(60, 10))
j8 = pp.create_junction(net, pn_bar=1.05, tfluid_k=supply_temperature_k, geodata=(10, 10))

pp.create_circ_pump_const_pressure(net, j1, j2, p_flow_bar=4, plift_bar=1.5,
t_flow_k=supply_temperature_k, type="auto")

pp.create_pipe(net, j2, j3, std_type=pipetype, length_km=0.01, k_mm=0.1, sections=5, text_k=283)
pp.create_pipe(net, j3, j4, std_type=pipetype, length_km=0.05, k_mm=0.1, sections=5, text_k=283)
pp.create_pipe(net, j4, j5, std_type=pipetype, length_km=0.025, k_mm=0.1, sections=5, text_k=283)
pp.create_pipe(net, j6, j7, std_type=pipetype, length_km=0.25, k_mm=0.1, sections=5, text_k=283)
pp.create_pipe(net, j7, j8, std_type=pipetype, length_km=0.05, k_mm=0.1, sections=5, text_k=283)
pp.create_pipe(net, j8, j1, std_type=pipetype, length_km=0.01, k_mm=0.1, sections=5, text_k=283)

pp.create_heat_consumer(net, j5, j6, loss_coefficient=0, qext_w=100000, treturn_k=328.15)
pp.create_heat_consumer(net, j4, j7, loss_coefficient=0, qext_w=200000, treturn_k=333.15)

pp.pipeflow(net, mode="bidirectional", iter=100)
return net

def test_bad_point_pressure_lift_controller(district_heating_net):
"""Test that controller maintains target pressure difference at worst point (default mode: fixed_preturn)."""
net = district_heating_net
target_dp = 1.5
tolerance = 0.1

# Add controller to network with default mode (fixed_preturn)
controller = BadPointPressureLiftController(net, target_dp_min_bar=target_dp,
tolerance=tolerance, proportional_gain=0.3)
net.controller.loc[len(net.controller)] = [controller, True, -1, -1, False, False]

# Get initial return pressure
initial_pflow = net.circ_pump_pressure["p_flow_bar"].iloc[0]
initial_plift = net.circ_pump_pressure["plift_bar"].iloc[0]
initial_preturn = initial_pflow - initial_plift

# Run pipeflow with control

run_control(net, mode="bidirectional", max_iter=100)

# Verify convergence and target achievement
dp_min, worst_point_idx = controller.calculate_worst_point(net)
final_pflow = net.circ_pump_pressure["p_flow_bar"].iloc[0]
final_plift = net.circ_pump_pressure["plift_bar"].iloc[0]
final_preturn = final_pflow - final_plift

assert net.converged, "Network should converge with controller"
assert worst_point_idx >= 0, "Controller should identify worst point"
assert abs(dp_min - target_dp) < tolerance, \
f"Controller should reach target {target_dp} bar, got {dp_min:.3f} bar"
# In fixed_preturn mode, return pressure should remain constant
assert abs(final_preturn - initial_preturn) < 0.01, \
f"Return pressure should remain constant in fixed_preturn mode, changed by {final_preturn - initial_preturn:.4f} bar"

def test_bad_point_controller_standby_mode(district_heating_net):
"""Test that controller enters standby mode when no heat demand is present."""
net = district_heating_net

# Remove heat demand
net.heat_consumer["qext_w"] = 0

controller = BadPointPressureLiftController(net, target_dp_min_bar=1.5,
min_plift=1.5, min_pflow=3.5)
controller.control_step(net)

# Verify standby mode sets minimum pressures
assert net.circ_pump_pressure["plift_bar"].iloc[0] == 1.5
assert net.circ_pump_pressure["p_flow_bar"].iloc[0] == 3.5

def test_bad_point_controller_fixed_pflow_mode(district_heating_net):
"""Test that controller works correctly in fixed_pflow mode."""
net = district_heating_net
target_dp = 1.5
tolerance = 0.1

# Add controller in fixed_pflow mode
controller = BadPointPressureLiftController(net, target_dp_min_bar=target_dp,
tolerance=tolerance, proportional_gain=0.3,
mode='fixed_pflow', min_preturn=2.0)
net.controller.loc[len(net.controller)] = [controller, True, -1, -1, False, False]

# Get initial values
initial_pflow = net.circ_pump_pressure["p_flow_bar"].iloc[0]

# Run pipeflow with control
run_control(net, mode="bidirectional", max_iter=100)

# Verify convergence and target achievement
dp_min, worst_point_idx = controller.calculate_worst_point(net)
final_pflow = net.circ_pump_pressure["p_flow_bar"].iloc[0]
final_plift = net.circ_pump_pressure["plift_bar"].iloc[0]
final_preturn = final_pflow - final_plift

assert net.converged, "Network should converge with controller"
assert worst_point_idx >= 0, "Controller should identify worst point"
assert abs(dp_min - target_dp) < tolerance, \
f"Controller should reach target {target_dp} bar, got {dp_min:.3f} bar"
# In fixed_pflow mode, flow pressure should remain constant
assert abs(final_pflow - initial_pflow) < 0.01, \
f"Flow pressure should remain constant in fixed_pflow mode, changed by {final_pflow - initial_pflow:.4f} bar"
# Return pressure should not fall below minimum
assert final_preturn >= 2.0 - 0.01, \
f"Return pressure should not fall below min_preturn (2.0 bar), got {final_preturn:.4f} bar"

if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading