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.
Prerequisites
- Python 3.9+ with a dedicated virtual environment (
venvorconda) ifcopenshell≥ 0.8.0 compiled with IFC4x3 EXPRESS schema supportpandas≥ 2.0 for tabular property assemblyshapely≥ 2.0 andpyproj≥ 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 betweenIfcPropertySetandIfcElementQuantity
# 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 IsDefinedBy → IfcRelDefinesByProperties → IfcPropertySet. 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 IfcGeometricRepresentationContext → HasCoordinateOperation → IfcMapConversion → IfcProjectedCRS. 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:
- Schema-level: assert
model.schema.startswith("IFC4X3")before any traversal. - Entity-level: verify that every
IfcAlignmentyields at least oneIfcAlignmentSegment— a model with no segments is authored incorrectly and should be rejected. - Property-level: assert the output DataFrame has no
ifc_guidduplicates 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.
Related Pages
- Core Format Fundamentals & Schema Mapping — parent section covering DXF, DWG, and IFC format foundations
- Mapping IFC Properties to GeoJSON Attributes — detailed walkthrough of
IfcPropertySetflattening and GeoJSON serialization - DXF Entity Structure Breakdown — how group code taxonomy differs from EXPRESS schema inheritance
- CRS Normalization Workflows — pyproj-based reprojection patterns for local-to-EPSG coordinate transforms
- ifcopenshell Workflow — geometry extraction and mesh conversion using the ifcopenshell Python API