Skip to content

Neo4j Export

pyDEXPI exports a model as Neo4j bulk-import CSV — a nodes.csv and a relationships.csv using the :ID / :LABEL / :START_ID / :END_ID / :TYPE header convention. An optional graph stylesheet is packaged alongside it to give the result a sensible default appearance in Neo4j Browser.

Quick start

from pydexpi.exports import export_neo4j_csv
from pydexpi.loaders import xml_serializer

serializer = xml_serializer.DexpiXmlSerializer()
model = serializer.load("data", "reference_pid")

paths = export_neo4j_csv(
    model,
    "output/neo4j",
    graph_level="conceptual",   # "complete" | "process" | "conceptual" | "process_flow"
    embed_shape_usage=True,     # adds x / y from the source diagram
)
# {"nodes": .../nodes.csv, "relationships": .../relationships.csv}
Parameter Description
graph_level complete keeps every DEXPI object as a node. process and conceptual collapse a P&ID's piping and signal objects into relationships. process_flow exports a process model's steps, step details, instrumentation activities and instrumentation systems as nodes and each stream, energy or information flow as one relationship (see Graph levels).
embed_shape_usage Adds x / y (and, in shapeusage mode, rotation and scale) from the source diagram. Requires a model with a populated diagram.
prefix Filename prefix, e.g. "plant_" → plant_nodes.csv.

The in-memory layers are also public: get_neo4j_records returns lists of dicts and export_neo4j_csv_strings returns the CSV text.

Importing the CSV files

The :LABEL / :TYPE / :ID headers are understood by the neo4j-admin bulk loader. Plain Cypher LOAD CSV does not interpret them — it would leave every node unlabelled with properties keyed under literal strings like ":LABEL".

Bulk import (fastest; needs an offline database, no APOC, not available on Aura):

neo4j-admin database import full <database-name> \
    --nodes=nodes.csv --relationships=relationships.csv

Cypher + APOC (works against a running database, including Aura). Dynamic labels and relationship types need APOC because plain Cypher cannot set them from a row value:

CREATE INDEX dexpi_node_id IF NOT EXISTS
FOR (n:_DexpiNode) ON (n.nodeId);

LOAD CSV WITH HEADERS FROM 'file:///nodes.csv' AS row
WITH row, apoc.map.removeKeys(row, [':ID', ':LABEL']) AS props
CALL apoc.create.node(
  split(row.`:LABEL`, ';') + ['_DexpiNode'],
  apoc.map.merge(props, {nodeId: row.`:ID`})
) YIELD node
RETURN count(node);

LOAD CSV WITH HEADERS FROM 'file:///relationships.csv' AS row
MATCH (start:_DexpiNode {nodeId: row.`:START_ID`})
MATCH (end:_DexpiNode   {nodeId: row.`:END_ID`})
CALL apoc.create.relationship(
  start, row.`:TYPE`,
  apoc.map.removeKeys(row, [':START_ID', ':END_ID', ':TYPE']), end
) YIELD rel
RETURN count(rel);

// optional cleanup once loaded
MATCH (n:_DexpiNode) REMOVE n:_DexpiNode;
DROP INDEX dexpi_node_id IF EXISTS;

nodeId, not sourceId

The composite :ID is written to a separate nodeId property. Merging it into sourceId — as this page once did — overwrites the raw DEXPI id that the export deliberately keeps alongside it, and that raw id is what joins the same object across abstraction levels (MATCH (a {sourceId: $id}), (b {sourceId: $id})). The module docstring in pydexpi/exports/neo4j_export.py has always described it this way; this page did not.

_DexpiNode is a temporary marker label added to every node so nodeId can be indexed and matched — Neo4j indexes are always label-scoped. On Aura, replace file:///... with an HTTPS URL, since Aura has no server filesystem.

Telling parallel relationships apart: edgeKey

A relationship row carries the key of the graph edge it was written from, as the edgeKey property, when that key is a string. At the process_flow level that key is the ProcessConnection's id, so two streams between the same two process steps arrive as two relationships that say which is which (the same id is also in connectionId):

MATCH (a {graph_level: 'process_flow'})-[s:Stream]->(b)
RETURN a.displayName, b.displayName, collect(s.edgeKey) AS streams;

