Validating IFC Georeferencing in CI

This page turns the checks a person would do by hand on a delivered IFC model into a CI job — asserting that a map conversion exists with a compound CRS, that units and scale agree, that the model’s footprint lands on its parcel in EPSG:25832, and that surveyed control points reproduce within tolerance — with a report a contractor can act on and an exit code that stops a bad model reaching the twin.

Why you hit this

A twin programme receives IFC models continuously, from different contractors, produced by different tools, and the georeferencing is wrong often enough that checking by hand becomes the bottleneck. Each failure is also expensive to find late: a model ingested with a missing vertical datum reaches a viewer weeks later as a building 47 m in the air, by which time the delivery has been accepted and the contractor has moved on. The checks themselves are the ones described in BIM and IFC georeferencing for digital twins; this page makes them automatic.

Prerequisites

  • Python 3.10+ with ifcopenshell>=0.8, pyproj>=3.6, numpy>=1.24, shapely>=2.0, geopandas>=0.14.
  • A cadastral parcel layer for the area in the target CRS, and — where available — surveyed control points per project.
  • A CI runner with the PROJ grids the target vertical datum needs, or PROJ_NETWORK=ON.
  • A delivery convention: each model arrives with a small JSON stating its project, expected CRS and control points.

Step-by-Step

1. Read the delivery contract

python
import json
from pathlib import Path

def load_contract(path):
    c = json.loads(Path(path).read_text())
    required = {"project", "expected_crs", "parcel_ids"}
    missing = required - set(c)
    if missing:
        raise SystemExit(f"delivery contract incomplete: missing {sorted(missing)}")
    c.setdefault("control_points", [])
    c.setdefault("tolerance_m", 0.05)
    return c

contract = load_contract("deliveries/2026-09/klinikum_c/contract.json")
print(contract["project"], contract["expected_crs"], len(contract["control_points"]), "control points")

The contract is what makes the gate meaningful. Without a stated expected CRS the job can only check internal consistency, which a wrongly placed model passes; with one it can check placement. Making the contract a required, version-controlled file also moves the conversation about georeferencing to the start of the project, which is where it belongs.

2. Assert the map conversion exists and is complete

python
import math

import ifcopenshell
import ifcopenshell.util.unit
from pyproj import CRS

def check_map_conversion(model, expected_crs):
    findings = []
    if model.schema == "IFC2X3":
        psets = [p for p in model.by_type("IfcPropertySet") if p.Name == "ePSet_MapConversion"]
        if not psets:
            findings.append(("critical", "no ePSet_MapConversion in an IFC2X3 model"))
            return findings, None
        params = {p.Name: p.NominalValue.wrappedValue for p in psets[0].HasProperties}
    else:
        convs = model.by_type("IfcMapConversion")
        if not convs:
            findings.append(("critical", "no IfcMapConversion entity"))
            return findings, None
        if len(convs) > 1:
            findings.append(("warning", f"{len(convs)} map conversions; using the model context's"))
        c = convs[0]
        params = {"Eastings": c.Eastings, "Northings": c.Northings,
                  "OrthogonalHeight": c.OrthogonalHeight,
                  "XAxisAbscissa": c.XAxisAbscissa, "XAxisOrdinate": c.XAxisOrdinate,
                  "Scale": c.Scale, "TargetCRS": c.TargetCRS.Name,
                  "VerticalDatum": getattr(c.TargetCRS, "VerticalDatum", None)}

    for key in ("Eastings", "Northings", "OrthogonalHeight"):
        if params.get(key) is None:
            findings.append(("critical", f"{key} is not set"))

    declared = str(params.get("TargetCRS") or "")
    try:
        crs = CRS.from_user_input(declared)
        epsg = crs.to_epsg()
    except Exception:
        crs, epsg = None, None
        findings.append(("critical", f"TargetCRS {declared!r} is not resolvable"))

    expected = CRS.from_user_input(expected_crs)
    if epsg and expected.sub_crs_list and epsg != expected.sub_crs_list[0].to_epsg():
        findings.append(("critical", f"declared CRS EPSG:{epsg} is not the expected "
                                     f"EPSG:{expected.sub_crs_list[0].to_epsg()}"))
    if not params.get("VerticalDatum") and (not crs or len(getattr(crs, "sub_crs_list", [])) < 2):
        findings.append(("critical", "no vertical datum declared: heights are ambiguous"))

    unit_to_m = ifcopenshell.util.unit.calculate_unit_scale(model)
    scale = params.get("Scale") or 1.0
    effective = scale / unit_to_m
    if not 0.99 < effective < 1.01:
        findings.append(("critical", f"Scale {scale} with project unit {unit_to_m} "
                                     f"gives an effective scale of {effective:.6f}"))
    return findings, params

