Skip to main content

Self-contained models (pack, unpack, reverse --self-contained)

A model normally lives as a pair of files: <name>.model.yaml, which you write, and <name>.model.symbols.csv, which generate maintains so that every node keeps its NodeId across regenerations. The self-contained profile puts the pair, plus what the model needs from its dependencies, into one file: <name>.opcua.yaml.

# pair → one file
opcua-modeler pack -i pump.model.yaml
# writes pump.opcua.yaml

# one file → pair
opcua-modeler unpack -i pump.opcua.yaml
# writes pump.model.yaml and pump.model.symbols.csv

# NodeSet2.xml → one file, in a single step
opcua-modeler reverse -i Opc.Ua.Pumps.NodeSet2.xml --self-contained
# writes Opc.Ua.Pumps.opcua.yaml

The design is specified in the Self-Contained Profile working draft; this page is the practical summary.


What is inside an .opcua.yaml

The file is a YAML stream: several documents separated by ---.

DocumentWritten byContent
first documentyouyour model, exactly as in .model.yaml, plus profile: self-contained
kind: symbolsthe toolthe NodeId table as a tree that follows your model's sections
kind: declarationsthe toolfor a dependency that is not a well-known companion spec: the nodes your model references, with their NodeIds

Everything below the first --- is generated. Editors fold it; you never edit it by hand.

namespaceUri: http://acme.com/UA/Pump/
version: 1.0.0
profile: self-contained
namespaces: [di]
objectTypes:
- browseName: CentrifugalPumpType
subtypeOf: di:DeviceType
components:
- { browseName: Speed, typeDefinition: ua:AnalogUnitType, dataType: ua:Double }
---
kind: symbols
namespace: http://acme.com/UA/Pump/
format: 1
sealed: sha256:9a07c2…
nextId: 1004
assigned:
objectTypes:
CentrifugalPumpType:
$id: 1001
Speed:
$id: 1002
ua:EngineeringUnits: 1003

The symbol tree reads like the model: a leaf is Name: id, a node with children is a mapping whose own id is $id. Browse names from another namespace keep their prefix (ua:EngineeringUnits, di:SerialNumber), so a name with an underscore can never be misread as two levels.

retired, reserved, sealed, nextId

  • retired keeps the ids of nodes that are no longer in the model (or that a symbol CSV listed without the model emitting them). A node that comes back at the same path regains its id. Retired ids are never reallocated.
  • reserved blocks ids or ranges that must never be allocated here, with a reason.
  • sealed is a digest of the model document. When it no longer matches, the table was written for an older model; the tool says how many nodes have no id yet.
  • nextId is the next free id; allocation never goes below it.

pack

opcua-modeler pack -i <model.yaml> [-o <name>.opcua.yaml] [--symbols <file.csv>] [--declarations private|all] [--force]

pack compiles the model with its CSV, then writes the stream. The compile is what makes the tree exact: a CSV alone cannot tell A_B/C from A/B_C. If the model does not compile, nothing is written.

OptionMeaning
-i, --input<name>.model.yaml with its <name>.model.symbols.csv beside it, or an existing .opcua.yaml to re-pack
-o, --outputdefault: <name>.opcua.yaml next to the input
-s, --symbolsanother CSV to seed from
--declarationsprivate (default): declaration documents only for dependencies that are not well-known companion specs, since every processor bundles those. all: one for every dependency, for a file readable without any tooling
-f, --forceoverwrite an existing output

unpack

opcua-modeler unpack -i <name>.opcua.yaml [-o <name>.model.yaml] [--force]

Writes the model document back to <name>.model.yaml and the table to <name>.model.symbols.csv, retired ids included as rows. unpack compiles the model in frozen mode against the embedded table: every node must have an id and every id must name a node, otherwise it refuses. That is also the check to run in CI on a file someone sent you.


reverse --self-contained

opcua-modeler reverse -i <NodeSet2.xml> --self-contained [--declarations private|all] [--keep-pair]

Reverses the NodeSet as usual, then packs the result with the NodeIds of the XML as the table. The dependencies named in <RequiredModel> are resolved from a pinned copy next to the input, from dependencies/ or model/dependencies/, or from the bundled companion specifications. --keep-pair also leaves the intermediate .model.yaml and .model.symbols.csv on disk.

The hosted API offers the same through POST /api/v1/reverse?profile=self-contained (&declarations=all optional); the response carries profile: "self-contained". The MCP tool opcua_model_reverse takes self_contained: true.


generate on an .opcua.yaml

opcua-modeler generate -i plant.opcua.yaml [--frozen]

generate reads the file directly. The NodeIds come from the file's symbol document (a sidecar CSV is not read), the NodeSet2 is written as usual, and on success the symbol document is rewritten in the file: new nodes get ids, nodes that disappeared move to retired, the digest is resealed. Other tail documents are left untouched.

  • If the model was edited after the table was sealed, generate says so and how many nodes have no id yet, then allocates them.
  • --frozen refuses instead: any node without an id, or any id without a node, is an error. That is the check to run in CI on a file you received.

How a self-contained file is read back

For every dependency named in the model, the tool tries, in order, and uses the first hit:

  1. the filename hint of the matching imports entry;
  2. a NodeSet2 file in the same folder, dependencies/ or model/dependencies/ whose <Model ModelUri> matches;
  3. the bundled copy, for a well-known companion specification;
  4. the kind: declarations document in the file, materialised as a minimal NodeSet2 holding exactly the declared nodes.

When step 4 is what compiled a dependency, generate says so in a warning. The winning source is compared with the pins (version, publicationDate, sha256); a mismatch is a warning. The model's own namespace is never resolved: its ids come from the symbols document.

A declared DataType carries its definition (enumeration values, structure fields and their types, unions) and its encoding objects, so a model that uses a foreign enumeration or structure compiles from the shim as it would from the real file.

What is not there yet

Embedding a private dependency as a full model (kind: model), and the rename and move operations that keep an id across a rename, are the next steps of this profile.