DEXPI: P&ID in, routed 3D model out¶
DEXPI (Data Exchange in the Process Industry) is the vendor-neutral
exchange specification for a P&ID: which equipment exists, which nozzles it has, and which piping,
signal and electrical lines connect them – with no notion whatsoever of physical size or plant
coordinates. ada.cadit.dexpi reads and writes it, and ada.from_dexpi closes the gap between
“here is a P&ID” and “here is a routed 3D model”: it resolves each item to a physical equipment
definition, generates a plausible topology to put the equipment in, turns the P&ID connectivity into
adapy systems, and routes them.
Nothing here is imported by import ada – ada.cadit.dexpi and its readers load lazily, so
the DEXPI support costs nothing until it is used.
Two wire formats, one model¶
DEXPI 2.0.0 (2025-10-10, CC BY 4.0) replaced the serialization without changing plant semantics. adapy reads and writes both, dispatched on the root tag:
DEXPI 1.3/1.4 (“Proteus XML”) |
DEXPI 2.0.0 (“DEXPI XML”) |
|
|---|---|---|
Root element |
|
|
Class |
|
|
Attributes |
|
|
Connectivity |
|
|
Files in the wild |
every vendor export; the bulk of the official test corpus |
essentially only the specification’s own reference P&ID |
Proteus is the default and the format almost every real emitter still writes; DEXPI 2.0 support
exists and is tested against the specification’s own reference P&ID, but real-world 2.0 files are
still rare. Both converge on one neutral, in-memory DexpiDocument,
so nothing downstream of the reader – equipment resolution, layout, systems – cares which flavour
a file came in as.
The class table behind item resolution (ada.cadit.dexpi.class_table) is generated once from the
CC BY 4.0 DEXPI 2.0.0 specification repository and checked in as JSON – 341 classes, 142 of them
under Plant/ProcessEquipment. Nothing here performs a live RDL lookup or XSD validation; both
are offline by policy (see Never, below).
Reading a P&ID¶
Reading a P&ID and building a plant are two different jobs, and adapy keeps them apart. There are three layers, each with one responsibility:
Layer |
What it holds |
|---|---|
|
A faithful parse of the file: every item, every attribute, and the raw |
The adapy-native model: |
|
The 3D model: generated decks, placed equipment, routed runs, structure. |
ada.from_dexpi is the one-call path through all three, and returns an ada.Assembly like
every other top-level ada.from_*:
import ada
from ada.topo_model.build_spec import ProceduralBuildSpec
from ada.topo_model.layout import LayoutRules
spec = ProceduralBuildSpec(layout=LayoutRules(max_length=24.0, max_width=12.0, deck_height=5.0))
a = ada.from_dexpi("files/dexpi_files/unit_separator_proteus.xml", spec=spec)
a.to_ifc("separator_unit.ifc")
Take the two steps separately whenever you want to look at what the P&ID resolved to before committing to a build, vary the build rules without re-reading, or write the model back out:
model = ada.SystemModel.from_dexpi("files/dexpi_files/unit_separator_proteus.xml")
print(sorted(eq.name for eq in model.equipment))
# ['E-201', 'P-201A', 'P-201B', 'PV-202.01', 'TE-203', 'TE-204', 'V-201']
print(sorted(s.name for s in model.systems))
# ['201/1', '202/1', '203/1', '203/2', '203/3', '204/1', '204/2', '204/3', '206/1']
a = model.to_assembly(spec) # decks, placement, routing
model.to_dexpi("separator_unit.xml") # write back
Everything about building lives on the ProceduralBuildSpec – deck bounds, design ruleset,
structural blueprint, route, relocate, base_doc, level of detail. Everything about
reading stays on the reader: definitions and inline_components decide what the P&ID
resolves to and what exists, which is why they are read arguments and deck bounds are not.
Three steps happen inside (ada.cadit.dexpi.read.to_procedural):
Equipment. Every item that
is_a(cls, "ProcessEquipment")resolves – per-tag override, then per-class override, then the shipped class default – to an envelope, an IFC class and a port per actual nozzle, placed on the box by a class-specific strategy (vessel/pump/exchanger/generic). A nestedChamber(a separator’s boot) folds into its owning equipment rather than becoming an asset of its own. A branch point – an in-line fitting that more than one segment ends at, typically aPipeTee– is materialised too, as a smallIfcPipeFittingwith one port per connection node;TE-203andTE-204above are the two in this file, and they are why the runs through them exist at all.Layout. The resolved envelopes go to
ada.topo_model.layout.plan_layout(), which generates deck spaces and shelf-packs equipment onto them – see The generated layout is not a plot plan, below.Instrumentation. Every instrument a signal line ends on – and every actuating system bound to the valve it drives – becomes a small placed object with
signalports, classed as the IFC4 control element its DEXPI role implies (IfcController,IfcSensor,IfcActuator). EachSignalConveyingFunctionthen becomes a routed run between two of them. See Instrumentation is connectivity, not decoration, below.Systems. One
TopoSystemper DEXPIPipingNetworkSegment, named<line number>/<segment number>, then routed with the standard design rules. APipeOffPageConnectorbecomes a site terminal (is_site=True); the parentPipingNetworkSystemsurvives as the run’s medium and provenance metadata.
The above example is the checked-in unit_separator_proteus.xml fixture, and it is a good
illustration of an honest gap, not a cherry-picked clean run: 9 of its 10 DEXPI segments turn into
routed systems and the tenth is reported rather than dropped. That last one, 205/1, is a relief
valve discharging to something the P&ID does not draw – a genuinely one-ended run, and
route_system() routes exactly ports[0] -> ports[-1], so there is
nothing to route it to. Nothing is dropped silently: every system or equipment that did not
reach the 3D model is collected into assembly.metadata["dexpi"]["report"] and summarised in one
logger.warning line; dexpi_import_report() renders it
as a table:
1 of 10 system(s) and 0 of 6 equipment did not reach the 3D model:
Kind Name Stage Reason
------ ----- ------------ -----------------------------------------------------------------
system 205/1 connectivity 1 endpoint(s) outside the segment; a routed run needs exactly two
Six of those nine runs meet at one of the file’s two tees, and used to be lost with them: a segment
whose end names a PipeTee names neither a nozzle nor an equipment nor an off-page connector.
The junction itself still never becomes one system – route_system has no branch concept, and
that is why there is one system per DEXPI segment – but each run into a junction is an ordinary
two-ended run once the fitting it ends at is a real placed object with real ports.
Pass strict=True to the read to raise instead of returning a model with a warning; it governs
what the P&ID could not be read into, not what could not be built. inline_components="equipment"
materialises in-line valves and strainers as their own small ada.Equipment rather than run
metadata. On the build side, ProceduralBuildSpec carries route=False (place without
routing), base_doc (merge onto placements you already corrected, for a non-destructive
re-import) and relocate.
The two reports are separate, and deliberately so. model.report names what the read could
not carry – a segment whose ends the P&ID never stated, an item that resolved to nothing placeable.
assembly.metadata["build"] names what the build could not carry – equipment no cell would
hold, a run the router could not path. They have different causes and different fixes, and one list
mixing them serves neither reader. Whether a vessel fits, in particular, is entirely a function of
the deck bounds a build was given, so it can only ever be a build gap.
ada.dexpi_to_procedural(path, ...) exposes the intermediate seam – the plain procedural
document plus the equipment catalog, ready for ProceduralBuilder.from_dict or to_excel
before anything is built.
Equipment definitions¶
A P&ID says a vessel is a ProcessColumn; it never says how big it is. adapy ships a hand-curated
table (ada.cadit.dexpi.equipment_defaults, backed by
resources/dexpi_equipment_defaults.json) of 29 curated DEXPI classes – envelope, IFC
element class, nozzle-layout strategy and an apparent density – resolved up the supertype DAG so
all 142 Plant/ProcessEquipment classes are covered (a CentrifugalPump inherits Pump’s
entry). Nozzle positions are always generated per item from its actual DEXPI nozzles, never fixed
per class, because nozzle count varies item by item.
These are placeholders for real vendor data, not engineering deliverables, and are meant to be
overridden. An equipment definition list (ada.cadit.dexpi.equipment_list) supplies full
overrides keyed by tag (wins) or DEXPI class (falls back), as either JSON
({tag_or_class: EquipmentTypeDoc}) or a two-sheet XLSX workbook (EquipmentTypes + a joined
Nozzles sheet, for hand-editing many nozzles at once). Pass a path or an already-loaded dict as
ada.SystemModel.from_dexpi(..., definitions=...).
definitions also accepts a callable (item) -> document | None – the escape hatch for a
definition that has to be computed rather than tabulated: looked up in a vendor database, derived
from an attribute the workbook has no column for, or generated. It is asked once per equipment item
and its return value is validated exactly like a table entry, so a computed definition cannot fail
later inside the compiler where the cause would be far harder to see.
Port category (process/signal/electrical) is not cosmetic: System.connect raises
on a category mismatch and the compiler then drops the whole system with only a warning. One trap
worth knowing about because it is easy to get backwards: the DEXPI spec makes Nozzle a subtype
of both ActuatingElectricalLocation and SensingLocation, so a naive “is this an electrical
location?” test types every ordinary process nozzle in the model as electrical.
ada.cadit.dexpi.nozzle_placers.category_for resolves the Nozzle family first, before any
instrumentation supertype test, for exactly that reason.
Instrumentation is connectivity, not decoration¶
A P&ID’s instrumentation is what says this controller drives that valve. It used to stop at the document: instruments were carried as metadata and echoed back out by the writer, and a model built from a P&ID contained no controller, no actuator and nothing joining them. Across the official corpus that was 174 devices and 103 signal runs that simply were not there.
They are modelled now, and two details make the difference between that working and quietly not:
Signal connectivity is stated differently from piping connectivity. A PipingNetworkSegment
owns <Connection> elements naming positional node indices. A SignalConveyingFunction owns
none at all – it is a function, so what it joins is stated as has logical start and
has logical end associations, and the line drawn on the sheet is presentation. Reading only the
piping form is exactly why none of this reached 3D before.
The sensing and acting halves of one loop are two devices. DEXPI nests them inside the loop
function that owns them: PI 4712.01 in the official C01 file contains its own
ProcessSignalGeneratingFunction and the signal line joining that element to the indicator.
Folding a nested instrument into its owner – the rule a Chamber follows – collapses both ends
of that line onto one object, and the run is then rejected for having no two distinct ends. So
instrumentation deliberately does not fold: membership is “something the signal graph refers
to, or something bound to a component it operates”.
Ports are signal rather than process, which is load-bearing and not cosmetic:
System.connect refuses a category mismatch and the compiler then drops the whole run with only a
warning. An instrument gets one port per line that ends on it – a controller that reads a
transmitter and drives a valve is the end of two, and a port already wired cannot take a second.
An actuator is placed with the valve it operates (via is fulfilled by) and named from its own
ActuatingSystemNumber: DEXPI numbers the actuator on PV-202 as PV-202.01, and letting it
borrow the valve’s tag instead would put two objects called PV-202 in the model with nothing to
tell them apart.
What is not claimed: a signal run is routed with the same rules as a pipe, so a pair of instruments packed close together can fail to route on bend clearance. Those failures are reported in the import report like any other, not hidden.
The generated layout is not a plot plan¶
There is no DEXPI concept of plant coordinates, and adapy does not invent good ones.
ada.topo_model.layout (a general capability, not DEXPI-specific) is shelf packing on physical
footprint alone: sort equipment by descending footprint, pack rows onto a deck within
LayoutRules.max_length/max_width, start a new deck when one fills. It has no process sense
whatsoever – a pump can land at the far end of a deck from the vessel it feeds. Equipment connected
by a segment is packed as one contiguous group, which is a cheap first heuristic and not a
substitute for engineering judgement.
Use layout=LayoutRules(...) to size the decks, and pass an explicit deck_height: the pitch
is uniform across the whole plan, so leaving it unset makes it “the tallest item plus headroom” –
one 15 m ProcessColumn would make every deck 16 m tall. base_doc lets a re-import keep
placements you have already corrected by hand instead of regenerating them.
Once a model is built and routed, ada.topo_model.relocate.propose_relocations() looks at
which runs did not route cleanly and proposes the equipment moves that would clear them. Plan first
with from_dexpi, relocate after – or pass relocate=True and let the import close that loop
itself: the model is routed, the moves that would clear the failures are computed and applied, and
it is routed again. Off by default, because a relocation changes where equipment stands; every
applied move is recorded in assembly.metadata["dexpi"]["relocations"], naming the runs it was
made for.
Making that loop work needed a fix in relocate worth knowing about, because
the symptom was silence rather than an error. The engine’s routing probe is meant to mirror the
compiler’s, but it skipped the step that inserts every port coordinate as a grid line before
occupancy is stamped – so a port that did not already land on the lattice left an un-blocked
corridor straight through an equipment box. That made the probe strictly more permissive than the
compiler: it reported a model as routing cleanly that the compiler then failed to route, found no
baseline problems, and proposed nothing at all. Its docstring’s claim that “routing feasibility
doesn’t depend on the built walls” is right about walls and was wrong about this.
What is modelled, what is not¶
Modelled |
Equipment (142 |
|---|---|
Deferred to metadata + verbatim echo |
|
Deferred to follow-up work |
Clustering equipment by room requirements into per-group cells (the |
Never |
Live RDL registry lookups (the symbol table ships with an out-of-tree package adapy does not
depend on); XSD validation of DEXPI 2.0 (would need |
Two real adapy gaps surfaced by this work, worth naming rather than working around:
ada.Equipment has no placement frame, so Port.get_global_position ignores rotation (harmless
today because equipment rotation is baked into each port’s position when it is placed); and
route_system has no branch/tee support, which is the reason a DEXPI segment must be two-ended to
route at all. The second is worked around rather than closed: materialising a branch point as
equipment gives every run at a tee two real ends, but the tee is still three runs meeting at a box,
not one branched system.
Writing back to DEXPI¶
SystemModel.to_dexpi(destination, flavour="proteus", from_scratch=False) writes an adapy model
back out. It is a merge, not a regeneration: starting from the source DexpiDocument the
model carries, it re-serializes everything adapy owns from the live objects so edits made in Python
land in the file, re-emits everything it does not model verbatim from DexpiItem.raw/extras,
drops items deleted in adapy along with their connections (logged), and mints fresh IDs for anything
adapy added. from_scratch=False with no source document raises, pointing at the flag – writing
a from-scratch model (from_scratch=True, e.g. from a hand-built model) is lossy by construction
and only claims that the equipment/port/system graph survives a re-read, not a byte-for-byte
anything.
The export lives on the model and not on the built assembly, and the reason is worth stating plainly: DEXPI has no coordinates. Nothing a build produces – deck elevations, equipment placements, routed geometry – is expressible in the format at all, so a built assembly has nothing to contribute to a write-back. Edit the model, write the model.
Running the wider corpus locally¶
The DEXPI test suite ships two official CC BY 4.0 example files under files/dexpi_files/vendor/
so the reader/writer are exercised against real, unvendored emitter output without any network
access. scripts/fetch_dexpi_testcases.py downloads the full official
TrainingTestCases corpus (currently around 220 P&IDs
spanning DEXPI 1.2 and 1.3) into the git-ignored files/dexpi_files/_external/:
$ python scripts/fetch_dexpi_testcases.py
$ pixi run -e tests pytest tests/core/cadit/dexpi/test_external_corpus.py
tests/core/cadit/dexpi/test_external_corpus.py parses every file under _external/ and
asserts the same lossless-echo round-trip (file -> doc -> XML -> doc' canonically equal) the
checked-in fixtures are held to. It is skipped, not run, whenever that directory does not exist, so
this stays purely a local, opt-in tool – pixi run -e tests test-core and CI never touch the
network. A run against the full corpus is a good pre-release check: it is what caught the 0-based
(rather than 1-based) positional node indexing that DEXPI 1.3 actually uses in the wild, which a
smaller fixture set would not have exposed.
It has since earned its keep twice more, on the first full run after the writers landed – 15 of the 220 files failed, both causes invisible to any fixture the branch had:
A connection nested in an element the model does not carry was written twice. The
<Connection> inside an <InformationFlow> is echoed verbatim along with its owner and was
re-emitted from doc.connections at document level, so an unedited round-trip gained a second,
owner-less copy of the edge – connectivity the P&ID never had. 14 files, DEXPI 1.2 signal
connectivity above all. The writer now skips any connection whose source element the echo already
carries, tested by element identity against the source parse.
A full RDL URI in ComponentClass changed the class on the way through. Emitters are not
supposed to write ComponentClass="http://sandbox.dexpi.org/rdl/ProcessInstrumentationFunction",
and some do. class_table.resolve took the last dot before the last slash, which lands in the
host name and yields the class org/rdl/ProcessInstrumentationFunction; the writer emitted that
and the reader then resolved it differently coming back in. Taking the path segment off first fixes
it and makes resolve idempotent, which is the property the round-trip actually depends on.
Both are pinned by checked-in fixtures in tests/core/cadit/dexpi/test_write_proteus.py, so CI
holds them without the corpus. Note that fetching the corpus puts third-party code in the tree:
pyproject.toml names _external/ in isort’s skip list, because isort – unlike black and
ruff – does not read .gitignore and would otherwise fail pixi run lint-check on it.
Running the 3D import over the same corpus – not just the round-trip the test asserts – was
worth another three. ada.from_dexpi raised on 72 of the 220 files before them and on none
after; equipment resolved went from 266 to 348 and systems built from 84 to 135:
Equipment named out of a vendor symbol library was not recognised as equipment. The shipped class
table is generated from the DEXPI 2.0.0 specification, but a DEXPI 1.2 export classes its
equipment out of the emitter’s own library – ComponentClass="Pumps", "VerticalDrums",
"Shell&TubeExchangers". is_a(cls, "ProcessEquipment") is False for every one, so a P&ID full
of equipment resolved to none at all, the layout generated no decks, and the procedural builder
raised. Where the class is not recognisable the Proteus <Equipment> element tag is now honoured
as the emitter’s statement of intent (equipment_list.is_equipment); Chamber and Nozzle
are excluded explicitly, because Proteus spells a chamber <Equipment ComponentClass="Chamber">.
An unrecognised class still resolves to an envelope – resolve_defaults reports
source="fallback" and hands back the ProcessEquipment catch-all.
A P&ID with nothing to lay out raised instead of reporting. An instrumentation-only sheet has no
equipment, so the generated layout has no decks, and ProceduralBuilder rightly refuses to
compile an empty document – but that ValueError reached the caller for drawings adapy had read
perfectly well. It is a property of the P&ID, so it is now an ImportIssue of kind model and
the schematic assembly comes back; strict=True still raises. Because a document-level failure
has no per-item counts behind it, DexpiImportReport.summary states the reason rather than the
tallies, which would otherwise read 0 of 0 ... did not reach the 3D model – indistinguishable
from a clean import.
The tag was read from only one of the two places Proteus puts it. TagName is carried both as
a TagNameAssignmentClass generic attribute and as a plain XML attribute on the element, and 122
of the 220 files – C01 among them – write only the latter. Items came back untagged, fell
back to a class-and-ID slug (verticaldrums-equipment-1 rather than T4750), and, less
visibly, every tag-keyed entry in an equipment definition list stopped matching, because the
tag it keys on did not exist. DexpiItem.tag now falls back to the XML attribute; across the
corpus that is 253 equipment items tagged where 6 remain genuinely untagged.
What the corpus still reports rather than models is dominated by one line – 234 of the 310 system
issues are N endpoint(s) outside the segment; a routed run needs exactly two – and it is worth
being precise about what that number is, because the obvious reading of it is wrong. It is not
the branch/tee limitation at scale. Taking the cases apart:
50 segments own no usable connection at all: the file writes a bare
<Connection />with neitherFromIDnorToID.93 own a connection with one end named and the other simply absent (
<Connection FromID="..."/>).30 end at an in-line component that no other segment or system references – a genuine dead end in the drawing.
16 end at a component that is referenced elsewhere, but by an instrumentation owner (a signal line to a valve), not by another piping segment. Not a piping junction.
In other words the bulk of these P&IDs do not state the connectivity in the data model at all; the
<CenterLine> carries it as drawing geometry instead. Inferring the missing ends from that
geometry was measured and rejected: of the free ends that are genuinely unknown, only 19 land on
another item’s connection node and 73 match nothing. (A first pass suggested 135 exact matches,
which was an artefact – for a connection with one end named, one end of the centre line trivially
coincides with the item that is named.) These runs are reported because the drawing does not say
where they go, which is the honest outcome rather than a gap in adapy.
The genuine branch case is smaller and separate: 89 branch points across 21 of the 220 files, 37 of
them where exactly two segments meet (a pass-through, not a branch) and 52 where three do. Those 52
do route today – as three two-ended runs meeting at a materialised fitting – and what is missing
is a single System spanning the junction, which is the Deferred to follow-up work row below.
Licensing and attribution¶
The generated class table, the two vendored official test files, and anything fetched by
scripts/fetch_dexpi_testcases.py are Creative Commons Attribution 4.0 International (CC BY 4.0),
copyright DEXPI e.V. – adapy’s own code and curated defaults are not. See the repository-root
NOTICE file for the full attribution, and files/dexpi_files/vendor/NOTICE.md for the
file-level detail on the two vendored test cases.