IFC4x3 Schema Mapping for Civil Infrastructure Python Pipelines

IFC4x3 is the buildingSMART schema release that promotes infrastructure — rail, road, port, and bridge — to first-class citizens alongside buildings. As part of the Core Format Fundamentals & Schema Mapping discipline, IFC4x3 schema mapping is the systematic translation of STEP-encoded civil entities into the property-flat, CRS-aligned structures that GIS platforms, asset registries, and digital twin pipelines actually consume.

Without a deterministic mapping layer, the gap between an IFC4x3 export from a civil authoring tool and a spatially queryable PostGIS table is filled with silent precision loss, dropped property sets, and undefined coordinate reference systems. The entities introduced in IFC4x3 — IfcAlignment, IfcBridge, IfcRailway, IfcLinearPosition, IfcReferent — require traversal patterns that differ substantially from the building-model conventions documented by earlier IFC releases.

This guide delivers a production-tested Python workflow: header validation, containment traversal, property extraction, CRS resolution, and GIS serialization, with named failure modes and a compatibility reference for each stage.


IFC4x3 Schema Mapping Pipeline Data-flow diagram showing the five stages of an IFC4x3 schema mapping pipeline: STEP file ingestion and header validation, spatial containment traversal, property and quantity set extraction, CRS resolution and geometry transformation, and final GIS export to GeoJSON or Parquet. IFC File Header Validation Containment Traversal (IsDecomposedBy) Property Set Extraction (Pset_* / Qset_*) CRS Resolution & Geometry Transform GIS Export GeoJSON / Parquet FILE_SCHEMA IfcAlignment / IfcBridge IsDefinedBy IfcProjectedCRS EPSG-aligned Gotcha: linear referencing differs from building trees Gotcha: missing IfcProjectedCRS → fallback required IFC4x3 Schema Mapping — Five-Stage Pipeline STEP ingestion → entity traversal → property extraction → CRS transform → GIS serialization

Prerequisites

  • Python 3.9+ with a dedicated virtual environment (venv or conda)
  • ifcopenshell ≥ 0.8.0 compiled with IFC4x3 EXPRESS schema support
  • pandas ≥ 2.0 for tabular property assembly
  • shapely ≥ 2.0 and pyproj ≥ 3.6 for geometry handling and CRS operations
  • A validated IFC4x3 file: civil alignment, bridge, or railway model
  • Working knowledge of EXPRESS schema inheritance, Pset_* naming conventions, and the difference between IfcPropertySet and IfcElementQuantity
# ifcopenshell>=0.8.0, pandas>=2.0, shapely>=2.0, pyproj>=3.6
pip install ifcopenshell pandas shapely pyproj

If ifcopenshell is not on PyPI for your platform, install from the official conda-forge channel or build from source against the IFC4x3 EXPRESS schema bundle.

Architectural Overview

