DEXPI branch: status and continuation notes¶
Note
This is a working document for the ``feat/dexpi`` branch, not user-facing feature documentation – that is DEXPI: P&ID in, routed 3D model out. It exists so the branch can be picked up cold, by another agent or another person, without re-deriving what has already been decided and verified. Delete it (or fold anything still true into DEXPI: P&ID in, routed 3D model out) once the branch merges.
Where things stand¶
feat/dexpi (pushed to origin/feat/dexpi, cut from main at 73b3498e) implements
DEXPI P&ID import, generated 3D layout and routing, and DEXPI export for adapy. The commit log
on the branch is the authoritative history; this document summarises it and records why things
ended up the way they did, which the log alone does not carry.
As of this writing the branch carries:
fix(dexpi): route the runs that meet at a shared PipeTee
feat(dexpi): merge writer, Assembly.to_dexpi, pickle case (PR 10)
fix(dexpi): import report summary compared dropped count against the wrong denominator
feat(dexpi): corpus fetch script, guarded corpus test, gap-report docs (PR 11)
feat(dexpi): Proteus and DEXPI 2.0 writers, example fixtures, vendored corpus (PR 5)
fix(topo_model): register wired systems on Assembly.systems
feat(dexpi): DEXPI P&ID to routed 3D model (PR 9)
feat(dexpi): DEXPI 2.0 reader and the flavour-dispatching store (PR 4)
feat(dexpi): equipment class defaults, nozzle placers and the definition list (PR 8)
feat(dexpi): Proteus reader with two-pass positional node normalization (PR 3)
feat(systems): carry process identity on ports, equipment and system segments (PR 7)
feat(dexpi): neutral in-memory model, flavour sniffing and the test oracles (PR 2)
feat(dexpi): vendor the DEXPI 2.0.0 class table (PR 1)
feat(topo_model): rule-based topology generation (ada.topo_model.layout) (PR 6)
Every one of these has been run independently (not just trusted from the agent that wrote it):
pixi run -e tests pytest tests/core/cadit/dexpi tests/core/topo_model tests/core/topology
tests/core/systems passes in full, pixi run lint-check is clean, and the branch diff against
main has been grepped for local paths, usernames, host names and organisation identifiers with
zero hits at every checkpoint.
The branch-point bug that was open when the first version of this document was written has since been fixed – see “A shared branch point is not the same problem as no branch support” below, which now records what the fix turned out to involve rather than what still had to be done.
The one pre-existing, unrelated test failure on a Windows checkout with a non-UTF-8 default
codepage is tests/core/cadit/step/test_ada_ext_codegen.py::test_ada_ext_header_matches_schema
(a read_text() call with no explicit encoding, decoding a committed em-dash as mojibake). It
predates this branch, is not caused by anything here, and should not be “fixed” as part of it.
How to resume¶
git fetch origin
git checkout feat/dexpi # or: git worktree add ../adapy-dexpi feat/dexpi
pixi run -e tests pytest tests/core/cadit/dexpi tests/core/topo_model tests/core/topology tests/core/systems -q
pixi run lint-check
The user-facing entry points are ada.from_dexpi(path, ...) and Assembly.to_dexpi(path, ...)
– see DEXPI: P&ID in, routed 3D model out for the full picture, including the gap table (what DEXPI content adapy models,
defers to metadata, or does not attempt at all).
Architecture, in one pass¶
src/ada/cadit/dexpi/
model.py, canonical.py, flavour.py, validate.py, class_table.py, units.py, attributes.py
resources/dexpi_classes.json # generated from the DEXPI 2.0.0 spec repo, CC BY 4.0
resources/dexpi_equipment_defaults.json # hand-curated adapy data, NOT DEXPI-authored
equipment_defaults.py, nozzle_placers.py, equipment_list.py
read/read_proteus.py, read/read_dexpi20.py, read/to_procedural.py
write/write_proteus.py, write/write_dexpi20.py, write/from_ada.py
store.py
src/ada/topo_model/layout.py # general, DEXPI-independent: shelf-packs
# equipment into decks; DEXPI is its first caller
src/ada/factories.py # ada.from_dexpi, ada.dexpi_to_procedural
src/ada/api/spatial/assembly.py # Assembly._dexpi_store, Assembly.to_dexpi
src/ada/api/systems/{ports,segments,base}.py # Port.tag/nominal_diameter/spec/metadata,
# SystemSegment, System.segments
files/dexpi_files/ # fixtures: tiny + realistic, both wire formats,
# plus two vendored official CC BY 4.0 test files
tests/core/cadit/dexpi/
tests/core/topo_model/test_layout.py
docs/documents/dexpi.rst # the user-facing feature doc
scripts/gen_dexpi_class_table.py, gen_dexpi_examples.py, fetch_dexpi_testcases.py
The pipeline: a DEXPI file (either wire format) parses to one neutral
DexpiDocument. Each equipment item resolves through the definition
list to a physical envelope with real 3D nozzles (class defaults, overridable per tag or class).
plan_layout() packs the resolved equipment into generated decks. Each
DEXPI PipingNetworkSegment becomes one two-ended TopoSystem – the load-bearing design
decision, because ada.topology.routing.route_system() routes exactly two ports and has no
branch/tee concept. The result compiles through the existing (pre-branch) procedural pipeline into
a routed ada.Assembly. Nothing is dropped silently: every system the compiler could not
wire or route, and every equipment the layout could not place, is collected into
assembly.metadata["dexpi"]["report"] and summarised in a warning.
Findings worth knowing before you touch this code¶
These were not obvious from the DEXPI specification and were each found by actually running the code against the official test corpus or the realistic generated fixture – not by reading the spec. Get them wrong and the importer parses cleanly while silently producing a wrong model.
- Proteus connection indices are 0-based, not 1-based
<Connection FromNode="1">addresses the second entry (index 1) of the owner’sConnectionPointslist, counting the symbol anchor as index 0 – proven against the official corpus (55 unambiguous cases in DEXPI 1.3, 0 counter-examples). Getting this backwards makes the reader resolve every connection to the anchor, silently and without error, because index 1 is always in range. The writer must emit 0-based indices too, pinned by an explicit test that checks the literal integer in the output XML.- A plain
Nozzleis a DEXPI subtype ofActuatingElectricalLocation Testing “is this an electrical location?” by supertype alone types every process nozzle in the model as electrical, which
System.connectthen refuses – silently dropping the system with only a log warning. Resolve theNozzlefamily before any instrumentation supertype test.- There is no DEXPI class literally named
Equipment The abstract base is
ProcessEquipment.ChamberandNozzledo not derive from it (both come straight offCore/ConceptualObject) – classify them by explicit class name, not by supertype.- RDL class URIs (
ComponentClassURI) cannot be synthesised They are not in the DEXPI 2.0.0 specification repository (which uses symbolic RDL references); only the source document’s own URI can be echoed on a merge write, and it must be omitted, not guessed, on a from-scratch write.
- Deck placement requires
LX/LY/LZto be stamped explicitly The procedural compiler requires them before the catalog-bbox fallback is ever reached (
ada.topo_model.compile._require_coords), so the layout module stamps them from the resolved equipment bbox rather than relying on the catalog resolver at compile time.- Deck pitch is uniform across a layout plan, not chosen per deck
“Tallest item on this deck plus headroom” is circular once pre-placed (
fixed) items exist – you need deck elevations to assign an item, and the item to compute the elevations. Pass an explicitdeck_height, or one tallProcessColumnmakes every deck as tall as it is.assembly.systemswas never populated by the procedural compiler_build_systemsbuilt and routed eachSystemobject but never handed it back to its caller, sowrite_ifc_systemshad nothing to read: every procedural (not just DEXPI) IFC export was silently missingIfcRelConnectsPortsand correctIfcDistributionSystemnaming andPredefinedType. Fixed by an optionalbuilt_systems_outout-parameter threaded throughProceduralBuilder.build_systems. Verified directly against exported IFC entity counts, not just by tests passing.- A shared branch point (e.g. a
PipeTee) is not the same problem as “no branch support” route_systemgenuinely cannot route a 3+-way junction as one system, and the plan’s segment-per-system design is the right answer to that. But a passive fitting referenced as an endpoint by more than onePipingNetworkSegmentis a different case: each run into the junction is an ordinary two-ended run, and only failed because its end named a fitting rather than a nozzle. Fixed (see the commit at the top of the log above): the importer materialises such a fitting as a smallIfcPipeFittingequipment with one port per connection node, and the flagship fixture went from 3 of 10 runs routed to 9 of 10. The tenth,205/1, is not a branch point at all – it is a relief valve discharging to something the P&ID never draws, so it has one end and is correctly reported instead of routed.This was found by running the checked-in realistic fixture (
files/dexpi_files/unit_separator_proteus.xml) end to end with the default layout – it has two such tees – something none of the automated tests happened to exercise, because the importer’s own test fixture and the example-file generator’s fixture were built by different people at different times and never run together until this was checked by hand.- Three things about the branch-point fix that are not obvious from the diff
First, the segment that nests the tee in the XML has no more claim to route through it than the two that reference it from outside, so the junction has to be pulled out of that segment’s interior set as well. Miss that half and the two outside runs resolve while the nesting one is still reported as having a single endpoint – which is exactly how the original symptom split into two different-looking report lines for the same cause.
Second, the merge writer has to know the same rule. A materialised tee is an
ada.Equipmentwhose name is a tag the source’sequipment_itemsnever yields, so the writer took it for equipment adapy had authored and minted a freshProcessEquipmentfor it, rewriting the tee and every connection into it on an unedited round-trip. The rule therefore lives inequipment_list.branch_points– one definition, imported by both sides – andfrom_ada._segment_boundaryexcludes junctions exactly the wayto_procedural._segment_specdoes. The naming pool has to be shared too (one_source_identitypass, equipment then junctions), or a tee tagged like a vessel comes back under a different name than the live object carries.Third, “reported as connected” and “geometrically connected” are different claims, and only the second is worth having.
test_branch_points.pytherefore checks the routed pipe ends land on the tee’s port positions (they do, to 1e-9 m) and that the three ports are taken by three different runs – a wiring-only assertion passes happily when all three runs resolve to the same port and three pipes converge on one point.- The official corpus finds what fixtures cannot, and it found two more
The first full run of
scripts/fetch_dexpi_testcases.py+test_external_corpus.pyafter the writers landed failed 15 of 220 files. Neither cause was reachable from any checked-in fixture, and both were in the unedited round-trip – nothing to do with the 3D path.A connection nested in an unmodelled element was written twice (14 files). A
<Connection>inside an<InformationFlow>is echoed verbatim with its owner, because the model does not carry information flows – and it is also indoc.connections, so_connections_by_owneremitted it again at document level. The re-read then saw two edges where the source had one, the second owner-less. It is worth being clear about why the obvious alternative is wrong: stripping<Connection>out of the echo instead and always emitting from the model does not work, because the writer cannot place an edge inside an element that is not an item, so the connection would come back at document level with its owner lost and T1 would still fail. The echo keeps it; the model side stands down. The test is element identity against the source parse –id(connection.raw) in echoed– which is exact rather than a guess fromowner_id, and covers thedoc.extrascase as well as the per-item one.A full RDL URI in
ComponentClasschanged class on the way through (1 file).class_table.resolvesplit on the last dot before the last slash, sohttp://sandbox.dexpi.org/rdl/ProcessInstrumentationFunctionresolved toorg/rdl/ProcessInstrumentationFunction– the dot it found was in the host name. The writer emitted that string and the reader resolved it again differently on the way back in. The real defect is thatresolvewas not idempotent; taking the path segment off before the dot makes it so, and a round-trip through a writer that emits resolved names depends on exactly that property. Worth remembering that emitters put things inComponentClassthe spec does not allow.Both are pinned by inline fixtures in
test_write_proteus.py(all four fail without the fixes, checked by reverting), so CI holds them without the git-ignored corpus. One process wrinkle the fetch introduces:_external/is third-party code in the working tree, and isort does not read.gitignorethe way black and ruff do, sopixi run lint-checkfails on it until the directory is named in[tool.isort] skip_glob– which it now is.- Running the 3D import over the corpus is a second, different gate
test_external_corpus.pyasserts the read/write round-trip and nothing else. Runningada.from_dexpi– layout, systems, routing – over the same 220 files is a separate check and it found three more defects. Before themfrom_dexpiraised on 72 of 220; after them, on none. Equipment resolved went 266 -> 348, systems built 84 -> 135.The class table is DEXPI 2.0.0; the files are not. A 1.2 export names equipment out of the emitter’s symbol library (
ComponentClass="Pumps","VerticalDrums","Shell&TubeExchangers"), sois_a(cls, "ProcessEquipment")– the ruleequipment_itemsdocumented as “and nothing else” – answered False for all of it. A P&ID full of equipment resolved to no equipment, and the failure surfaced two steps later as a builder error about an empty document, which is why it read as a layout bug rather than a recognition one. The Proteus<Equipment>tag is now honoured when the class is unrecognisable. Watch the exclusion: Proteus spells a chamber<Equipment ComponentClass="Chamber">, so the tag rule would promote a separator boot to a plant asset of its own ifChamber/Nozzlewere not excluded by kind. The predicate isequipment_list.is_equipmentand the merge writer inherits it for free, which is the whole reason the rule lives in that module – same lesson asbranch_points.A P&ID with no equipment is not an error. Instrumentation-only sheets have nothing to lay out, so the layout has no decks and
ProceduralBuilderrefuses to compile. Reporting it as anImportIssueof kindmodeland returning the schematic assembly is thestrict=Falsecontract;strict=Truestill raises. The summary needed widening too, or a document that produced nothing at all reports0 of 0 system(s) and 0 of 0 equipment did not reach the 3D model, which is indistinguishable from a clean import.The tag is in two places and adapy read one.
TagNameappears both as aTagNameAssignmentClassgeneric attribute and as a plain XML attribute; 122 of 220 files write only the XML attribute. The visible symptom is cosmetic (verticaldrums-equipment-1instead ofT4750); the invisible one is not – an equipment definition list keyed by tag silently stops matching, because the tag is not there. Fixed onDexpiItem.tagso both flavours and both sides get it, with the generic attribute still winning where both exist.234 of the 310 system issues the corpus reports are
N endpoint(s) outside the segment; a routed run needs exactly two(169 with one endpoint, 65 with none). That is not the branch/tee limitation at scale, which is the natural but wrong reading – an earlier version of this document said exactly that before the cases were taken apart. 50 of them own a bare<Connection />with no ends at all; 93 own one with a single end named and the other simply absent; 30 end at an in-line component nothing else references (a real dead end); 16 end at a component referenced only by an instrumentation owner, i.e. a signal line to a valve, not a piping junction. These drawings do not state their connectivity in the data model; the<CenterLine>carries it as geometry.Recovering it from that geometry was measured and rejected, and the measurement is worth repeating rather than trusting: a first pass found 135 centre-line free ends landing exactly on a connection node, which looks conclusive until you notice that for a connection with one end named, one end of the centre line is the named item’s own node and matches trivially. Measuring only the genuinely unknown end gives 19 exact matches against 73 that match nothing. Not a foundation to infer connectivity on.
The real branch case is smaller and worth sizing honestly before anyone builds for it: 89 branch points across 21 of the 220 files, 37 of degree two (a pass-through, not a branch) and 52 of degree three. All of them already route, as separate two-ended runs into a materialised fitting; what does not exist is one
Systemspanning the junction.- The relocate feedback loop was proposing nothing, and silently
propose_relocationshas always known which equipment moves would clear a run that failed to route; nothing fed that back into the layout. Wiring it up (from_dexpi(relocate=True),relocate.apply_relocations/relocate_doc) turned out to be the easy half.The hard half: it proposed nothing, on a model the compiler had just failed to route. The engine’s own routing probe (
_route_and_collect) is supposed to mirrorcompile._build_systems, and it diverged in a way that only ever errs one direction – it skipped_augment_grid_with_portsand used a clearance with no margin, so its occupancy did not cover the lines the router would actually use. A port that did not land on the 0.5 m lattice left an un-blocked corridor through an equipment box. The probe therefore reported zero problems where the compiler reported two, and a search that sees no problems proposes no moves.Measured, not guessed: on
C01V01-HEX.EX02the compiler fails two runs; the probe found none; adding the port augmentation alone reproduced one of them (the other fails to wire, which no routing probe can see and no relocation can fix). With the fix the flagship path goes from two routing failures to one, cleared by a single 0.5 m shift.Two traps if you touch this again. First,
_cramped_docintest_relocate.pycannot tell the two probes apart – it fails in both – so an outcome-based assertion there is vacuous and passes with the fix reverted. I wrote that test first and it did exactly that. The grid comparison now used needs equipment placed off the lattice, or the two grids come out identical and the test is vacuous again for a second reason. Second, a relocation proposal names equipment origins while the document places equipment by its corner; apply the delta rather than reconstructing the corner, and the two conventions cannot drift apart.from_dexpireturned an Assembly, and that was the wrong containerThe signature had reached thirteen arguments and roughly half of them –
layout,base_doc,build_3d,route,relocate,design_rules– said nothing about DEXPI. They described how to build. That is the symptom; the cause is that anAssemblyis a spatial container and a P&ID has no coordinates, so forcing the schematic into one was a category error. It showed:build_3d=Falsereturned an assembly built from a procedural document and catalog that were then discarded, so you could not build from it afterwards. A dead end nobody had a reason to visit.There are three layers now.
DexpiDocumentis the faithful parse.ada.SystemModelis the adapy-native model – equipment with real ports and the systems joining them, no coordinates.Assemblyis the 3D build.ada.SystemModel.from_dexpireads,model.to_assembly(spec)builds,model.to_dexpi()writes back, andada.from_dexpicomposes the first two so every top-levelada.from_*still hands back an Assembly.Named
SystemModelrather thanProcessModelafter checking the code rather than the intuition: adapy already hasDuctSystem,CableSystemandElectricalSystemas peers ofPipingSystem, and the take-off treats HVAC and electrical as disciplines in their own right. A pure cabling model is first-class here, so “process” would have over-claimed.Four things about the split that are not obvious from the diff:
First, the export belongs on the model, not the assembly, and not merely for tidiness: DEXPI cannot express a coordinate, so nothing a build produces is writable back and the built assembly has literally nothing to contribute.
Assembly.to_dexpiandAssembly._dexpi_storeare gone;Assemblyno longer mentions DEXPI at all.Second, the layout still runs inside the read, and the model hides it.
SystemModelholds aprocedural_factorycallable rather than a finished procedural document, because the decks and coordinates in that document are products of the build’sLayoutRules– storing one would bake a single layout into the model and make a second build with different bounds impossible. The factory is re-run per build. Cost: resolution runs twice for a single build (once for the native view). It is pure CPU and worth the correctness; memoise it if it ever shows up in a profile.Third, the conversion itself has since been split, which is what made the API honest rather than merely well-arranged.
dexpi_to_resolvedreads (equipment resolved to envelopes and ports, segments to systems, no coordinates) andresolved_to_procedural_docplaces. Before the split the read had to run the whole monolithic conversion once with a placeholderProceduralBuildSpec()purely to shape its output, then discard the coordinates and filter the resulting layout-stage issues out of its report – special-case code whose only job was to hide the effects of running something that should not have run. That filter is gone, andtest_reading_runs_no_layout_at_allasserts the layout engine is called zero times by a read and exactly once, with the build’s own bounds, by a build.test_bounds_too_small_is_a_build_gap_not_a_read_onepins the report split.Fourth,
Equipment.originis the box’s base centre (X + LX/2, Y + LY/2, Z), so an unplaced equipment is not at(0,0,0)– it sits at its own half-extents. Assertingorigin == (0,0,0)never holds, and asserting onplacement.originholds for placed equipment too, so it proves nothing. Both mistakes were made and caught here; the helper_is_unplacedintest_system_model.pyis the correct test.The equipment definition list now also accepts a callable
(item) -> document | None, for a definition that must be computed rather than tabulated. Its return value is validated exactly like a table entry, because a resolver quietly contributing nothing comes back as a model the wrong size three steps downstream.
Process notes for whoever continues this¶
Never use bare
git stash/git stash popon this machine – the stash stack is shared across worktrees and sessions. Use a WIP commit if you need to set work aside; that is what saved an in-progress PR here when a session hit a rate limit mid-task.Never
git commit --amendonce other work may have landed on the branch – it happened once during this work (an agent amended afterHEADhad moved), was caught immediately via the reflog, and was recovered withgit reset --softrather than losing anything. Prefer a new commit.No local machine, username, or organisation identifiers may appear in anything committed – absolute paths, host names, private IPs, employer name. Grep the diff before every commit. This repo is public.
Independently re-run whatever an agent reports rather than trusting the summary – several of the findings above (the
assembly.systemsgap, the branch-point gap, an import-report phrasing bug) were caught only by actually running the code by hand against a real fixture, not from a test suite passing.