Reading IfcMapConversion with IfcOpenShell

This page extracts the georeferencing of an IFC4 or IFC4X3 building model with IfcOpenShell — the IfcMapConversion parameters, the IfcProjectedCRS it points to, the project length unit and the scaled IFC4X3 variant — into a single normalised record that a pipeline can validate and apply.

Why you hit this

Every tiling or twin-ingestion job that accepts BIM needs to know where the building is, and the answer is spread over at least three entities with optional attributes, schema-dependent names and a unit system that is not the map’s. Code that reads model.by_type("IfcMapConversion")[0].Eastings works on the first sample file and fails on the second, because the second has two representation contexts, or a None scale, or a CRS name written as "ETRS89 / UTM zone 32N" rather than an EPSG code. The theory of what each parameter means is in BIM and IFC georeferencing for digital twins; this page is the defensive reader.

Prerequisites

  • ifcopenshell>=0.8.0, pyproj>=3.6, Python 3.10+.
  • An IFC4, IFC4X1 or IFC4X3 file. IFC2X3 files have no IfcMapConversion entity at all — see georeferencing IFC2x3 models without IfcMapConversion.
  • Knowledge of the CRS the project was delivered in, as an EPSG code, so the reader’s output can be checked against it — for example EPSG:25832 for ETRS89 / UTM 32N or EPSG:2263 for New York State Plane Long Island in US feet.

Step-by-Step

1. Find the model context the conversion belongs to

A model can carry several IfcGeometricRepresentationContext entities — a 3D model context, a 2D plan context, sub-contexts for body and axis. The map conversion hangs off one of them through the inverse attribute HasCoordinateOperation.

python
import ifcopenshell

model = ifcopenshell.open("clinic_block_c.ifc")
assert model.schema.startswith("IFC4"), f"{model.schema}: no IfcMapConversion in this schema"

contexts = [
    c for c in model.by_type("IfcGeometricRepresentationContext")
    if not c.is_a("IfcGeometricRepresentationSubContext")
]
for c in contexts:
    ops = c.HasCoordinateOperation or ()
    print(c.id(), c.ContextType, c.CoordinateSpaceDimension, [o.is_a() for o in ops])

Iterating contexts rather than calling by_type("IfcMapConversion") directly is what makes the reader correct for files with a second, unrelated conversion — the pattern some authoring tools use when a plan context is georeferenced separately from the model context. Prefer the context whose ContextType is "Model" and whose dimension is 3.

2. Read the conversion into a normalised record

python
import math
from dataclasses import dataclass

@dataclass(frozen=True)
class MapConversion:
    crs_name: str
    vertical_datum: str | None
    eastings: float
    northings: float
    height: float
    rotation_rad: float
    scale_xy: float
    scale_z: float

def read_map_conversion(model):
    ctx = next(c for c in contexts if c.ContextType == "Model" and c.CoordinateSpaceDimension == 3)
    op = next((o for o in (ctx.HasCoordinateOperation or ()) if o.is_a("IfcMapConversion")), None)
    if op is None:
        return None
    crs = op.TargetCRS
    abscissa = op.XAxisAbscissa if op.XAxisAbscissa is not None else 1.0
    ordinate = op.XAxisOrdinate if op.XAxisOrdinate is not None else 0.0
    if op.is_a("IfcMapConversionScaled"):                 # IFC4X3 only
        base = op.Scale if op.Scale is not None else 1.0
        sx, sy, sz = op.FactorX, op.FactorY, op.FactorZ
        assert abs(sx - sy) < 1e-9, "anisotropic horizontal scale is not a conformal map"
        scale_xy, scale_z = base * sx, base * sz
    else:
        scale_xy = scale_z = op.Scale if op.Scale is not None else 1.0
    return MapConversion(
        crs_name=crs.Name,
        vertical_datum=getattr(crs, "VerticalDatum", None),
        eastings=float(op.Eastings),
        northings=float(op.Northings),
        height=float(op.OrthogonalHeight),
        rotation_rad=math.atan2(ordinate, abscissa),
        scale_xy=scale_xy,
        scale_z=scale_z,
    )

mc = read_map_conversion(model)
print(mc)