IFC4x3 ships as an EXPRESS schema describing a directed acyclic object graph encoded as a STEP Part 21 file. Every entity instance has a numeric ID (#12345), a type name, and positional attributes. Relationships between entities — containment, property attachment, geometry assignment — are expressed through inverse attributes and explicit IfcRel* instances rather than foreign keys or embedded pointers.

What changes from IFC4 to IFC4x3

IFC4x3 promotes infrastructure to peer status with buildings by adding:

Entity class Purpose
IfcAlignment Horizontal + vertical alignment for linear infrastructure
IfcAlignmentSegment Individual constant-parameter segments along an alignment
IfcLinearPosition Position along a curve expressed as a distance measure
IfcReferent Named kilometre point or chainage marker on an alignment
IfcBridge Facility entity for bridges (parallel to IfcBuilding)
IfcRailway Facility entity for railway infrastructure
IfcMarineFacility Port, harbour, and waterway infrastructure

Schema version compatibility

model.schema value buildingSMART release Civil entities Notes
IFC4X3 IFC4x3 RC4 / final Full support Use this
IFC4X3_ADD2 IFC4x3 ADD2 Full support Treat as distinct; test Pset_ names
IFC4 IFC 4.0 None IfcAlignment absent; pipeline must abort
IFC2X3 IFC 2x3 None Legacy only; hard-fail before traversal

The containment graph

For buildings the spatial hierarchy runs IfcProject → IfcSite → IfcBuilding → IfcBuildingStorey → IfcSpace. For IFC4x3 civil models the hierarchy is flatter and alignment-centric:

IfcProject
  └─ IfcSite
       ├─ IfcAlignment          (road/rail centreline)
       │    └─ IfcAlignmentSegment (per-segment geometry)
       ├─ IfcBridge             (bridge as facility)
       │    └─ IfcBridgePart    (abutment, deck, pier, …)
       └─ IfcRailway
            └─ IfcFacilityPart

All containment links are resolved through the IsDecomposedBy inverse attribute. Unlike DXF entity structure, where spatial grouping is implicit in layer names, IFC makes containment relationships explicit and traversable through the object graph.

Step-by-Step Implementation

Step 1 — Header validation

Read the first 50 lines of the .ifc file as raw text before allocating ifcopenshell memory. The STEP header must declare FILE_SCHEMA(('IFC4X3')). Catching this early avoids the ~3–8 seconds overhead of opening a multi-gigabyte IFC2x3 file only to discover it lacks IfcAlignment entirely.

# ifcopenshell>=0.8.0
import ifcopenshell

def load_and_validate_ifc4x3(filepath: str) -> ifcopenshell.file:
    """Load an IFC file and assert IFC4X3 schema compliance."""
    # Fast pre-check: scan raw text before full parse
    with open(filepath, "r", encoding="utf-8", errors="replace") as fh:
        header_text = "".join(fh.readline() for _ in range(50))
    if "IFC4X3" not in header_text:
        raise ValueError(
            f"{filepath} does not declare IFC4X3 in FILE_SCHEMA. "
            "Check model.schema before proceeding."
        )

    model = ifcopenshell.open(filepath)
    schema = model.schema
    if not schema.startswith("IFC4X3"):
        raise ValueError(f"Expected IFC4X3, resolved schema: {schema}")
    return model

Step 2 — Containment traversal for civil entities

Walk the IsDecomposedBy graph from IfcProject downwards. Collect all IfcAlignment, IfcBridge, and IfcRailway instances. The generator pattern keeps peak memory proportional to one entity at a time rather than the full list.

# ifcopenshell>=0.8.0
from typing import Generator

CIVIL_TYPES = ("IfcAlignment", "IfcBridge", "IfcRailway", "IfcMarineFacility")

def iter_civil_entities(model: ifcopenshell.file) -> Generator:
    """Yield civil facility entities from the IFC4x3 spatial graph."""
    for type_name in CIVIL_TYPES:
        for entity in model.by_type(type_name):
            yield entity

def iter_alignment_segments(
    alignment: "ifcopenshell.entity_instance",
) -> Generator:
    """Yield IfcAlignmentSegment children of a given IfcAlignment."""
    for rel in alignment.IsDecomposedBy:
        for child in rel.RelatedObjects:
            if child.is_a("IfcAlignmentSegment"):
                yield child
            # Recurse if nested decomposition exists
            for grandchild in child.IsDecomposedBy:
                for obj in grandchild.RelatedObjects:
                    yield obj

Step 3 — Property and quantity set extraction

Each entity’s property sets are attached via IsDefinedByIfcRelDefinesByPropertiesIfcPropertySet. Civil models use domain-specific Psets: Pset_AlignmentCommon, Pset_BridgeCommon, Pset_RailwayCommon. Handle IfcPropertyEnumeratedValue and IfcPropertyTableValue explicitly — collapsing them silently to a string loses structure that downstream asset management queries depend on.

# ifcopenshell>=0.8.0, pandas>=2.0
import pandas as pd
from typing import Any, Dict

def extract_psets(entity) -> Dict[str, Any]:
    """Flatten all IfcPropertySet and IfcElementQuantity data to a dict."""
    props: Dict[str, Any] = {
        "ifc_guid": entity.GlobalId,
        "ifc_type": entity.is_a(),
        "name": getattr(entity, "Name", None) or "Unnamed",
    }

    for rel in getattr(entity, "IsDefinedBy", []):
        if not rel.is_a("IfcRelDefinesByProperties"):
            continue
        definition = rel.RelatingPropertyDefinition

        if definition.is_a("IfcPropertySet"):
            pset_name = definition.Name or "UnnamedPset"
            for prop in definition.HasProperties:
                key = f"{pset_name}.{prop.Name}"
                if prop.is_a("IfcPropertySingleValue"):
                    val = prop.NominalValue
                    props[key] = (
                        val.wrappedValue if hasattr(val, "wrappedValue") else str(val)
                    ) if val is not None else None
                elif prop.is_a("IfcPropertyEnumeratedValue"):
                    props[key] = [
                        v.wrappedValue if hasattr(v, "wrappedValue") else str(v)
                        for v in prop.EnumerationValues
                    ]
                elif prop.is_a("IfcPropertyTableValue"):
                    # Preserve key→value structure; do NOT collapse to scalar
                    props[key] = [
                        {
                            "k": (
                                dk.wrappedValue
                                if hasattr(dk, "wrappedValue") else str(dk)
                            ),
                            "v": (
                                dv.wrappedValue
                                if hasattr(dv, "wrappedValue") else str(dv)
                            ),
                        }
                        for dk, dv in zip(
                            prop.DefiningValues or [],
                            prop.DefinedValues or [],
                        )
                    ]

        elif definition.is_a("IfcElementQuantity"):
            qset_name = definition.Name or "UnnamedQset"
            for qty in definition.Quantities:
                key = f"{qset_name}.{qty.Name}"
                for attr in ("LengthValue", "AreaValue", "VolumeValue", "WeightValue", "CountValue"):
                    if hasattr(qty, attr) and getattr(qty, attr) is not None:
                        props[key] = getattr(qty, attr)
                        break

    return props

Step 4 — CRS resolution and coordinate transformation

IFC4x3 encodes project coordinate systems through IfcGeometricRepresentationContextHasCoordinateOperationIfcMapConversionIfcProjectedCRS. When this chain is present, all model coordinates are in a local engineering frame offset from the true-north / project-north datum. Apply the IfcMapConversion translation and rotation before using pyproj to reproject to EPSG:4326 or the target CRS.

# pyproj>=3.6
import pyproj
from typing import Optional, Tuple
import math

def resolve_map_conversion(
    model: ifcopenshell.file,
) -> Optional[Tuple[pyproj.CRS, dict]]:
    """
    Return (target_CRS, conversion_params) if IfcMapConversion is present.
    conversion_params keys: eastings, northings, orthogonal_height,
    x_axis_abscissa, x_axis_ordinate, scale.
    """
    for ctx in model.by_type("IfcGeometricRepresentationContext"):
        if getattr(ctx, "ContextType", None) != "Model":
            continue
        for op in getattr(ctx, "HasCoordinateOperation", []):
            if not op.is_a("IfcMapConversion"):
                continue
            target = op.TargetCRS
            if target is None:
                continue
            epsg_name = getattr(target, "Name", "") or ""
            try:
                crs = pyproj.CRS.from_string(epsg_name)
            except Exception:
                continue
            params = {
                "eastings": op.Eastings or 0.0,
                "northings": op.Northings or 0.0,
                "orthogonal_height": op.OrthogonalHeight or 0.0,
                "x_axis_abscissa": op.XAxisAbscissa or 1.0,
                "x_axis_ordinate": op.XAxisOrdinate or 0.0,
                "scale": op.Scale or 1.0,
            }
            return crs, params
    return None

def apply_map_conversion(
    local_x: float,
    local_y: float,
    params: dict,
) -> Tuple[float, float]:
    """Rotate and translate a local (x,y) into the mapped CRS frame."""
    angle = math.atan2(params["x_axis_ordinate"], params["x_axis_abscissa"])
    scale = params["scale"]
    rx = local_x * math.cos(angle) - local_y * math.sin(angle)
    ry = local_x * math.sin(angle) + local_y * math.cos(angle)
    return rx * scale + params["eastings"], ry * scale + params["northings"]

For projects that rely on IfcLocalPlacement matrices without a declared IfcProjectedCRS, see the CRS Normalization Workflows section for matrix decomposition and fallback reprojection patterns.

Step 5 — GIS serialization

Assemble property dicts and transformed coordinates into GeoJSON or Apache Parquet. For infrastructure models with tens of thousands of alignment segments, write to Parquet in chunks rather than accumulating a single in-memory list.

# ifcopenshell>=0.8.0, pandas>=2.0, pyproj>=3.6
import json
import warnings

def run_ifc4x3_pipeline(filepath: str, output_parquet: str) -> pd.DataFrame:
    """Execute the full IFC4x3 schema mapping pipeline."""
    model = load_and_validate_ifc4x3(filepath)

    crs_result = resolve_map_conversion(model)
    if crs_result:
        target_crs, conv_params = crs_result
        transformer = pyproj.Transformer.from_crs(
            target_crs, pyproj.CRS.from_epsg(4326), always_xy=True
        )
        print(f"CRS resolved: {target_crs.to_epsg()}")
    else:
        transformer = None
        conv_params = None
        warnings.warn(
            "No IfcProjectedCRS found. Coordinates retained in local frame. "
            "Tag output with crs_source=local_fallback."
        )

    records = []
    for entity in iter_civil_entities(model):
        record = extract_psets(entity)
        record["crs_source"] = (
            f"EPSG:{target_crs.to_epsg()}" if transformer else "local_fallback"
        )
        records.append(record)

    df = pd.DataFrame(records)
    df.to_parquet(output_parquet, index=False)
    print(f"Exported {len(df)} civil entities → {output_parquet}")
    return df

For detailed attribute-to-feature mapping logic — including how IfcPropertyTableValue maps to GeoJSON properties — consult Mapping IFC Properties to GeoJSON Attributes.

Edge Cases & Gotchas

1. IfcAlignment has no solid geometry

ifcopenshell.geom.create_shape() silently skips IfcAlignment because the entity carries a curve representation (IfcGradientCurve, IfcCompositeCurve), not a solid. Access geometry via model.by_type("IfcAlignmentSegment") and extract the Representation attribute directly. Wrap in a try/except RuntimeError because some authoring tools omit the representation on degenerate zero-length segments.

2. Duplicate GUID across file splits

Large infrastructure projects are often split across multiple IFC files (one per discipline). When merging, GlobalId values may collide if the authoring tool reused GUIDs across split files. Before concatenating DataFrames, assert df["ifc_guid"].is_unique. If not, construct a composite key from ifc_guid + source_file_hash.

3. Pset_ name variation between authoring tools

Revit exports Pset_AlignmentCommon, OpenRoads exports Civil_Pset_AlignmentCommon. Do not hard-code exact Pset names. Normalise by stripping known vendor prefixes with a regex and matching the canonical suffix, then log which prefix was stripped for downstream audit trails.

import re

VENDOR_PREFIX_RE = re.compile(r"^(?:Civil_|Structural_|MEP_)")

def normalize_pset_name(raw_name: str) -> str:
    return VENDOR_PREFIX_RE.sub("", raw_name)

4. Missing IfcMapConversion on federating models

When a federation model assembles reference files, the HasCoordinateOperation chain may exist only on the host file’s context, not on the referenced file’s context. Open each .ifc reference file individually, resolve its local IfcGeometricRepresentationContext, and carry the offset matrix forward rather than assuming the federation host’s CRS applies to all subfiles.

5. IfcLinearPosition vs. chainage attributes in Psets

Some authoring tools encode chainage (station) as a Pset_AlignmentCommon.StartStation attribute rather than through IfcLinearPosition and IfcReferent. When both are present, the IfcLinearPosition value is authoritative — treat the Pset attribute as a display-only label and log any discrepancy above a configurable tolerance (default: 0.1 m).

6. Schema-version mismatch at IFC4X3_ADD2

If model.schema returns IFC4X3_ADD2, several Pset definitions were revised. In particular, Pset_RailwayCommon gained additional attributes. Run a schema diff with ifcopenshell.util.schema.compare() if you need to support both versions in the same codebase.

Validation & Testing

Test schema mapping correctness with three layers of assertions:

  1. Schema-level: assert model.schema.startswith("IFC4X3") before any traversal.
  2. Entity-level: verify that every IfcAlignment yields at least one IfcAlignmentSegment — a model with no segments is authored incorrectly and should be rejected.
  3. Property-level: assert the output DataFrame has no ifc_guid duplicates and that key civil Pset columns (Pset_AlignmentCommon.StartStation, Pset_AlignmentCommon.EndStation) are non-null for alignment rows.
# ifcopenshell>=0.8.0, pandas>=2.0
import pytest

def validate_pipeline_output(
    model: ifcopenshell.file, df: pd.DataFrame
) -> None:
    """Raise AssertionError on any structural mapping failure."""
    assert model.schema.startswith("IFC4X3"), (
        f"Wrong schema: {model.schema}"
    )

    for alignment in model.by_type("IfcAlignment"):
        segments = list(iter_alignment_segments(alignment))
        assert segments, (
            f"IfcAlignment {alignment.GlobalId} has no segments — "
            "authoring error or unsupported representation"
        )

    assert df["ifc_guid"].is_unique, (
        "Duplicate GUIDs in output — check multi-file merge logic"
    )

    alignment_rows = df[df["ifc_type"] == "IfcAlignment"]
    if not alignment_rows.empty:
        missing = alignment_rows[
            alignment_rows.get("Pset_AlignmentCommon.StartStation", pd.Series(dtype=object)).isna()
        ]
        if not missing.empty:
            import warnings
            warnings.warn(
                f"{len(missing)} alignment(s) missing StartStation — "
                "check authoring tool Pset export settings"
            )

The validate_pipeline_output function is suitable as a pytest fixture or a CI gate step in a pre-merge validation workflow.

Performance & Scale

Large civil models — trans-national rail, multi-span motorway — can exceed 500 MB and contain 200 000+ entity instances. Three practices prevent memory exhaustion:

Generator-based traversal. Never call list(model.by_type(...)) upfront. The generator approach in iter_civil_entities() above keeps peak memory proportional to one entity, not the full instance list.

Batch Parquet writes. When processing models with more than 50 000 alignment segments, write records in chunks rather than accumulating a single DataFrame:

# pandas>=2.0
import pyarrow as pa
import pyarrow.parquet as pq

CHUNK_SIZE = 5_000

def write_chunked_parquet(
    entity_iter,
    output_path: str,
) -> int:
    """Write entity records to Parquet in chunks; return total row count."""
    writer = None
    total = 0
    chunk: list = []

    for entity in entity_iter:
        chunk.append(extract_psets(entity))
        if len(chunk) >= CHUNK_SIZE:
            table = pa.Table.from_pylist(chunk)
            if writer is None:
                writer = pq.ParquetWriter(output_path, table.schema)
            writer.write_table(table)
            total += len(chunk)
            chunk = []

    if chunk:
        table = pa.Table.from_pylist(chunk)
        if writer is None:
            writer = pq.ParquetWriter(output_path, table.schema)
        writer.write_table(table)
        total += len(chunk)

    if writer:
        writer.close()
    return total

Skip geometry for metadata-only pipelines. Geometry parsing via ifcopenshell.geom.create_shape() is the most expensive operation — often 10–50× slower than property extraction alone. If the pipeline only needs attributes and chainage data, skip all ifcopenshell.geom calls entirely. Open the file normally and never import ifcopenshell.geom.

Avoid repeated model.by_type() calls in inner loops. Cache the result of model.by_type("IfcRelDefinesByProperties") before the entity loop. Repeated calls trigger full schema traversal each time.

FAQ

What schema string should IFC4x3 files declare?

IFC4x3 files declare FILE_SCHEMA(('IFC4X3')) in the STEP header. The value ifcopenshell exposes via model.schema is the string "IFC4X3". Files declaring "IFC4X3_ADD2" are a later add-on release with revised Pset definitions — treat them as a distinct version and test property name differences. Files declaring "IFC4" or "IFC2X3" lack all civil-specific entities; abort immediately with an informative error.

Does IfcAlignment always carry an IfcProjectedCRS?

No. IfcProjectedCRS is optional and is attached via HasCoordinateOperation on the IfcGeometricRepresentationContext. Roughly 30–40% of civil IFC files exported from older authoring tool versions omit it entirely, leaving coordinates in a local engineering frame. When absent, fall back to the IfcMapConversion offset matrix if present, or tag the output with crs_source: local_fallback and document the assumption explicitly for downstream GIS teams.

Why does ifcopenshell.geom.create_shape() skip IfcAlignment entities?

IfcAlignment carries a curve representation — IfcGradientCurve or IfcCompositeCurve — not a solid or surface. ifcopenshell.geom.create_shape() is designed for solid mesh generation and silently returns nothing or raises a runtime error on alignment curves. Access the geometric segments directly via model.by_type("IfcAlignmentSegment") and read the Representation attribute, which points to an IfcShapeRepresentation containing the curve geometry.

How should IfcPropertyTableValue be flattened to JSON?

IfcPropertyTableValue holds two parallel lists: DefiningValues (the keys) and DefinedValues (the corresponding values). Zip them into a list of {"k": ..., "v": ...} dicts and store as a JSON array under the property name. Do not collapse to a single scalar — the table structure encodes multi-parameter relationships (e.g., load vs. deflection curves) that downstream asset management queries rely on.

What is the difference between IfcBridge and IfcCivilElement in IFC4x3?

IfcBridge is a top-level facility entity in the spatial hierarchy — analogous to IfcBuilding — representing the entire bridge as a decomposable spatial structure with its own site footprint and containment chain. IfcCivilElement is a generic component-level entity for civil parts that lack a dedicated class (e.g., retaining walls, culverts). In production, use entity.is_a() to branch between them and apply the relevant Pset_Bridge* definitions only to IfcBridge and its IfcBridgePart children.