model = ifcopenshell.open("deliveries/2026-09/klinikum_c/model.ifc")
findings, params = check_map_conversion(model, contract["expected_crs"])

Each assertion maps to a failure that has happened. A missing vertical datum is the most common and the most expensive. An effective scale far from one is the millimetre trap. A declared CRS that resolves but is not the expected one is the contractor who used the neighbouring UTM zone, which puts the building a few hundred kilometres away and looks entirely plausible inside the file.

3. Compare the footprint against the cadastre

python
import geopandas as gpd
import numpy as np
from shapely.geometry import MultiPoint

def model_footprint(model, params):
    import ifcopenshell.geom
    settings = ifcopenshell.geom.settings()
    settings.set("use-world-coords", True)
    slabs = model.by_type("IfcSlab") or model.by_type("IfcWall")
    it = ifcopenshell.geom.iterator(settings, model, include=slabs)
    pts = []
    if it.initialize():
        while True:
            v = np.asarray(it.get().geometry.verts, dtype=np.float64).reshape(-1, 3)
            pts.append(v)
            if not it.next():
                break
    local = np.vstack(pts)
    theta = math.atan2(params["XAxisOrdinate"] or 0.0, params["XAxisAbscissa"] or 1.0)
    c, s = math.cos(theta), math.sin(theta)
    e = c * local[:, 0] - s * local[:, 1] + params["Eastings"]
    n = s * local[:, 0] + c * local[:, 1] + params["Northings"]
    return MultiPoint(list(zip(e, n))).convex_hull

def check_against_cadastre(hull, parcel_ids, cadastre_path, epsg):
    parcels = gpd.read_file(cadastre_path).to_crs(epsg)
    target = parcels[parcels["parcel_id"].isin(parcel_ids)]
    if target.empty:
        return [("critical", f"parcels {parcel_ids} not found in the cadastre")]
    union = target.geometry.union_all()
    inter = hull.intersection(union).area
    findings = []
    share = inter / hull.area if hull.area else 0.0
    if share < 0.6:
        findings.append(("critical", f"only {share:.0%} of the model footprint is on its parcels"))
    elif share < 0.9:
        findings.append(("warning", f"{share:.0%} of the model footprint is on its parcels"))
    return findings

hull = model_footprint(model, params) if params else None
if hull is not None:
    findings += check_against_cadastre(hull, contract["parcel_ids"],
                                       "reference/cadastre.gpkg", 25832)

The cadastre check is the one that catches errors no internal consistency test can. A model with a perfect map conversion, correct units and a plausible CRS can still be 40 m off because the surveyor’s origin was mis-transcribed, and the only way to notice is to compare with something external. Parcel overlap is a blunt instrument and blunt is enough: 95% overlap is right, 30% is a placement error, and the intermediate cases deserve a human.

The gate's checks, in order of cost Four tiers. Reading a few entities takes under a second and catches a missing map conversion, a wrong CRS or a scale error. Extracting the footprint takes a minute and catches misplacement against the cadastre. Applying control points takes seconds once geometry is loaded and measures accuracy. Full geometry validation takes many minutes and is left to a separate job. < 1 second map conversion, CRS, units, scale blocks immediately ~1 minute footprint against the cadastre blocks below 60% overlap seconds more control-point residuals blocks above tolerance many minutes full geometry validation separate job, not this gate Ordering by cost means most rejections happen in the first second of the job.
The cheap checks catch the common failures, so the expensive ones only run on models that have already earned it.

4. Reproduce the control points

