LearnSCADA

Part III · Chapter 15

A Monitoring and Control Project

Time to build something whole. This chapter assembles everything from Part III (client, server, decoding, the polling loop) into one small, complete application: a monitor for a pump station that reads, decodes, displays, and sends commands back to hold a tank near a target level.

By the end of this chapter you can

  • Read a register map that spans all four tables and mixes data types.
  • Build a simulated device whose data changes in response to your writes.
  • Write a polling loop that reads, decodes, decides, and writes a command back.
  • Explain hysteresis, and why control code needs safety logic.

What we are building

Our imaginary device is a pump station that keeps a tank near a desired level. It exposes a handful of points across the four tables: tank level and flow rate as measurements, a high-level alarm as a status bit, and a pump run command as a writable coil.

The program reads those points on a schedule, shows them, and switches the pump on when the level drops below a setpoint and off when it rises above: simple closed-loop control. We'll run a simulated station so you can build it all in software, but every line would work unchanged against real hardware. This is the shape of a great deal of real industrial software.

A microcomputer running monitor.py is connected over Modbus TCP to a pump station with a tank, a pump, and a high-level alarm. Requests go out for level and flow, the alarm, and the pump state; replies come back; then a write turns the pump on and the tank level begins to rise. Microcomputer monitor.py polls every 2 s Modbus TCP · 127.0.0.1 port 5020 pump station (station_server.py) tank level · setpoint 60% level alarm flow 12.5 pump FC04 read IR 0 ×3 level 500 · flow 12.5 FC02 read DI 0 alarm 0 FC01 read coil 0 pump off FC05 coil 0 = ON level 50.0% < 60% → pump ON
Figure 15.1 · The project. A microcomputer runs the monitor, polling the station for level, flow, and alarm, and writing the pump command back: a complete monitoring-and-control loop over Modbus.

The register map

Every project begins with the register map, and ours is small. Notice the spread across all four tables and the mix of types (a scaled integer, a two-register float, and bits), which is exactly why Chapter 14's decoding work was worth doing:

PointTable / addressTypeRead with
Tank levelInput register 0Integer, ÷ 10, percentFC04
Flow rateInput registers 1–2Float (two registers)FC04
High-level alarmDiscrete input 0BitFC02
Pump run commandCoil 0Bit (read/write)FC01 / FC05
Level setpointHolding register 0Integer, ÷ 10, percentFC03

Figure 15.2 · The pump station's register map. Level and flow sit side by side in input registers 0–2, so one FC04 request for three registers fetches both. (The listings below keep the setpoint in a Python constant; reading it from holding register 0 instead is a natural extension.)

The simulated station

First we build the device to monitor, extending the Chapter 12 server with a simulation that behaves like a real tank: the level rises while the pump runs and falls while it's off, and the alarm trips if the tank gets too full. Save this as station_server.py. The data blocks and contexts are exactly as Chapter 12 described:

PYTHON — station_server.py (setup)
import threading, time, struct
from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
    ModbusSequentialDataBlock,
    ModbusSlaveContext, ModbusServerContext)

di = ModbusSequentialDataBlock(0, [0] * 10)
co = ModbusSequentialDataBlock(0, [0] * 10)
ir = ModbusSequentialDataBlock(0, [0] * 10)
hr = ModbusSequentialDataBlock(0, [0] * 10)
dev = ModbusSlaveContext(di=di, co=co, ir=ir,
                         hr=hr, zero_mode=True)
ctx = ModbusServerContext(slaves=dev, single=True)

Only the background thread is new. It reads the pump coil the monitor will write, moves the level accordingly, computes the alarm, and stores level, flow (packed as a float across two registers), and alarm back into the datastore: a compact model of real plant behavior.