The other relationships of that level have types and composed keys of their own: composition (<attribute>.<child id>, a sub-step or detail under its step, or an activity under its instrumentation system), delegation (delegation.<delegate port id>) and measures (measures.<activity id>.<measured id>). Their keys hold a ., which no id of a schema-valid DEXPI XML document does, and what a key is composed of is also in the relationship's properties, so it never has to be split.

Process models: delegated ports and measurements

A port of a step that is handed down to a step inside it is a delegation relationship from the outer step to the inner one, with the two ports in superPort and subPort. So a stream that ends at the outer step is followed into the sub-step by a path, where the port the stream ends at is the port that is delegated:

MATCH (source)-[s:Stream]->(outer {graph_level: 'process_flow'})-[d:delegation]->(inner)
WHERE d.superPort = s.targetPort
RETURN s.edgeKey AS stream, outer.displayName, inner.displayName, d.subPort;

The relationship runs from the delegated port's step to the delegate's, whichever way the port faces; for an outlet, match d.superPort = s.sourcePort on the stream leaving outer. d.reciprocal is true where the delegated port lists the delegate back, and false where only the delegate names the link.

A measurement is a node (MeasuringProcessVariable) with a measures relationship to what it measures. A measured step or step detail is its end; a measured connection is a relationship, so the measurement has one measures relationship to each of the two nodes the connection runs between, with the connection's key in measuredConnection and the end in connectionEnd:

// What is measured on each step
MATCH (m:MeasuringProcessVariable {graph_level: 'process_flow'})-[r:measures]->(x)
WHERE r.measuredConnection IS NULL
RETURN m.displayName, r.measuredProperty, x.displayName;

// The measurements on a stream, with the stream
MATCH (m:MeasuringProcessVariable)-[r:measures {connectionEnd: 'source'}]->(a)-[s]->(b)
WHERE s.edgeKey = r.measuredConnection
RETURN m.displayName, r.measuredProperty, type(s), s.edgeKey, a.displayName, b.displayName;

measuredProperty is the name the measured value has on the measured node or stream (pressure, with its unit in pressure_unit). The properties that carried delegation before these relationships existed (delegatedPorts on the node, sourcePortSuperReference and the like on a stream) are still written.

A control loop is a node as well (InstrumentationSystemActivity), with the loop's own identifier and dexpiLabel, and a composition relationship to each activity it composes. So the measurements of one loop, and what each measures, are a path:

MATCH (loop:InstrumentationSystemActivity {graph_level: 'process_flow'})-[:composition]->(m)
OPTIONAL MATCH (m)-[r:measures]->(x)
RETURN loop.displayName, m.displayName, r.measuredProperty, x.displayName;

The property that named the loop before it was a node, instrumentationSystemActivity on each activity, is still written. A composition relationship of this level therefore starts at a process step or at a loop; its attr_name says which (subProcessSteps, processStepDetails, instrumentationActivities).

At the P&ID levels the key is an artefact of how the edge was made, and tells parallel relationships apart without naming the object one stands for. At process it is the id of the collapsed pipe or signal line. At conceptual, where nozzles are removed and the edges through them stitched, a stitched relationship is keyed by the nozzle's id: in the reference P&ID 13 of the 37 keyed relationships are, 12 of the 25 Pipe ones among them (Nozzle1 for the pipe into CentrifugalPump1) and one reference. The pipe a stitched Pipe row stands for is in its collapsed_node_id (Pipe1), and the nozzle in stitched_from; the stitched reference row (sensingLocation, through Nozzle17) stands for no collapsed object and carries only stitched_from. So do not join on edgeKey at a P&ID level; use it with the two endpoints to tell rows apart, which is all Neo4j, giving relationships no :ID, needs it for. An edge nothing collapsed or stitched is keyed by networkx's integer counter, which identifies nothing, so it has no edgeKey; where the property is set it is always a string.

Node captions: displayName

Every exported node carries a derived displayName property, produced by pydexpi.toolkits.display_name.get_display_name. It combines a humanised class name with the object's identifying tag:

Node displayName
CentrifugalPump with tag P4711 Centrifugal Pump (P4711)
BallValve with tag 73KH12 Ball Valve (73KH12)
PipingNetworkSegment S1 Piping Network Segment (S1)
Stream with identifier 13 Stream (13)

