Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/agentic-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ What is left, after the scar tissue, is five ideas and one posture.
| **`handoffs/*.md`** | 116 files, 100 done; `Goal / Why / Constraints / Definition of done / Out of scope / Result` | **the draft rows that already exist** — a document revision, a trigger, an adjustment, a reason, a severity | `Why` cites its provenance; `Result` carries *Verified* and *Left out, and why*. A proposal's `rationale` and `evidence` are the same two fields |
| **`journal/`** | 479 stanzas, append-only, several concurrent writers, no locking — timestamps run backwards and three files carry a duplicate heading | **`ai_turns` and `audit_log`** | Already there and already better: indexed, pruned to a stated horizon, gated on `audit.read`, and one writer per row |
| **`reports/fitness-*.md`** | the crew measuring itself weekly | **the eval suite and `fsmes assist coverage`** | Both exist. Neither is on the AI tab yet (§8) |
| **`bin/crew-guard`** | seven mechanical refusals | **the capability checks, and `shadow.REGISTER`'s 36 entries** | The lesson, not the mechanism: *every rule in the guard exists because prose failed first.* The role file said "you do not loop" and the agent looped for four days |
| **`bin/crew-guard`** | seven mechanical refusals | **the capability checks, and `shadow.REGISTER`'s 38 entries** | The lesson, not the mechanism: *every rule in the guard exists because prose failed first.* The role file said "you do not loop" and the agent looped for four days |
| **panes, sessions, `--resume`, permission modes, `clear_trust_dialog`** | ~80 of `crew-up`'s 200 lines | **nothing** | This is the "not exactly". A server process has one long-lived loop already (`api/app.py`'s lifespan) and needs none of it |

### The five ideas worth taking, in order
Expand Down
183 changes: 183 additions & 0 deletions docs/operate/scenarios.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Run a product through a line by writing tags

A scenario is a file of "at this time, this tag takes this value". `fsmes
scenario play` writes it into an OPC UA server — KEPServerEX, or any other —
so every system that reads that server sees the same thing happen: this MES,
the MES you run today, a script you are testing. One run, two MESs, the same
cans.

```bash
fsmes scenario check line-trial.json # is the file right?
fsmes scenario check line-trial.json --endpoint opc.tcp://kepware:49320 \
--security Basic256Sha256,SignAndEncrypt --user mes --password ...
fsmes scenario play line-trial.json --endpoint opc.tcp://kepware:49320 ...
```

`play` prints a line per write as the clock reaches it and stops at the end or
on Ctrl-C. `--speed 4` plays four times as fast; `--from 02:00` starts two
minutes in. The connection flags default to the agent's own settings
(`MES_OPC_ENDPOINT`, `MES_OPC_SECURITY`, `MES_OPC_USER`, `MES_OPC_PASSWORD`)
and the same client certificate, so a server that already trusts the agent
trusts this.

## The safety rule

Only tags on a **Simulator** or **Memory Based** channel are ever written. A
write to a tag on a PLC driver reaches the PLC, so before writing anything the
player reads every tag it will write and refuses the whole run if any one of
them fails:

- **Refused** — the tag's channel is not declared in the file's `drivers:`
block as `Simulator` or `Memory Based`; or it is declared as anything else
(a PLC driver, `Advanced Simulator`); or Kepware shows signs of real
equipment behind it (see below), whatever the file says. The refusal names
the channel and the reason. There is no flag to override it.
- **Read-only** — the server does not let this client write the tag. Reported,
never written.
- **Missing** or **type** — the tag is not on the server, or a value in the
file cannot be written as the tag's type (text into a number, 40000 into a
Short, three values into an array of four).

`fsmes scenario check --endpoint` runs exactly these checks, writes nothing,
and prints one line per tag: type, access, node.

In shadow mode (`MES_SHADOW=true`) `play` writes nothing at all: shadow mode
promises this MES changes nothing outside its own database, and a scenario
changes tags on another server. Run the player from a shell without it.

