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
48 changes: 48 additions & 0 deletions docs/LEARNING_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,54 @@ This file should be updated by Codex after each meaningful change.
### What to learn next
```

## 2026-05-22 - Dockerized OPC UA demo simulator

### What changed

Added a Docker Compose service for a local OPC UA demo simulator. The service
starts in normal mode and exposes the manufacturer-demo site, line,
filler/checkweigher assets, process tags, and demo state tags.

### Why it matters

The manufacturer demo can now show an OPC UA-style local source without
connecting to a real plant or implying production connector readiness.

### How it works

The simulator uses a small static namespace for `fill_weight`,
`filler_nozzle_pressure`, `line_speed`, `scenario`, and `drift_active`.
Docker Compose builds the service from the repo and exposes
`opc.tcp://localhost:4840/ofi/demo`.

### How to run it

```bash
docker compose -f infra/docker/docker-compose.yml up --build opcua-simulator
```

### How to test it

```bash
.venv/bin/python -m pytest services/simulator/tests/test_opcua_demo_server.py
docker compose -f infra/docker/docker-compose.yml config
```

### Key files

- `services/simulator/factory_simulator/opcua_demo.py`
- `services/simulator/factory_simulator/opcua_server.py`
- `infra/docker/docker-compose.yml`
- `infra/docker/opcua-simulator.Dockerfile`
- `services/simulator/tests/test_opcua_demo_server.py`
- `services/simulator/README.md`

### What to learn next

Use this read-only demo server as the source for a future ingestion adapter or
smoke test, keeping production security, certificates, and connector framework
work in separate issues.

## 2026-05-22 - OPC UA demo namespace contract

### What changed
Expand Down
3 changes: 3 additions & 0 deletions docs/demo/MANUFACTURER_DEMO_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,9 @@ For the intentionally small OPC UA-style demo namespace and mapping from demo
tags to FactoryEvent process and quality events, see
`docs/demo/OPC_UA_DEMO_NAMESPACE.md`.

For the Dockerized OPC UA demo simulator endpoint, startup command, and tag
list, see `services/simulator/README.md`.

For the screen-by-screen user goal, component, data dependency, safety language,
risk, and success-criteria map, see
`docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md`.
Expand Down
11 changes: 11 additions & 0 deletions docs/demo/OPC_UA_DEMO_NAMESPACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,19 @@ payload itself.
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `filler_f_201.fill_weight` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight` | `FillWeight` | `filler_f_201` | `g` | `495.0` to `505.0` | `500.0` | `500.12` | `506.81` | Stable baseline for the first 8 samples, then about `+0.33 g` per sample with small noise. | `process.measurement.recorded` with `payload.signal_id=fill_weight`, `payload.signal_name=Fill Weight`, and `payload.tag_name=filler_f_201.fill_weight` |
| `filler_f_201.filler_nozzle_pressure` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure` | `NozzlePressure` | `filler_f_201` | `bar` | `1.9` to `2.4` | `2.1` | `2.10` | `2.31` | Stable baseline for the first 8 samples, then about `+0.01 bar` per sample with small noise. | `process.measurement.recorded` with `payload.signal_id=filler_nozzle_pressure`, `payload.signal_name=Filler Nozzle Pressure`, and `payload.tag_name=filler_f_201.filler_nozzle_pressure` |
| `line_2.line_speed` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.LineSpeed` | `LineSpeed` | `line_2` | `bottles_per_min` | `115.0` to `125.0` | `120.0` | `120.0` | `120.0` | Held steady at startup for normal-mode OPC UA demo operation. | OPC UA demo support tag; no current Process Sentinel rule consumes it. |
| `checkweigher_cw_201.final_fill_weight` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight` | `FinalFillWeight` | `checkweigher_cw_201` | `g` | `495.0` to `505.0` | none | `500.20 pass` | `506.70 fail` | Recorded every third sample as the inline quality marker that confirms fill-weight drift is visible in quality results. | `quality.measurement.recorded` with `payload.quality_check_type=inline_check` and `payload.measurement_name=Final Fill Weight` |

## Demo State Tags

The Dockerized OPC UA demo server starts in normal operation mode and exposes
these state tags:

| Demo tag name | Node ID | Startup value | Purpose |
| --- | --- | --- | --- |
| `scenario` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.Scenario` | `fill_weight_drift_demo` | Identifies the deterministic manufacturer demo scenario. |
| `drift_active` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.DriftActive` | `false` | Confirms the OPC UA server starts in normal mode before any future drift behavior is enabled. |

