""" 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"])