A DEXPI 2.0 process object is captioned by its identifier, falling back to its free-text label. That label is a node property in its own right, under the key dexpiLabel: in the graph the label key is the class name, which get_nodes_by_label and the abstraction rules read. The Neo4j node labels come from the separate labels key, the class's inheritance chain with the class name included.

Without displayName, Neo4j Browser guesses a caption property per node and picks a different one for different node types. Its guess takes the first property matching /name$/i, which on a DEXPI node is often not displayName — an ActuatingFunction has deviceTypeName (value "general"), equipment has tagName, valves have pipingComponentName, nozzles have subTagName. The result is nodes captioned general instead of Actuating Function (PV4712.02).

The packaged stylesheet therefore repeats caption: '{displayName}' on every node rule, not just the generic one. Browser only suppresses its own generated caption when a label-scoped rule supplies one, so repeating it is what guarantees displayName wins regardless of which labels a given node carries.

Keeping the _DexpiNode marker label

The APOC import recipe above adds a temporary _DexpiNode label to every node. If you keep it — or if your pipeline adds an equivalent DexpiNode or DexpiElement label — the stylesheet uses it as a universal anchor. Rules for all three spellings are included, so every node is guaranteed a displayName caption and a defined colour even if the DEXPI inheritance labels are curated or partially stripped on import.

pyDEXPI's own export cannot provide such a label, because get_inheritance_from_dexpi_class deliberately stops before PyDexpiBaseModel. There is no single DEXPI label carried by every node — ConceptualObject is the broadest, and even that misses PipingConnection nodes.

Physical quantities: value and unit

A DEXPI PhysicalQuantity attribute reaches the graph as two properties, never as one combined string:

pyDEXPI Neo4j property
quantity.value upperLimitDesignPressure — a number
quantity.unit.value upperLimitDesignPressure_unit — the string "Bar"

The suffix is defined once, as pydexpi.loaders.graph_loader.UNIT_ATTRIBUTE_SUFFIX. Keeping the value numeric is what makes range and ordering predicates possible:

MATCH (c:Chamber)
WHERE c.upperLimitDesignPressure > 5
  AND c.upperLimitDesignPressure_unit = 'Bar'
RETURN c;

Match the unit. Values are not normalised — each one is exactly what the model carries, in the unit the model carries it in. Nothing prevents one node from using Bar and another Pascal, so a comparison across records is only meaningful once _unit is constrained, as above. Canonical/SI values and quantity-kind validation are not part of this export.

The numeric type survives on the driver path, not through the CSV. get_neo4j_records returns a real Python float. Writing those records through the Neo4j driver preserves it — including when you pass them as parameters to apoc.create.node, which is what an in-process projection does.

The documented LOAD CSV + APOC recipe is a different path and does not preserve it: LOAD CSV yields strings, so row.upperLimitDesignPressure is already text by the time APOC sees it. The CSV export stringifies every value and its headers carry no neo4j-admin property type, so both documented import routes — bulk importer and LOAD CSV + APOC — load the column as text, the same situation as the x / y coordinates below. Under LOAD CSV, cast at query time:

WITH toFloat(row.upperLimitDesignPressure) AS pressure

Typed CSV headers (upperLimitDesignPressure:double) would fix the bulk-import path but break LOAD CSV, which takes the header text verbatim as a map key; they are deliberately left to a follow-up.

A PhysicalQuantityVector -- a composition's fractions -- is split as well, under its own name: values holds its values as one JSON array string, in order (null for an Undefined value), and values_unit its unit literal. A list has no scalar column, so the values stay text, but value stays a number wherever it is one.

Optional Browser stylesheet

A Neo4j Browser graph stylesheet (GRASS) ships inside the package:

pydexpi/exports/resources/pydexpi-neo4j-browser.grass

It only affects visualisation. It never alters graph data, node labels, relationship types or properties, and nothing in pyDEXPI requires it — graph consumers can ignore it entirely.