## Mapping Rules

1. OPC UA source values are normalized into existing `FactoryEvent` envelopes.
Expand Down
14 changes: 13 additions & 1 deletion infra/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ services:
timeout: 5s
retries: 10

opcua-simulator:
build:
context: ../..
dockerfile: infra/docker/opcua-simulator.Dockerfile
environment:
OPCUA_DEMO_HOST: 0.0.0.0
OPCUA_DEMO_PORT: "4840"
OPCUA_DEMO_ENDPOINT_PATH: /ofi/demo
OPCUA_DEMO_SCENARIO: fill_weight_drift_demo
OPCUA_DEMO_DRIFT_ACTIVE: "false"
ports:
- "4840:4840"

volumes:
factory_postgres_data:

21 changes: 21 additions & 0 deletions infra/docker/opcua-simulator.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM python:3.12-slim

WORKDIR /app

COPY requirements-dev.txt pyproject.toml ./
RUN python -m pip install --no-cache-dir --upgrade pip \
&& python -m pip install --no-cache-dir -r requirements-dev.txt

COPY packages ./packages
COPY services/simulator ./services/simulator

ENV PYTHONPATH=/app/packages/factory-events:/app/services/simulator
ENV OPCUA_DEMO_HOST=0.0.0.0
ENV OPCUA_DEMO_PORT=4840
ENV OPCUA_DEMO_ENDPOINT_PATH=/ofi/demo
ENV OPCUA_DEMO_SCENARIO=fill_weight_drift_demo
ENV OPCUA_DEMO_DRIFT_ACTIVE=false

EXPOSE 4840

CMD ["python", "-m", "factory_simulator.opcua_server"]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
description = "Simulator-backed Factory Intelligence Platform MVP skeleton."
requires-python = ">=3.12"
dependencies = [
"asyncua>=1.1.5",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"psycopg[binary]>=3.2.0",
Expand Down Expand Up @@ -35,4 +36,3 @@ target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
asyncua>=1.1.5
fastapi>=0.115.0
httpx>=0.27.0
psycopg[binary]>=3.2.0
pydantic>=2.8.0
pytest>=8.3.0
ruff>=0.6.0
uvicorn>=0.30.0

42 changes: 42 additions & 0 deletions services/simulator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,48 @@ The expected demo detection lookup is:
det_fill_weight_gradual_drift
```

## Dockerized OPC UA Demo Simulator

The local Docker Compose stack includes a small OPC UA demo simulator for the
manufacturer demo. It exposes normal-mode demo tags for one site, one line, one
filler asset, and one checkweigher asset. It is simulator-backed demo
infrastructure, not a production OPC UA connector.

Start only the OPC UA demo server:

```bash
docker compose -f infra/docker/docker-compose.yml up --build opcua-simulator
```

Endpoint from the host:

```text
opc.tcp://localhost:4840/ofi/demo
```

The service starts in normal operation mode:

- `scenario`: `fill_weight_drift_demo`
- `drift_active`: `false`

Required demo process tags:

| Tag | Node ID | Normal value | Unit |
| --- | --- | --- | --- |
| `filler_f_201.fill_weight` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight` | `500.12` | `g` |
| `filler_f_201.filler_nozzle_pressure` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure` | `2.1` | `bar` |
| `line_2.line_speed` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.LineSpeed` | `120.0` | `bottles_per_min` |

Required demo state tags:

| Tag | Node ID | Startup value |
| --- | --- | --- |
| `scenario` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.Scenario` | `fill_weight_drift_demo` |
| `drift_active` | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.State.DriftActive` | `false` |

Startup logs include the endpoint, namespace URI, scenario name, drift flag, and
a warning that the service is simulator-backed demo infrastructure.

## Connect Simulator Output To Ingestion

After generating JSONL, pass the same path to ingestion:
Expand Down
192 changes: 192 additions & 0 deletions services/simulator/factory_simulator/opcua_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

NAMESPACE_URI = "urn:open-factory-initiative:factory-intelligence-platform:demo"
DEFAULT_ENDPOINT_PATH = "/ofi/demo"
DEFAULT_SCENARIO = "fill_weight_drift_demo"
DEFAULT_DRIFT_ACTIVE = False

DEMO_SITE_ID = "greenville_demo_site"
DEMO_AREA_ID = "packaging_area"
DEMO_LINE_ID = "line_2"
DEMO_WORK_ORDER_ID = "WO-DEMO-1007"
DEMO_BATCH_ID = "BATCH-DEMO-1007"
DEMO_PRODUCT_ID = "ofi_demo_beverage"
DEMO_PRODUCT_NAME = "OFI Demo Beverage"


DemoValueType = Literal["float", "bool", "string"]


@dataclass(frozen=True)
class OpcUaDemoNode:
node_id: str
browse_name: str
tag_name: str
value: float | bool | str
value_type: DemoValueType
category: Literal["process", "quality", "state", "context"]
asset_id: str | None = None
unit: str | None = None
normal_min: float | None = None
normal_max: float | None = None
target_value: float | None = None
description: str = ""


DEMO_CONTEXT_NODES: tuple[OpcUaDemoNode, ...] = (
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.SiteId",
browse_name="SiteId",
tag_name="site_id",
value=DEMO_SITE_ID,
value_type="string",
category="context",
description="Demo site identifier.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.AreaId",
browse_name="AreaId",
tag_name="area_id",
value=DEMO_AREA_ID,
value_type="string",
category="context",
description="Demo production area identifier.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.LineId",
browse_name="LineId",
tag_name="line_id",
value=DEMO_LINE_ID,
value_type="string",
category="context",
description="Demo line identifier.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.WorkOrderId",
browse_name="WorkOrderId",
tag_name="work_order_id",
value=DEMO_WORK_ORDER_ID,
value_type="string",
category="context",
description="Demo work order identifier.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.BatchId",
browse_name="BatchId",
tag_name="batch_id",
value=DEMO_BATCH_ID,
value_type="string",
category="context",
description="Demo batch identifier.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.ProductId",
browse_name="ProductId",
tag_name="product_id",
value=DEMO_PRODUCT_ID,
value_type="string",
category="context",
description="Demo product identifier.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.ProductName",
browse_name="ProductName",
tag_name="product_name",
value=DEMO_PRODUCT_NAME,
value_type="string",
category="context",
description="Demo product name.",
),
)

