diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 67a88fa..c664bec 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -22,6 +22,53 @@ This file should be updated by Codex after each meaningful change. ### What to learn next ``` +## 2026-05-22 - OPC UA demo namespace contract + +### What changed + +Added an intentionally small OPC UA-style namespace and data-contract reference +for the manufacturer demo. The doc defines context nodes, process and quality +tag names, node IDs, units, normal ranges, drift behavior, and example mapped +FactoryEvent payloads for the fill-weight drift scenario. + +### Why it matters + +The demo now has a clear bridge between future OPC UA simulator tags and the +existing FactoryEvent contracts without expanding into production namespace +browsing, connector configuration, or autonomous factory actions. + +### How it works + +The namespace mirrors the existing `fill_weight_drift_demo` scenario: +`filler_f_201.fill_weight` and `filler_f_201.filler_nozzle_pressure` map to +process measurement events, while `checkweigher_cw_201.final_fill_weight` maps +to the inline quality marker. Context nodes provide site, line, work order, +batch, and product identifiers for Process Sentinel and the demo talk track. + +### How to run it + +```bash +make demo +``` + +### How to test it + +```bash +.venv/bin/python -m pytest services/simulator/tests/test_opc_ua_demo_namespace_docs.py +``` + +### Key files + +- `docs/demo/OPC_UA_DEMO_NAMESPACE.md` +- `docs/demo/MANUFACTURER_DEMO_RUNBOOK.md` +- `services/simulator/tests/test_opc_ua_demo_namespace_docs.py` + +### What to learn next + +Use this namespace as the source for the future demo OPC UA simulator or adapter +issue, keeping the first implementation read-only and aligned with the current +FactoryEvent payloads. + ## 2026-05-22 - Demo-safe copy guidelines ### What changed diff --git a/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md b/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md index ac080a7..31e05f2 100644 --- a/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md +++ b/docs/demo/MANUFACTURER_DEMO_RUNBOOK.md @@ -308,6 +308,10 @@ during the local demo, see `docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md`. For approved demo terminology, words to avoid, and sample Workbench microcopy, see `docs/demo/DEMO_SAFE_COPY_GUIDELINES.md`. +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 screen-by-screen user goal, component, data dependency, safety language, risk, and success-criteria map, see `docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md`. diff --git a/docs/demo/OPC_UA_DEMO_NAMESPACE.md b/docs/demo/OPC_UA_DEMO_NAMESPACE.md new file mode 100644 index 0000000..6e24386 --- /dev/null +++ b/docs/demo/OPC_UA_DEMO_NAMESPACE.md @@ -0,0 +1,206 @@ +# OPC UA Demo Namespace and Data Contract + +## Purpose + +This document defines the minimum OPC UA-style namespace for the +simulator-backed manufacturer demo. It is intentionally small and demo-specific: +it exists to make the fill-weight drift story concrete, reviewable, and aligned +with the current `FactoryEvent` contracts. + +This is not a production namespace-browsing design, not a general tag-mapping UI, +and not a full connector configuration framework. The demo remains read-only, +simulator-backed, advisory, and human-reviewed. + +## Namespace Boundary + +- Namespace URI: `urn:open-factory-initiative:factory-intelligence-platform:demo` +- Example namespace index: `ns=2` +- Demo site: `greenville_demo_site` +- Demo area: `packaging_area` +- Demo line: `line_2` +- Work order: `WO-DEMO-1007` +- Batch: `BATCH-DEMO-1007` +- Product: `ofi_demo_beverage` / `OFI Demo Beverage` + +The namespace mirrors the current `fill_weight_drift_demo` scenario. Node IDs +are stable demo identifiers so a future OPC UA simulator can expose the same +source signals while preserving the existing event contract. + +## Context Nodes + +These nodes provide the line, work order, batch, and product context needed by +Process Sentinel and the demo talk track. + +| Context | Node ID | Example value | FactoryEvent mapping | +| --- | --- | --- | --- | +| Site | `ns=2;s=OFI.Demo.Greenville.SiteId` | `greenville_demo_site` | `context.site_id` | +| Area | `ns=2;s=OFI.Demo.Greenville.Packaging.AreaId` | `packaging_area` | `context.area_id` | +| Line | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.LineId` | `line_2` | `context.line_id` | +| Work order | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.WorkOrderId` | `WO-DEMO-1007` | `context.work_order_id` | +| Batch | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.BatchId` | `BATCH-DEMO-1007` | `context.batch_id` | +| Product ID | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.ProductId` | `ofi_demo_beverage` | production work order and batch event payloads | +| Product name | `ns=2;s=OFI.Demo.Greenville.Packaging.Line2.ProductName` | `OFI Demo Beverage` | production work order and batch event payloads | + +Process and quality measurement events carry site, area, line, asset, work +order, and batch context directly. Product context belongs to the demo scenario +and production work order or batch events rather than the process measurement +payload itself. + +## Process and Quality Tags + +| Demo tag name | Node ID | Browse name | Asset | Unit | Normal or spec range | Target | Normal example | Drifting example | Drift behavior | FactoryEvent mapping | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `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` | +| `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` | + +## Mapping Rules + +1. OPC UA source values are normalized into existing `FactoryEvent` envelopes. +2. Process tags become `process.measurement.recorded` events. +3. Quality marker values become `quality.measurement.recorded` events. +4. `context.site_id`, `context.area_id`, `context.line_id`, + `context.work_order_id`, and `context.batch_id` come from the context nodes. +5. `context.asset_id` comes from the owning asset in the tag table. +6. Process event `payload.tag_name` uses the current simulator tag format: + `.`. +7. Quality events do not have a `payload.tag_name` field in the current + contract, so the OPC UA tag name is documented as source mapping metadata and + the FactoryEvent payload uses `measurement_name`. +8. In the existing local simulator path, events still use + `source.system=factory-simulator` and `source.adapter=simulator`. A future + demo OPC UA adapter can keep the same context and payload fields while + preserving the OPC UA node ID in `source.source_event_id` or adapter metadata. + +## Example Normal Process Event + +```json +{ + "event_id": "evt_opcua_demo_fill_weight_normal", + "event_type": "process.measurement.recorded", + "schema_version": "1.0.0", + "timestamp": "2026-01-01T12:02:00Z", + "source": { + "system": "factory-simulator", + "adapter": "simulator", + "source_event_id": "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight@2026-01-01T12:02:00Z" + }, + "context": { + "site_id": "greenville_demo_site", + "area_id": "packaging_area", + "line_id": "line_2", + "asset_id": "filler_f_201", + "batch_id": "BATCH-DEMO-1007", + "work_order_id": "WO-DEMO-1007" + }, + "payload": { + "signal_id": "fill_weight", + "signal_name": "Fill Weight", + "tag_name": "filler_f_201.fill_weight", + "value": 500.12, + "unit": "g", + "quality": "good", + "normal_min": 495.0, + "normal_max": 505.0, + "target_value": 500.0 + }, + "metadata": { + "simulated": true, + "trace_id": "trace_opcua_demo_normal" + } +} +``` + +## Example Drifting Process Event + +```json +{ + "event_id": "evt_opcua_demo_fill_weight_drifting", + "event_type": "process.measurement.recorded", + "schema_version": "1.0.0", + "timestamp": "2026-01-01T12:28:00Z", + "source": { + "system": "factory-simulator", + "adapter": "simulator", + "source_event_id": "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight@2026-01-01T12:28:00Z" + }, + "context": { + "site_id": "greenville_demo_site", + "area_id": "packaging_area", + "line_id": "line_2", + "asset_id": "filler_f_201", + "batch_id": "BATCH-DEMO-1007", + "work_order_id": "WO-DEMO-1007" + }, + "payload": { + "signal_id": "fill_weight", + "signal_name": "Fill Weight", + "tag_name": "filler_f_201.fill_weight", + "value": 506.81, + "unit": "g", + "quality": "good", + "normal_min": 495.0, + "normal_max": 505.0, + "target_value": 500.0 + }, + "metadata": { + "simulated": true, + "trace_id": "trace_opcua_demo_drifting" + } +} +``` + +## Example Drifting Quality Event + +```json +{ + "event_id": "evt_opcua_demo_quality_drifting", + "event_type": "quality.measurement.recorded", + "schema_version": "1.0.0", + "timestamp": "2026-01-01T12:29:20Z", + "source": { + "system": "factory-simulator", + "adapter": "simulator", + "source_event_id": "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight@2026-01-01T12:29:20Z" + }, + "context": { + "site_id": "greenville_demo_site", + "area_id": "packaging_area", + "line_id": "line_2", + "asset_id": "checkweigher_cw_201", + "batch_id": "BATCH-DEMO-1007", + "work_order_id": "WO-DEMO-1007" + }, + "payload": { + "quality_check_type": "inline_check", + "measurement_name": "Final Fill Weight", + "value": 506.7, + "unit": "g", + "result_status": "fail", + "result": "fail", + "severity": "high", + "spec_min": 495.0, + "spec_max": 505.0 + }, + "metadata": { + "simulated": true, + "trace_id": "trace_opcua_demo_quality" + } +} +``` + +## Process Sentinel Sufficiency + +The namespace is sufficient for the current Process Sentinel fill-weight drift +detection because it exposes: + +- The required `fill_weight` process signal on `filler_f_201`. +- The supporting `filler_nozzle_pressure` process signal on the same asset. +- The inline `Final Fill Weight` quality marker on `checkweigher_cw_201`. +- The Greenville demo site, packaging area, Line 2, work order, batch, and + product identifiers needed to explain the scenario. +- Normal ranges, target values, and example drifting values for evidence and + RCA/CAPA draft support. + +No autonomous control, equipment writeback, QMS/MES writeback, production +namespace browsing, or production validation is implied by this demo namespace. diff --git a/services/simulator/tests/test_opc_ua_demo_namespace_docs.py b/services/simulator/tests/test_opc_ua_demo_namespace_docs.py new file mode 100644 index 0000000..1757a83 --- /dev/null +++ b/services/simulator/tests/test_opc_ua_demo_namespace_docs.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +from factory_events import FactoryEvent, validate_event +from factory_simulator.scenarios import scenario_definition_for + +REPO_ROOT = Path(__file__).resolve().parents[3] +NAMESPACE_DOC = REPO_ROOT / "docs" / "demo" / "OPC_UA_DEMO_NAMESPACE.md" +MANUFACTURER_RUNBOOK = REPO_ROOT / "docs" / "demo" / "MANUFACTURER_DEMO_RUNBOOK.md" + + +def _content() -> str: + return NAMESPACE_DOC.read_text(encoding="utf-8") + + +def _factory_event_examples() -> list[FactoryEvent]: + content = _content() + blocks = re.findall(r"```json\n(.*?)\n```", content, flags=re.DOTALL) + return [validate_event(json.loads(block)) for block in blocks] + + +def test_opc_ua_demo_namespace_exists_and_is_linked() -> None: + assert NAMESPACE_DOC.exists() + assert "docs/demo/OPC_UA_DEMO_NAMESPACE.md" in MANUFACTURER_RUNBOOK.read_text( + encoding="utf-8" + ) + + +def test_opc_ua_demo_namespace_defines_demo_boundary_and_context() -> None: + content = _content() + scenario = scenario_definition_for("fill_weight_drift_demo") + + required_terms = [ + "intentionally small and demo-specific", + "not a production namespace-browsing design", + "not a general tag-mapping UI", + "not a full connector configuration framework", + "simulator-backed", + "human-reviewed", + "urn:open-factory-initiative:factory-intelligence-platform:demo", + "ns=2", + scenario.line_context.site_id, + scenario.line_context.area_id, + scenario.line_context.line_id, + scenario.line_context.work_order_id, + scenario.line_context.batch_id, + scenario.product.product_id, + scenario.product.product_name, + "Product context belongs to the demo scenario", + ] + + for term in required_terms: + assert term in content + + +def test_opc_ua_demo_namespace_defines_required_tags_and_node_ids() -> None: + content = _content() + + expected_terms = [ + "filler_f_201.fill_weight", + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.FillWeight", + "FillWeight", + "Fill Weight", + "495.0", + "505.0", + "500.0", + "+0.33 g", + "filler_f_201.filler_nozzle_pressure", + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.FillerF201.NozzlePressure", + "NozzlePressure", + "Filler Nozzle Pressure", + "1.9", + "2.4", + "2.1", + "+0.01 bar", + "checkweigher_cw_201.final_fill_weight", + "ns=2;s=OFI.Demo.Greenville.Packaging.Line2.CheckweigherCW201.FinalFillWeight", + "FinalFillWeight", + "Final Fill Weight", + "inline_check", + "quality.measurement.recorded", + "process.measurement.recorded", + ] + + for term in expected_terms: + assert term in content + + +def test_opc_ua_demo_namespace_examples_validate_against_factory_event_contracts() -> None: + events = _factory_event_examples() + + assert len(events) == 3 + assert [event.event_type for event in events] == [ + "process.measurement.recorded", + "process.measurement.recorded", + "quality.measurement.recorded", + ] + assert all(event.schema_version == "1.0.0" for event in events) + assert all(event.metadata.simulated is True for event in events) + + +def test_opc_ua_demo_namespace_examples_cover_fill_weight_drift_detection_needs() -> None: + events = _factory_event_examples() + fill_weight_events = [ + event + for event in events + if event.event_type == "process.measurement.recorded" + and event.payload.signal_id == "fill_weight" + ] + quality_events = [ + event for event in events if event.event_type == "quality.measurement.recorded" + ] + + assert len(fill_weight_events) == 2 + assert len(quality_events) == 1 + + for event in fill_weight_events: + assert event.context.site_id == "greenville_demo_site" + assert event.context.area_id == "packaging_area" + assert event.context.line_id == "line_2" + assert event.context.asset_id == "filler_f_201" + assert event.context.work_order_id == "WO-DEMO-1007" + assert event.context.batch_id == "BATCH-DEMO-1007" + assert event.payload.tag_name == "filler_f_201.fill_weight" + assert event.payload.normal_min == 495.0 + assert event.payload.normal_max == 505.0 + assert event.payload.target_value == 500.0 + + assert fill_weight_events[0].payload.value == 500.12 + assert fill_weight_events[1].payload.value == 506.81 + assert fill_weight_events[1].payload.value > fill_weight_events[1].payload.normal_max + + quality_event = quality_events[0] + assert quality_event.context.asset_id == "checkweigher_cw_201" + assert quality_event.context.work_order_id == "WO-DEMO-1007" + assert quality_event.context.batch_id == "BATCH-DEMO-1007" + assert quality_event.payload.quality_check_type == "inline_check" + assert quality_event.payload.measurement_name == "Final Fill Weight" + assert quality_event.payload.result_status == "fail" + assert quality_event.payload.severity == "high"