PYTHON — station_server.py (simulation)
def sim(context):
    level = 500            # 50.0 %, scaled x10
    while True:
        time.sleep(2)
        pump = context[0].getValues(1, 0, 1)[0]
        level += 30 if pump else -20
        level = max(0, min(1000, level))
        alarm = 1 if level > 900 else 0
        fb = struct.pack(">f", 12.5)
        hi, lo = struct.unpack(">HH", fb)
        context[0].setValues(4, 0, [level])
        context[0].setValues(4, 1, [hi, lo])
        context[0].setValues(2, 0, [alarm])

threading.Thread(target=sim, args=(ctx,),
                 daemon=True).start()
StartTcpServer(context=ctx,
               address=("0.0.0.0", 5020))

context[0].getValues(1, 0, 1)[0] reads coil 0 (function code 1) to see whether the monitor has commanded the pump on. The three setValues calls write level and flow into the input registers (function code 4) and the alarm into the discrete inputs (function code 2). So every two seconds the level moves +3.0 % with the pump on or −2.0 % with it off, clamped to 0–100 %, and the alarm is set above 90 %. Run this server in its own terminal: it's now a living pump station waiting to be watched.

The monitor

Now the application itself. Save it as monitor.py. It opens with the float decoder from Chapter 14 (high word first, the station's convention) and a connection, guarded against failure:

PYTHON — monitor.py (setup)
import struct, time
from pymodbus.client import ModbusTcpClient

def regs_to_float(regs):
    raw = struct.pack(">HH", regs[0], regs[1])
    return struct.unpack(">f", raw)[0]

SETPOINT = 60.0   # percent

client = ModbusTcpClient("127.0.0.1", port=5020)
if not client.connect():
    raise SystemExit("Cannot reach station")

The heart is the polling loop. Each cycle it reads the three input registers, the alarm bit, and the pump coil; checks for errors as Chapter 11 taught; decodes the scaled level and the float flow; applies the control rule; and prints a one-line dashboard before sleeping until the next cycle:

PYTHON — monitor.py (the loop)
while True:
    ir = client.read_input_registers(0, count=3)
    di = client.read_discrete_inputs(0, count=1)
    co = client.read_coils(0, count=1)
    if ir.isError() or di.isError() or co.isError():
        print("read error; retrying")
        time.sleep(2)
        continue

    level = ir.registers[0] / 10
    flow = regs_to_float(ir.registers[1:3])
    alarm = di.bits[0]
    pump = co.bits[0]

    if level < SETPOINT and not pump:
        client.write_coil(0, True)
        pump = True
    elif level >= SETPOINT and pump:
        client.write_coil(0, False)
        pump = False

    print("Level %.1f%%  Flow %.1f  "
          "Pump %s  Alarm %s" % (level, flow,
          "ON" if pump else "off",
          "!" if alarm else "-"))
    time.sleep(2)

Three reads, one per table it needs (FC04, FC02, FC01), and a write (FC05) only when the pump must change state. Note that the monitor writes only on a change: it checks not pump before switching on and pump before switching off, so it doesn't send a redundant command every cycle.

The control loop as a cycle of six steps: read the station, decode the values, display them, decide whether to switch the pump, write the command if needed, and wait. A marker travels around the cycle, highlighting each step in turn. monitor.py · while True: one pass ≈ 2 s 1 · ReadFC04 · FC02 · FC01 2 · Decode÷ 10 · float 3 · Displayprint one line 4 · Decidelevel vs setpoint 5 · WriteFC05, only on change 6 · Waittime.sleep(2)
Figure 15.3 · The control loop. Each cycle reads the station, decodes, displays, decides whether to switch the pump, writes that command, and waits: Chapter 3's polling loop with a control step added.

Watching it run

With the station server running in one terminal, start the monitor in another. The readout updates every two seconds, the level climbing while the pump runs and easing back after it switches off as the tank crosses the setpoint:

OUTPUT — monitor.py
Level 53.0%  Flow 12.5  Pump ON  Alarm -
Level 56.0%  Flow 12.5  Pump ON  Alarm -
Level 59.0%  Flow 12.5  Pump ON  Alarm -
Level 62.0%  Flow 12.5  Pump off  Alarm -
Level 60.0%  Flow 12.5  Pump off  Alarm -
Level 58.0%  Flow 12.5  Pump ON  Alarm -

Read across those lines. The level rises while the pump runs; once it climbs past the 60 % setpoint, the monitor writes the coil to switch the pump off, and the level begins to fall; when it drops back below, the pump comes on again. The Pump column shows the state after that cycle's decision, so the line where the level crosses the setpoint already reads off (or ON). Your program is holding the tank near 60 %, on its own, by reading and writing Modbus: the essence of supervisory control.

Your first line or two may differ (the two programs sleep independently, so where the monitor catches the station's cycle varies), but the rhythm is the same: +3.0 per cycle with the pump on, −2.0 with it off. The simulator below runs the same code paths in your browser so you can watch it, and break it, without a microcomputer.

Pump-station simulator

A line-for-line port of sim() and the monitor.py loop. Each cycle the monitor polls and decides, then the station moves the level (+3.0 % pump on, −2.0 % off, clamped 0–100, alarm above 90 %). The station starts at 50.0 %.

Live tank: the blue fill shows the station's level, dashed lines mark the setpoint and the 90 percent alarm level, the pump spins when running, and the alarm lamp lights above 90 percent. 90 alarm SP 60 50.0% HIGH PUMP
Level (station)50.0%
Flow12.5
Pump coiloff
Alarm-
Cycle0
Trend of the last 60 levels the monitor read, with the setpoint, the hysteresis band when enabled, the 90 percent alarm line, and a strip along the bottom showing when the pump ran. 90% 0% pump on
OUTPUT — monitor.py (live)

Modbus traffic, latest cycle

MBAP header Unit address Function code Data CRC

Things to try: run at the book's setting and count how often the pump switches; tick Hysteresis and watch the cycle slow and widen; set SETPOINT to 95, drag the level to 92 %, and compare the alarm with and without Alarm forces pump off.

The control rule, and a caution

The control logic here is the simplest possible: pump on below the setpoint, off above it. With the level hovering right at the setpoint, that rule makes the pump chatter, switching on and off almost every cycle. A real system adds a small gap between the on and off thresholds, a technique called hysteresis.

Two level traces drawn over time. Top, with a single 60 percent setpoint, the level zigzags tightly around 60 and the pump switches about four times every five cycles. Bottom, with hysteresis, pump on below 58 and off above 62, the level swings between 57 and 65 and the pump switches only twice every five cycles. No gap: on below 60 %, off at 60 % or above 60 pump With hysteresis: on below 58 %, off above 62 % 62 58 pump ≈ 4 pump switches every 5 cycles 2 switches every 5 cycles
Figure 15.5 · The control decision, cycle by cycle, using the station's real +3 / −2 dynamics. A single threshold makes the pump chatter; a gap between the on and off points gives a slower, calmer cycle.

You'd also think hard about safety, because writing commands to physical equipment is consequential. A monitor that only reads is harmless; one that writes can start pumps and open valves. Real control code needs careful limits, alarms, and fail-safe behavior when communication drops.

That caution is the real lesson of this project. The mechanics of Modbus control are easy: you've just written them. The hard, important parts are everything around them: how often to poll, what to do when a read fails, how to keep a stalled device from freezing the loop, and how to make writes safe. Those are the subject of Part IV, which turns from "how do I make Modbus work" to "how do I make a Modbus system work well and safely."

Check your understanding

1. Which function code does the monitor use to read the high-level alarm?

The alarm is discrete input 0, a read-only bit, so it's read with read_discrete_inputs, function code 2.

2. Input register 0 reads 535. What level does the monitor print?

The map says level is an integer ÷ 10, so the monitor computes ir.registers[0] / 10 = 53.5.

3. The monitor reads 62.0 % with the pump running and SETPOINT 60. What does it send?

level >= SETPOINT and pump is true, so it calls client.write_coil(0, False): Write Single Coil with 0000.

4. Why add hysteresis to the control rule?

A gap between the on and off thresholds means the level must travel across the band before the pump switches again, giving a slower, calmer cycle.