Water pump station simulator: a HEART-embedded system you can run and watch#
A demonstration only. It simulates a town, a river intake, a pump station, its PLC and a HEART. It is not a control system, models no real station or utility, and must never be connected to real plant.
One Python file, pumpStationSimulator.py, with only the standard library. Run it, register it on HEART Hub with the code it prints, and watch its health on your own private page while it lives a simulated life: the town's demand rising for breakfast and the evening, two pumps taking turns, a level sensor that sticks, a lost telemetry link, cavitation, a motor running hot, a PLC trip that stops both pumps, and a mains failure.
The lesson of this one: HEART changes nothing. The PLC runs the station and holds its interlocks (dry-run protection, the high-pressure trip, the motor thermal trip). HEART reads the PLC's values, notices, explains and tells a person. Every change request is refused with 403 capability-not-allowed.
The case study behind it, step by step for a real station, is guides/Water-Pump-Station.md.
On a real device: Water Pump Station, for real shows the hardware we assume, where the HEART runs, the code to change, how to build and install it, the factory steps and what the owner sees. For illustration only.
| File | What it is |
|---|---|
pumpStationSimulator.py | The simulator: town and river, station and PLC, HEART, local HEART API, HEART Hub reporting |
test_e2e.py | End-to-end test against a monitor site on your own computer |
README.md | This file |
1. Download and run#
You need Python 3.8 or newer. Nothing to install.
python3 pumpStationSimulator.py --scenario all
It starts at once and prints three things:
HEART Hub: register this simulated pump station
1. Sign in at https://heartcortex.ai/login with your email (a 6-digit code is sent to you).
2. Open https://heartcortex.ai/register?id=5be1c07a93d2f418
3. Enter the claim code R4WQ8-KM2TZ-7HXCB-9NPDA
4. Watch it at https://heartcortex.ai/device/5be1c07a93d2f418 (private to you until you share it)
10:44:22 HEART API on http://127.0.0.1:43305/heart/v1/pulse (owner key for private views: ...)
10:44:37 sump 3.00 m · reservoir 3.33 m · P1 RUN P2 off (duty P1) · flow 305 m3/h · town 139 m3/h · 4.47 bar · motor 69 Cel · vib 2.5 mm/s · mains on · link up · HEART healthy
Every 15 seconds it prints one line of what the PLC reports. When something goes wrong, its HEART says so:
10:58:12 HEART critical system.main-function-lost: The station cannot pump water. The PLC has tripped the station on
high discharge pressure and locked both pumps out. The town is drawing 144 m3/h; the reservoir is at 3.39 m,
about 24.9 h of supply above its low level. Send the on-call operator.
10:59:36 HEART resolved system.main-function-lost: the station cannot pump water
Stop it with Ctrl-C. The device ID, claim code and key are kept in pump-station-monitor.json (readable only by you), so the same simulated station comes back next time. Delete that file to make a new device.
2. Register it and watch it on HEART Hub#
- Sign in. Open
https://heartcortex.ai/loginand enter your email address. A 6-digit code arrives; type it in. There are no passwords. - Register. Open the
register?id=...link the simulator printed, and enter the claim code. The claim code is the proof that you hold the device: it is shown only on the simulator's console, and the site holds only its hash. - Watch. Your device's page,
https://heartcortex.ai/device/<ID>, shows its status (healthy, degraded or failing) and its open and recent alerts, each with the plain-language meaning of its fault code. All your devices are athttps://heartcortex.ai/devices. - Share, if you want. The page is private until you make a share link or make it public.
- Silence. Stop the simulator, or wait for the staged telemetry-link loss: reports go out through the station's router, so while the link is down nothing is sent, and after three missed Pulse intervals (90 seconds) the page shows a Flatline and you get one email. The site checks for silent devices every 5 minutes, so the email can take a few minutes more. When it reports again, you get one more.
Until you register it, the site stores nothing: the simulator's reports are answered "not registered yet" and dropped. After registering, it can take up to five minutes for the first report to arrive, or less as soon as something changes.
What the site sees. Only the public Pulse and public alerts, and it keeps only the status and timing and each alert's fault code, severity and times. Never a level, a flow, a pressure, a motor reading or a PLC event. A real water utility should run its own monitor site rather than send a station's alerts to a public one: see the warning in the case study, Step 8.
3. The staged faults#
With --scenario, each fault starts at a set time, lasts a while, and is then "fixed by the maintenance crew", so you can watch an alert open, escalate and resolve. Times are in simulated seconds: at --speed 1 they are real seconds.
| Scenario | Starts | Lasts | What happens in the station | What HEART raises |
|---|---|---|---|---|
stuck | 1 min | 3 min | The sump level transmitter freezes at one value | sensors.stuck (warning) after 90 s with not one change. The PLC's dry-run protection is not affected: it uses its own float switch |
link | 4.5 min | 5 min | The telemetry router to the control room drops out | connectivity.link-lost (warning). No reports while it is down: the page shows a Flatline, then recovers. The pumps carry on |
cavitation | 10 min | 2.5 min | The suction strainer blocks: flow falls to about 60 %, vibration rises to 8 to 11 mm/s | system.main-function-degraded (warning) |
hot | 13 min | 4 min | Pump 1's cooling fan is blocked. Its winding heats; the PLC trips it at 120 Cel and starts pump 2 | performance.too-hot (warning) from 100 Cel, before the PLC's trip |
trip | 17.5 min | 2 min | A valve on the rising main is shut by mistake. Pressure rises to shut-off head; the PLC trips the station on high pressure and locks both pumps out until a local reset. The town keeps drawing water | system.main-function-lost (critical), with the hours of supply left in the reservoir |
power | 20 min | 2 min | Mains supply is lost. The PLC and the gateway run on the UPS; the PLC restarts a pump 20 s after mains returns | power.supply-lost (error) and system.main-function-lost (critical) |
all | All of the above |
Without --scenario, faults still happen now and then on random timers, as in life, but rarely: a telemetry dropout every 3 to 8 hours, a grid fault every 12 to 48 hours.
4. Options#
| Option | Default | Meaning |
|---|---|---|
--scenario | none | stuck, link, cavitation, hot, trip, power (comma-separated) or all |
--speed | 1 | Simulated seconds per real second. --speed 5 runs the staged faults in about 4.5 minutes (too fast for Flatline emails, which need real minutes) |
--monitor-url | https://heartcortex.ai | Any HEART Hub site, including your own. http only for 127.0.0.1 |
--no-monitor | Report to no site: local only | |
--state-file | pump-station-monitor.json | Where the device ID, claim code and key are kept |
--port | 43305 | The local HEART API (falls back to 7685; 0 for none) |
--seed | random | Repeat the same simulated life |
--duration | until Ctrl-C | Stop after this many real seconds |
5. The local HEART API#
The simulator also serves the HEART API on your own computer, as a real gateway would:
curl http://127.0.0.1:43305/.well-known/heart-card.json # the Card: what it is
curl http://127.0.0.1:43305/heart/v1/pulse # public health
curl http://127.0.0.1:43305/heart/v1/arrhythmias # public alerts: codes only
curl http://127.0.0.1:43305/heart/v1/properties # public values: none, see the table below
KEY=... # the owner key it printed
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:43305/heart/v1/properties # every reading from the PLC
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:43305/heart/v1/arrhythmias # alerts with explanations
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:43305/heart/v1/plc-events # the PLC's event log: starts, stops, trips, resets
curl -X PUT -H "Authorization: Bearer $KEY" http://127.0.0.1:43305/heart/v1/properties # 403 capability-not-allowed
While the level sensor is stuck, the owner's sump_level property carries "quality": "bad".
What HEART reads, and how private it is. The HEART reads these from the PLC's Modbus map. Addresses are the 0-based protocol addresses within each table, as sent on the wire (Signal Tables, modbus-tcp; never old reference numbers such as 30001). In the simulator they are read from the simulation; on a real gateway, over Modbus TCP.
| Signal | Table | Register | Scale, unit | Privacy |
|---|---|---|---|---|
| Health status, open fault codes | (derived by HEART) | public | ||
| Sump level (transmitter LT-101) | input | 0 | 0.001 m | internal |
| Reservoir level | input | 1 | 0.001 m | internal |
| Station flow | input | 2 | 0.1 m3/h | internal |
| Discharge pressure | input | 3 | 0.01 bar | internal |
| Pump 1, pump 2 motor current | input | 4, 5 | 0.1 A | internal |
| Pump 1, pump 2 motor winding temperature | input | 6, 7 | 0.1 Cel | internal |
| Pump 1, pump 2 vibration (RMS velocity) | input | 8, 9 | 0.01 mm/s | internal |
| Pump 1 run hours (32 bits, high word first) | input | 10, 11 | 0.1 h | internal |
| Pump 2 run hours (32 bits, high word first) | input | 12, 13 | 0.1 h | internal |
| Mains voltage | input | 14 | 0.1 V | internal |
| PLC status word: bit 0, 1 pump thermal trip; 2 high-pressure trip; 3 dry-run stop; 4 mains lost; 5 link down; 6 duty is pump 2; 7 PLC calls for water | input | 15 | bits | internal |
| Outflow to the town | input | 16 | 0.1 m3/h | internal |
| UPS charge | input | 17 | 1 % | internal |
| Pump 1, pump 2 running | discrete | 0, 1 | 1 = running | internal |
| Low-level float switch (the PLC's dry-run input) | discrete | 2 | 1 = low | internal |
| UPS on battery | discrete | 3 | 1 = on battery | internal |
| Telemetry link healthy | discrete | 4 | 1 = up | internal |
| PLC event log | (the PLC's own log) | internal | ||
| Anything about people | none | not collected |
Every value is internal: the operator's, not the public's. Nothing is private, because nothing is personal: the station knows no customer, no household and no operator name. For critical infrastructure, public stays as small as the standard allows: status and fault codes only.
HEART reads only the two read-only tables (input, function 4; discrete, function 2). It has no write function at all, and the PLC's setpoints (holding registers) are not in its map.
Every message it serves is a valid HEART message: test_e2e.py checks each one against the official schemas.
6. How the code is built#
World ──► Station (plant + PLC) ──(Modbus map, read only)──► Heart ──► local HEART API (127.0.0.1)
river, town pumps, interlocks, duty watches └► HEART Hub (outbound only)
| Part | In the file | In a real station |
|---|---|---|
World | The river head feeding the sump, and the town's daily demand curve, each on its own random timer | The real river and town |
Station | The sump, two pumps, the rising main and the reservoir, and the PLC's scan: dry-run stop from the float switch, high-pressure trip with local reset, motor thermal trips, start and stop on reservoir level, duty and standby with rotation, restart after mains returns. Writes its Modbus map each scan | The plant, the PLC and its program, and any safety relays. Never part of HEART |
Readings | One poll of the PLC's map with read_input_registers() and read_discrete_inputs(), scaled to units | The code you add: a Modbus TCP client, read only |
Heart | RULES turn readings into alerts with fault codes; EXPLAIN says what it means and what to do; escalate() tells a person; serves only what each caller may see | The code you add, on the edge gateway |
monitor() | Hello, then reports to HEART Hub; follows the site's nextSec; stops for a wrong key; says hello again if forgotten; sends nothing while the telemetry link is down | The same, unchanged |
handler() | The HEART API: Card, Pulse, alerts, properties, PLC events; every change refused | The same, behind TLS on the station network |
Nothing in Heart can write to Station: there is no setter, no command, no capability and no Modbus write. That is the design, not an omission. HEART's own thresholds (vibration 7.1 mm/s, motor 100 Cel, the expected flow) sit below the PLC's trips, so a person hears before the PLC acts, and the PLC acts whether or not HEART is there.
7. What code to add to make a real product HEART-embedded#
For a real station, HEART runs on an edge gateway in the station's control panel, next to the PLC, on the station's own network: see Step 1 of the case study. Never in the PLC, never in the safety relays. Pick a starting point, then add four things.
Starting points
| Your gateway | Start from |
|---|---|
| An industrial Linux gateway (most stations) | The Rust Core heartd (heart-core-rs) with its Modbus TCP adapter. heart-core-rs/examples/pump-station is a complete configuration with a Modbus pump simulator: for a monitoring-only station, keep its read-only property definitions (custom/tank-level.json is the pattern) and leave out its command (custom/set-flow.json). Draft the property definitions from the PLC's register map with heart-templates/tools/heart_signals.py draft --modbus regs.csv and export |
| A small Linux box or a SCADA server you already run | This file's Readings, Heart, monitor() and handler(); or the Python template heart-templates/templates/python; or the JavaScript template heart-templates/templates/javascript-node |
| A microcontroller gateway with Ethernet or Wi-Fi | The ESP32 Full starter heart-templates/templates/esp32-full (its signals.json, main-job check and Monitor reporting) |
A gap to know about. heartd's Modbus TCP adapter today reads with function 3 (holding registers) and writes with function 6. This map uses the read-only tables (function 4, input registers; function 2, discrete inputs). Either have the PLC mirror these values into a block of holding registers for the gateway, or extend the adapter with functions 2 and 4. Either way, give the gateway no write path: configure the PLC's Modbus server, or a firewall in front of it, to accept only read functions from the gateway's address.
The four things you add
- Read the PLC. Replace
WorldandStationwith a Modbus TCP client for your PLC's real map. It must be read only: HEART gets values, never a way to write a register or a coil.
``python class Readings: """CHANGE: fill these from your PLC's register map. Read only: functions 2 and 4, nothing else.""" def __init__(self, plc, heart): ir = plc.read_input_registers(0, 18) # your Modbus TCP client, function 4, the PLC's unit ID di = plc.read_discrete_inputs(0, 5) # function 2 self.sump, self.flow, self.pressure = ir[0] / 1000, ir[2] / 10, ir[3] / 100 self.running = [bool(di[0]), bool(di[1])] ... ``
If the PLC stops answering, that is a fault too: raise connectivity.dependency-unreachable, and never guess the values.
- Your rules and thresholds. Edit
RULESandEXPLAINwith your pumps' commissioning curves, the vibration limits for your machines (ISO 10816 or ISO 20816), and motor temperatures set below the PLC's trips. Keep the standard fault codes (Part 3), so every SCADA system and monitor site reads them the same way. - Your escalation route. Change
escalate()to raise an alarm in the control room's SCADA and page the on-call operator, and to light the station's local alarm lamp, which needs no network. When the telemetry link is down, the lamp is the only route: say so to the crew in the commissioning notes. - Your privacy table. Go through
properties()and give every value its level (Step 3 of the case study). Operational data isinternal; only status and fault codes arepublic. Keep operator names, call-out rosters and customer data off the gateway.
Then keep monitor() as it is, store the device ID, claim code and key in the gateway's protected storage, and print the claim code and a QR code of https://<your monitor site>/register?id=<ID> on a label inside the panel door. Serve the API over TLS on the station network (or keep it on loopback), run the HEART-Rating client and Part 9's tests, and claim HEART Basic, Autonomy Level A1.
8. Test it#
From the project's top folder, with Node 20+ and Python 3 with pip install --user "jsonschema[format]>=4.18" referencing:
python3 pumpStationSimulator/test_e2e.py
It starts a HEART Hub site on this computer (port 8805), runs the simulator with every staged fault at 5x speed (local API on port 43305), signs in, registers the device through the site's own form, and checks for about five minutes that every message is valid, public views hold only public data, measurements and the PLC event log need the owner key, every change request is refused with 403 capability-not-allowed even with the owner key, every staged fault reaches the owner's page with its fault code, no measurement reaches the site, and the page stays private.
Licence: Apache-2.0. Part of HEART, the open standard at heartcortex.ai.