python
def check_control_points(params, control_points, tolerance_m):
    if not control_points:
        return [("warning", "no control points supplied: placement accuracy unverified")]
    theta = math.atan2(params["XAxisOrdinate"] or 0.0, params["XAxisAbscissa"] or 1.0)
    c, s = math.cos(theta), math.sin(theta)
    findings, residuals = [], []
    for cp in control_points:
        lx, ly, lz = cp["local"]
        e = c * lx - s * ly + params["Eastings"]
        n = s * lx + c * ly + params["Northings"]
        h = lz + params["OrthogonalHeight"]
        de, dn, dh = e - cp["world"][0], n - cp["world"][1], h - cp["world"][2]
        residuals.append((cp["label"], math.hypot(de, dn), dh))
    worst_h = max(r[1] for r in residuals)
    worst_v = max(abs(r[2]) for r in residuals)
    if worst_h > tolerance_m or worst_v > tolerance_m:
        findings.append(("critical", f"control residuals exceed {tolerance_m * 100:.0f} cm: "
                                     f"horizontal {worst_h * 100:.1f} cm, vertical {worst_v * 100:.1f} cm"))
    return findings, residuals

cp_findings, residuals = check_control_points(params, contract["control_points"],
                                              contract["tolerance_m"]) if params else ([], [])
findings += cp_findings

Control points measure what the cadastre check cannot: accuracy rather than plausibility. Reading the residual pattern also identifies the cause, as in the parent guide — a uniform offset is the translation, a rotation is grid versus true north, and growth with distance is the scale.

Fixture models that keep the gate honest Five small IFC fixtures, each with one deliberate defect, and the finding each must produce: a model with no map conversion, one declaring the neighbouring UTM zone, one with a millimetre scale trap, one without a vertical datum, and one correct model that must produce no critical findings. fixturerequired finding no_map_conversion.ifccritical: no IfcMapConversion entity wrong_zone.ifccritical: declared CRS is not the expected one mm_scale.ifccritical: effective scale far from one no_vertical.ifccritical: no vertical datum declared good.ifcno critical findings at all
The last row matters as much as the others: a gate that rejects everything is as useless as one that accepts everything.

5. Report in a form the contractor can act on

python
def write_report(path, contract, findings, residuals, params):
    severity = "fail" if any(s == "critical" for s, _ in findings) else (
        "warn" if findings else "pass")
    lines = [
        f"# IFC georeferencing report — {contract['project']}", "",
        f"- result: **{severity}**",
        f"- expected CRS: `{contract['expected_crs']}`",
        f"- declared CRS: `{(params or {}).get('TargetCRS', 'none')}`",
        f"- vertical datum: `{(params or {}).get('VerticalDatum', 'none')}`", "",
    ]
    if findings:
        lines += ["## Findings", ""]
        for sev, msg in findings:
            lines.append(f"- **{sev}**: {msg}")
        lines.append("")
    if residuals:
        lines += ["## Control points", "", "| point | horizontal | vertical |", "|---|---|---|"]
        for label, h, v in residuals:
            lines.append(f"| {label} | {h * 100:.1f} cm | {v * 100:+.1f} cm |")
    Path(path).write_text("\n".join(lines) + "\n")
    return severity

severity = write_report("build/georef_report.md", contract, findings, residuals, params)
print(Path("build/georef_report.md").read_text())

A report that names the parameter and the expected value is the difference between a rejected delivery that gets fixed and one that gets re-sent unchanged. Writing it as Markdown means CI systems render it in the job summary, and it can be attached to the rejection e-mail without editing.

6. Wire it into the pipeline

yaml
name: ifc-georeferencing
on:
  push:
    paths: ["deliveries/**/model.ifc", "deliveries/**/contract.json"]
jobs:
  validate:
    runs-on: ubuntu-latest
    env:
      PROJ_NETWORK: "ON"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install ifcopenshell pyproj numpy shapely geopandas
      - run: python tools/validate_georeferencing.py deliveries/2026-09/klinikum_c
      - if: always()
        run: cat build/georef_report.md >> "$GITHUB_STEP_SUMMARY"
      - if: always()
        uses: actions/upload-artifact@v4
        with: { name: georef-report, path: build/georef_report.md }