What it does:

  • captions every node with displayName;
  • assigns colours and sizes by semantic category rather than per concrete DEXPI class, so the graph reads as a topology rather than as a colour chart;
  • sizes nodes by significance — process equipment largest, piping components medium, nozzles and piping nodes smallest, drawing artefacts neutral;
  • uses light fills with darker borders and dark text, closer to an engineering drawing than to a set of saturated circles;
  • makes piping the strongest relationship layer and pushes structural edges back;
  • captions composition and reference relationships with the underlying DEXPI attribute name, so an edge reads nozzles or sensingLocation instead of a repeated type name;
  • captions collapsed relationships with their displayName, which pyDEXPI fills with an engineer-facing term — Measurement, Signal, Actuation, Direct connection, and Pipe (47125) for a pipe.

Getting the file

Because it is packaged data, read it through importlib.resources rather than building a filesystem path — the resource may not exist as an ordinary file in every installation context:

from importlib.resources import files

grass = (
    files("pydexpi.exports.resources")
    .joinpath("pydexpi-neo4j-browser.grass")
    .read_text(encoding="utf-8")
)

with open("pydexpi-neo4j-browser.grass", "w", encoding="utf-8") as fh:
    fh.write(grass)

Importing into Neo4j Browser

Run the :style command to open the stylesheet view. From there:

  • drag and drop the .grass file onto the stylesheet area, or
  • run :style <url> with an HTTPS URL serving the file, or
  • run :style reset to return to the generated defaults.

The :style view also offers a download button, which exports whatever style is currently active — useful for saving your own tweaks back out.

Importing in Aura (console.neo4j.io)

In Aura the same stylesheet is applied through the Query view:

  1. Open the Aura instance in console.neo4j.io.
  2. Click Query to launch the Cypher interface.
  3. Run:

    :style
    
  4. In the style result card, use the Import / Upload control and select the .grass file. Depending on the Browser version you may also be able to drag the .grass file directly onto the Query page.

  5. Re-run your graph query — the new style applies to the returned graph.

The style is stored per browser profile, not in the database, so it does not travel with the data and each user imports it once.

Note

Neo4j is replacing the classic Browser with a rebuilt interface — the default for localhost and Desktop since Neo4j 2025.05, and rolled out to Aura from 2025.08. The control labels above may therefore differ slightly between versions, which is why step 4 gives both the upload and the drag-and-drop route. The stylesheet itself is a plain static file and does not depend on which interface loads it.

The visual grammar

Six colour families carry the meaning. Related types share a hue, so a signal relationship matches the instrumentation nodes it connects.

Family Meaning Applies to
Blue Physical equipment ProcessEquipment, Chamber
Cyan Piping components PipingComponent, PipeFitting, OperatedValve, Nozzle, PipingNode
Teal / green Piping connectivity PipingNetworkSystem, PipingNetworkSegment, Pipe and DirectPipingConnection relationships
Orange / yellow Measurement and instrumentation ProcessInstrumentationFunction, ProcessSignalGeneratingFunction, MeasuringSystem, SignalConveyingFunction
Purple Actuation and control ActuatingSystem, ActuatingFunction, ControlledActuator, OperatedValveReference
Neutral grey Structural and helper objects inheritance labels, drawing artefacts, composition and reference relationships
Red-orange border Safety-relevant SafetyValveOrFitting

Safety is marked on the border only, over a light fill, so a plant with many relief devices does not read as one big alarm.

These colours are a navigation aid, not a standard

The palette exists to make topology readable at a glance. It is not a normative DEXPI colour scheme, and it does not follow ISA-5.1 or any other P&ID drafting convention. Do not infer engineering meaning from a hue beyond the families above.

Node sizes reinforce the same hierarchy, so the graph stays readable when colour does not survive — equipment 68 px, piping systems 60 px, segments 50 px, valves 48 px, components 46 px, fittings 40 px, nozzles 20 px, piping nodes 12 px.

Equipment is capped at 68 px rather than made as large as possible. Browser computes a relationship caption's budget as the centre distance minus both node radii, so an oversized equipment node silently deletes the captions on the pipes attached to it.

Staying visible on the canvas

Neo4j Query draws on a white-to-light-grey canvas that GRASS cannot control, so the stylesheet has to supply the separation itself. Three rules keep engineering nodes from reading as empty space, each enforced by a test:

  • fills are darker than #E0E8EC — near-white fills made pipe tees and blind flanges disappear;
  • borders contrast at least 2.5:1 with their own fill — the outline, not the fill, is what makes a small node register;
  • nodes at or below 32 px carry a 2–3 px border, since they have little fill area to work with.