Two defaults matter here. XAxisAbscissa and XAxisOrdinate are optional, and their absence means no rotation, so they default to (1, 0) — not (0, 0), which would make atan2 return zero by accident and hide a genuinely malformed file behind a plausible value. Scale is optional and defaults to 1.0 per the schema, which the reader records honestly; deciding whether 1.0 is right is the validation step’s job, not the reader’s.

The entities a reader has to walk The IfcProject owns representation contexts. The three-dimensional model context has an inverse HasCoordinateOperation link to IfcMapConversion, which holds the six numeric parameters and points to IfcProjectedCRS through TargetCRS. The project's unit assignment is a separate branch that decides what Scale has to convert. IfcProject Model context, 3D ContextType = Model IfcUnitAssignment LENGTHUNIT: MILLI METRE IfcMapConversion IfcProjectedCRS E · N · H · abscissa · ordinate · Scale inverse The unit branch never touches the conversion, which is why Scale and units disagree so often.
Georeferencing lives on the model context, not the project or the site; the length unit that Scale must reconcile lives on a separate branch.

3. Resolve the CRS name to an EPSG code

IfcProjectedCRS.Name should be an EPSG identifier, and in practice is whatever the authoring tool wrote.

python
from pyproj import CRS
from pyproj.database import query_crs_info
from pyproj.exceptions import CRSError

def resolve_crs(name):
    try:
        crs = CRS.from_user_input(name)          # "EPSG:25832", "epsg:25832"
    except CRSError:
        hits = [i for i in query_crs_info(auth_name="EPSG") if i.name.lower() == name.lower()]
        if len(hits) != 1:
            raise ValueError(f"cannot resolve CRS name {name!r} to exactly one EPSG code")
        crs = CRS.from_epsg(int(hits[0].code))
    epsg = crs.to_epsg()
    if epsg is None or not crs.is_projected:
        raise ValueError(f"{name!r} is not a projected CRS with an EPSG code")
    return epsg, crs

epsg, crs = resolve_crs(mc.crs_name)
print(f"EPSG:{epsg}", crs.name, [a.unit_name for a in crs.axis_info])

The name lookup handles the common authoring-tool output — the CRS’s descriptive name, such as "ETRS89 / UTM zone 32N" — and refuses anything ambiguous. Printing the axis units catches the US case: EPSG:2263 is in US survey feet, so the map-unit side of Scale is feet and a metric model needs a scale near 3.2808, not 1.0.

4. Reconcile Scale against the project length unit

python
import ifcopenshell.util.unit

unit_to_m = ifcopenshell.util.unit.calculate_unit_scale(model)
map_unit_to_m = crs.axis_info[0].unit_conversion_factor     # 1.0 for metres, 0.3048006 for US ft

expected = unit_to_m / map_unit_to_m
ratio = mc.scale_xy / expected
print(f"project unit→m {unit_to_m}, map unit→m {map_unit_to_m:.7f}, "
      f"Scale {mc.scale_xy}, expected ≈ {expected:.7f}, ratio {ratio:.6f}")

if abs(ratio - 1.0) < 0.001:
    verdict = "consistent"
elif abs(ratio * 1000 - 1.0) < 0.001 or abs(ratio / 1000 - 1.0) < 0.001:
    verdict = "off by 1000: Scale ignores the millimetre project unit"
else:
    verdict = "unexplained: check for a projection scale factor or a wrong unit"
print(verdict)

A ratio within a few parts per ten thousand of 1.0 is consistent — the residual is the projection scale factor, which some authors fold into Scale and some do not, and 0.9996 at a UTM central meridian is legitimate. A ratio of 1000 or 0.001 is the classic millimetre mistake. Anything else needs a human.

Reading the Scale ratio A number line of the ratio between the stored Scale and the unit-derived expected scale. Values within a part per thousand of one are consistent, including the 0.9996 UTM scale factor. Values near one thousandth or one thousand indicate a millimetre unit mismatch. Anything else is unexplained and should stop the job. ≈ 0.001 mm ignored 1.000 ± 0.001 consistent ≈ 1000 mm applied twice unexplained unexplained 0.9996 on a UTM central meridian falls here, legitimately ratio = Scale ÷ (project unit in metres ÷ map unit in metres), on a log axis
Only the middle band is safe to apply automatically. The outer bands have a known cause and a known fix; the gaps between them go to a person.