Triggering on the delivery paths means the check runs when a model arrives rather than on a schedule, and the always() steps publish the report whether the job passed or failed — which matters, because a passing report with warnings is the one somebody should read.

Delivery to decision A delivery folder containing the model and its contract triggers the job. The job reads the contract, asserts the map conversion, compares the footprint with the cadastre and reproduces the control points, then writes a Markdown report. A critical finding fails the build and the model is not ingested; warnings pass with the report attached. model.ifc +contract.json assert conversion,CRS, units cadastre overlap,control residuals critical → fail,not ingested warning → passwith the report clean → ingestinto the twin
The report is produced on every path, so a delivery that passes with warnings still leaves a record of what was tolerated.

Expected Output & Verification

text
Klinikum Nord Haus C EPSG:25832+7837 4 control points

# IFC georeferencing report — Klinikum Nord Haus C

- result: **fail**
- expected CRS: `EPSG:25832+7837`
- declared CRS: `EPSG:25832`
- vertical datum: `none`

## Findings

- **critical**: no vertical datum declared: heights are ambiguous
- **warning**: 87% of the model footprint is on its parcels

## Control points

| point | horizontal | vertical |
|---|---|---|
| gcp_A | 1.2 cm | +0.9 cm |
| gcp_B | 1.4 cm | +1.1 cm |
| gcp_C | 1.1 cm | +0.8 cm |
| gcp_D | 1.8 cm | +1.0 cm |

That is a realistic result: the placement is excellent, the footprint is slightly off its parcel because a canopy overhangs the pavement, and the delivery is rejected for one line of missing metadata. The rejection is cheap for the contractor to fix and expensive for the twin to discover later.

Verify the gate against fixtures rather than trusting it:

python
FIXTURES = {
    "no_map_conversion.ifc": "no IfcMapConversion entity",
    "wrong_zone.ifc": "is not the expected",
    "mm_scale.ifc": "effective scale",
    "no_vertical.ifc": "no vertical datum",
    "good.ifc": None,
}
for name, expected in FIXTURES.items():
    m = ifcopenshell.open(f"tests/fixtures/{name}")
    f, _ = check_map_conversion(m, "EPSG:25832+7837")
    msgs = " | ".join(msg for _, msg in f)
    if expected is None:
        assert not any(s == "critical" for s, _ in f), f"{name}: unexpected critical: {msgs}"
    else:
        assert expected in msgs, f"{name}: expected {expected!r}, got {msgs!r}"
print("gate behaves correctly on all fixtures")

Five small IFC files, each with one deliberate defect, are enough to keep the gate honest through refactors and library upgrades. They are also the fastest way to onboard the next person: reading the fixtures explains what the gate is for.

Common Errors

The job passes everything because params is None. When the map conversion is missing, later checks have nothing to work with and silently skip. Return early with a critical finding, and assert in tests that a missing conversion produces exactly one failure rather than none.

The cadastre comparison fails for a legitimate model. Canopies, balconies and basements extend beyond the parcel. Use the ground-floor slab outline rather than all geometry, and set the threshold from a sample of accepted models.

PROJ cannot find the geoid grid on the runner. Vertical checks then silently use a ballpark transformation. Set PROJ_NETWORK=ON or install the grid, and assert that the transformation PROJ chose names the grid.

Control points are in the contract but in the wrong frame. A contract listing coordinates without their CRS is as ambiguous as the model. Require the CRS in the contract and validate it against the expected one.

Frequently Asked Questions

Should the gate reject or quarantine?

Quarantine: fail the ingestion but keep the file, with the report, in a holding area. A rejected delivery that is deleted has to be re-sent; one that is held can be re-validated the moment a corrected parameter arrives.

What tolerance should control residuals have?

Whatever the project’s specification says, typically 2–5 cm for a building set out from survey. Put the number in the contract per project rather than in the code, since a heritage survey and a warehouse have different expectations.

Can this run without control points?

Yes, and it then verifies consistency and plausibility but not accuracy. The report says so explicitly, which is the point: “unverified” is a different state from “accurate”.

Back to BIM and IFC Georeferencing for Digital Twins.