Pale grey fills are reserved for helper and inheritance rules, where fading out is the intent.

Colour-vision accessibility

Text sits at 7.0:1 contrast or better against every fill, comfortably past the WCAG AA 4.5:1 threshold.

Under simulated red-green colour-vision deficiency the picture is more mixed, and worth stating plainly. The warm families stay clearly separate from the cool ones — the closest instrumentation-or-safety to equipment-piping-actuation distance is a CIE76 ΔE of about 79 under deuteranopia, which is a wide margin. Within the cool families, however, equipment blue, piping teal, component cyan and actuation purple converge and become hard to tell apart (ΔE of 1–3).

The stylesheet does not rely on hue alone to carry those distinctions:

  • size separates equipment (68 px) from components (46 px) and fittings (40 px);
  • topology separates them again — equipment sits at pipe endpoints, fittings in the middle of runs, actuation hangs off valves;
  • relationship width separates piping (2–3 px) from structural edges (1 px).

What genuinely degrades is telling an actuation node from an equipment node by colour alone. Reading the caption resolves it. This is an accepted limitation of a blue-cyan- teal-purple palette rather than something the stylesheet can tune away, and swapping in an unrelated hue purely to maximise colour distance would cost more in coherence than it buys.

There is no dashed or dotted relationship styling

GRASS exposes only three things for a relationship's appearance: caption, colour and width. There is no verified support for dashed, dotted, line-style or stroke-dasharray, and adding one would be dropped silently rather than reported.

This was checked two ways against the Browser source. The renderer's default style and the style editor's property pickers expose only color, shaft-width and caption for relationships (plus diameter, border-color, border-width, text-color-internal and font-size for nodes). Searching the repository for line-style tokens returns only page CSS and licence text — nothing in graph styling.

The practical consequence: a measurement path and a control path cannot be distinguished by line pattern, the way they would be on a P&ID. The stylesheet separates them by hue and width instead — orange and yellow at 1 px for measurement, purple at 2 px for actuation, teal at 2–3 px for piping. A test asserts these properties never appear.

Captions must be a single token

Every caption in the stylesheet is either <type> or exactly one {property} placeholder, with no literal text around it. This is a hard constraint of the rebuilt Neo4j Browser (default on localhost since 2025.05, Aura from 2025.08), which resolves a caption as a single property name.

A template such as '{lineNumber} {nominalDiameterRepresentation}' renders as nothing at all there — while the colour and width of the same rule still apply, so the rule looks healthy and only the label is missing. The classic Browser interpolates the identical template correctly, so the failure appears on one engine only, and silently.

It is also why the friendly wording of a collapsed relationship has to exist as data on the edge rather than as a template. pyDEXPI writes it into displayName when it collapses the node, so every collapsed relationship rule captions with {displayName} alone:

:TYPE Caption renders as
MeasuringLineFunction Measurement
SignalConveyingFunction Signal
OperatedValveReference Actuation
DirectPipingConnection Direct connection
Pipe Pipe (47125) — the class name plus the line number

Nominal diameter, fluid code and piping class are all one click away in the relationship details panel. To caption pipes by one of those instead, change both pipe rules to a different single property:

Caption Renders as
caption: '{displayName}'; Pipe (47125) — the packaged default
caption: '{lineNumber}'; 47125
caption: '{nominalDiameterRepresentation}'; DN 50

Change the caption line in both relationship.Pipe and relationship.DirectPipingConnection. Do not add a comment next to it — GRASS has no comment syntax, and the text would be merged into the following rule's selector.

Note that lineNumber belongs to PipingNetworkSystem and segmentNumber to PipingNetworkSegment, not to the pipe itself. They are present on the relationship only because the process and conceptual abstractions merge the collapsed parents' properties onto the edge — which is exactly what lets the pipe's displayName name it by line number.

What the rebuilt Browser changes on import

Running :style after importing shows the stylesheet translated into the new engine's own vocabulary. This is expected and not a sign of a failed import:

