What the simulated floor does, and the stories it plants¶
A plant that simulates runs two things no PLC can give it. The line is
machines: states, counters and process values, replayed from
labs/kepsim/out by fsmes run-opc-sim --replay. The floor is people:
fsmes run-operations records quality checks, issues material, names stops,
works through non-conformances and finishes orders — over the public API,
exactly as an operator's browser would.
Neither of them is in the product's code. The line is
labs/kepsim/line.json;
the floor is a floor script the plant pack names with [files] floor
(MES_FLOOR_SCRIPT_FILE). A plant whose pack names no floor script records
what the tag said, names no gauge and labels no stop — which is what every
real plant wants, because it has people instead.
Why a plant that behaves is not enough¶
Measured on the bottling lab plant on 2026-10-04, after a week up:
- 0 of the last 500 quality checks named a gauge. The column was there and nothing wrote it, so no reading could answer the first question anybody asks about a point on a control chart: did the process move, or did the gauge?
- Seven days of downtime, 100 % unlabelled. The pareto was one bar.
- 887 non-conformances raised and never reviewed. The quality screen said the same thing for a week, and so did every answer about the biggest problem on the plant.
None of that is a bug in the MES. It is a plant with no people in it, and an analysis is only as good as what the plant recorded.
The six stories¶
Each one is data, each leaves a trace in the records, and each recurs once an hour because the line's file loops — so any hour of this plant's history contains all six.
| The story | Where it is written | What it leaves behind |
|---|---|---|
| The filler's supply pressure falls away 10 % for six minutes, with no alarm | line.json, an offset event with an effect on FillWeight |
every bottle in that window about four grams light, and NozzlePressure one tag over says why |
| The washer runs three degrees hot | line.json, a scrap_burst with an effect downstream |
failing fill weights at the filler whose cause is a station upstream |
| The first bottles after a changeover run heavy | line.json, an offset beginning where the changeover ends |
a point or two above the control limit, immediately after a planned stop — which the floor labels changeover, so it can be found |
| One of the two scales is drifting | the pack's floor.json |
that scale's readings run about 0.6 g above the other's, until the supervisor calibrates it |
| The night shift's readings of the same process are more spread out | the pack's floor.json |
a wider spread on the night shift with no change in the process behind it |
| The nozzle the changeover left behind fills a shade over | the pack's floor.json, sampling.fill_height.after_changeover |
for twenty-five line minutes after each labelled changeover, the fill-height sample means sit about a millimetre and a half above the centre line while the sample ranges do not move: the process shifted, its spread did not. The checkweigher's own band absorbs it, so it is invisible on fill weight and plain on the height chart — which is what an X-bar chart is for. A millimetre and a half is 2.9 sigma of a sample mean on this line and lands just under A₂R̄, so nearly every post-changeover sample mean breaks the two-sigma line (rule 2 fires reliably) and the tallest break the three-sigma one (rule 1); the pack's _how_big_a_millimetre_and_a_half_is has the arithmetic |
And one that is about the MES rather than the plant: a tag goes quiet for
fifteen minutes. FillWeight stops arriving while the filler runs on and
every other signal on it reports. The MES's last sample keeps the moment it
really arrived, so the floor can tell a stale value from a steady one — and it
records a plausible value with no gauge and no station on it rather than
writing the frozen number down once a minute. null in those two columns is
not recorded, which is a different fact from a reading nothing measured.
What the floor will not say¶
It does not label an idle machine. This line's tag map maps starved and
blocked both onto idle, so by the time the MES holds the interval the
difference is gone — and the plant's vocabulary has a word for each, neither
of which anything recorded can choose between. Guessing one would put a cause
in the pareto that nothing observed. The floor says so once, in its log, as a
finding about the state map rather than as a gap in the script.
It does not name a stop it did not see. A stop shorter than one look
(MES_OPS_WATCH_EVERY, fifteen seconds) is missed, and the pareto reports
those seconds as unlabelled. That residue is real, and house rule 3 says it is
reported as unlabelled rather than filed under "other".
It names a stop afterwards, not as it begins. The moment a machine goes
down is the moment nobody knows why yet, and on a plant whose states arrive
from an OPC agent nobody is ever asked. POST /equipment/{code}/stops/label
is where somebody says what it was; it never creates an interval, because an
interval is this MES's own observation and manufacturing one from a claim
would put seconds into availability that nobody watched.
No tool sends that route. Naming a stop is somebody's account of why a
machine stopped, and a model choosing between six reasons from the shape of
the data would be writing fiction into the pareto with a confidence interval
on it. The assistant's honest answer is to show which stops are unnamed and
let whoever was there say. Another system's label has its own front door,
fsmes inbound, and it records who claimed it.
A floor that has stopped, and how to tell¶
The floor signs in to the plant like anybody else, and a login is good for one
shift — [auth] token_ttl_seconds, twelve hours by default. On 2026-10-06 both
lab plants' floors stopped at 00:52 and stayed stopped for nine hours. Nothing
had crashed: twelve hours to the minute after they started, every request they
made came back 401, each step logged shop floor step failed and tried
again on the next tick, and the plant went on answering every screen and every
health check perfectly, because a work order read answers fine whether or not
anybody is working.
Two things came out of that, and both are in the product now.
The floor signs in again. It replaces its login halfway through the token's
life — read out of the token the plant issued, not out of this floor's copy of
the configuration — so the first failed step never happens; and a 401 it meets
anyway is answered by signing in once and sending that step again, rather
than losing it and recovering on the next tick. Each re-sign-in is one line in
the log, the floor signed in again, with why. A plant that will not issue a
new login at all — a password changed under a running floor — is said once,
as an error, not once a step.
GET /health says when the floor last recorded a check.
"floor": { "last_check_recorded": "2026-10-06T14:52:16", "seconds_since": 31.4 }
A plant that is up and a plant that is being worked are two facts, and a
monitor that only has the first reads silence as everything being fine. A
plant where nobody has recorded a check yet answers null for both — never
inspected is not a long time ago, and it is not zero.
What to watch it with: the floor inspects every eight line seconds, so
seconds_since above a few minutes on a plant that is meant to be simulating
means the floor has stopped, whatever else the plant says about itself.
/health needs no credential.
Keeping the book full¶
A pack's order book is finite. Bottling's ten orders are about twenty-five
hours of its line's rated output, and the lab plant runs for weeks: on
2026-10-07 at 04:21 UTC, forty-three hours after it was built, it finished
WO-ACME-4720 and from then on had no released or running order at all. The
station page said Nothing queued on this machine, the characteristic
dropdown was empty, and every quality check carried no order — the screens
and the analysis on top of them all hang off an open order.
The floor does not fix that by inventing one; it never will again (decision 0019). What was missing was a person. A plant has three — an operator, a shift supervisor, and a production planner, who in a real plant is where the orders come from, or the ERP is. A lab plant has no ERP, so a pack can ask for the planner:
{
"planning": {
"keep_planned": 3
}
}
Three orders planned behind whatever is on the line. FLOOR-PLAN — a third
identity, holding orders.create and plant.read and nothing else — creates them over
POST /workorders, and what it writes comes out of the plant's own book,
read back over the API: the materials, quantities and priorities this plant
has already made, in rotation; the code sequence continued from the highest
one in the book; the due dates spaced the way the book's own are. It opens no
pack file and invents nothing. A plant whose book has never held an order
gets no order — it says the pattern is missing, once, and plans nothing.
It plans and does not release. orders.release belongs to the supervisor, so
a planned order waits until work_the_book puts it on the line, and the
audit trail reads planner, then supervisor, then floor — three rows, three
names, the way a real plant's would.
keep_planned is 0 by default, and 0 is no planner. A pack that says
nothing behaves exactly as it did before this existed, which is what
labs/experiments/over-run.toml depends on: a book that runs out is the
whole point of that experiment. How often the planner looks is the floor's
own cadence, [floor] plan_every (MES_OPS_PLAN_EVERY, sixty seconds),
divided by the replay speed like every other cadence here.
A plant initialised before the account existed has no FLOOR-PLAN to sign in
as. The floor says so once — no planner account; the order book will not be
topped up — and runs as it did before. fsmes plant <name> migrate adds it.
Reading the script¶
The bottling pack's
floor.json
is the worked example, and every block in it carries a _why. The shape:
{
"planning": {"keep_planned": 3},
"measurement": {
"stale_after_s": 150,
"shift_spread": {"NIGHT": 2.5},
"characteristics": {
"fill_weight": {
"spread": 0.3,
"gauges": [
{"gauge": "SCALE-FILL-01", "share": 3},
{"gauge": "SCALE-FILL-02", "share": 1,
"drift": {"per_day": 0.01, "max_bias": 0.6}}
]
}
}
},
"stops": {"micro_stop_under_s": 60, "micro_stop": "micro_stop",
"longer_than_a_micro_stop": "breakdown", "changeover": "changeover"},
"nonconformances": {"per_pass": 6, "leave_open": 3,
"by_severity": {"major": {"disposition": "scrap", "reason": "…"}},
"otherwise": {"disposition": "use_as_is", "reason": "…"}}
}
A characteristic nobody's machine publishes¶
Fill height is measured by hand on a bench: five bottles come off the filler, are carried to the QI station, are measured one after another, and the five readings are posted together as one sample (decision 0040). No tag on this line carries a height, so the floor has to make the pieces — and the arithmetic that turns a weight into a height is this plant's bottle, not the product's, which is why it is in the pack:
{
"sampling": {
"fill_height": {
"every_line_s": 900,
"from": {"equipment": "FILL01", "tag": "FillWeight"},
"convert": {"offset": 12.0, "per_unit": 0.26},
"piece_to_piece": 0.35,
"after_changeover": {"line_minutes": 25, "offset": 0.5}
}
}
}
every_line_s— line seconds between samples, divided by the replay speed like every other cadence here. Nine hundred is four samples an hour.from— the five pieces are the last five stored readings of that tag off that machine: five different bottles, each weighed at a different instant, which is what a sample of five is. If five readings newer than the last sample are not there, this floor takes nothing and says so once rather than measuring the same bottle five times. A sample of five that is one bottle copied five times has a range of zero and would make every chart drawn from it a lie.convert—height_mm = offset + per_unit × weight_g, one straight line, because the bottle is a cylinder over the band that matters.piece_to_piece— millimetres, one sigma: the moulding varies, so two bottles holding the same weight do not stand at the same height. This is the variation a sample of five is taken to measure, and it is deliberately larger than the gauge's own spread — an X-bar and R chart whose range came mostly from the instrument would be charting the instrument.after_changeover— the planted cause in the table above.
Each of the five pieces is then measured through the bench gauge from the register, the same way a single reading is: its resolution rounds the value and its drift, if it has any, biases it.
The gauges themselves are master data, not script: they are in the pack's
masterdata/gauges.json and on the plant's own register, so a calibration
recorded on the Gauges screen is in force for the next reading this floor
takes. The script only says which gauge measures what, how often each is
picked up, and which one is drifting.
A disposition's reason is in the script on purpose. Use as is on a batch that failed its specification is a concession somebody has to defend a year later, and a simulated plant that invented the sentence would be writing the one field nobody can check.