Technology · for reading on your own
A factory that evidences its interventions
Five plants, two protocols, a historised twin and a control loop that may change manipulated variables — but only after four instances in turn have had the chance to say no and one of them is a human. This page shows how that is built and exactly where the automation reaches into a machine.
5
plants, 22 units — four over OPC UA, one over Modbus/TCP
21
writable points out of 670 metrics. Everything else is read-only.
0
models in the control loop. The loop is rule-based — one is trained, it predicts tool life and proposes nothing.
250
tests, green. Most of them pin down a bug that was once real.
The cross-section
There is exactly one way up and exactly one way back. The way back is the same one a third-party control system would have to take — not a shortcut into the simulation.
Every layer computes only its own thing. The simulation knows no protocol, the controller knows no database, the control loop does not know a single plant by name — it knows Regelwerk objects (rule sets). That is why a fifth plant costs a module and an entry, not a rebuild.
| Layer | Component | Technology | Port |
|---|---|---|---|
| 0 | Simulation | Python 3.12, own core — named random streams, deterministic | — |
| 1 | Controller | asyncua 2.0 — real OPC UA server, browsable with UaExpert | 4840 |
| 1 | Legacy machine | pymodbus 3.15 — Modbus/TCP slave, readable with mbpoll | 5020 |
| 2 | Edge gateway | pysparkplug via paho-mqtt, Eclipse Mosquitto 2 | 1883 |
| 2 | Modbus connector | pymodbus client, KPI calculation at the edge | — |
| 3 | Historian | TimescaleDB on PostgreSQL 16 — hypertable and continuous aggregation | 5442 |
| 3 | API | FastAPI on uvicorn, asyncpg pool | 8021 |
| 3 | Control loop | MAPE-K, cycle 20 s; ledger with Ed25519 | — |
| 4 | Interface | SvelteKit with adapter-node, three.js for the hall view | 5180 |
Four plants speak OPC UA, one speaks Modbus. Both chains end in the same Sparkplug group, with metric names that are the idShortPath of the AAS — no translation step, no mapping table that somebody has to maintain. How the cables run is described under Data paths.
Where the automation intervenes
The automation intervenes at exactly 21 of 670 points — and even there only after four instances in turn have had the chance to stop it.
Writable is what the unit itself reports as a manipulated variable — not what is on a list. The plant additionally clamps its limits itself.
- M
Monitor
loop/monitor.pyReads the AAS twin, not the raw sensor data. Cockpit, control loop and third-party tools thereby see the same truth. Aggregated time-weighted, because the gateway only sends changes.
- A
Assess
loop/regelwerke.pyThis is where the domain knowledge lives, one per plant: on the line the bottleneck, in the cell the queue, at the filling line the back-pressure, at the reactor the exotherm. What comes out is a sentence a foreman would say.
- P
Plan
loop/plan.pyTurns the finding into a limited proposal: a target value, a step, a predicted effect as a number. Without this forecast it could not later be checked whether the proposal was good.
- 1
The gate
can say no loop/policies/policies.yamlChecks against the policy and justifies every rejection individually: limits, step size, minimum confidence 0.55, at least 15 minutes of observation, 45 minutes of plant runtime, 20 minutes spacing — and a four-hour lockout for a target value that has already been rejected once. A YAML file, not code.
- 2
The human
Approval requiredWithout approval nothing happens. The proposal lapses after 30 minutes because the situation it relied on is then no longer the current one. Who approved it appears in the evidence ledger.
- 3
The trial run
can say no loop/canary.pyAn approved change is not adopted but run on trial. Afterwards the measured effect decides, not the hoped-for one. Whatever delivers less than announced goes back. No grey area.
- 4
The watchdog
abortsRuns alongside during the experiment. If scrap rises above the limit or the OEE drops away, it aborts immediately. A safety event at the reactor is even checked before the grace period: KPIs fluctuate after a change, an emergency shutdown does not.
- 5
The noise band
indistinguishableA measured change that is smaller than the KPI’s natural scatter is neither evidence nor refutation. Booking it as a success would be a sales brochure, as a failure a statement the measurement does not support.
- K
Execute and attest
loop/execute.pyThe only write path: Sparkplug DCMD to the gateway, which puts it onto OPC UA — the same way a third-party control system would have to go. Every step before and after is appended, hash-chained and signed.
What comes out
| Outcome | Count | What it means |
|---|---|---|
| Rejected by the gate | – | Every rejection with an individual rationale: limits, step size, confidence, observation period, spacing. The gate is not a form. |
| Confirmed | – | Effect measured and sufficiently close to the forecast. |
| Rolled back | – | The trial run did not confirm the forecast — the old value came back. |
| Applied and standing | – | Permanently adopted changes. |
| Autonomy | – | The policy requires confirmed forecasts before autonomy is even up for debate. |
From /api/loop/bilanz — the numbers are not in the source code. In live operation they count over the entire runtime, in the public version over the recorded window; that is why they are smaller there.
Why no model
In the entire repository there is no torch, no sklearn, no tensorflow and no model call. The control loop is rule-based, and deliberately so.
The reason is in a comment above the rule sets: a model that says “set it to 1.06” is worthless at the acceptance meeting if nobody can explain why. What comes out here instead is a sentence a foreman would say — with KPIs beside it and a forecast against which the proposal must later be measured.
The second reason is metrological and weighs more: the scatter is larger than the effect. On the line, output fluctuates by ±14 % at an unchanged manipulated variable, at the filling line by ±25 %, at the reactors by ±77 %. A model that learns from such data learns the noise first. Before learning becomes meaningful, the measurement must get better — not the algorithm. How a model would dock on nevertheless is described under Model and docking point.
The evidence
Every finding, every proposal, every rejection with its rationale, every approval with a name, every verdict: one entry, appended, hash-chained, signed with Ed25519. The hash covers the form the database stores — the payload first runs once through jsonb, because numeric has no negative zero.
The chain check reports a break. It stays.
The cause was exactly this negative zero, found and fixed — not tampering. What happened is recorded as its own entry in the chain itself. An append-only record is not cleaned up, not even by its operator: at the meeting a visible break with an explanation is the better argument than a smooth chain. The check worked, and nobody tidied up. It can be seen under Control loop.
What it is not
The limits are stated here so that they are not found at the meeting.
Not a real plant
The physics is simulated
Taylor tool life, Arrhenius kinetics, MTBF/MTTR — plausibly parameterised and checked against synthetic cases, but not calibrated on a real machine. Protocols, twin and control loop, by contrast, are real.
Conformity
Two of thirteen submodel types follow IDTA templates
Nameplate and technical data. Operating state, OEE, manipulated variables and everything plant-specific sit under their own IRI — and the conformity report in the AAS Explorer states exactly that, instead of writing a foreign semanticId beside it.
Open
One plant cannot evidence its effect
In the CNC cell the manipulated variable acts on only one of five machines. The loop permanently says “indistinguishable” there — correct, but unsatisfying. The comparison against an unchanged sister unit is the next planned step.
Open
No AAS server, no evidence layer
The twin lives in the historian; a BaSyx server alongside would be the next conformity argument. Order and batch context down to the individual workpiece is planned but not started.
Check for yourself
In this project the rule is: where a number is asserted, the command to recompute it belongs beside it. That applies to this page too.
# Status and tests systemctl --user status zenfactory.target .venv/bin/python -m pytest -q 207 passed # The twin, live curl -s localhost:8021/api/anlagen | jq curl -s localhost:8021/api/ledger/verify | jq # The protocols raw — without ZenFactory in between scripts/opcua_browse.py --watch ST020 OPC UA mbpoll -a 1 -r 1 -c 15 -1 127.0.0.1 -p 5020 Modbus # One plant's operating point, measured reproducibly .venv/bin/python scripts/sweep_arbeitspunkt.py REAKTOREN --stunden 96 --seeds 3
| Question | File |
|---|---|
| What may the control layer do? | loop/policies/policies.yaml |
| Where does the domain knowledge per plant come from? | loop/regelwerke.py |
| Where is the single write path? | loop/execute.py |
| What does the twin look like? | aas/models/ |
| What is on the wire of the legacy machine? | config/registerkarte_zs100.yaml |
| Which assumptions are in the euro amount? | config/wirtschaft.yaml |
| How is the whole thing put together? | docs/ARCHITEKTUR.md |
| What happens on a restart? | docs/BETRIEB.md |
| Why is something the way it is? | docs/journal/decisions.md |