In the file After import
diameter: 80px size: 43px (a radius, so roughly half)
shaft-width: 3px width: 3px
caption: '{displayName}' caption: "{displayName}" (re-emitted double-quoted)
generic node { } / relationship { } dropped — the new engine keeps only label-scoped rules

It also generates its own rule for every label the stylesheet does not cover — node.BallValve, node.Tank, every structural mixin — with a random colour, size: 25px and no caption. Those generated rules are placed before the imported ones, so the packaged rules still win. This is the reason every node rule in the stylesheet declares both a caption and a colour: it leaves nothing for the generator to fill in.

Browser drops captions that do not fit

Neo4j Browser hides a relationship caption rather than overflowing it. The space it measures against is

shaftLength = centreDistance − sourceRadius − targetRadius − arrowHeadHeight

and the arrow head grows with shaft-width. If the caption is wider than that, Browser shortens it, and if it still does not fit it renders nothing at all — no ellipsis, no fallback to the type name.

Two consequences worth knowing:

  • Bigger nodes mean fewer edge captions. The 80 px equipment and 55 px valve diameters consume most of the gap between adjacent nodes. The pipe rules keep to a moderate shaft-width: 3px, font-size: 8px and padding: 1px precisely to stay within the remaining budget — the 47127 DN 50 caption needs roughly 46 px, against a typical 64–68 px of available shaft.
  • Long captions vanish first. If pipe captions still disappear in a dense layout, the reliable fix is a shorter caption. Editing relationship.Pipe to caption: '{nominalDiameterRepresentation}' (DN 50, about 26 px) or caption: '{lineNumber}' (47127, about 22 px) makes them survive much tighter spacing. Spreading the layout out by dragging nodes apart also brings them back.

Node captions are handled differently — they wrap onto multiple lines inside the circle and are truncated with an ellipsis when they still do not fit, so a long displayName such as Spring Loaded Globe Safety Valve (...) shows only its first words. Clicking the node shows the full property list.

Coordinates are not used for layout

When exported with embed_shape_usage=True, nodes carry x and y holding the original diagram coordinates (Proteus convention: y-up, millimetres). At conceptual level, collapsed Pipe relationships carry them too.

GRASS has no positional or layout properties at all, so the stylesheet does not and cannot use x / y. Neo4j Browser always applies its own force-directed layout, and node positions in Browser bear no relation to the source diagram. The coordinates are preserved for compatible downstream visualisation clients or future tooling, and for spatial queries — they are stored as numbers, so they work in range and ordering predicates, the same guarantee physical quantity values carry.

x and y are also only present for objects that have a shape usage in the source diagram, which is a subset of all nodes. Nodes without coordinates render and caption exactly like any other, since no style rule references them.

Structural edges are deliberately quiet

composition and reference relationships are styled as thin neutral grey lines. They describe how the DEXPI model is assembled rather than how the plant is connected, and at complete level they are the overwhelming majority of edges — leaving them prominent buries the process topology entirely.

That is a genuine trade-off at conceptual level, where only a handful of reference edges survive and each one is meaningful. They stay visible but recede. If you work exclusively at conceptual level, giving relationship.reference a stronger colour and shaft-width: 2px is a reasonable local change.

A caption cannot be removed entirely, only replaced: GRASS drops a property whose value is empty, so caption: ''; leaves the inherited caption in place rather than blanking it. Structural edges therefore keep the {attr_name} caption, which at least names the DEXPI attribute (nozzles, sensingLocation) rather than repeating the type.

Model provenance

Neo4j has no graph object, so project / drawing / revision / originating-system provenance has nowhere to live by default. create_graph_metadata_node=True appends one synthetic node carrying all of it:

export_neo4j_csv(model, "out/", graph_level="process", create_graph_metadata_node=True)

It is an ordinary row in the columns that matter — a composite :ID, graph_level, the id_namespace when one was given, and a displayName — so level and namespace filters reach it:

MATCH (g:_DexpiGraph {graph_level: 'process'})
MATCH (n {graph_level: 'process'}) WHERE NOT n:_DexpiGraph
RETURN g.projectName, g.drawingNumber, g.revisionNumber, count(n);

Off by default, because it adds a node that does not exist in the model. If the model already contains an object with the id _DexpiGraph, the export raises rather than emit two rows with the same :ID.