AutomationML should have a first-class Python SDK, not just an XSD conversion. The SDK should make JSON the everyday representation while still emitting valid CAEX XML for existing AutomationML tools.
The JSON serialization uses the canonical CAEX names from the XSD:
- XML elements become JSON object properties.
- XML attributes also become JSON object properties.
- Repeating XML elements are represented as arrays, even when they contain one item.
- Simple text XML elements such as
DescriptionandVersionuse{ "value": "..." }so theirChangeModeattribute can still be represented.
This keeps the JSON mechanically close to CAEX while remaining readable.
The Python API should not force CAEX casing into application code. A user writes:
doc.instance_hierarchies[0].internal_elements[0].ref_base_system_unit_pathand the SDK serializes:
{
"InstanceHierarchy": [
{
"InternalElement": [
{
"RefBaseSystemUnitPath": "..."
}
]
}
]
}The same holds for type checkers. Model fields declare their CAEX alias inside
Annotated and keep the default on the right-hand side:
name: Annotated[str, Field(alias="Name")]
attributes: Annotated[list[Attribute], Field(alias="Attribute")] = Field(
default_factory=list
)Type checkers name a Pydantic model's __init__ parameters after the alias of
the assigned Field, but ignore Field metadata inside Annotated. Written
this way, editors accept InternalElement(name=...), while Pydantic still
validates and serializes the Name alias. A plain = Field(alias="Name")
would make pyright and Pylance reject the snake_case name.
XML is an adapter, not the primary model. The XML layer:
- uses the CAEX namespace
http://www.dke.de/CAEX; - emits elements in the XSD order;
- preserves CAEX attributes as XML attributes;
- can parse XML back into the same JSON-first models.
The SDK accepts existing practical JSON, including documents that omit
SourceDocumentInformation. The stricter XSD-required checks are available via:
doc.assert_caex_valid(strict_xsd=True)That split lets migration projects load real-world files first, then ratchet up conformance when producing association-grade artifacts.
OPC UA conversion follows the same Python-first design:
UANode,UAReference,UAScalarValue,UAModel, andUANodeSetare frozen Pydantic value objects with forbidden extra fields and shape checks.- Repeated values are tuples and aliases are exposed as an immutable mapping, so a validated graph cannot be invalidated by mutating a nested collection.
UANodeSetBuilderis the deliberate mutable boundary. It detects duplicate NodeIds when they are added and revalidates the complete graph atbuild().UANodeSet.from_xml()andto_xml()provide a strict adapter for the subset used by the AutomationML mapping. Unsupported NodeClasses fail explicitly.CAEXFile.to_opcua_nodeset()returns that Pydantic graph directly; callers only useto_opcua_nodeset_xml()when XML is actually the boundary format.- Forward and reverse converters use the same frozen
AMLUAMappingProfile. Each many-to-one datatype rule declares its canonical AML inverse. Contextual role edges declare their AutomationML owner and reverse relationship.
The user-facing roundtrip operation never proves itself with embedded source data:
result = document.round_trip_opcua(publication_date="2026-08-17")
if result.semantically_equivalent:
recovered = result.assert_equivalent()
else:
print(result.source_document)
print(result.recovered_document)OPCUARoundTripResult intentionally retains the source model, generated
NodeSet XML, and reconstructed model. A Boolean alone would make a failed or
canonicalized mapping impossible to investigate.
The OPC UA side is an equal entry point:
nodeset = UANodeSet.from_xml(opcua_xml)
document = nodeset.to_automationml()
result = nodeset.round_trip_automationml(publication_date="2026-08-17")
canonical_nodeset = result.assert_equivalent()UANodeSetRoundTripResult verifies the semantic projection
UA -> AML -> UA -> AML. NodeIds, aliases, namespace prefixes, and XML order
may be canonicalized, so raw XML equality is deliberately not the criterion.
Tests replace all generated document NodeIds with OPC-UA-authored identifiers
and prove that reconstruction does not depend on the mapper's NodeId layout.
Both result types contain frozen RoundTripDifference records addressed by
JSON Pointer. Their semantically_equivalent field is computed from the empty
or non-empty difference tuple; callers cannot supply a contradictory Boolean.
Shared Python code does not make a non-injective standard mapping invertible. Such cases must have a documented canonical inverse in the profile or fail as ambiguous. This is the meaning of the strict-implicit policy.