# 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](https://heartcortex.ai/monitor) 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`](../guides/Water-Pump-Station.md).

**On a real device:** [Water Pump Station, for real](https://heartcortex.ai/docs/real-devices/Water-Pump-Station.md) 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.

```sh
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

1. **Sign in.** Open `https://heartcortex.ai/login` and enter your email address. A 6-digit code arrives; type it in. There are no passwords.
2. **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.
3. **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 at `https://heartcortex.ai/devices`.
4. **Share, if you want.** The page is private until you make a share link or make it public.
5. **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:

```sh
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**

1. **Read the PLC.** Replace `World` and `Station` with 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.

2. **Your rules and thresholds.** Edit `RULES` and `EXPLAIN` with 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.

3. **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.

4. **Your privacy table.** Go through `properties()` and give every value its level (Step 3 of the case study). Operational data is `internal`; only status and fault codes are `public`. 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`:

```sh
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](https://heartcortex.ai).