Why the file has to name the driver: Kepware's address space does not say which
driver a channel uses — its System Tags list names none
([Kepware Server help, System Tags](https://support.ptc.com/help/kepware/kepware_server/en/kepware/server/system-tags.html)).
What it does show is the difference between driver classes: a serial driver's
channel has `_System._ComId`, an Ethernet driver's channel has
`_System._NetworkAdapter`, and an Ethernet device's `_System._DeviceId` is its
IP address. The player looks for those three on every Kepware channel it writes,
and any one of them refuses the channel even if the file says `Simulator`.
(A Kepware server is recognised by `KEPServerEX` in its namespace table.)

## The file

JSON, one object. Any key starting with `_` is a note and is ignored; any other
unknown key is a mistake and `check` names its line.

```json
{
"name": "three cans through the line",
"drivers": {"SimScratch": "Simulator"},
"tags": {
"ld.good": "ns=2;s=SimScratch.LD.GoodCount",
"ld.serial": "ns=2;s=SimScratch.LD.Serial",
"qi.pass": {"node": "ns=2;s=SimScratch.QI.Pass", "type": "Boolean"}
},
"steps": [
{"at": "00:00", "tag": "ld.serial", "value": "TRIAL-1"},
{"at": "00:10", "tag": "ld.good", "ramp": {"from": 0, "to": 400, "over": 120, "every": 10}}
],
"product": {
"id": "CAN-{n:04d}", "units": 3, "every_s": 20, "advance_s": 8, "start": "00:02",
"stations": [
{"station": "LD", "count": "ld.good", "id": "ld.serial"},
{"station": "QI", "set": {"qi.pass": true}}
]
}
}
```

**`tags`** — a short name for each tag, so the rest of the file reads
`ld.good` and not a node id. A tag is a node id (`ns=2;s=<channel>.<device>.<tag>`
on Kepware) or a browse path from Objects (`LD/FeedRateSP`, in the file's
`namespace`). `type` is read only by `scenario serve`; against a real server
the tag's own type is read from the server.

**`drivers`** — each channel the scenario writes, and its driver. The channel is
the first part of the address: `SimScratch` in `ns=2;s=SimScratch.LD.GoodCount`,
`LD` in `LD/FeedRateSP`.

**`steps`** — `at` (seconds, `"mm:ss"` or `"h:mm:ss"` from the start), `tag`,
and either a `value` (a number, true/false, text, or a list for an array tag)
or a `ramp`: `from`, `to`, `over` seconds, a write `every` seconds (default 1).
A ramp between two whole numbers writes whole numbers.

**`product`** — one block, or a list of them. A product goes through
`stations` in order, `advance_s` seconds at each (a station can give its own
`advance_s`). `units` products enter `every_s` apart, the first at `start`.
At each station, on arrival:

| key | what is written |
|---|---|
| `count` | the tag goes up by `by` (default 1) **from what it holds now** — read from the server at that moment, never reset |
| `state` | set to `running` (default 1); set to `after`, if given, when the product leaves |
| `id` | set to the product's id: `id` with `{n}` filled in (`CAN-{n:04d}` → `CAN-0001`), numbered from `first` (default 1) |
| `set` | each tag: value given |

Two writes at the same second go in the order the file gives them. `fsmes
scenario check <file> --timeline` prints every write in order before anything
is played.

## Rehearse without Kepware

```bash
fsmes scenario serve labs/kepsim/scenarios/product-through-the-line.json
fsmes scenario play labs/kepsim/scenarios/product-through-the-line.json \
--endpoint opc.tcp://127.0.0.1:4945/fsmes/scenario
```

`serve` holds every tag of the file as a writable memory tag at the file's own
addresses, like a Kepware Memory Based channel, on loopback, anonymous and
unencrypted. Anything that reads OPC UA can watch it.

Two examples live in [`labs/kepsim/scenarios/`](https://github.com/factorysemantics/factorysemantics-mes/tree/main/labs/kepsim/scenarios):
`product-through-the-line.json` (three cans through the six KepSim stations,
for `serve` or a scratch Kepware channel) and `setpoint-drill.json` (steps and
a ramp on the setpoints of the KepSim replay server, `fsmes run-opc-sim
--replay` — the only tags that server lets a client write; its station tags
are replayed from a file and read-only, and `check` says so).

## Kepware: what can be written

From Kepware's own documentation:

- **Simulator driver** — register (`R`), constant (`K`) and string (`S`)
addresses are Read/Write and hold what is written. **But an `R` register
read as an integer type goes up by one on every read** — it is the driver's
"live data". Put counts and states on `K` addresses (`K0000`, Short or
DWord), text on `S` addresses, and arrays as `K0010 [4]`. The simulation
functions — `RAMP`, `SINE`, `RANDOM`, `USER` — are read-only and keep
generating; the player cannot write them. (Simulator Driver Help, "Address
Descriptions" and "Simulation Functions are Read Only Objects".)
- **Memory Based driver** — a simulated device inside the server that keeps
its tag values, even between server runs (Memory Based Driver Help,
overview). Its help lists a "read only" address error but the access table
was not found; `check --endpoint` reads each tag's real access level, so
your server answers this.
- **Advanced Simulator driver** — tags come from rows of a database over ODBC
(its product sheet). Whether they accept a write was not found in its
documentation; refused here regardless, because the next row overwrites
whatever a scenario writes.
- **Any PLC driver** (Siemens, Allen-Bradley, Modbus, …) — a write reaches the
PLC. Refused here, always.

Two Kepware habits worth knowing:

- **The first connection is rejected** until the client certificate is trusted:
OPC UA Configuration Manager → Trusted Clients → select the rejected
certificate → Trust, then reconnect (some versions want the runtime
reinitialised). The certificate is the agent's own
(`certs/mes_twin_client.der`), so trusting it once covers both.
- **Writes carry no timestamp.** Kepware, like many servers, refuses a write
with a source timestamp; the player sends value only.

## A scratch channel with your own tag names

So both MESs read the scenario where they already read the line, make a
channel that only the scenario writes:

1. In the Kepware configuration, **Add Channel → Simulator** (or Memory
Based), named for what it is — `Scratch`, say.
2. Add a device under it with the device name your MESs expect, then a tag per
tag the trial needs, on `K` and `S` addresses, with the data type the real
tag has.
3. Point each MES at the scratch tags for the trial (a tag map in this MES; the
equivalent in the other), and list the channel in the file's `drivers:`.
4. `fsmes scenario check <file> --endpoint ...` until every tag says `write`.
5. `fsmes scenario play <file> --endpoint ...`.
1 change: 1 addition & 0 deletions labs/kepsim/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ generated at.
| `kepware_setup.py` | Config API: channel + devices (`--discover` / `--delete`) |
| `verify_readback.py` | OPC UA read-back proof, driven by the MES tag map |
| `scenario.md` | what breaks, when, and why it's interesting |
| `scenarios/` | files for `fsmes scenario play`: a product through the six stations, a setpoint drill — see [docs/operate/scenarios.md](../../docs/operate/scenarios.md) |

The MES-side pieces live in the main package:
`src/fsmes/integrations/opc/csv_replay.py` (the replay server),
Expand Down
30 changes: 30 additions & 0 deletions labs/kepsim/scenarios/product-through-the-line.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"_note": "Three cans through the KepSim line's six stations, LD to Palletiser, 20 s apart. The tags sit on a scratch Simulator channel, SimScratch, so the replayed line is never touched. Rehearse with `fsmes scenario serve` (docs/operate/scenarios.md).",
"name": "three cans through the line",
"drivers": {"SimScratch": "Simulator"},
"tags": {
"ld.state": "ns=2;s=SimScratch.LD.State",
"ld.good": "ns=2;s=SimScratch.LD.GoodCount",
"ld.serial": "ns=2;s=SimScratch.LD.Serial",
"rd.state": "ns=2;s=SimScratch.RD.State",
"rd.good": "ns=2;s=SimScratch.RD.GoodCount",
"wash.state": "ns=2;s=SimScratch.Washer.State",
"wash.good": "ns=2;s=SimScratch.Washer.GoodCount",
"qi.good": "ns=2;s=SimScratch.QI.GoodCount",
"qi.serial": "ns=2;s=SimScratch.QI.Serial",
"qi.pass": {"node": "ns=2;s=SimScratch.QI.Pass", "type": "Boolean"},
"fill.good": "ns=2;s=SimScratch.Refill.GoodCount",
"pal.good": "ns=2;s=SimScratch.Palletiser.GoodCount"
},
"product": {
"id": "CAN-{n:04d}", "units": 3, "every_s": 20, "advance_s": 8, "start": "00:02",
"stations": [
{"station": "LD", "state": "ld.state", "running": 1, "after": 0, "count": "ld.good", "id": "ld.serial"},
{"station": "RD", "state": "rd.state", "running": 1, "after": 0, "count": "rd.good"},
{"station": "Washer", "state": "wash.state", "running": 1, "after": 0, "count": "wash.good"},
{"station": "QI", "count": "qi.good", "id": "qi.serial", "set": {"qi.pass": true}},
{"station": "Refill", "count": "fill.good"},
{"station": "Palletiser", "count": "pal.good"}
]
}
}
17 changes: 17 additions & 0 deletions labs/kepsim/scenarios/setpoint-drill.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"_note": "A set-and-ramp drill against the KepSim replay server (`fsmes run-opc-sim --replay`): its setpoints are the tags it lets a client write. Feed rate steps down, the washer is ramped up three degrees over a minute and set back. The process values that follow them move on the replay's own lag.",
"name": "setpoint drill",
"namespace": "urn:mes-twin:sim",
"drivers": {"LD": "Simulator", "Washer": "Simulator"},
"tags": {
"ld.feed": "LD/FeedRateSP",
"wash.temp": "Washer/WashTempSP"
},
"steps": [
{"at": "00:00", "tag": "ld.feed", "value": 110.0},
{"at": "00:05", "tag": "ld.feed", "value": 90.0},
{"at": "00:10", "tag": "wash.temp", "ramp": {"from": 71.0, "to": 74.0, "over": 60, "every": 10}},
{"at": "01:20", "tag": "wash.temp", "value": 71.0},
{"at": "01:30", "tag": "ld.feed", "value": 110.0}
]
}
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ nav:
- Tutorial — deploy: operate/deploy.md
- How-to — one plant on two boxes: operate/redundancy.md
- How-to — read-only OPC UA commissioning: operate/opc-readonly.md
- How-to — run a product through a line by writing tags: operate/scenarios.md
- Explanation — losing sight of a machine: operate/opc-disconnections.md
- Explanation — how much of the window did the MES see: operate/coverage.md
- Reference — what the floor assistant can do for you: operate/assistant-coverage.md
Expand Down
Loading
Loading