Water pump station: on a real device#
A two-pump station with a HEART on an edge gateway in the control panel, reading the PLC over Modbus TCP, read only: the parts, the network, the configuration, how to install it, the commissioning steps, and what the operators see.
For illustration only. The hardware, the register map and the addresses below are assumptions, chosen so the steps can be concrete. Your station, PLC and network will differ. This product has not been built.
heartdand its Modbus TCP adapter have been tested only on an x86-64 Linux virtual machine, against the small simulated Modbus device inheart-core-rs/examples/pump-stationand a mock model: never against a real PLC, never on an industrial gateway, never at a real station. Treat this page as a plan to check on a test rig, under your utility's own engineering and change procedures.
| Placement | B: beside a controller. The HEART runs on an edge gateway in the panel. The PLC and its interlocks run the station; HEART only reads the PLC's Modbus map. |
| Start from | heartd, the Rust Core's daemon, with its Modbus TCP adapter. heart-core-rs/examples/pump-station is the pattern. |
| Claim | HEART Basic, Autonomy Level A1. This HEART changes nothing: it has no commands at all. |
| Try it first | The simulator, the design case study, and heartd's quick start with its simulated pump |
1. The station we assume#
| Part | Assumed choice | Notes |
|---|---|---|
| PLC | A compact PLC with a built-in Modbus TCP server and analogue input cards, such as a Siemens S7-1200 or a Schneider Modicon M241 | Already there. Runs the pumps, duty and standby, and every trip. Never part of HEART. |
| Sump level | A hydrostatic level transmitter, 4–20 mA for 0–5 m (LT-101, as in the simulator), into the PLC | HEART watches it for sticking |
| Low-level float switch | Hardwired into the PLC's dry-run input and the motor starters | The dry-run protection. HEART sees it only as a copied bit. |
| Reservoir level | Reaches the PLC from the reservoir over the existing telemetry or a cable | |
| Flow | An electromagnetic flowmeter on the rising main, 4–20 mA for 0–500 m³/h | |
| Discharge pressure | A pressure transmitter, 4–20 mA for 0–10 bar | The PLC's high-pressure trip uses it, with a local reset |
| Motor windings | A PT100 in each motor winding, into an RTD card on the PLC (or a motor protection relay) | The PLC trips at 120 °C |
| Vibration | A 4–20 mA vibration transmitter (RMS velocity, 0–25 mm/s) on each pump's bearing housing | |
| Mains and UPS | A voltage transducer into the PLC; a 24 V DC UPS whose "on battery" and charge outputs go to the PLC | The PLC and the gateway both run from the UPS |
| Edge gateway | A DIN-rail industrial Linux computer with two Ethernet ports, a relay output and a 24 V DC input, such as a Siemens SIMATIC IOT2050 or a Revolution Pi Connect | Runs heartd and two small helpers |
| Router | The existing telemetry router (fibre or cellular), with its firewall | Carries reports to the control room and the monitor site |
| Alarm lamp | A separate "HEART" lamp on the panel door, on the gateway's relay output | Tells a crew on site, with no network. It is not one of the PLC's alarms. |
Check your own: card types, ranges and trip settings come from the station's drawings and commissioning records.
Scaling. The PLC turns each 4–20 mA input into an integer in engineering units, as the simulator's map does. For example, the flowmeter at 12 mA: (12 − 4) / 16 × 500 = 250 m³/h, written as 2500 in units of 0.1 m³/h.
The register map HEART reads#
The simulator's map (Section 5 of its README) puts the values in input registers (function 4) and discrete inputs (function 2), which are read-only tables. heartd's adapter today reads only holding registers (function 3), and can write one (function 6). The guide gives two ways round this (Step 1): have the PLC mirror the values into a block of holding registers, or add functions 2 and 4 to the adapter. This page assumes the mirror, because it works with heartd as it is:
| Holding register (0-based, on the wire) | Copied from | Scale, unit |
|---|---|---|
| 100 | Input register 0: sump level | 0.001 m |
| 101 | Input register 1: reservoir level | 0.001 m |
| 102 | Input register 2: station flow | 0.1 m³/h |
| 103 | Input register 3: discharge pressure | 0.01 bar |
| 104, 105 | Input registers 4, 5: pump 1, pump 2 motor current | 0.1 A |
| 106, 107 | Input registers 6, 7: pump 1, pump 2 winding temperature | 0.1 Cel |
| 108, 109 | Input registers 8, 9: pump 1, pump 2 vibration | 0.01 mm/s |
| 110 to 113 | Input registers 10 to 13: run hours (32 bits each, high word first) | 0.1 h |
| 114 | Input register 14: mains voltage | 0.1 V |
| 115 | Input register 15: the 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) | bits |
| 116 | Input register 16: outflow to the town | 0.1 m³/h |
| 117 | Input register 17: UPS charge | 1 % |
| 118 | Discrete inputs 0 to 4, as bits 0 to 4: pump 1 running, pump 2 running, float switch low, UPS on battery, telemetry link up | bits |
Addresses are 0-based protocol addresses, as the Signal Tables require. (heartd also accepts old reference numbers: it subtracts 40001 from any number of 40001 or more, so 40101 would mean address 100. Do not mix the two.)
Rules for the mirror block, for the PLC engineer:
- The PLC program writes it and never reads it back. Nothing in the program may depend on it.
- It is refused to every network write, if the PLC can do that. If it cannot, the program overwrites the whole block every scan, so a stray write lasts at most one scan and controls nothing.
- It is a PLC program change: make it under the utility's own change procedure, and test it in the PLC's simulator first.
Network#
| Address (assumed) | What |
|---|---|
| 10.20.1.10 | PLC, on the station control VLAN 10.20.1.0/24 |
| 10.20.1.20 | Gateway port 1, on the control VLAN |
| 10.20.2.20 | Gateway port 2, on the telemetry VLAN 10.20.2.0/24, towards the router |
| Flow | Allowed |
|---|---|
| Gateway 10.20.1.20 → PLC 10.20.1.10, TCP 502 | Yes. Modbus function 3 only, if the firewall or managed switch can filter Modbus function codes (many industrial firewalls can). |
| Anything → PLC from the gateway, other than the above | No |
| PLC → gateway | No new connections |
| Control room SCADA → gateway 10.20.2.20, TCP 43278 | Yes: the HEART API, over TLS |
| Gateway → the monitor site, TCP 443 | Yes: HTTPS reports, outbound, to that one host |
| Gateway → the utility's time server, UDP 123 | Yes: signed requests and timestamps need a good clock |
| Engineering workstation → gateway, SSH | Yes, from that workstation only |
| Everything else | No |
The gateway does not route between its two ports (net.ipv4.ip_forward = 0). The rule that matters most, "the gateway may only read from the PLC", must be enforced outside the gateway, by the firewall or switch in front of the PLC and by the PLC itself: a gateway that has been broken into could change its own firewall.
2. Where everything runs#
sensors, float switch, pumps ──(4-20 mA, wired I/O)──► PLC: runs the station, holds every trip and interlock
│ Modbus TCP, port 502, function 3 only
│ (firewall: the gateway may only read)
┌──────────────── edge gateway (Linux, in the panel) ──▼──────────────────────────┐
│ heartd: polls its property definitions, applies rules, serves the HEART API │
│ heart-pump-feeder: reads registers 100-118, sends vitals and events ─► host socket
│ heart-monitor-report: public Pulse and alerts ──────────────────────────────────────► monitor site (HTTPS, outbound)
│ relay output ──► HEART lamp on the panel door │
└──────────────────────────────┬──────────────────────────────────────────────────┘
│ HEART API, TLS, port 43278
▼
router ──► control room SCADA (reads alerts; pages the on-call operator)
- The PLC does not know HEART is there. If the gateway crashes, is removed or is hacked, the PLC runs the station exactly as before (Part 6, Section 9, rules 1, 3 and 6).
- HEART reads safety signals; it never writes them. The trip bits and the float switch reach it only as copies in registers 115 and 118.
- Two readers, both read only.
heartdpolls its property definitions, so operators can read each value.heartd's rules do not yet watch polled properties (only values from its host feed), so a small feeder reads the same block once every 5 s and sends what the rules need (3.4).
3. The configuration#
| File | Where | What you do |
|---|---|---|
heartd | /usr/local/bin/heartd | Build it (Section 4) |
heartd.toml | /etc/heart/heartd.toml | The daemon's settings (3.1) |
card.json | /etc/heart/card.json | Who the station is (3.2) |
custom/*.json | /etc/heart/custom/ | One read-only property definition per register (3.3) |
rules.json | /etc/heart/rules.json | What becomes an alert, with which fault code (3.5) |
heart-pump-feeder.py | /usr/local/lib/heart/ | You write it (3.4) |
heart-monitor-report.py | /usr/local/lib/heart/ | You write it, from the Python template (3.6) |
Relative paths in heartd.toml are read from the file's own folder, and unknown keys are refused (CONFIGURATION.md). The example's heartd.toml, card.json, custom/tank-level.json and rules.json are the pattern. Leave out its command custom/set-flow.json: it writes a register with function 6.
3.1 heartd.toml#
[system]
card = "card.json"
instance = "station-01" # a label only: never a place name
custom_dir = "custom"
rules = "rules.json"
[server]
bind = "10.20.2.20" # the telemetry-side port, for SCADA; off loopback, TLS is required
tls_cert = "tls/gateway.crt" # /etc/heart/tls/, root:heart, key mode 0640
tls_key = "tls/gateway.key"
unix_socket = "/run/heart/api.sock" # for the two helpers on the gateway
host_socket = "/run/heart/host.sock" # the feeder's input, mode 0600
[auth]
issuer = "https://auth.water.example" # your authorisation server
public_key = "<base64 of its raw Ed25519 public key>"
[storage]
state_file = "/var/lib/heart/state.json" # the identity key is kept beside it
[core]
mode = "observe" # takes no action
autonomy_level = 1
escalate_after_sec = 600 # an error or critical alert open for 10 min is marked escalated
escalate_to = "on-call operator" # a label recorded on the alert
Leave out [model], [brain], [presence] and [uplink]: no AI model, nothing to approve, no Gateway. SCADA reads the internal values with an access token from your authorisation server, with heart_clearance internal. Off the loopback address, heartd refuses tokens that are not bound to the caller with DPoP (Security Advisory HEART-2026-001), so SCADA's HEART client must support that.
3.2 card.json#
{ "system": { "id": "org.example.water.pump-station", "name": "Example pump station", "version": "1.0.0" },
"level": "basic", "bindings": ["http-server", "unix-socket"], "pulse": { "intervalSec": 30 },
"modules": ["properties"], "shares": { "pulse": "public", "arrhythmia": "public", "property": "internal" },
"autonomy": { "level": 1 }, "capabilities": [],
"ext": { "org.heartcore.poll": { "sec": 10 } } }
The Card offers properties and nothing else. Without the custom and native modules, nobody can add a mapping or send a raw Modbus request through the API: those operations answer 501. org.heartcore.poll sets how often heartd reads its properties (the example uses 1 s; the default is 5 s).
3.3 The property definitions#
Draft them from the register map with the signal tool, then let a person check and seal them. The CSV columns are name,title,table,register,type,scale,unit,min,max,writable,description:
name,title,table,register,type,scale,unit,min,max,writable,description
sump_level,Sump level (LT-101),holding,100,int,0.001,m,0,5,,Copied from input register 0
station_flow,Station flow,holding,102,int,0.1,m3/h,0,500,,Copied from input register 2
p1_winding,Pump 1 winding temperature,holding,106,int,0.1,Cel,-20,180,,Copied from input register 6
plc_status,PLC status word,holding,115,int,1,,0,65535,,Copied from input register 15
python3 heart-templates/tools/heart_signals.py draft --system org.example.water.pump-station \
--modbus registers.csv --modbus-target tcp:10.20.1.10:502:1 > signals.json
python3 heart-templates/tools/heart_signals.py check signals.json
python3 heart-templates/tools/heart_signals.py seal signals.json --by "A. Engineer"
python3 heart-templates/tools/heart_signals.py export signals.json --out custom/
The draft makes nothing writable, and marks names that suggest a safety function (trip, interlock) as safety. Check every unit it converted. export writes one file per signal, in the format of the example's custom/tank-level.json. Sump level:
{
"name": "org.example.water.pump-station.sump_level",
"kind": "property",
"title": "Sump level (LT-101)",
"description": "Copied from input register 0",
"valueSchema": { "type": "number", "minimum": 0, "maximum": 5 },
"writable": false,
"privacy": "internal",
"native": {
"protocol": "modbus-tcp",
"target": "tcp:10.20.1.10:502:1",
"request": { "function": 3, "register": 100, "count": 1 },
"response": { "extract": { "value": { "from": "registers[0]", "scale": 0.001 } } },
"timeoutMs": 1000
},
"unit": "m"
}
Before you install them: check that every file says "function": 3 and "writable": false, and that there is no kind command. The format is in the API Standard, Section 14.
Two limits of the adapter today:
- One short TCP connection per value.
heartdopens a connection for each property it reads. Twelve properties every 10 s is 12 / 10 = 1.2 connections a second: check that your PLC's Modbus server allows it, and keep the list short. - One register per value. The extract shown reads
registers[0]. Leave the 32-bit run hours out, or have the PLC also write them in a single register.
Every value is internal, as in the guide's Step 3. Nothing is private, because nothing is personal.
3.4 The feeder#
heart-pump-feeder.py reads registers 100 to 118 in one function 3 request every 5 s, works out the guide's conditions, and sends them to heartd's host feed: one JSON line per value or event.
import json, socket
def heart_feed(line):
"""One way: values go to heartd, nothing comes back but ok."""
with socket.socket(socket.AF_UNIX) as s:
s.settimeout(1)
s.connect("/run/heart/host.sock")
s.sendall((json.dumps(line) + "\n").encode())
s.recv(256)
def poll(plc, seen):
"""CHANGE: plc is your Modbus TCP client. Read only: function 3, nothing else."""
r = plc.read_holding_registers(100, 19) # registers 100 to 118
status, bits = r[15], r[18]
running = [bool(bits & 1), bool(bits & 2)]
flow = r[2] / 10
tripped = [bool(status & 1), bool(status & 2)]
station_off = status & (4 | 8 | 16) # high-pressure trip, dry-run stop, mains lost
available = 0 if station_off else sum(not t for t in tripped)
if available == 0 or (status & 128 and not any(running)):
heart_feed({"event": "station.cannot-pump", "severity": "critical", "component": "station"})
else:
heart_feed({"recovered": "station"})
heart_feed({"vital": "p1_winding", "value": seen.lowest("p1_winding", r[6] / 10)}) # lowest of the last 5 s
...
| What the feeder sends | When | Component |
|---|---|---|
Event station.cannot-pump | No pump available (tripped, locked out, dry-run, no mains), or the PLC calls (bit 7) and nothing runs | station |
Event station.cavitation | A pump runs with vibration above 7.1 mm/s and station flow below 75 % of its commissioned flow. Assume 300 m³/h commissioned: 0.75 × 300 = 225 m³/h | p1-cavitation, p2-cavitation |
Event station.level-stuck | The sump level register has not changed for 90 s while flow is above 10 m³/h | lt-101 |
Vitals p1_winding, p2_winding | Every 5 s, the lowest reading of those 5 s, so one odd reading is not a fault | the vital's name |
Event station.mains-lost | Status bit 4, or "UPS on battery" | mains |
Event station.link-lost | Status bit 5, or "telemetry link up" is 0 | telemetry |
{"recovered": "<component>"} | The condition has cleared (a winding back below 95 °C: an assumed 5 °C hysteresis) | Resolves every open alert of that component |
If the PLC does not answer, the feeder sends nothing and never guesses. heartd notices by itself (the rule plc-silent below). The socket is mode 0600, so the feeder runs as user heart, like heartd.
3.5 rules.json#
Each line of the guide's fault table becomes a Tending rule. The utility adopts them by writing "status": "adopted" (with adoptedAt, as the rule schema asks). They only raise alerts: none has a capability.
[
{ "id": "cannot-pump", "enabled": true, "status": "adopted", "origin": "owner", "adoptedAt": "2026-11-12T09:00:00Z",
"description": "The station cannot pump water",
"when": { "event": "station.cannot-pump" },
"then": { "raise": { "code": "system.main-function-lost", "severity": "critical",
"summary": "The station cannot pump water. Check the reservoir level, and send the on-call operator." } } },
{ "id": "p1-hot", "enabled": true, "status": "adopted", "origin": "owner", "adoptedAt": "2026-11-12T09:00:00Z",
"description": "Pump 1 winding at 100 Cel or more; the PLC trips at 120",
"when": { "signal": "p1_winding", "op": ">=", "value": 100 },
"then": { "raise": { "code": "performance.too-hot", "severity": "warning",
"summary": "Pump 1's motor winding is at 100 °C or more. The PLC trips it at 120 °C. Check its cooling fan." } } },
{ "id": "plc-silent", "enabled": true, "status": "adopted", "origin": "owner", "adoptedAt": "2026-11-12T09:00:00Z",
"description": "The PLC stops answering the gateway",
"when": { "event": "component.failed", "component": "tcp:10.20.1.10:502:1", "count": 3, "windowSec": 30 },
"then": { "raise": { "code": "connectivity.dependency-unreachable", "severity": "error",
"summary": "The PLC is not answering the gateway. The station runs without HEART; its values here are old." } } }
]
| Rule | when | Fault code | Severity |
|---|---|---|---|
cannot-pump | event station.cannot-pump | system.main-function-lost | critical |
cavitation | event station.cavitation | system.main-function-degraded | warning |
level-stuck | event station.level-stuck | sensors.stuck | warning |
p1-hot, p2-hot | p1_winding, p2_winding >= 100 | performance.too-hot | warning |
mains-lost | event station.mains-lost | power.supply-lost | error |
link-lost | event station.link-lost | connectivity.link-lost | warning |
plc-silent | heartd's own event component.failed for the PLC's target, 3 times in 30 s | connectivity.dependency-unreachable | error |
Every code is in Part 3, Section 5.4. heartd raises component.failed each time a property read fails, and resolves the alert by itself when the PLC answers again. HEART's thresholds sit below the PLC's trips on purpose: a person hears that a motor is hot before the PLC stops it, and the PLC stops it whether or not HEART is there. Take the real numbers from the pumps' commissioning curves, ISO 10816 or ISO 20816 for vibration, and the motor maker's data.
The Pulse follows the worst open alert: critical makes it failing; error or warning make it degraded.
3.6 Reporting, and the lamp#
heartd has no HEART Hub setting: there is no such section in CONFIGURATION.md. Write heart-monitor-report.py from the Python template's HEART Hub code (templates README): copy monitor_identity(), monitor_post() and the hello and report loop of monitor(), and fill each report from heartd:
- Read
/heart/v1/pulseand/heart/v1/arrhythmiasthrough/run/heart/api.sock(public views, no token). For example:curl --unix-socket /run/heart/api.sock http://localhost/heart/v1/pulse. - Copy each alert's
kindintocode.heartddoes not yet put the Part 3codeon its alerts (CONFORMANCE.mdlists it as not implemented); the rule'sraise.codeends up inkind. A monitor site refuses an alert withoutcode. - Send at most 16 alerts, newest first, as in HEART Hub, Section 5. If
heartddoes not answer, send nothing, so the page shows Not heard from. - Light the lamp on the panel door while the Pulse is not
healthy, through the gateway's relay output (for example withgpiosetfrom libgpiod). This needs no network: when the telemetry link is down, the lamp is the only route.
Which monitor site. The guide's warning holds: even "this station has lost its main function" tells someone when a town's water is vulnerable. A utility should run its own monitor site (the code is open, in heart-monitor/), inside its own network rules. https://heartcortex.ai is for a trial on a test rig, not for live plant.
4. Build and install#
Build heartd for the gateway. For example, on an arm64 Linux machine for an IOT2050 or a Revolution Pi (or on x86-64 for an x86 gateway):
cd heart-core-rs
cargo build --release -p heartd
It has not been built for ARM in this project yet. Try it first against the example's simulator, as in the quick start.
Install it on the gateway, with a service user:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin heart
sudo install -m 0755 target/release/heartd /usr/local/bin/heartd
sudo install -d -m 0755 /etc/heart /etc/heart/custom /usr/local/lib/heart
sudo install -d -m 0750 -g heart /etc/heart/tls
sudo install -m 0644 heartd.toml card.json rules.json /etc/heart/
sudo install -m 0644 custom/*.json /etc/heart/custom/
sudo install -m 0644 gateway.crt /etc/heart/tls/
sudo install -m 0640 -g heart gateway.key /etc/heart/tls/
sudo install -m 0644 heart-pump-feeder.py heart-monitor-report.py /usr/local/lib/heart/
sudo heartd check /etc/heart/heartd.toml # prints "configuration is valid"
echo "net.ipv4.ip_local_reserved_ports = 43278" | sudo tee /etc/sysctl.d/99-heart.conf
sudo sysctl --system
The TLS certificate comes from the utility's own certificate authority, for the gateway's name on the telemetry VLAN.
Run heartd as a service. Use packaging/heartd.service from heart-core-rs unchanged (Running as a service):
[Unit]
Description=HEART Core
After=network.target
[Service]
ExecStart=/usr/local/bin/heartd run /etc/heart/heartd.toml
Restart=on-failure
RestartSec=2
StartLimitIntervalSec=300
StartLimitBurst=5
User=heart
Group=heart
StateDirectory=heart
RuntimeDirectory=heart
# The Core must never take resources from the product.
MemoryMax=32M
CPUQuota=10%
Nice=10
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/heart /run/heart
[Install]
WantedBy=multi-user.target
It writes only /var/lib/heart (state and identity key) and /run/heart (the two sockets). The two helpers get units of their own. The feeder (/etc/systemd/system/heart-pump-feeder.service):
[Unit]
Description=HEART pump feeder (reads the PLC, read only)
After=heartd.service
BindsTo=heartd.service
[Service]
ExecStart=/usr/bin/python3 /usr/local/lib/heart/heart-pump-feeder.py
User=heart
Group=heart
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
MemoryMax=48M
CPUQuota=5%
[Install]
WantedBy=multi-user.target
The reporter is the same, except: ExecStart=/usr/bin/python3 /usr/local/lib/heart/heart-monitor-report.py, Environment=HEART_MONITOR_URL=https://monitor.water.example (your own site), StateDirectory=heart-monitor and WorkingDirectory=/var/lib/heart-monitor for its device ID, claim code and key, and no PrivateDevices, with SupplementaryGroups= the group that may use the relay's GPIO.
sudo systemctl daemon-reload
sudo systemctl enable --now heartd heart-pump-feeder
journalctl -u heartd -u heart-pump-feeder -f # the logs
curl --unix-socket /run/heart/api.sock http://localhost/heart/v1/pulse
curl --cacert utility-ca.pem https://10.20.2.20:43278/.well-known/heart-card.json # from SCADA's side
heartd supervise adds a watchdog of its own (it needs [supervisor] heartbeat_file); systemd's Restart=on-failure is enough to start with.
5. At commissioning#
For each station:
- The PLC engineer adds the mirror block (Section 1), under the utility's change procedure, and checks it in the PLC's simulator. The station's own trips and interlocks are not touched.
- Mount the gateway on the DIN rail, on the UPS-backed 24 V supply. Port 1 to the control VLAN, port 2 to the telemetry VLAN. Turn off IP forwarding. SSH by key, from the engineering workstation only. Point its clock at the utility's time server.
- Set the firewall in front of the PLC (Section 1), then prove it: from the gateway, try a Modbus write (function 6) to a spare register with a test tool. It must be refused, and logged.
- Install
heartd, its files and the two helpers (Section 4), and startheartdand the feeder. - Compare every value that
GET /heart/v1/propertiesshows (with aninternaltoken) against the PLC's HMI or SCADA. For example, the sump level must read 2.500 m on both. A wrong scale here makes every rule wrong. - Turn reporting on:
sudo systemctl enable --now heart-monitor-report. Read the registration line withjournalctl -u heart-monitor-report, such as (made-up values):HEART Hub: register this device at https://monitor.water.example/register?id=c27e94a1b05d3f88 with claim code T6RJ9-QW2MK-HX7CN-4BZPE. - Label the inside of the panel door with the device ID, the claim code and a QR code of
https://<your monitor site>/register?id=<ID>. - Register the station on the utility's monitor site, signed in with the operations team's address, and share its page with the on-call operators. Write the device ID in the commissioning record. The claim code stays in the journal: keep the gateway's journal on the gateway.
- Run the tests of Section 7 that are safe at a live station (PLC not answering, gateway power, telemetry link, write refused). The rest belong on the test rig before installation.
- Tell the crew in the commissioning notes: the HEART lamp is separate from the PLC's alarms, and when the telemetry link is down it is the only route.
6. What the operators see#
- In the control room. SCADA reads the gateway's HEART API: the Pulse, the open alerts with their explanations, and every value, with a token. It raises its own alarm and pages the on-call operator, as for any other alarm.
- On the monitor site. The station's page shows Healthy, private to the team it is shared with. See the owner's manual.
- Every day. Nothing. The PLC runs the station; the HEART watches it.
- When something goes wrong:
| Real event | The HEART notices | The operators see |
|---|---|---|
| The sump level transmitter freezes | sensors.stuck, after 90 s with no change while water moves | Degraded: "do not trust LT-101". The PLC's dry-run protection still uses its own float switch. |
| The suction strainer blocks: flow falls, vibration rises | system.main-function-degraded (likely cavitation) | Degraded, before the pump is damaged |
| Pump 1's cooling fan is blocked | performance.too-hot from 100 °C | Degraded, and the lamp. At 120 °C the PLC trips pump 1 and starts pump 2, with or without HEART. |
| A valve on the rising main is shut by mistake: the PLC trips both pumps on high pressure | system.main-function-lost | Failing; SCADA pages the on-call operator. The reset is local, on purpose: someone must find the valve. |
| Mains is lost: the PLC and gateway run on the UPS | power.supply-lost and system.main-function-lost | Failing until mains returns and the PLC restarts a pump |
| The telemetry router drops out | connectivity.link-lost (from the PLC's bit) | Nothing reaches the control room or the site: Not heard from after 90 s (three Pulse intervals of 30 s), and one email. On site, the lamp. |
| The PLC stops answering the gateway | connectivity.dependency-unreachable, within about 10 to 30 s | Degraded. The station runs on; its values in HEART are old until the PLC answers again. |
| The gateway fails | Nothing can be sent | Not heard from after 90 s. The station runs exactly as before. |
The monitor site keeps only the status, the timing and the fault codes: never a level, a flow, a pressure or a PLC event.
- Changing anything. There is nothing to change through HEART. Pumps, trips and setpoints stay with the PLC and the people at the station.
7. Test it on the bench#
On a test rig before installation: the same PLC model with the station's program, the gateway, the firewall, and a loop calibrator and a resistance decade box in place of the transmitters. Cause every fault for real, and check the right fault code on GET /heart/v1/arrhythmias and on the monitor page, and that the PLC behaves exactly the same with the gateway on, off and removed.
| Fault | How to cause it | Expect |
|---|---|---|
| Level stuck | Hold LT-101's input at a fixed current with the calibrator while the flow input changes | sensors.stuck after 90 s. Lower the float switch: the PLC's dry-run stop still works. |
| Cavitation | Set the vibration input to 9 mm/s: 4 + 9 / 25 × 16 = 9.76 mA. Set the flow input to 180 m³/h (60 % of 300): 4 + 180 / 500 × 16 = 9.76 mA | system.main-function-degraded |
| Motor hot | Replace pump 1's PT100 with the decade box: 138.5 Ω for 100 °C, then about 146.1 Ω for 120 °C | performance.too-hot at 100 °C; at 120 °C the PLC trips pump 1. Repeat with heartd stopped: the PLC trips the same. |
| High-pressure trip | Raise the pressure input above the trip setting | system.main-function-lost (critical); the PLC needs its local reset |
| Mains lost | Switch off mains to the rig; the UPS carries the PLC and gateway | power.supply-lost and system.main-function-lost |
| Telemetry link lost | Unplug the router's uplink for 3 minutes | connectivity.link-lost; Not heard from, then Healthy again; the lamp on site |
| PLC not answering | Unplug the gateway's port 1 cable | connectivity.dependency-unreachable; resolved by itself when plugged back |
| Gateway removed | Pull the gateway's power | The PLC runs on unchanged; Not heard from after 90 s |
| A write to the PLC | From the gateway, send function 6 and function 16 to registers 100 and 0 with a test tool | Refused by the firewall (and by the PLC, if it can); nothing changes in the PLC |
| A write through HEART | PUT /heart/v1/properties/org.example.water.pump-station.sump_level, and PUT /heart/v1/custom/anything, with an internal token | Refused: the property is not writable, and custom answers 501. Note the codes you get: the guide's 403 capability-not-allowed is the simulator's. |
Then run the HEART-Rating client against the gateway, from the station network, and claim only what passed. Expect it to notice that alerts carry kind and not code (Section 3.6) until heartd adds it.
8. The safety boundary and standards: what to expect#
| Layer | In this station | Run by | HEART's role |
|---|---|---|---|
| Safety (trips, interlocks) | Dry-run stop, high-pressure trip, thermal trips, emergency stop | The PLC and hardwired relays | None. Read-only copies at most |
| Real-time control | Start and stop on level, duty and standby, the PLC scan | The PLC | None in the loop. It reads values |
| Supervision | Start and stop levels, duty rotation | Operators and engineers | Nothing, in this design |
| Maintenance and diagnosis | Cavitation, hot motors, stuck sensors | Engineers | Its main value: notices, explains, tells a person |
- Read only, three times over.
heartdhas only function 3 definitions and offers no way to add others (nocustomornativemodule). The PLC serves a mirror block that controls nothing. The firewall in front of the PLC passes only reads from the gateway. Keep all three:heartd's adapter can send function 6, so the configuration alone is not enough. - No Stop button, by design. The standard asks for a HEART Stop control only from Autonomy Level A3. This HEART takes no action. The station's emergency stop is a hardwired safety function, not a HEART control.
- Fail safe. Every test in Section 7 that removes the gateway must show the station unchanged. If one does not, HEART is in the wrong place.
- HEART conformance says nothing about the safety or security of the station. The PLC program, the safety functions and the network still need their own work: for example IEC 61508 or IEC 61511 for safety functions, IEC 62443 for industrial security (the gateway sits in the control zone, with a conduit to the telemetry zone), and the utility's own regulator.
9. Going further#
- Read the input tables directly. Add functions 2 and 4 to
heartd's Modbus adapter (crates/heartd/src/adapters.rsinheart-core-rs), and the mirror block is no longer needed. Give the gateway no write function at all, and keep the firewall rule. - Rules on polled values. Today
heartd's rules watch only values from its host feed, so the feeder sends them. Ifheartdfed its polled properties to its rules, the feeder could shrink to the checks across two signals (cavitation, the stuck level). - Autonomy Level A2 ("flow fell from 300 to 175 m³/h and vibration rose from 2.2 to 9.4 mm/s within a minute: likely a blocked strainer, confidence medium"): HEART Connected, with Rhythm events as evidence. Set the Card's
leveltoconnected. - A3, only in the supervision layer, such as "make pump 2 duty until pump 1's bearing is checked": HEART Full, a person's approval with a passkey (
[presence]), and the PLC still accepts or refuses it with its own interlocks. That would need a write path, a new safety review, and is not part of this design. Resetting a trip stays local, at every level. - Many stations. Each gateway can join one HEART Gateway in the control room (
heartd gateway, with[uplink]on each station), so SCADA reads one port for the whole network. - An AI model that explains alerts, under a brain lease from a Command Center: the
[brain]section, shown commented out in the example'sheartd.toml. It only explains and suggests, and the utility's own site would be the Command Center.