DEMO_PROCESS_NODES: tuple[OpcUaDemoNode, ...] = (
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight",
browse_name="FillWeight",
tag_name="filler_f_201.fill_weight",
value=500.12,
value_type="float",
category="process",
asset_id="filler_f_201",
unit="g",
normal_min=495.0,
normal_max=505.0,
target_value=500.0,
description="Normal-mode demo fill weight signal.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure",
browse_name="NozzlePressure",
tag_name="filler_f_201.filler_nozzle_pressure",
value=2.1,
value_type="float",
category="process",
asset_id="filler_f_201",
unit="bar",
normal_min=1.9,
normal_max=2.4,
target_value=2.1,
description="Normal-mode demo filler nozzle pressure signal.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.LineSpeed",
browse_name="LineSpeed",
tag_name="line_2.line_speed",
value=120.0,
value_type="float",
category="process",
asset_id="line_2",
unit="bottles_per_min",
normal_min=115.0,
normal_max=125.0,
target_value=120.0,
description="Normal-mode demo line speed signal.",
),
)

DEMO_QUALITY_NODES: tuple[OpcUaDemoNode, ...] = (
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight",
browse_name="FinalFillWeight",
tag_name="checkweigher_cw_201.final_fill_weight",
value=500.2,
value_type="float",
category="quality",
asset_id="checkweigher_cw_201",
unit="g",
normal_min=495.0,
normal_max=505.0,
description="Normal-mode inline quality marker for final fill weight.",
),
)

DEMO_STATE_NODES: tuple[OpcUaDemoNode, ...] = (
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.State.Scenario",
browse_name="Scenario",
tag_name="scenario",
value=DEFAULT_SCENARIO,
value_type="string",
category="state",
description="Current demo scenario name.",
),
OpcUaDemoNode(
node_id="OFI.Demo.Greenville.Packaging.Line2.State.DriftActive",
browse_name="DriftActive",
tag_name="drift_active",
value=DEFAULT_DRIFT_ACTIVE,
value_type="bool",
category="state",
description="Normal-mode drift flag. False at startup.",
),
)

DEMO_OPC_UA_NODES: tuple[OpcUaDemoNode, ...] = (
*DEMO_CONTEXT_NODES,
*DEMO_PROCESS_NODES,
*DEMO_QUALITY_NODES,
*DEMO_STATE_NODES,
)
Loading
Loading