PID Level Control¶
In this tutorial you will put a PID controller on the tank of the previous page so that the outlet valve holds the level at a setpoint, in DWSIM's Classic UI. You will fill the tank to the setpoint, store that state, then hit the loop with a feed change and a setpoint change and watch what proportional and integral action each do. You will also see what happens when the controller is in manual, when the gains are too high and when they are too low.
What you will learn
- How to wire a PID controller: controlled variable, manipulated variable, setpoint, direction, output limits, offset and span
- What proportional-only control leaves behind (an offset) and what integral action removes
- How to store a flowsheet state and start a schedule from it, how to switch a controller to manual, and what the PID tuning tool does
Prerequisites
- Completed From Steady State to Dynamic: the tank, the two valves, the integrator and the schedule are reused as they are
- The PID equation, at least in words: the output is a gain times the error, plus a gain times the integral of the error
Process Overview¶
The tank of the previous tutorial with a level controller closed around it:
- Inlet delivers 10 kg/s (Flow specification), later stepped to 15 kg/s
- T-01 is the 2 m³ tank; its liquid level is the controlled variable
- LIC-01 is the PID controller, reading the level and writing the opening of V-02
- V-02 is the outlet valve, Kv 400, the manipulated variable
- Outlet is held at 1 atm
Process Flow Diagram¶
graph LR
IN["Inlet<br/>10 kg/s → 15 kg/s"] --> V1["V-01"]
V1 --> T["T-01<br/>(Tank)"]
T --> V2["V-02<br/>opening = MV"]
V2 --> OUT["Outlet<br/>1 atm"]
T -. level (PV) .-> LIC["LIC-01<br/>SP 1.0 m"]
LIC -. opening (MV) .-> V2
Key Design Parameters¶
| Parameter | Value | Unit |
|---|---|---|
| Controlled variable (PV) | T-01 Liquid Level | m |
| Manipulated variable (MV) | V-02 Opening | % |
| Setpoint | 1.0 | m |
| Action | Reverse (level up, valve open) | - |
| Output limits | 0 to 100 | % |
| Offset (MV at zero output) | 29 | % |
| Manipulated variable span | 100 | % |
| Kp, Ki, Kd (base tuning) | 2, 0.1, 0 | - |
| Integration step, duration | 5, 10 | s, min |
Step-by-Step in the Classic UI¶
1. Open the tank flowsheet¶
Open the saved file of the previous tutorial, dynamic_tank_open_loop.dwxmz, and check that Dynamics > Dynamic Mode Active is on (the file opens with it off). The integrator Int1 and the schedule Sch1 exist. This Sch1 starts from the stored state NewState1, saved with the tank empty, so the fill of step 3 starts from an empty tank; its numbers are measured on this file.
2. Insert the controller¶
Drag a PID Controller from the Object Palette (Logical Blocks) to the canvas and rename it LIC-01. Open it:
- Controlled Object
T-01, Controlled PropertyLiquid Level, Unitsm - Manipulated Object
V-02, Manipulated PropertyOpening, Units% - Set-Point:
1.0 - Reverse Acting: ticked
- Min./Max Output (Absolute):
0/100 - Offset (Bias):
29 - Kp / Ki / Kd:
2/0.1/0 - Manipulated Variable Span (Advanced tab):
100

Direction, offset and span
The controller computes a dimensionless error (PV − SP)/|SP| and an output = Kp·error + Ki·∫error + Kd·d(error)/dt. With a span set, the valve opening is Offset + Output × Span for a reverse-acting controller. Reverse acting is what a level loop on an outlet valve needs: the level rises above the setpoint, the error is positive, the valve opens further. The offset is the opening the valve should have when the error is zero; 29 % is what V-02 needs to pass 10 kg/s with 1 m of head. Left at zero, the span switches the controller to its older arithmetic, in which the output is scaled by the setpoint; that only makes sense when the manipulated and the controlled variable are numbers of the same size.
3. Fill the tank to the setpoint¶
Run Sch1 from Integrator Controls (10 minutes). The tank starts empty. The controller shuts V-02 within 10 s and keeps it nearly closed while the level is far below the setpoint, then opens it as the level approaches 1 m. The level passes 1 m at 115 s, peaks at 1.022 m at 120 s and holds 1.000 m from about 3 min, with V-02 at 28.8 %.
Filling from your own flowsheet
If you continue with your own flowsheet of the previous page (Outlet back at 101325 Pa, V-02 at 50 %, 5 s step), its Sch1 starts from the current state and empties the tank before the run. The fill then goes a little differently: V-02 shuts at the first step, the level passes 1 m at 105 s, peaks at 1.039 m at 115 s and holds 1.000 m from about 3 min. It still ends at 1.000 m with V-02 at 28.8 %, and every run from step 5 on starts from the state stored in step 4, so the rest of the page gives the same numbers.
4. Store the steady state¶
Press Store Current in the Flowsheet States group of the flowsheet toolbar and name the state Steady. A stored state holds every object, the tank content and the controller included, so a schedule that starts from it always starts from the same 1.000 m and 28.8 %.
5. A feed disturbance¶
In the Dynamics Manager:
- Event Sets: add
FeedStepwith one event at00:02:00: objectInlet, propertyMass Flow, value15kg/s, step change. - Schedules: add
Disturbancewith integratorInt1, event setFeedStepand initial stateSteady.
Run Disturbance. The level holds at 1.000 m for two minutes, rises to 1.062 m 15 s after the step, and is back at 1.000 m by 4 min; V-02 goes from 28.8 % to 43.3 % and stays there, passing the new 15 kg/s.

