Seed the canonical OT schemas content under 3yearplan/schemas/ as a temporary location until a dedicated schemas repo is created (Gitea push-to-create is disabled, the dedicated repo needs a manual UI step). Initial seed contributed by the OtOpcUa team to unblock the EquipmentClassRef integration timeline (lmxopcua decision #112) and to provide the future cross-team owner with a concrete starting point rather than a blank slate. Marked DRAFT throughout with prominent "ownership TBD" framing in README and CONTRIBUTING — the future owner team should treat this seed as a starting point and revise format / structure / naming as the open questions in README "Open Questions" get resolved.
Includes: README explaining purpose / scope / temporary-location framing / format decision, CONTRIBUTING.md with proposed workflow + per-class semver versioning policy + validation commands, format/equipment-class.schema.json defining the shape of a class template (classId, version, displayName, applicability, signals, alarms, optional stateModel), format/tag-definition.schema.json defining the shape of a single canonical signal (name, dataType, category, unit, isArray, accessLevel, writeIdempotent, isHistorized, scaling), format/uns-subtree.schema.json defining the shape of a per-site UNS subtree (enterprise + site + areas + lines), classes/fanuc-cnc.json as the worked pilot class with 16 signals + 3 alarms + suggested state-derivation notes (per OtOpcUa corrections doc D1), uns/example-warsaw-west.json as a worked UNS subtree example, docs/overview.md (what / why / lifecycle / what's NOT in this repo), docs/format-decisions.md (8 numbered decisions covering JSON Schema choice per corrections D2, per-class semver, additive-only minor bumps, _default placeholder reservation, signal-name vs UNS-segment regex distinction, stateModel-as-informational, no per-equipment overrides at this layer, applicability.drivers as OtOpcUa driver enumeration), docs/consumer-integration.md (how OtOpcUa / Redpanda / dbt each integrate). $id URLs in the JSON schemas resolve at the actual current path so validators don't 404. Top-level README adds a row to the Component Detail Files table pointing to schemas/. Corrections doc B2 (schemas-repo dependencies) marked partially RESOLVED with the seed location and a list of what still needs the plan team or cross-team owner to decide (owner team naming, dedicated repo migration, format-decision ratification, FANUC CNC pilot confirmation, CI gate setup, Redpanda + dbt consumer integration plumbing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ The plan also declares a **Unified Namespace (UNS)** composed of OtOpcUa + Redpa
|
||||
| [`current-state/legacy-integrations.md`](current-state/legacy-integrations.md) | Pillar 3 denominator: 3 legacy IT/OT integrations to retire |
|
||||
| ~~`current-state/equipment-protocol-survey.md`~~ | Removed — protocol survey no longer needed; OtOpcUa v2 team committed driver list directly |
|
||||
| [`goal-state/digital-twin-management-brief.md`](goal-state/digital-twin-management-brief.md) | Digital twin management conversation brief (completed) |
|
||||
| [`schemas/`](schemas/) | Canonical OT equipment definitions (DRAFT seed contributed by OtOpcUa team — UNS hierarchy + equipment-class templates + format JSON Schemas + worked FANUC CNC pilot). Temporary location until a dedicated `schemas` repo is created and an owner team is named — see `schemas/README.md` |
|
||||
|
||||
### Output Generation
|
||||
|
||||
|
||||
@@ -103,9 +103,23 @@ The v2 design ships `Equipment.EquipmentClassRef` as a nullable hook column (per
|
||||
**Plan should say instead:** The plan should make the schemas-repo dependency explicit on the OtOpcUa critical path:
|
||||
- Schemas repo creation should be a Year 1 deliverable (its own handoff doc, distinct from OtOpcUa's)
|
||||
- Until it exists, OtOpcUa equipment configurations are hand-curated and prone to drift
|
||||
- The Equipment Protocol Survey output should feed both: (a) OtOpcUa core driver scope, and (b) the initial schemas repo equipment-class list
|
||||
- A pilot equipment class (proposed: FANUC CNC, see D1 below) should land in the schemas repo before tier 1 cutover begins, to validate the template-consumer contract end-to-end
|
||||
|
||||
**Resolution — partial** (2026-04-17): the OtOpcUa team contributed an initial seed at `3yearplan/schemas/` (temporary location until the dedicated `schemas` repo is created — Gitea push-to-create is disabled and the dedicated repo needs a manual UI step). The seed includes:
|
||||
- README + CONTRIBUTING explaining purpose, scope, ownership-TBD framing, format decision, and proposed workflow
|
||||
- JSON Schemas defining the format (`format/equipment-class.schema.json`, `format/tag-definition.schema.json`, `format/uns-subtree.schema.json`)
|
||||
- The pilot equipment class as a worked example (`classes/fanuc-cnc.json` — 16 signals + 3 alarm definitions + suggested state-derivation notes, per D1)
|
||||
- A worked UNS subtree (`uns/example-warsaw-west.json`)
|
||||
- Documentation: `docs/overview.md`, `docs/format-decisions.md` (8 numbered decisions), `docs/consumer-integration.md`
|
||||
|
||||
What still needs the plan team / cross-team owner:
|
||||
- Name an owner team for the schemas content
|
||||
- Decide whether to move it to a dedicated `gitea.dohertylan.com/dohertj2/schemas` repo (proposed; would be cleaner than living under `3yearplan/`) or keep it as a 3-year-plan sub-tree
|
||||
- Ratify or revise the format decisions in `schemas/docs/format-decisions.md` (8 items including JSON Schema choice, per-class semver, additive-only minor bumps, `_default` placeholder, signal-name vs UNS-segment regex distinction, stateModel-as-informational, no per-equipment overrides at this layer, applicability.drivers as OtOpcUa driver enumeration)
|
||||
- Confirm the FANUC CNC pilot is the right starting point (D1 recommendation)
|
||||
- Establish the CI gate for JSON Schema validation
|
||||
- Decide on consumer-integration plumbing for Redpanda Protobuf code-gen and dbt macro generation per `schemas/docs/consumer-integration.md`
|
||||
|
||||
---
|
||||
|
||||
### B3. Per-node `ApplicationUri` uniqueness and trust-pinning is an OPC UA spec constraint with operational implications
|
||||
|
||||
55
schemas/CONTRIBUTING.md
Normal file
55
schemas/CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Contributing
|
||||
|
||||
> Initial seed by the OtOpcUa team. Future ownership TBD — the contribution process below is a starting suggestion the schemas-repo owner team should ratify or revise.
|
||||
|
||||
## Workflow (proposed)
|
||||
|
||||
1. **Open an issue first** for any new equipment class, UNS subtree, or format change. Describe the use case + the consumer(s) that need it.
|
||||
2. **Branch + PR** — work on a feature branch, open a PR against `main`.
|
||||
3. **CI gate** validates every JSON file against the schema in `format/`.
|
||||
4. **Review** — at least one schemas-repo maintainer + one consumer-team representative (OtOpcUa, Redpanda, or dbt depending on what changed).
|
||||
5. **Merge + tag** — merge to `main` and create a semver tag. Consumers pin to tags.
|
||||
|
||||
## Adding a new equipment class
|
||||
|
||||
1. Create `classes/{class-id}.json` (lowercase hyphenated).
|
||||
2. Reference the class schema: `"$schema": "../format/equipment-class.schema.json"`.
|
||||
3. Fill in: `classId`, `version` (start at `0.1.0`), `displayName`, `vendor`, `applicability`, `signals`, optionally `alarms` and `stateModel`.
|
||||
4. Validate locally — the CI gate will reject malformed JSON or schema violations.
|
||||
5. Open the PR with a brief description of which equipment populations are covered.
|
||||
|
||||
## Adding a new UNS subtree
|
||||
|
||||
1. Create `uns/{site-name}.json`.
|
||||
2. Reference the schema: `"$schema": "../format/uns-subtree.schema.json"`.
|
||||
3. Fill in: `enterprise` (must match the org-wide value), `site`, `displayName`, `areas` with `lines`.
|
||||
4. Coordinate with the OtOpcUa team — every cluster at the site must declare its `Enterprise` and `Site` matching this file.
|
||||
|
||||
## Format changes
|
||||
|
||||
Editing files in `format/` is a breaking change for downstream consumers. Process:
|
||||
|
||||
1. Open an issue with the proposed change + rationale.
|
||||
2. Notify all consumer teams (OtOpcUa, Redpanda, dbt, anyone else listed in `docs/consumer-integration.md`).
|
||||
3. Get explicit signoff from each before merging.
|
||||
4. Bump the major version of every affected class file simultaneously (consumers use this to detect breaking changes).
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
# Per-file validation
|
||||
ajv validate -s format/equipment-class.schema.json -d classes/fanuc-cnc.json
|
||||
ajv validate -s format/uns-subtree.schema.json -d uns/example-warsaw-west.json
|
||||
|
||||
# Bulk validate everything
|
||||
for f in classes/*.json; do ajv validate -s format/equipment-class.schema.json -d "$f"; done
|
||||
for f in uns/*.json; do ajv validate -s format/uns-subtree.schema.json -d "$f"; done
|
||||
```
|
||||
|
||||
## Versioning policy (proposed)
|
||||
|
||||
- **Major bump** (`0.x.y` → `1.x.y`) — breaking change: signal removed, signal renamed, dataType changed, isRequired changed false→true, alarm removed
|
||||
- **Minor bump** (`x.0.y` → `x.1.y`) — additive non-breaking: new optional signal, new alarm, new state in stateModel, displayName/description updates
|
||||
- **Patch bump** (`x.y.0` → `x.y.1`) — documentation/clarification only: description text updates, examples added, no semantic change
|
||||
|
||||
Consumers pin to a major.minor in production; staging environments can track `main`.
|
||||
81
schemas/README.md
Normal file
81
schemas/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# schemas — Canonical OT Equipment Definitions
|
||||
|
||||
> **Status**: DRAFT — initial seed contributed by the OtOpcUa team (`lmxopcua/docs/v2/acl-design.md`, `lmxopcua/docs/v2/plan.md` decisions #112, #115, corrections doc D1+D2). **Ownership of this content is TBD** — should be assigned to a cross-team authority since it's consumed by OT and IT systems alike. The future owner team should treat this seed as a starting point, not a finished spec.
|
||||
>
|
||||
> **Temporary location**: this seed lives at `3yearplan/schemas/` as a sub-tree of the 3-year-plan repo because Gitea's push-to-create is disabled and creating the dedicated `schemas` repo requires a manual UI step. Once the dedicated repo is created (proposed: `gitea.dohertylan.com/dohertj2/schemas`) and an owner team is named, this content should migrate over and references update accordingly. Until then, treat this directory as the canonical location for the seed.
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for the org's canonical OT equipment definitions:
|
||||
|
||||
1. **UNS hierarchy** — the per-site Unified Namespace subtree (Enterprise / Site / Area / Line / Equipment) per the 3-year-plan handoff §UNS Naming Hierarchy
|
||||
2. **Equipment-class templates** — per-class declarations of which raw signals each equipment type exposes (FANUC CNCs surface these; Modbus PLCs surface those; etc.)
|
||||
3. **Type vocabulary** — the canonical machine-state values (`Running` / `Idle` / `Faulted` / `Starved` / `Blocked`) and any other shared enumerations consumers need
|
||||
|
||||
## Who consumes this
|
||||
|
||||
Three surfaces per the 3-year-plan handoff §"Canonical Model Integration":
|
||||
|
||||
| Consumer | How |
|
||||
|----------|-----|
|
||||
| **OtOpcUa equipment namespace** | At deploy/config time, OtOpcUa nodes fetch the equipment-class template referenced by `Equipment.EquipmentClassRef` and use it to validate the operator-configured tag set. Drift = config validation error |
|
||||
| **Redpanda topics + Protobuf schemas** | Equipment-class templates derive Protobuf message definitions for canonical events (`equipment.state.transitioned`, etc.) |
|
||||
| **dbt curated layer in Snowflake** | Same templates derive column definitions and dimension tables for the curated analytics model |
|
||||
|
||||
OtOpcUa is one consumer of three. Decisions about format, structure, and naming live with the schemas-repo owner team (TBD), not with any one consumer.
|
||||
|
||||
## Format
|
||||
|
||||
**JSON Schema (Draft 2020-12) authored in this repo; Protobuf code-generated for wire serialization where needed** (per OtOpcUa corrections doc D2 recommendation, blessed by the OtOpcUa team but pending schemas-repo team review).
|
||||
|
||||
Why JSON Schema as the authoring format:
|
||||
- Idiomatic for .NET (System.Text.Json + JsonSchema.Net) — OtOpcUa reads templates with no extra dependencies
|
||||
- Idiomatic for CI tooling (every CI runner can `jq` / validate JSON Schema without extra toolchain)
|
||||
- Supports validation at multiple layers: operator-visible Admin UI errors in OtOpcUa, schemas-repo CI gates, downstream consumer runtime validation
|
||||
- Better authoring experience than Protobuf (binary format, .proto compiler, poor merge story)
|
||||
- Where wire-format efficiency matters (Redpanda events), code-generate Protobuf from the JSON Schema source. One-way derivation is simpler than bidirectional sync.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
schemas/
|
||||
├── README.md This file
|
||||
├── CONTRIBUTING.md How to add a new class, validate, PR process
|
||||
├── format/ JSON Schemas defining the format of everything below
|
||||
│ ├── equipment-class.schema.json Shape of an equipment-class template
|
||||
│ ├── uns-subtree.schema.json Shape of a per-site UNS subtree definition
|
||||
│ └── tag-definition.schema.json Shape of an individual tag declaration inside a template
|
||||
├── classes/ Equipment-class templates
|
||||
│ └── fanuc-cnc.json Pilot class per OtOpcUa corrections D1
|
||||
├── uns/ Per-site UNS subtree definitions
|
||||
│ └── example-warsaw-west.json Worked example
|
||||
└── docs/
|
||||
├── overview.md What this repo is, who uses it, lifecycle
|
||||
├── format-decisions.md Why JSON Schema, structure rationale, Protobuf derivation
|
||||
└── consumer-integration.md How each of the 3 consumers integrates
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Author** — humans edit JSON files in this repo, validated against the schemas in `format/` by the CI gate
|
||||
2. **Publish** — merging to `main` makes the new content the authoritative version; semver tag on each release
|
||||
3. **Consume** — each consumer pulls a specific tagged version (or `main` for staging environments) and integrates per `docs/consumer-integration.md`
|
||||
|
||||
The current state has no consumers actively reading from this repo — content is seed-only. First wiring happens in OtOpcUa Phase 1+ when `Equipment.EquipmentClassRef` validation lands (see `lmxopcua/docs/v2/plan.md` decision #112).
|
||||
|
||||
## Open Questions
|
||||
|
||||
Owner-team-decisions (not for OtOpcUa or any single consumer to make alone):
|
||||
|
||||
- **Repo ownership and review process**: who owns this repo? PR review SLA? Format changes need who to sign off?
|
||||
- **Versioning policy**: semver per release? Per-class versioning vs whole-repo versioning? How do consumers pin versions?
|
||||
- **Backward-compatibility policy**: when a class adds a new required signal, do all existing equipment in OtOpcUa's central config DB need to be updated atomically? Or graceful degradation?
|
||||
- **Cross-class shared types**: how does a `Position` measurement (used by both FANUC CNC and a Beckhoff axis) avoid duplicate definitions?
|
||||
- **Pilot class scope**: FANUC CNC chosen per OtOpcUa corrections D1 because FOCAS already has a fixed pre-defined hierarchy. Confirm with the schemas-repo team that this is the right starting class.
|
||||
- **Enterprise shortname** (currently `ent` placeholder) — the UNS subtree definitions reference an Enterprise segment; the canonical value comes from organizational naming authority.
|
||||
|
||||
## References
|
||||
|
||||
- 3-year-plan handoff: `gitea.dohertylan.com/dohertj2/3yearplan` → `handoffs/otopcua-handoff.md` §"UNS Naming Hierarchy" + §"Canonical Model Integration" + §"Digital Twin Touchpoints"
|
||||
- OtOpcUa corrections doc: `gitea.dohertylan.com/dohertj2/3yearplan` → `handoffs/otopcua-corrections-2026-04-17.md` §B2 (schemas-repo dependencies) + §D1 (pilot class) + §D2 (format)
|
||||
- OtOpcUa v2 plan: `gitea.dohertylan.com/dohertj2/lmxopcua` → `docs/v2/plan.md` decisions #108–115 + §"UNS naming hierarchy"
|
||||
87
schemas/classes/fanuc-cnc.json
Normal file
87
schemas/classes/fanuc-cnc.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"$schema": "../format/equipment-class.schema.json",
|
||||
"classId": "fanuc-cnc",
|
||||
"version": "0.1.0",
|
||||
"displayName": "FANUC CNC",
|
||||
"description": "FANUC CNC machine tools (mills, lathes, machining centers) accessed via FOCAS2. Pilot equipment class per OtOpcUa corrections doc D1 — chosen because the FOCAS driver already exposes a fixed pre-defined node hierarchy (lmxopcua/docs/v2/driver-specs.md §7) which is essentially a class template already; this file formalizes it.",
|
||||
"vendor": "FANUC",
|
||||
"applicability": {
|
||||
"drivers": ["Focas"],
|
||||
"models": ["0i-F", "0i-D", "30i-B", "31i-B", "32i-B"]
|
||||
},
|
||||
"signals": [
|
||||
{ "name": "SeriesNumber", "category": "Identity", "dataType": "String", "description": "CNC series (e.g. '0i-F'). From cnc_sysinfo()." },
|
||||
{ "name": "Version", "category": "Identity", "dataType": "String", "description": "CNC software version. From cnc_sysinfo()." },
|
||||
{ "name": "MaxAxes", "category": "Identity", "dataType": "Int32", "description": "Maximum axes supported by this CNC. From cnc_sysinfo()." },
|
||||
{ "name": "AxisCount", "category": "Identity", "dataType": "Int32", "description": "Active axis count. From cnc_rdaxisname()." },
|
||||
|
||||
{ "name": "RunState", "category": "Status", "dataType": "Int32", "description": "0=STOP, 1=HOLD, 2=START, 3=MSTR. From cnc_statinfo()." },
|
||||
{ "name": "Mode", "category": "Status", "dataType": "Int32", "description": "0=MDI, 1=AUTO, 3=EDIT, 4=HANDLE, 5=JOG, 7=REF. From cnc_statinfo()." },
|
||||
{ "name": "MotionState", "category": "Status", "dataType": "Int32", "description": "0=idle, 1=MOTION, 2=DWELL, 3=WAIT. From cnc_statinfo()." },
|
||||
{ "name": "EmergencyStop", "category": "Status", "dataType": "Boolean", "description": "From cnc_statinfo()." },
|
||||
{ "name": "AlarmActive", "category": "Status", "dataType": "Int32", "description": "Bitmask of active alarm categories. From cnc_statinfo()." },
|
||||
|
||||
{
|
||||
"name": "AxisAbsolutePosition",
|
||||
"category": "Position",
|
||||
"dataType": "Float64",
|
||||
"unit": "mm",
|
||||
"isArray": true,
|
||||
"arrayDimension": 32,
|
||||
"description": "Per-axis absolute position. Indexed by axis number. From cnc_absolute(). FANUC scaled-integer source converted via cnc_getfigure().",
|
||||
"scaling": { "method": "DecimalPlaces", "decimalPlaces": 3 }
|
||||
},
|
||||
{
|
||||
"name": "AxisMachinePosition",
|
||||
"category": "Position",
|
||||
"dataType": "Float64",
|
||||
"unit": "mm",
|
||||
"isArray": true,
|
||||
"arrayDimension": 32,
|
||||
"description": "Per-axis machine position. From cnc_machine().",
|
||||
"scaling": { "method": "DecimalPlaces", "decimalPlaces": 3 }
|
||||
},
|
||||
{
|
||||
"name": "ActualFeedRate",
|
||||
"category": "Velocity",
|
||||
"dataType": "Float64",
|
||||
"unit": "mm/min",
|
||||
"description": "Actual feed rate. From cnc_actf()."
|
||||
},
|
||||
{
|
||||
"name": "SpindleActualSpeed",
|
||||
"category": "Velocity",
|
||||
"dataType": "Float64",
|
||||
"unit": "rpm",
|
||||
"isArray": true,
|
||||
"arrayDimension": 8,
|
||||
"description": "Per-spindle actual speed. From cnc_acts() / cnc_acts2()."
|
||||
},
|
||||
{
|
||||
"name": "SpindleLoad",
|
||||
"category": "Process",
|
||||
"dataType": "Float64",
|
||||
"unit": "%",
|
||||
"isArray": true,
|
||||
"arrayDimension": 8,
|
||||
"description": "Per-spindle load percentage. From cnc_rdspmeter(). FOCAS2."
|
||||
},
|
||||
|
||||
{ "name": "MainProgramNumber", "category": "Process", "dataType": "Int32", "description": "From cnc_rdprgnum()." },
|
||||
{ "name": "RunningProgramNumber", "category": "Process", "dataType": "Int32", "description": "From cnc_rdprgnum()." },
|
||||
{ "name": "RunningProgramName", "category": "Process", "dataType": "String", "description": "Full program path/name. From cnc_exeprgname(). FOCAS2." },
|
||||
{ "name": "SequenceNumber", "category": "Process", "dataType": "Int32", "description": "Current N-number. From cnc_rdseqnum()." },
|
||||
{ "name": "PartsCount", "category": "Counter", "dataType": "Int64", "description": "Parts produced. From cnc_rdparam(6711/6712)." },
|
||||
|
||||
{ "name": "ActiveAlarmCount", "category": "Alarm", "dataType": "Int32", "description": "Number of active alarms. From cnc_rdalmmsg(). FOCAS2." }
|
||||
],
|
||||
"alarms": [
|
||||
{ "alarmId": "ps-alarm", "displayName": "Program/Setup Alarm", "severity": "High", "description": "P/S alarm category." },
|
||||
{ "alarmId": "ot-alarm", "displayName": "Overtravel Alarm", "severity": "Critical", "description": "OT alarm category — axis overtravel." },
|
||||
{ "alarmId": "sv-alarm", "displayName": "Servo Alarm", "severity": "Critical", "description": "Servo subsystem alarm." }
|
||||
],
|
||||
"stateModel": {
|
||||
"states": ["Running", "Idle", "Faulted", "Starved", "Blocked"],
|
||||
"derivationNotes": "Derivation lives at Layer 3 (System Platform / Ignition), not in OtOpcUa. Suggested mapping (informational): RunState=2 (START) AND MotionState=1 (MOTION) AND AlarmActive=0 → Running; RunState=0 (STOP) OR RunState=1 (HOLD) → Idle; AlarmActive≠0 OR EmergencyStop=true → Faulted; Starved/Blocked are inferred from upstream/downstream signals not exposed by FOCAS itself."
|
||||
}
|
||||
}
|
||||
50
schemas/docs/consumer-integration.md
Normal file
50
schemas/docs/consumer-integration.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Consumer Integration
|
||||
|
||||
How each of the three canonical-model consumers integrates with this repo.
|
||||
|
||||
## OtOpcUa equipment namespace
|
||||
|
||||
**Reference**: `lmxopcua/docs/v2/plan.md` decisions #112, #115; `lmxopcua/docs/v2/config-db-schema.md` Equipment table.
|
||||
|
||||
**What it pulls**: equipment-class templates from `classes/` keyed by `Equipment.EquipmentClassRef` in the central config DB.
|
||||
|
||||
**Integration points**:
|
||||
- At Admin UI draft-publish time: `sp_ValidateDraft` checks that every Equipment row's tag set satisfies the referenced class's required-signal list. Missing required signals = draft validation error.
|
||||
- At cluster runtime: each OtOpcUa node fetches the relevant class templates at startup (cached locally per the LiteDB cache pattern) and uses them to validate the applied generation.
|
||||
|
||||
**Versioning**: each `EquipmentClassRef` value carries a `classId@version` form (e.g. `fanuc-cnc@0.1.0`). Pinning to a specific version protects against breaking changes in the schemas repo.
|
||||
|
||||
**Status (2026-04-17)**: `Equipment.EquipmentClassRef` ships as a nullable hook column in OtOpcUa Phase 1 with no validation enforcement. Enforcement lands when the schemas repo is publishable and the OtOpcUa team wires the validator into `sp_ValidateDraft`.
|
||||
|
||||
## Redpanda topics + Protobuf schemas
|
||||
|
||||
**What it pulls**: equipment-class templates derive Protobuf message definitions for canonical equipment events (`equipment.state.transitioned`, `equipment.signal.changed`, etc.).
|
||||
|
||||
**Integration points**:
|
||||
- A code-generation step in the Redpanda team's CI reads `classes/*.json` and emits `.proto` files for each class.
|
||||
- The generated `.proto` files publish to a Schema Registry (or equivalent) for runtime consumers.
|
||||
- Versioning: Protobuf schema versions track this repo's tag versions one-for-one.
|
||||
|
||||
**Status (2026-04-17)**: not wired. Redpanda team to design the codegen step when the schemas repo has a stable initial class set.
|
||||
|
||||
## dbt curated layer in Snowflake
|
||||
|
||||
**What it pulls**: equipment-class templates derive column definitions for the curated equipment-state and equipment-signal models in dbt.
|
||||
|
||||
**Integration points**:
|
||||
- A dbt macro reads `classes/*.json` and generates per-class staging models with the canonical signal columns.
|
||||
- UNS subtree definitions (`uns/*.json`) drive the dim_site / dim_area / dim_line dimension tables.
|
||||
- Versioning: dbt project pins to a specific schemas-repo tag; updates require an explicit dbt deploy.
|
||||
|
||||
**Status (2026-04-17)**: not wired. dbt team to design the macro when the schemas repo has a stable initial class set.
|
||||
|
||||
## Cross-consumer compatibility
|
||||
|
||||
A breaking change in a class template (major version bump per CONTRIBUTING.md) requires:
|
||||
|
||||
1. Schemas repo PR with the change + rationale
|
||||
2. Each consumer team confirms the impact on their integration
|
||||
3. All three consumers either upgrade simultaneously OR pin to the prior major version until they can upgrade
|
||||
4. OtOpcUa specifically: a breaking change to a class with existing equipment in production requires a config-generation publish that updates the equipment's `EquipmentClassRef` to the new version + reconciles tag changes
|
||||
|
||||
Breaking changes should be rare. Preferred pattern: add new optional signals (minor bump) + deprecate old ones across multiple minor releases before removing in a major.
|
||||
56
schemas/docs/format-decisions.md
Normal file
56
schemas/docs/format-decisions.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Format Decisions
|
||||
|
||||
Why the schemas repo looks the way it does. Each decision is open for the schemas-repo owner team to revisit.
|
||||
|
||||
## D1 — JSON Schema (Draft 2020-12) as the authoring format
|
||||
|
||||
**Alternatives considered**: Protobuf (`.proto`), YAML, custom DSL.
|
||||
|
||||
**Choice**: JSON Schema.
|
||||
|
||||
**Why**:
|
||||
- Idiomatic for .NET 10 (System.Text.Json + JsonSchema.Net) — OtOpcUa reads templates with no extra dependencies
|
||||
- Idiomatic for CI tooling — every CI runner can `jq` and validate JSON Schema without extra toolchain (`ajv`, `jsonschema`, etc.)
|
||||
- Best authoring experience: text format, mergeable in git, structured diffing, IDE autocomplete via the `$schema` reference
|
||||
- Validation at multiple layers: operator-visible Admin UI errors in OtOpcUa, schemas-repo CI gates, downstream consumer runtime validation
|
||||
- Protobuf is better for *wire* serialization (size, speed, generated code) but worse for *authoring* (binary, requires `.proto` compiler, poor merge story in git)
|
||||
- Where wire-format efficiency matters (Redpanda events), we code-generate Protobuf from the JSON Schema source. One-way derivation is simpler than bidirectional sync.
|
||||
|
||||
## D2 — Per-class versioning, semver
|
||||
|
||||
**Alternatives considered**: whole-repo versioning, no versioning.
|
||||
|
||||
**Choice**: each class file has its own `version` field (semver); the repo also tags overall releases.
|
||||
|
||||
**Why**:
|
||||
- Different classes evolve at different rates (FANUC CNC may stabilize while Modbus PLC catalog grows)
|
||||
- Consumers can pin per-class for fine-grained compatibility (e.g. `fanuc-cnc@0.1.0` + `modbus-plc@0.3.2`)
|
||||
- Repo-level tags exist to bundle a known-good combination for consumers that want one anchor
|
||||
|
||||
## D3 — Strict additive policy on minor bumps
|
||||
|
||||
**Why**: removes ambiguity. If I see `class@1.3.0`, I know its signal set is a strict superset of `class@1.0.0` (and `class@1.x.y` for any earlier x.y). Breaking changes only happen at major-version boundaries.
|
||||
|
||||
## D4 — `_default` reserved as placeholder for unused UNS levels
|
||||
|
||||
Imported from `lmxopcua/docs/v2/plan.md` decision #108.
|
||||
|
||||
**Why**: some sites have no Area-level distinction (single-building sites). Rather than letting the UNS path have inconsistent depth across sites, we mandate 5 levels always with `_default` as the placeholder. Downstream consumers can rely on path depth.
|
||||
|
||||
## D5 — Tag names use PascalCase or snake_case (class's choice), NOT UNS-segment regex
|
||||
|
||||
**Why**: UNS path segments (Enterprise/Site/Area/Line/Equipment) are infrastructure-level identifiers — they go on the wire of every browse, every URI, every dashboard filter. The regex (`^[a-z0-9-]{1,32}$`) reflects that constraint.
|
||||
|
||||
Signal names (level 6) are vocabulary-level identifiers — they live inside an equipment node. Keeping them in PascalCase or snake_case (e.g. `RunState`, `actual_feedrate`) is more readable for operators looking at OPC UA browse output, and matches OPC UA SDK conventions which expect identifier-style names rather than URL-safe slugs.
|
||||
|
||||
## D6 — `stateModel` is informational, not authoritative
|
||||
|
||||
**Why**: state derivation lives at Layer 3 (System Platform / Ignition). Placing the derivation rules in the schemas repo would create dual sources of truth (and the schemas-repo version would inevitably drift). Instead, the class template lists which states the class supports + an informational note about what the rough mapping looks like; Layer 3 owns the actual derivation logic.
|
||||
|
||||
## D7 — No per-equipment overrides at this layer
|
||||
|
||||
**Why**: per-equipment config (which specific CNC has which program, etc.) is OtOpcUa's central config DB concern. Mixing per-instance config with per-class definitions in this repo would muddy the separation and cause the repo to grow with deployment-specific data instead of staying small + reusable.
|
||||
|
||||
## D8 — `applicability.drivers` lists OtOpcUa drivers explicitly
|
||||
|
||||
**Why**: the schemas repo is OT-side-focused. The OtOpcUa driver enumeration is the closest thing to a canonical "how do you get raw data from this equipment" vocabulary that exists across the org. If a future class is populated by a non-OtOpcUa source, the field becomes optional or extends. For now, listing OtOpcUa driver IDs makes the consumer-side validation (per `lmxopcua/docs/v2/plan.md` decision #111 — driver type ↔ namespace kind) trivial.
|
||||
42
schemas/docs/overview.md
Normal file
42
schemas/docs/overview.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Overview
|
||||
|
||||
The `schemas` repo is the org's single source of truth for OT equipment definitions. Three things live here:
|
||||
|
||||
1. **UNS hierarchy** — per-site declarations of which Areas and Lines exist (`uns/`).
|
||||
2. **Equipment-class templates** — per-class declarations of which raw signals each equipment type exposes (`classes/`).
|
||||
3. **Format definitions** — JSON Schemas defining what UNS and class files must look like (`format/`).
|
||||
|
||||
## Why a central repo
|
||||
|
||||
Three OT/IT systems consume the same canonical model:
|
||||
|
||||
- **OtOpcUa** equipment namespace — exposes raw signals over OPC UA
|
||||
- **Redpanda + Protobuf** event topics — canonical event shape on the wire
|
||||
- **dbt curated layer in Snowflake** — analytics model
|
||||
|
||||
Without a central source, they would drift. With one repo, every consumer pulls a versioned snapshot and validates against it. Drift becomes a CI failure, not a production incident.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
[author edits JSON in this repo]
|
||||
│
|
||||
▼
|
||||
[CI validates against format/*.schema.json]
|
||||
│
|
||||
▼
|
||||
[PR review by maintainer + consumer reps]
|
||||
│
|
||||
▼
|
||||
[merge to main → tag a semver release]
|
||||
│
|
||||
▼
|
||||
[each consumer pulls the tag and integrates per docs/consumer-integration.md]
|
||||
```
|
||||
|
||||
## What's NOT in this repo
|
||||
|
||||
- **Per-equipment configuration** — which specific CNC at Warsaw West runs which program. That's per-instance config in OtOpcUa's central config DB (`lmxopcua/docs/v2/config-db-schema.md` Equipment table), not template-level material.
|
||||
- **State derivation rules** — how raw signals derive into `Running` / `Idle` / `Faulted` / `Starved` / `Blocked`. That's Layer 3 logic in Aveva System Platform / Ignition. Equipment-class templates can declare which states the class supports (`stateModel.states`) but not the derivation itself.
|
||||
- **Wire-format Protobuf schemas** — those are code-generated from this repo into a separate output artifact for Redpanda. The authoring source is here; the binary wire format is derived.
|
||||
- **Authoritative LDAP groups, ACL grants, OPC UA security policies** — those live in the consuming systems (OtOpcUa central config DB, identity provider).
|
||||
93
schemas/format/equipment-class.schema.json
Normal file
93
schemas/format/equipment-class.schema.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://gitea.dohertylan.com/dohertj2/3yearplan/raw/branch/main/schemas/format/equipment-class.schema.json",
|
||||
"title": "Equipment-Class Template",
|
||||
"description": "Declares the canonical raw signal vocabulary for an equipment class. Consumed by OtOpcUa (validates per-equipment tag config), Redpanda (derives Protobuf event schemas), and dbt (derives column definitions for the curated layer).",
|
||||
"type": "object",
|
||||
"required": ["classId", "version", "displayName", "signals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"classId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,64}$",
|
||||
"description": "Stable logical ID for the class. Lowercase, hyphens allowed. Once published, never changes — clients pin to it."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
||||
"description": "Semver. Major bump = breaking change; minor = additive non-breaking; patch = doc/clarification only."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Human-readable name (e.g. 'FANUC CNC')."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 1024,
|
||||
"description": "Optional longer description."
|
||||
},
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Vendor or family (e.g. 'FANUC', 'Allen-Bradley', 'Siemens')."
|
||||
},
|
||||
"applicability": {
|
||||
"type": "object",
|
||||
"description": "When this class applies. Optional; omit for vendor-agnostic classes.",
|
||||
"properties": {
|
||||
"drivers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["Galaxy", "ModbusTcp", "AbCip", "AbLegacy", "S7", "TwinCat", "Focas", "OpcUaClient"]
|
||||
},
|
||||
"description": "Which OtOpcUa drivers can populate equipment of this class."
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Free-text model identifiers (e.g. '0i-F', '30i-B' for FANUC; '1756-L84E' for Logix)."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"signals": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "tag-definition.schema.json" },
|
||||
"description": "Required signals every equipment of this class must expose. Operator-configured tags in OtOpcUa are validated against this list — missing required signals = config error; extra signals are allowed (operator-extensible)."
|
||||
},
|
||||
"alarms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["alarmId", "displayName", "severity"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"alarmId": { "type": "string", "pattern": "^[a-z0-9-]{1,64}$" },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"severity": { "type": "string", "enum": ["Low", "Medium", "High", "Critical"] },
|
||||
"description": { "type": "string", "maxLength": 1024 }
|
||||
}
|
||||
},
|
||||
"description": "Optional canonical alarm definitions for this class."
|
||||
},
|
||||
"stateModel": {
|
||||
"type": "object",
|
||||
"description": "Optional declaration of how this class's raw signals derive into the canonical machine-state vocabulary at Layer 3 (System Platform / Ignition). Informational — derivation lives at Layer 3, not in OtOpcUa.",
|
||||
"properties": {
|
||||
"states": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["Running", "Idle", "Faulted", "Starved", "Blocked", "Changeover", "Maintenance", "Setup"]
|
||||
}
|
||||
},
|
||||
"derivationNotes": { "type": "string", "maxLength": 2048 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
83
schemas/format/tag-definition.schema.json
Normal file
83
schemas/format/tag-definition.schema.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://gitea.dohertylan.com/dohertj2/3yearplan/raw/branch/main/schemas/format/tag-definition.schema.json",
|
||||
"title": "Tag Definition (Signal)",
|
||||
"description": "Declares a single canonical signal an equipment class exposes. Used inside equipment-class templates.",
|
||||
"type": "object",
|
||||
"required": ["name", "dataType", "category"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$",
|
||||
"description": "Canonical signal name. PascalCase or snake_case at the equipment class's discretion; must be unique within the class. This is the OPC UA browse-name children of the equipment node."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Human-readable name for dashboards and Admin UI. Defaults to `name` if omitted."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 1024
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["Identity", "Status", "Process", "Position", "Velocity", "Acceleration", "Temperature", "Pressure", "Flow", "Counter", "Setpoint", "Command", "Alarm", "Diagnostic", "Other"],
|
||||
"description": "Coarse-grained category for filtering and dashboarding. Adds taxonomy without forcing a deep type hierarchy."
|
||||
},
|
||||
"dataType": {
|
||||
"type": "string",
|
||||
"enum": ["Boolean", "Int16", "Int32", "Int64", "UInt16", "UInt32", "UInt64", "Float32", "Float64", "String", "DateTime", "Reference"],
|
||||
"description": "OPC UA built-in type. Mirrors the values OtOpcUa drivers map to (per lmxopcua/docs/v2/driver-specs.md DataType column)."
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"maxLength": 32,
|
||||
"description": "Optional engineering unit (e.g. 'mm', 'mm/min', 'rpm', 'degC', 'bar'). Use UCUM where possible (https://ucum.org/)."
|
||||
},
|
||||
"isArray": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "True if this signal is an array (e.g. per-axis position vector)."
|
||||
},
|
||||
"arrayDimension": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Required when isArray=true. Maximum array length."
|
||||
},
|
||||
"accessLevel": {
|
||||
"type": "string",
|
||||
"enum": ["Read", "ReadWrite"],
|
||||
"default": "Read",
|
||||
"description": "Default access level when this signal is exposed via OPC UA. Per-equipment ACL grants (see lmxopcua/docs/v2/acl-design.md) can further restrict write access."
|
||||
},
|
||||
"writeIdempotent": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "True if writing the same value twice is safe (setpoint overwrite, mode selection). Drives OtOpcUa's Polly write-retry policy (lmxopcua decision #44–45)."
|
||||
},
|
||||
"isRequired": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "True (default) means every equipment of this class MUST expose this signal. False means it's class-supported but optional per equipment."
|
||||
},
|
||||
"isHistorized": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "True if this signal is expected to feed historian. Drives HistoryRead permission and OtOpcUa's IHistorianDataSource binding."
|
||||
},
|
||||
"scaling": {
|
||||
"type": "object",
|
||||
"description": "Optional scaling for raw integer values that represent decimal quantities (e.g. FANUC scaled-integer positions).",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": { "type": "string", "enum": ["Linear", "DecimalPlaces", "DriverDefined"] },
|
||||
"multiplier": { "type": "number" },
|
||||
"offset": { "type": "number" },
|
||||
"decimalPlaces": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
schemas/format/uns-subtree.schema.json
Normal file
64
schemas/format/uns-subtree.schema.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://gitea.dohertylan.com/dohertj2/3yearplan/raw/branch/main/schemas/format/uns-subtree.schema.json",
|
||||
"title": "UNS Subtree (per-site)",
|
||||
"description": "Declares the canonical UNS subtree for a site: Enterprise (level 1) + Site (level 2) + the Areas / Lines that exist at that site. Equipment (level 5) is configured per-cluster in OtOpcUa, not declared here — this file defines the higher-level structure operators are required to use.",
|
||||
"type": "object",
|
||||
"required": ["enterprise", "site", "areas"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enterprise": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,32}$",
|
||||
"description": "UNS level 1. Must match `ServerCluster.Enterprise` in every OtOpcUa cluster at this site."
|
||||
},
|
||||
"site": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,32}$",
|
||||
"description": "UNS level 2. Must match `ServerCluster.Site` in every OtOpcUa cluster at this site."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Human-readable site name (e.g. 'Warsaw West')."
|
||||
},
|
||||
"areas": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "displayName"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "pattern": "^[a-z0-9-]{1,32}$" },
|
||||
{ "const": "_default" }
|
||||
],
|
||||
"description": "UNS level 3 segment. `_default` is the reserved placeholder for sites where Area doesn't apply."
|
||||
},
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "displayName"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "pattern": "^[a-z0-9-]{1,32}$" },
|
||||
{ "const": "_default" }
|
||||
],
|
||||
"description": "UNS level 4 segment."
|
||||
},
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 128 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
schemas/uns/example-warsaw-west.json
Normal file
24
schemas/uns/example-warsaw-west.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "../format/uns-subtree.schema.json",
|
||||
"enterprise": "ent",
|
||||
"site": "warsaw-west",
|
||||
"displayName": "Warsaw West (example)",
|
||||
"areas": [
|
||||
{
|
||||
"name": "bldg-3",
|
||||
"displayName": "Building 3",
|
||||
"lines": [
|
||||
{ "name": "line-1", "displayName": "Line 1 — Assembly" },
|
||||
{ "name": "line-2", "displayName": "Line 2 — Machining" },
|
||||
{ "name": "line-3", "displayName": "Line 3 — Inspection" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bldg-4",
|
||||
"displayName": "Building 4",
|
||||
"lines": [
|
||||
{ "name": "line-1", "displayName": "Line 1 — Injection Molding" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user