5. Emit the record as a sidecar

python
import json
from dataclasses import asdict
from pathlib import Path

record = asdict(mc) | {
    "epsg": epsg,
    "project_unit_to_m": unit_to_m,
    "scale_verdict": verdict,
    "rotation_deg": math.degrees(mc.rotation_rad),
    "source_file": "clinic_block_c.ifc",
    "schema": model.schema,
}
Path("clinic_block_c.georef.json").write_text(json.dumps(record, indent=2))

Writing the normalised record next to the model makes the georeferencing reviewable in a pull request and diffable between model revisions — a rotation that changes by 0.8° between two deliveries of the same building is visible at a glance in JSON and invisible in a 300 MB STEP file.

The record is also the right place to stop a delivery. A CI job that runs the reader on every incoming revision can compare the new sidecar with the last accepted one and fail when any parameter moves by more than its tolerance — a centimetre on the translation, a hundredth of a degree on the rotation, a part per million on the scale. Genuine changes to georeferencing are rare once a project is set out, so an alert on movement catches the far more common case of an export setting that changed between two authors, or a model re-saved from a template with a different origin. Keeping the tolerances in the same repository as the sidecars means a deliberate re-survey is recorded as a reviewed change rather than an override.

Expected Output & Verification

For a millimetre IFC4 model delivered in ETRS89 / UTM 32N with the projection scale folded in:

text
EPSG:25832 ETRS89 / UTM zone 32N ['metre', 'metre']
project unit→m 0.001, map unit→m 1.0000000, Scale 0.0009996, expected ≈ 0.0010000, ratio 0.999600
consistent

Verify the record rather than the reader. Load the sidecar in a fresh process, apply it to four surveyed corners in the model’s local coordinates and require every horizontal residual under 5 cm; then check that rotation_deg agrees with the angle of a long, straight facade measured on the cadastral map in the same EPSG code, to within a tenth of a degree.

Reader output and its independent checks The reader turns an IFC file into a JSON sidecar. Two independent checks consume the sidecar: surveyed control points test the translation and scale, and a facade bearing measured on the cadastral map tests the rotation. Only when both pass is the sidecar applied to the geometry. .ifc georef.json sidecar control points E0 · N0 · Scale facade bearing rotation θ apply to geometry Neither check reads the IFC file, so neither can agree with a wrong reader by construction.
The sidecar is tested against evidence that did not come from the model, which is the only kind of test that can catch a model that is wrong.

Common Errors

AttributeError: entity instance of type 'IFC4.IfcGeometricRepresentationSubContext' has no attribute 'HasCoordinateOperation'. Sub-contexts inherit from the context entity but do not carry the inverse in every IfcOpenShell build. Filter them out as step 1 does before reading the inverse.

StopIteration on the model context. The file has only a "Plan" context, typical of exports from 2D-first tools, or a context whose ContextType is None. Fall back to the first context with CoordinateSpaceDimension == 3 and log that the model context was unnamed.

CRSError: Invalid projection: ETRS89 / UTM zone 32N. The authoring tool wrote the descriptive name rather than an identifier. The database lookup in step 3 resolves it; if it returns several candidates, the name is genuinely ambiguous and the project’s delivery specification has to supply the code.

Frequently Asked Questions

Can a model legitimately have more than one IfcMapConversion?

Yes — one per representation context, for instance a georeferenced plan context alongside the model context. They should agree. When they do not, the model context wins for 3D work, and the disagreement belongs in the validation report.

Why not use ifcopenshell.util.geolocation instead of writing a reader?

Use it for the arithmetic if it suits your IfcOpenShell version; its helpers have changed names between releases. The reader above exists for the defensive part — context selection, defaults, CRS resolution and the scale verdict — which a transform helper does not do for you.

Does the reader need the geometry at all?

No, and it should not load it. Opening a 400 MB model and reading a handful of entities takes seconds; triangulating it takes minutes. Keep the reader fast so it can run as a pre-flight check on every delivered revision.

Back to BIM and IFC Georeferencing for Digital Twins.