ifcore-platform / teams /structures /tools /checker_foundation.py
IFCore Deploy
deploy(prod): 2026-02-21T01:10:43Z
51982d6
"""
Foundation Compliance Checker — IFCore Platform Contract
Regulations: Metropolitan Building Ordinances (Art. 69, Art. 128) + DB SE-AE
Returns list[dict] per element; each dict maps to one element_results DB row.
"""
import re
import ifcopenshell
import ifcopenshell.util.element
from typing import Optional, Any
# ── Regulatory Constants ─────────────────────────────────────────────────────
MIN_SLAB_THICKNESS_MM = 300 # Art. 69: 150 mm waterproof concrete + 150 mm drainage
MIN_BEAM_WIDTH_MM = 300 # DB SE-AE minimum bearing beam width
MIN_BEAM_DEPTH_MM = 300 # DB SE-AE minimum bearing beam depth
DEFAULT_BEARING_CAPACITY_KN_M2 = 150.0 # Conservative default (soft-medium soil)
DEFAULT_FLOOR_LOAD_KN_M2 = 7.0 # DB SE-AE residential: 5.0 dead + 2.0 live
# ── Private Helpers ───────────────────────────────────────────────────────────
def _get_length_scale(model: ifcopenshell.file) -> float:
"""Returns factor to convert model length units → metres."""
try:
for assignment in model.by_type("IfcUnitAssignment"):
for unit in assignment.Units:
if hasattr(unit, "UnitType") and unit.UnitType == "LENGTHUNIT":
prefix_map = {"MILLI": 0.001, "CENTI": 0.01, "DECI": 0.1, "KILO": 1000.0}
if hasattr(unit, "Prefix") and unit.Prefix:
return prefix_map.get(unit.Prefix, 1.0)
return 1.0 # SI base METRE, no prefix
except Exception:
pass
return 1.0
def _get_pset_value(element, *keys) -> Optional[Any]:
"""Search all property sets for the first matching key. Returns None if not found."""
try:
psets = ifcopenshell.util.element.get_psets(element)
for key in keys:
for pset_props in psets.values():
if key in pset_props and pset_props[key] is not None:
return pset_props[key]
except Exception:
pass
return None
def _get_footing_dimensions(footing, scale: float) -> dict:
"""
Extract length, width, and thickness from an IfcFooting or IfcSlab.
Returns: {length_m, width_m, thickness_m} — any may be None.
Path 1: Quantity sets (Qto_FootingBaseQuantities)
Path 2: IfcExtrudedAreaSolid → IfcRectangleProfileDef geometry
"""
dims = {"length_m": None, "width_m": None, "thickness_m": None}
# Path 1: Quantity sets
try:
if hasattr(footing, "IsDefinedBy"):
for rel in footing.IsDefinedBy:
if rel.is_a("IfcRelDefinesByProperties"):
prop_def = rel.RelatingPropertyDefinition
if prop_def.is_a("IfcElementQuantity"):
for qty in prop_def.Quantities:
if hasattr(qty, "LengthValue"):
if qty.Name in ("Length", "FootingLength") and dims["length_m"] is None:
dims["length_m"] = qty.LengthValue * scale
elif qty.Name in ("Width", "FootingWidth") and dims["width_m"] is None:
dims["width_m"] = qty.LengthValue * scale
elif qty.Name in ("Depth", "Thickness", "Height") and dims["thickness_m"] is None:
dims["thickness_m"] = qty.LengthValue * scale
except Exception:
pass
# Path 2: Geometry — IfcExtrudedAreaSolid
if any(v is None for v in dims.values()):
try:
if hasattr(footing, "Representation") and footing.Representation:
for rep in footing.Representation.Representations:
for item in rep.Items:
if item.is_a("IfcExtrudedAreaSolid"):
if dims["thickness_m"] is None:
dims["thickness_m"] = item.Depth * scale
area = item.SweptArea
if area.is_a("IfcRectangleProfileDef"):
if dims["length_m"] is None:
dims["length_m"] = area.XDim * scale
if dims["width_m"] is None:
dims["width_m"] = area.YDim * scale
except Exception:
pass
# Path 3: Parse dimensions from element name (Revit-style naming conventions)
# e.g. "Bearing Footing - 900 x 300" → width=900 mm, thickness=300 mm
# e.g. "150mm Exterior Slab on Grade" → thickness=150 mm
if any(v is None for v in dims.values()):
try:
name = getattr(footing, "Name", "") or ""
# Pattern A: two numbers separated by "x" / "X" / "×"
match_x = re.search(r'(\d+)\s*[xX×]\s*(\d+)', name)
if match_x:
a, b = int(match_x.group(1)), int(match_x.group(2))
# Larger value is the plan width; smaller is the depth/thickness
if dims["width_m"] is None:
dims["width_m"] = max(a, b) / 1000.0
if dims["thickness_m"] is None:
dims["thickness_m"] = min(a, b) / 1000.0
else:
# Pattern B: a standalone "NNNmm" token (e.g. "150mm Slab on Grade")
if dims["thickness_m"] is None:
match_mm = re.search(r'(\d+)\s*mm', name, re.IGNORECASE)
if match_mm:
dims["thickness_m"] = int(match_mm.group(1)) / 1000.0
except Exception:
pass
return dims
def _get_element_elevation(element, scale: float) -> Optional[float]:
"""Return the Z coordinate of an element's placement in metres."""
try:
coords = element.ObjectPlacement.RelativePlacement.Location.Coordinates
return coords[2] * scale if len(coords) > 2 else 0.0
except Exception:
return None
def _get_bearing_beams(model: ifcopenshell.file, scale: float):
"""
Find IfcBeam (or IfcMember fallback) assigned to the lowest IfcBuildingStorey.
Returns: (beams: list, blocked_reason: str | None)
blocked_reason is a human-readable explanation when the list is empty.
"""
def _storey_elev(s):
try:
coords = s.ObjectPlacement.RelativePlacement.Location.Coordinates
return coords[2] * scale if len(coords) > 2 else 0.0
except Exception:
return 0.0
storeys = model.by_type("IfcBuildingStorey")
if not storeys:
return [], (
"Model has no IfcBuildingStorey elements — cannot determine the foundation level. "
"Add storeys in your BIM tool and assign structural elements to them."
)
lowest_storey = min(storeys, key=_storey_elev)
storey_name = lowest_storey.Name or f"Storey #{lowest_storey.id()}"
storey_elev = _storey_elev(lowest_storey)
# Collect beams assigned to the lowest storey
beams_in_storey = []
for ifc_type in ("IfcBeam", "IfcMember"):
if beams_in_storey:
break
for elem in model.by_type(ifc_type):
try:
for rel in elem.ContainedInStructure:
if rel.RelatingStructure == lowest_storey:
beams_in_storey.append(elem)
break
except Exception:
pass
if not beams_in_storey:
total_beams = len(list(model.by_type("IfcBeam"))) + len(list(model.by_type("IfcMember")))
if total_beams > 0:
reason = (
f"Model has {total_beams} IfcBeam/IfcMember element(s) but none are spatially "
f"assigned to the lowest storey '{storey_name}' (elevation {storey_elev:.2f} m). "
f"In your BIM tool, ensure foundation beams are contained in the correct storey."
)
else:
reason = (
f"No IfcBeam or IfcMember elements exist in the model. "
f"Foundation bearing beams must be modelled as IfcBeam and assigned to "
f"storey '{storey_name}' (elevation {storey_elev:.2f} m)."
)
return [], reason
# Extract cross-section dimensions for each beam
results = []
for beam in beams_in_storey:
width_m, depth_m = None, None
dim_source = None
# Path 1: IfcExtrudedAreaSolid → IfcRectangleProfileDef
try:
if hasattr(beam, "Representation") and beam.Representation:
for rep in beam.Representation.Representations:
for item in rep.Items:
if item.is_a("IfcExtrudedAreaSolid"):
swept = item.SweptArea
if swept.is_a("IfcRectangleProfileDef"):
width_m, depth_m = swept.XDim * scale, swept.YDim * scale
dim_source = "geometry"
except Exception:
pass
# Path 2: Property sets
if width_m is None:
val = _get_pset_value(beam, "Width", "CrossSectionWidth", "b")
if val is not None:
width_m = float(val) * scale
dim_source = "property set"
if depth_m is None:
val = _get_pset_value(beam, "Depth", "Height", "CrossSectionHeight", "h")
if val is not None:
depth_m = float(val) * scale
dim_source = dim_source or "property set"
results.append({
"id": beam.GlobalId,
"name": beam.Name or f"{beam.is_a()} #{beam.id()}",
"ifc_type": beam.is_a(),
"storey_name": storey_name,
"width_mm": round(width_m * 1000, 1) if width_m is not None else None,
"depth_mm": round(depth_m * 1000, 1) if depth_m is not None else None,
"dim_source": dim_source,
})
return results, None
# ── Check Functions (IFCore Contract) ────────────────────────────────────────
def check_foundation_slab_thickness(model: ifcopenshell.file) -> list:
"""
Art. 69 — Foundation slab minimum thickness (ground floor only).
Scope: IfcFooting + IfcSlab[BASESLAB] at the lowest storey only.
Floor finishes (IfcSlab[FLOOR]) and upper-floor slabs are excluded.
Required: 150 mm waterproof concrete + 150 mm drainage layer = 300 mm total.
"""
scale = _get_length_scale(model)
results = []
# ── Determine ground-level elevation cut-off ──────────────────────────────
storeys = model.by_type("IfcBuildingStorey")
ground_elev_cutoff = None
if storeys:
def _selev(s):
try:
c = s.ObjectPlacement.RelativePlacement.Location.Coordinates
return c[2] * scale if len(c) > 2 else 0.0
except Exception:
return 0.0
ground_elev = _selev(min(storeys, key=_selev))
ground_elev_cutoff = ground_elev + 1.0 # 1 m tolerance above lowest storey
def _is_at_ground(elem):
"""True when element is at or just above the lowest storey level."""
if ground_elev_cutoff is None:
return True # no storey info — include all
elev = _get_element_elevation(elem, scale)
return elev is None or elev <= ground_elev_cutoff
# ── Candidates: IfcFooting (always foundation elements — no elevation filter needed)
# + IfcSlab[BASESLAB or FLOOR-on-grade] at ground level only ──────────
candidates = list(model.by_type("IfcFooting")) # IfcFooting is always a foundation type
for slab in model.by_type("IfcSlab"):
ptype = getattr(slab, "PredefinedType", None)
name_lower = (getattr(slab, "Name", "") or "").lower()
is_base = ptype == "BASESLAB"
# Include FLOOR-type slabs that are explicitly described as ground/on-grade slabs
is_on_grade = ptype == "FLOOR" and (
"on grade" in name_lower or "slab on grade" in name_lower
)
if (is_base or is_on_grade) and _is_at_ground(slab):
candidates.append(slab)
if not candidates:
return [{
"element_id": None,
"element_type": "IfcFooting / IfcSlab",
"element_name": "No foundation elements found",
"element_name_long": "Art. 69 — No IfcFooting or IfcSlab[BASESLAB/on-grade] in model",
"check_status": "blocked",
"actual_value": None,
"required_value": f"{MIN_SLAB_THICKNESS_MM} mm",
"comment": "Model contains no IfcFooting or on-grade slab elements to check",
"log": None,
}]
for elem in candidates:
name = elem.Name or f"{elem.is_a()} #{elem.id()}"
dims = _get_footing_dimensions(elem, scale)
thickness_m = dims["thickness_m"]
thickness_mm = round(thickness_m * 1000, 1) if thickness_m is not None else None
if thickness_mm is None:
status = "blocked"
comment = "Thickness not found in quantity sets, geometry, or element name"
elif thickness_mm >= MIN_SLAB_THICKNESS_MM:
status = "pass"
comment = f"Art. 69 satisfied: {thickness_mm} mm ≥ {MIN_SLAB_THICKNESS_MM} mm"
else:
deficit = MIN_SLAB_THICKNESS_MM - thickness_mm
status = "fail"
comment = (f"Art. 69: {deficit:.0f} mm below minimum "
f"(requires 150 mm concrete + 150 mm drainage layer)")
results.append({
"element_id": elem.GlobalId,
"element_type": elem.is_a(),
"element_name": name,
"element_name_long": f"{name} — Art. 69 Foundation Slab Thickness",
"check_status": status,
"actual_value": f"{thickness_mm} mm" if thickness_mm is not None else None,
"required_value": f"{MIN_SLAB_THICKNESS_MM} mm",
"comment": comment,
"log": None,
})
return results
def check_foundation_dimensions(model: ifcopenshell.file) -> list:
"""
Load Check — Foundation footing dimensions vs calculated required area.
Required area = (n_floors × floor_load × provided_area) / bearing_capacity.
Bearing capacity from IFC property sets; defaults to 150 kN/m².
"""
scale = _get_length_scale(model)
n_floors = max(len(model.by_type("IfcBuildingStorey")), 1)
# Floor load from IfcSpace properties or default
floor_load = DEFAULT_FLOOR_LOAD_KN_M2
spaces = model.by_type("IfcSpace")
if spaces:
val = _get_pset_value(spaces[0], "DesignLoad", "FloorLoad", "LoadBearingCapacity")
if val is not None:
try:
floor_load = float(val)
except (TypeError, ValueError):
pass
footings = list(model.by_type("IfcFooting"))
if not footings:
return [{
"element_id": None,
"element_type": "IfcFooting",
"element_name": "No footings found",
"element_name_long": "Load Check — No IfcFooting elements in model",
"check_status": "blocked",
"actual_value": None,
"required_value": None,
"comment": "Model contains no IfcFooting elements",
"log": None,
}]
results = []
for footing in footings:
name = footing.Name or f"Footing #{footing.id()}"
# Bearing capacity fallback chain
bearing_val = _get_pset_value(
footing,
"BearingCapacity", "AllowableBearingCapacity",
"WorkingStress", "FatiguesDeTraball", "SoilBearingCapacity",
)
used_default = bearing_val is None
bearing = float(bearing_val) if not used_default else DEFAULT_BEARING_CAPACITY_KN_M2
if bearing <= 0:
bearing = DEFAULT_BEARING_CAPACITY_KN_M2
used_default = True
dims = _get_footing_dimensions(footing, scale)
L, W = dims["length_m"], dims["width_m"]
if L is None or W is None:
results.append({
"element_id": footing.GlobalId,
"element_type": "IfcFooting",
"element_name": name,
"element_name_long": f"{name} — Foundation Dimensions / Load Check",
"check_status": "blocked",
"actual_value": None,
"required_value": None,
"comment": "Footing L/W not found in geometry or quantity sets",
"log": f"dims={dims}",
})
continue
provided_area = round(L * W, 4)
total_load = n_floors * floor_load * provided_area # kN
required_area = round(total_load / bearing, 4) # m²
if provided_area >= required_area:
status = "pass"
comment = (f"Provided {provided_area:.2f} m² ≥ required {required_area:.2f} m² "
f"({n_floors} floors × {floor_load} kN/m² / {bearing} kN/m²)")
else:
deficit = round(required_area - provided_area, 3)
status = "fail"
comment = (f"Deficit {deficit:.3f} m²: required {required_area:.2f} m² > "
f"provided {provided_area:.2f} m² "
f"({n_floors} floors, q={floor_load} kN/m², σ={bearing} kN/m²)"
+ (" [σ default]" if used_default else ""))
results.append({
"element_id": footing.GlobalId,
"element_type": "IfcFooting",
"element_name": name,
"element_name_long": f"{name} — Foundation Dimensions / Load Check",
"check_status": status,
"actual_value": f"{provided_area:.2f} m² ({L:.2f} × {W:.2f} m)",
"required_value": f"{required_area:.2f} m²",
"comment": comment,
"log": (f"L={L:.3f}m W={W:.3f}m bearing={bearing}kN/m² "
f"q={floor_load}kN/m² n={n_floors} "
f"{'[default bearing]' if used_default else ''}"),
})
return results
def check_bearing_beam_section(model: ifcopenshell.file) -> list:
"""
DB SE-AE — Foundation bearing beam minimum cross-section.
Checks beams at the lowest storey only.
Required: width ≥ 300 mm AND depth ≥ 300 mm.
"""
scale = _get_length_scale(model)
beams, blocked_reason = _get_bearing_beams(model, scale)
if not beams:
return [{
"element_id": None,
"element_type": "IfcBeam / IfcMember",
"element_name": "Bearing beams not found",
"element_name_long": "DB SE-AE — Bearing beam check blocked",
"check_status": "blocked",
"actual_value": None,
"required_value": f"{MIN_BEAM_WIDTH_MM}×{MIN_BEAM_DEPTH_MM} mm",
"comment": blocked_reason,
"log": None,
}]
results = []
for beam in beams:
w = beam["width_mm"]
d = beam["depth_mm"]
name = beam["name"]
src = beam.get("dim_source")
if w is None or d is None:
# Beam was found but its cross-section geometry is missing from IFC
missing = []
if w is None:
missing.append("width")
if d is None:
missing.append("depth")
status = "blocked"
actual = (f"w={w:.0f} mm" if w is not None else "w=N/A") + \
" / " + (f"d={d:.0f} mm" if d is not None else "d=N/A")
comment = (
f"Cross-section {' and '.join(missing)} not found. "
f"Searched: IfcExtrudedAreaSolid → IfcRectangleProfileDef (geometry), "
f"and property sets (Width/Depth/Height keys). "
f"Ensure the beam has a rectangular structural profile assigned in your BIM tool."
)
else:
w_ok = w >= MIN_BEAM_WIDTH_MM
d_ok = d >= MIN_BEAM_DEPTH_MM
actual = f"{w:.0f}×{d:.0f} mm"
if w_ok and d_ok:
status = "pass"
comment = (f"DB SE-AE satisfied: {w:.0f}×{d:.0f} mm ≥ "
f"{MIN_BEAM_WIDTH_MM}×{MIN_BEAM_DEPTH_MM} mm"
+ (f" (from {src})" if src else ""))
else:
parts = []
if not w_ok:
parts.append(f"width {w:.0f} mm < min {MIN_BEAM_WIDTH_MM} mm")
if not d_ok:
parts.append(f"depth {d:.0f} mm < min {MIN_BEAM_DEPTH_MM} mm")
status = "fail"
comment = "DB SE-AE violation: " + "; ".join(parts)
results.append({
"element_id": beam["id"],
"element_type": beam["ifc_type"],
"element_name": name,
"element_name_long": f"{name} @ {beam['storey_name']} — DB SE-AE Bearing Beam",
"check_status": status,
"actual_value": actual if (w is not None or d is not None) else None,
"required_value": f"{MIN_BEAM_WIDTH_MM}×{MIN_BEAM_DEPTH_MM} mm",
"comment": comment,
"log": f"dim_source={src}",
})
return results
def check_floor_capacity(model: ifcopenshell.file) -> list:
"""
Art. 128 — Maximum floors the foundation can bear.
max_floors = floor(bearing_capacity / floor_load_per_m2)
addable_floors = max_floors - existing_floors
"""
n_existing = max(len(model.by_type("IfcBuildingStorey")), 1)
floor_load = DEFAULT_FLOOR_LOAD_KN_M2
spaces = model.by_type("IfcSpace")
if spaces:
val = _get_pset_value(spaces[0], "DesignLoad", "FloorLoad", "LoadBearingCapacity")
if val is not None:
try:
floor_load = float(val)
except (TypeError, ValueError):
pass
footings = list(model.by_type("IfcFooting"))
if not footings:
return [{
"element_id": None,
"element_type": "IfcFooting",
"element_name": "No footings found",
"element_name_long": "Art. 128 — No IfcFooting elements in model",
"check_status": "blocked",
"actual_value": None,
"required_value": None,
"comment": "Cannot compute floor capacity without IfcFooting elements",
"log": None,
}]
if floor_load <= 0:
floor_load = DEFAULT_FLOOR_LOAD_KN_M2
results = []
for footing in footings:
name = footing.Name or f"Footing #{footing.id()}"
bearing_val = _get_pset_value(
footing,
"BearingCapacity", "AllowableBearingCapacity",
"WorkingStress", "FatiguesDeTraball", "SoilBearingCapacity",
)
used_default = bearing_val is None
bearing = float(bearing_val) if not used_default else DEFAULT_BEARING_CAPACITY_KN_M2
if bearing <= 0:
bearing = DEFAULT_BEARING_CAPACITY_KN_M2
used_default = True
max_floors = int(bearing / floor_load)
addable = max_floors - n_existing
if addable > 0:
status = "pass"
comment = (f"Art. 128: {addable} floor(s) can be added. "
f"Current: {n_existing}, capacity: {max_floors}")
elif addable == 0:
status = "warning"
comment = (f"Art. 128: Foundation is at capacity — no additional floors possible. "
f"Current: {n_existing} = max: {max_floors}")
else:
status = "fail"
comment = (f"Art. 128: Existing {n_existing} floors exceeds capacity of "
f"{max_floors} floors by {abs(addable)}. Underpinning required.")
default_note = " [bearing: default 150 kN/m²]" if used_default else ""
results.append({
"element_id": footing.GlobalId,
"element_type": "IfcFooting",
"element_name": name,
"element_name_long": f"{name} — Art. 128 Floor Capacity",
"check_status": status,
"actual_value": f"{n_existing} existing floors",
"required_value": (f"max {max_floors} floors "
f"(σ={bearing} kN/m², q={floor_load} kN/m²){default_note}"),
"comment": comment,
"log": (f"bearing={bearing}kN/m² q={floor_load}kN/m² "
f"max={max_floors} existing={n_existing} "
f"addable={addable}"),
})
return results
if __name__ == "__main__":
import sys
ifc_path = sys.argv[1] if len(sys.argv) > 1 else "data/01_Duplex_Apartment.ifc"
print("Loading:", ifc_path)
_model = ifcopenshell.open(ifc_path)
print("Footings:", len(list(_model.by_type("IfcFooting"))),
" Slabs:", len(list(_model.by_type("IfcSlab"))),
" Storeys:", len(list(_model.by_type("IfcBuildingStorey"))))
_ICON = {"pass": "PASS", "fail": "FAIL", "warning": "WARN", "blocked": "BLKD", "log": "LOG "}
for _fn in [check_foundation_slab_thickness, check_foundation_dimensions,
check_bearing_beam_section, check_floor_capacity]:
print("\n" + "=" * 60)
print(" ", _fn.__name__)
print("=" * 60)
for _row in _fn(_model):
print(" ", "[" + _ICON.get(_row["check_status"], "?") + "]", _row["element_name"])
if _row["actual_value"]:
print(" actual :", _row["actual_value"])
if _row["required_value"]:
print(" required :", _row["required_value"])
print(" comment :", _row["comment"])