6. Proportional only¶
Add an event set POnly with two events: at 00:00:05 set LIC-01 Ki to 0, and the same feed step at 00:02:00. Add a schedule POnly from Steady with it and run.
The level rises to 1.070 m and settles at 1.065 m. It never comes back: with proportional action alone, the valve only opens further while there is an error, and passing 15 kg/s takes 41.9 % of opening, which needs an error of 0.065 m to produce. That standing error is the offset of proportional control. Raise Kp and it shrinks; only integral action removes it.
Why change the gain through an event
A schedule that starts from a stored state restores the controller with that state, tuning included. Setting Ki = 0 in the editor before the run would be undone the moment the run starts from Steady. An event at 5 s changes it inside the run; the alternative is to change the tuning, then store a new state.
7. Manual mode¶
Add an event set Manual with an event at 00:00:05 setting LIC-01 ManualOverride to 1, plus the feed step at 2 min. Run a schedule from Steady with it.
V-02 stays at 28.8 %. After the step the level climbs 0.26 m in the first minute, passes the top of the tank (2 m) at 9.4 min and reads 2.03 m at 10 min. When it passes 2 m the tank writes a warning to the log: the tank has no overflow in this model, so the level goes on rising. This is the open-loop response the controller was hiding: 5 kg/s more in than out is 5 L/s into a 1 m² tank, 0.3 m per minute at first, slowing down as the growing head pushes more water through V-02.
Moving the valve in manual
In manual the controller writes its output to V-02 at every step, so an event on the V-02 opening is undone at the next step. To move the valve with the loop in manual, put the event on LIC-01 OutputAbs, the output in %.
8. Too much and too little¶
Two more event sets, each with the feed step at 2 min and gain changes at 5 s:
Aggressive: Kp20, Ki1. The loop turns unstable as soon as the gains change at 5 s, before the feed step: the level swings between 0.84 and 1.13 m for the rest of the run, V-02 slams between 0 and 100 %, and the integral of the absolute error (IAE) is 36.3 m·s.Sluggish: Kp0.5, Ki0.005. The level rises to 1.158 m and is still at 1.060 m after 10 min; IAE 40.3 m·s.
The base tuning (Kp 2, Ki 0.1) gave a peak of 1.062 m and an IAE of 1.7 m·s. Both errors are more than twenty times worse.
9. Setpoint change¶
Add an event set SPStep with one event at 00:01:00: LIC-01 SetPointAbs = 1.5. Run from Steady. V-02 shuts completely to build level, the level reaches 1.49 m at 2 min, overshoots to 1.53 m and settles at 1.500 m by 4 min with V-02 back at 23.6 %: less opening than before, because 1.5 m of head pushes the same 10 kg/s through a smaller Kv.
10. The PID tuning tool and real time¶
Dynamics > PID Controller Tuning runs a schedule repeatedly with a simplex search on the gains, minimising the sum of the absolute errors of the selected controllers. Point it at Disturbance and LIC-01 to see whether it finds anything better than the base tuning.
To watch the loop in real time, switch the flowsheet to Control Panel Mode, pick a schedule in Integrator Controls and press Real-Time (the clock button). A real-time run starts from the schedule's initial state and plays its events, so Disturbance brings the feed step two minutes in. Change the setpoint from the controller's face plate while it runs.
Results and Validation¶
| Case | Peak level | Final level | Final V-02 | Unit |
|---|---|---|---|---|
| Fill from empty, PI | 1.022 | 1.000 | 28.8 | m, m, % |
| Feed step, PI (Kp 2, Ki 0.1) | 1.062 | 1.000 | 43.3 | m, m, % |
| Feed step, P only (Kp 2) | 1.070 | 1.065 | 41.9 | m, m, % |
| Feed step, manual | 2.03 at 10 min (above the 2 m top from 9.4 min) | 2.03, still rising | 28.8 | m, m, % |
| Feed step, Kp 20, Ki 1 | 1.13, oscillating | 0.84 to 1.13, still swinging | 0 to 100 | m, m, % |
| Feed step, Kp 0.5, Ki 0.005 | 1.158 | 1.060 (still moving) | - | m, m |
| Setpoint 1.0 → 1.5 m, PI | 1.53 | 1.500 | 23.6 | m, m, % |
Expected results
With the base tuning the loop rejects a 50 % feed increase with a 6 cm peak and no offset, and follows a 50 cm setpoint change with 5 % overshoot. Proportional action alone leaves 6.5 cm of offset. In manual the level passes the top of the tank in under ten minutes.
Understanding the Results¶
A level loop on an outlet valve is the simplest loop there is, and it shows every feature of PID control. Proportional action moves the valve in proportion to the error, so it needs an error to hold a new steady state: the offset. Integral action keeps moving the valve while any error remains, so the offset goes to zero; too much of it, and the valve keeps moving after the error has changed sign, which is the oscillation of the aggressive case. Derivative action was left at zero here; on a noisy measurement it does more harm than good, and a tank level has no dynamics fast enough to need it.
The manual case is the one to remember. With V-02 fixed, the outflow still grows with the square root of the head, so this tank does find a new balance, at about 2.25 m (2.246 m after 30 min), above its top. The controller was hiding how far, and how slowly, the level goes when nothing moves the valve. A tank drained by a pump, whose flow does not depend on the level, has no balance at all: it fills or empties at a constant rate. That is why level loops are always closed, and why a level controller in manual is the first thing an operator looks for when a tank alarm sounds.
Bumpless transfer
When the controller goes from manual back to automatic, DWSIM initialises the integral term so that the output starts from the current valve position, with no jump. Try it: an event ManualOverride = 0 at 5 min in the Manual schedule brings the loop back with no jump at the switch; what follows is the controller's normal answer to a 0.6 m error.
Automating This Tutorial¶
Files in this repository
- Python script:
examples/dynamics/02_pid_level_control.py - Pre-built flowsheet:
examples/saved/dynamic_tank_level_control.dwxmz - In DWSIM: Samples list on the welcome screen,
Dynamic Simulation - Water Tank Level Control
import os
import sys
import pythonnet
pythonnet.load("netfx") # DWSIM is a .NET Framework application
import clr # noqa: E402
DWSIM_BIN = os.environ.get("DWSIM_BIN")
if not DWSIM_BIN:
raise SystemExit("Set DWSIM_BIN to the DWSIM installation folder before running.")
for sub in ("", "unitops", "ppacks", "extenders"):
sys.path.append(os.path.join(DWSIM_BIN, sub))
clr.AddReference("DWSIM.Automation")
clr.AddReference("DWSIM.Automation.FluentAPI")
clr.AddReference("DWSIM.UnitOperations")
from DWSIM.Automation.FluentAPI import Flowsheet, PropertyPackages, Q
from DWSIM.UnitOperations.UnitOperations import Valve
from System import TimeSpan
fs = (Flowsheet.Create("TankLevelControl")
.WithCompound("Water").WithPropertyPackage(PropertyPackages.SteamTables))
inlet = (fs.AddMaterialStream("Inlet").At(Q.Kelvin(298.15), Q.Pascal(130000.0))
.WithMassFlow(Q.KgPerSecond(10.0)).AsFlowSpec())
s1 = fs.AddMaterialStream("S-1").AsPressureSpec()
s2 = fs.AddMaterialStream("S-2").AsPressureSpec()
outlet = fs.AddMaterialStream("Outlet").AsPressureSpec()
(fs.AddValve("V-01").WithCalcMode(Valve.CalculationMode.Kv_Liquid).WithKv(100.0)
.WithOpeningPercent(50.0).ConnectFeed(inlet, 0).ConnectProduct(s1, 0))
tank = (fs.AddTank("T-01").WithVolume(Q.CubicMeters(2.0)).WithHeight(Q.Meters(2.0))
.ConnectFeed(s1, 0).ConnectProduct(s2, 0))
(fs.AddValve("V-02").WithCalcMode(Valve.CalculationMode.Kv_Liquid).WithKv(400.0)
.WithOpeningKvRelationship().WithOpeningPercent(50.0)
.ConnectFeed(s2, 0).ConnectProduct(outlet, 0))
fs.AutoLayout()
fs.Solve()
outlet.Object.Phases[0].Properties.pressure = 101325.0
lic = (fs.AddPIDController("LIC-01")
.Controls("T-01", "Liquid Level", "m")
.Manipulates("V-02", "PROP_VA_5", "")
.WithSetPoint(1.0).WithTuning(2.0, 0.1, 0.0)
.WithOutputLimits(0.0, 100.0).WithOffset(29.0).ReverseActing(True))
lic.Object.ManipulatedVariableSpan = 100.0
(fs.Dynamics.DefineIntegrator("Int1")
.WithIntegrationStep(TimeSpan.FromSeconds(5)).WithDuration(TimeSpan.FromMinutes(10))
.Monitor("T-01", "Liquid Level", "m", "level")
.Monitor("V-02", "PROP_VA_5", "", "V-02 opening")
.Monitor("Inlet", "PROP_MS_2", "kg/s", "inlet flow"))
fs.Dynamics.WithHistorian(True, 5000)
# fill the tank to the setpoint, then keep that state
fs.Dynamics.DefineSchedule("Fill").WithIntegrator("Int1").UseCurrentStateAsInitial(True).MakeCurrent()
fs.RunDynamics("Fill").Execute()
fs.Dynamics.StoreCurrentStateAs("Steady")
# a feed step from the stored state
(fs.Dynamics.DefineEventSet("FeedStep")
.AddStepChange("Inlet", "PROP_MS_2", 15.0, Q.Seconds(120.0), "kg/s", "feed step"))
(fs.Dynamics.DefineSchedule("Disturbance").WithIntegrator("Int1")
.WithEventSet("FeedStep").WithInitialState("Steady").MakeCurrent())
result = fs.RunDynamics("Disturbance").Execute()
level = result.GetSeries("level")
print(f"peak = {level.Max:.3f} m, final = {level.Final:.3f} m, IAE = {level.IAE(1.0):.2f} m.s")
print(f"V-02 final = {result.GetSeries('V-02 opening').Final:.1f} %")
# proportional only: change the gain inside the run, because the stored state restores it
(fs.Dynamics.DefineEventSet("POnly")
.AddStepChange("LIC-01", "Ki", 0.0, Q.Seconds(5.0), "", "Ki off")
.AddStepChange("Inlet", "PROP_MS_2", 15.0, Q.Seconds(120.0), "kg/s", "feed step"))
(fs.Dynamics.DefineSchedule("POnly").WithIntegrator("Int1")
.WithEventSet("POnly").WithInitialState("Steady").MakeCurrent())
level = fs.RunDynamics("POnly").Execute().GetSeries("level")
print(f"P only: offset = {level.Final - 1.0:.3f} m")
# save the settled tank at 10 kg/s with the base tuning
fs.Inner.LoadProcessData(fs.Inner.StoredSolutions["Steady"])
fs.Dynamics.Schedule("Fill").MakeCurrent()
fs.Save("dynamic_tank_level_control.dwxmz")
Add the controller with dwsim.unitop.add (type PID Controller) and set its properties with dwsim.unitop.set; define event sets and schedules with the dwsim.dynamics.* tools, store the state, and run each schedule.
Output may vary
Results depend on the LLM's reasoning quality and tool-use accuracy. Always verify the simulation matches your intent before relying on the numbers.
Open the water tank dynamic flowsheet (tank between two Kv valves, inlet 10 kg/s
flow spec, outlet 1 atm) in DWSIM via the MCP server and:
- Add a PID controller "LIC-01": controlled variable T-01 liquid level (m),
manipulated variable V-02 opening (%), setpoint 1.0 m, reverse acting,
output limits 0-100 %, offset 29 %, manipulated variable span 100,
Kp 2, Ki 0.1, Kd 0
- Run the existing schedule (5 s step, 10 min) to fill the tank to the setpoint,
then store the flowsheet state as "Steady"
- Define an event set with a step of the inlet mass flow to 15 kg/s at 2 min and
a schedule "Disturbance" that starts from "Steady" with it; run it
- Report the peak level, the final level and the final V-02 opening
- Repeat with an extra event at 5 s that sets the controller's Ki to 0, and report
the final level (the proportional offset)
Exercises
- With proportional action only, double Kp to 4 and then to 8. How does the offset change? What happens to the valve movement?
- Add Kd = 5 to the base tuning and rerun the feed step. Does the peak change? Look at the valve opening in the first minute after the step.
- Put an event
ManualOverride= 0 at 5 min in theManualschedule. Where does the level end, and how long does the controller take to bring it back?
Further Reading¶
Selected references from the DWSIM technical bibliography. Click the DOI link to access each paper.
- K. J. Åström & T. Hägglund. (2006). Advanced PID Control. ISA
- D. E. Seborg, T. F. Edgar, D. A. Mellichamp & F. J. Doyle. (2016). Process Dynamics and Control. Wiley
Next Steps¶
In Separator Pressure and Level Control, two loops share one vessel: a pressure controller on the gas outlet and a level controller on the liquid outlet, with setpoint changes scheduled as events.