IFCore Deploy
deploy(prod): 2026-02-21T01:10:43Z
51982d6
"""Standalone IFC wall compliance checker (single-file version)."""
import argparse
import re
import unicodedata
import ifcopenshell
import ifcopenshell.util.element as element
try:
import ifcopenshell.util.pset as pset
except ImportError:
pset = None
QSET_CANDIDATES = ["Qto_WallBaseQuantities", "BaseQuantities", "Dimensions"]
CLIMATE_ZONE_U_LIMITS = {
"A": 0.80,
"B": 0.65,
"C": 0.57,
"D": 0.49,
"E": 0.37,
}
SERVICE_SPACE_KEYWORDS = {
"kitchen",
"cocina",
"bath",
"bathroom",
"toilet",
"wc",
"lavatory",
"restroom",
"aseo",
"bano",
"corridor",
"hallway",
"circulation",
"pasillo",
"pasadizo",
"distribuidor",
}
GENERAL_SPACE_KEYWORDS = {
"living",
"salon",
"sala",
"comedor",
"dining",
"bedroom",
"dormitorio",
"habitacion",
"room",
"office",
"study",
"classroom",
}
# ----------------------------
# Extraction helpers
# ----------------------------
def safe_float(value):
try:
return float(value) if value is not None else None
except:
return None
def get_wall_type(wall):
for rel in getattr(wall, "IsDefinedBy", []) or []:
if rel.is_a("IfcRelDefinesByType"):
return rel.RelatingType
return None
def get_container_storey(wall):
try:
container = element.get_container(wall)
if container and container.is_a("IfcBuildingStorey"):
return {
"StoreyName": getattr(container, "Name", None),
"StoreyGlobalId": getattr(container, "GlobalId", None),
"StoreyElevation": getattr(container, "Elevation", None),
}
except:
pass
return {"StoreyName": None, "StoreyGlobalId": None, "StoreyElevation": None}
def get_psets(obj):
if not obj:
return {}
if hasattr(element, "get_psets"):
try:
return element.get_psets(obj, psets_only=True) or {}
except TypeError:
all_sets = element.get_psets(obj) or {}
return {name: values for name, values in all_sets.items() if not str(name).startswith("Qto_")}
if pset and hasattr(pset, "get_psets"):
return pset.get_psets(obj) or {}
return {}
def get_qtos(obj):
if not obj:
return {}
if hasattr(element, "get_psets"):
try:
return element.get_psets(obj, qtos_only=True) or {}
except TypeError:
all_sets = element.get_psets(obj) or {}
return {
name: values
for name, values in all_sets.items()
if str(name).startswith("Qto_") or str(name) in QSET_CANDIDATES
}
if pset and hasattr(pset, "get_quantities"):
return pset.get_quantities(obj) or {}
return {}
def get_quantities(wall):
qtos = get_qtos(wall)
for name in QSET_CANDIDATES:
if name in qtos:
return name, qtos[name]
return None, {}
def _space_usage_hints(space):
hints = []
for attr in ("Name", "LongName", "ObjectType", "Description"):
value = getattr(space, attr, None)
if isinstance(value, str) and value.strip():
hints.append(value.strip())
psets = get_psets(space)
for pset_name, props in (psets or {}).items():
if not isinstance(props, dict):
continue
for key in ("Reference", "Category", "OccupancyType", "RoomTag", "Function", "Usage"):
value = props.get(key)
if isinstance(value, str) and value.strip():
hints.append(value.strip())
if isinstance(pset_name, str) and pset_name.strip():
hints.append(pset_name.strip())
return list(dict.fromkeys(hints))
def get_space_boundary_data(model):
data = {}
rel_names = [
"IfcRelSpaceBoundary",
"IfcRelSpaceBoundary1stLevel",
"IfcRelSpaceBoundary2ndLevel",
]
for rel_name in rel_names:
try:
rels = model.by_type(rel_name) or []
except:
rels = []
for rel in rels:
wall = getattr(rel, "RelatedBuildingElement", None)
if not wall:
continue
wid = wall.id() if hasattr(wall, "id") else id(wall)
wall_data = data.setdefault(
wid,
{
"count": 0,
"space_names": [],
"space_ids": [],
"space_hints": [],
},
)
wall_data["count"] += 1
space = getattr(rel, "RelatingSpace", None)
if space:
sid = getattr(space, "GlobalId", None)
sname = getattr(space, "Name", None)
if sid and sid not in wall_data["space_ids"]:
wall_data["space_ids"].append(sid)
if sname and sname not in wall_data["space_names"]:
wall_data["space_names"].append(sname)
for hint in _space_usage_hints(space):
if hint not in wall_data["space_hints"]:
wall_data["space_hints"].append(hint)
return data
def extract_material_info(obj):
out = {
"MaterialName": None,
"LayerNames": [],
"LayerThicknesses": [],
"TotalLayerThickness": None,
}
try:
for rel in getattr(obj, "HasAssociations", []) or []:
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
mat = rel.RelatingMaterial
if hasattr(mat, "Name") and mat.Name:
out["MaterialName"] = mat.Name
return out
if mat and mat.is_a("IfcMaterialLayerSetUsage"):
ls = mat.ForLayerSet
if ls and hasattr(ls, "MaterialLayers"):
total = 0.0
for layer in ls.MaterialLayers or []:
name = getattr(getattr(layer, "Material", None), "Name", None)
th = safe_float(getattr(layer, "LayerThickness", None))
if name:
out["LayerNames"].append(name)
if th is not None:
out["LayerThicknesses"].append(th)
total += th
out["TotalLayerThickness"] = total if out["LayerThicknesses"] else None
out["MaterialName"] = ", ".join(out["LayerNames"]) if out["LayerNames"] else None
return out
if mat and mat.is_a("IfcMaterialLayerSet"):
total = 0.0
for layer in mat.MaterialLayers or []:
name = getattr(getattr(layer, "Material", None), "Name", None)
th = safe_float(getattr(layer, "LayerThickness", None))
if name:
out["LayerNames"].append(name)
if th is not None:
out["LayerThicknesses"].append(th)
total += th
out["TotalLayerThickness"] = total if out["LayerThicknesses"] else None
out["MaterialName"] = ", ".join(out["LayerNames"]) if out["LayerNames"] else None
return out
except:
pass
return out
def pick_thickness_mm(qset_name, q, psets_inst, psets_type, mat_inst, mat_type):
if q:
for key in ["Width", "Thickness"]:
v = safe_float(q.get(key))
if v is not None:
return v, f"QTO:{qset_name}.{key}"
for pset_name in ["Pset_WallCommon", "Construction", "Dimensions"]:
ps = psets_inst.get(pset_name) or {}
for key in ["Width", "Thickness"]:
v = safe_float(ps.get(key))
if v is not None:
return v, f"PSET:{pset_name}.{key}"
for pset_name in ["Pset_WallCommon", "Construction", "Dimensions"]:
ps = psets_type.get(pset_name) or {}
for key in ["Width", "Thickness"]:
v = safe_float(ps.get(key))
if v is not None:
return v, f"TYPE_PSET:{pset_name}.{key}"
if mat_inst.get("TotalLayerThickness") is not None:
return mat_inst["TotalLayerThickness"], "MAT:Instance LayerThickness sum"
if mat_type.get("TotalLayerThickness") is not None:
return mat_type["TotalLayerThickness"], "MAT:Type LayerThickness sum"
return None, "NOT_FOUND"
def extract_walls(model):
walls = []
seen_ids = set()
for w in model.by_type("IfcWall") + model.by_type("IfcWallStandardCase"):
wid = w.id() if hasattr(w, "id") else id(w)
if wid in seen_ids:
continue
seen_ids.add(wid)
walls.append(w)
out = []
space_boundary_data = get_space_boundary_data(model)
for w in walls:
wtype = get_wall_type(w)
wall_id = w.id() if hasattr(w, "id") else id(w)
sb_data = space_boundary_data.get(wall_id, {})
sb_count = int(sb_data.get("count", 0))
sb_space_names = list(sb_data.get("space_names", []))
sb_space_ids = list(sb_data.get("space_ids", []))
sb_space_hints = list(sb_data.get("space_hints", []))
qset_name, q = get_quantities(w)
psets_inst = get_psets(w)
psets_type = get_psets(wtype) if wtype else {}
mat_inst = extract_material_info(w)
mat_type = (
extract_material_info(wtype)
if wtype
else {"MaterialName": None, "LayerNames": [], "LayerThicknesses": [], "TotalLayerThickness": None}
)
thickness_mm, thickness_src = pick_thickness_mm(qset_name, q, psets_inst, psets_type, mat_inst, mat_type)
wc = psets_inst.get("Pset_WallCommon") or {}
storey = get_container_storey(w)
row = {
"GlobalId": getattr(w, "GlobalId", None),
"Name": getattr(w, "Name", None) or "Unnamed Wall",
"IfcType": w.is_a(),
"ObjectType": getattr(w, "ObjectType", None),
"Tag": getattr(w, "Tag", None),
"WallTypeName": getattr(wtype, "Name", None) if wtype else None,
"WallTypeId": getattr(wtype, "GlobalId", None) if wtype and hasattr(wtype, "GlobalId") else None,
**storey,
"QSet": qset_name,
"Length_mm": safe_float(q.get("Length")) if q else None,
"Height_mm": safe_float(q.get("Height")) if q else None,
"Area_m2": safe_float((q.get("GrossArea") or q.get("NetArea"))) if q else None,
"Volume_m3": safe_float((q.get("GrossVolume") or q.get("NetVolume"))) if q else None,
"Thickness_mm": thickness_mm,
"ThicknessSource": thickness_src,
"IsExternal": wc.get("IsExternal"),
"LoadBearing": wc.get("LoadBearing"),
"FireRating": wc.get("FireRating"),
"ThermalTransmittance": wc.get("ThermalTransmittance"),
"Material_Instance": mat_inst.get("MaterialName"),
"Material_Type": mat_type.get("MaterialName"),
"Layers_Instance": mat_inst.get("LayerNames"),
"LayerThicknesses_Instance": mat_inst.get("LayerThicknesses"),
"TotalLayerThickness_Instance": mat_inst.get("TotalLayerThickness"),
"Layers_Type": mat_type.get("LayerNames"),
"LayerThicknesses_Type": mat_type.get("LayerThicknesses"),
"TotalLayerThickness_Type": mat_type.get("TotalLayerThickness"),
"Psets_Instance": psets_inst,
"Psets_Type": psets_type,
"Qtos_All": get_qtos(w),
"SpaceBoundaryCount": sb_count,
"HasSpaceBoundary": sb_count > 0,
"AdjacentSpaceNames": sb_space_names,
"AdjacentSpaceIds": sb_space_ids,
"AdjacentSpaceHints": sb_space_hints,
}
out.append(row)
return out
# ----------------------------
# Rules
# ----------------------------
def _coerce_float(value):
try:
return float(value) if value is not None else None
except:
return None
def _is_true(value):
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"true", "t", "1", "yes"}
return False
def _wall_label(w):
gid = w.get("GlobalId", "NO_ID")
name = w.get("Name", "Unnamed")
return gid, name
def _normalize_text(value):
if not isinstance(value, str):
return ""
text = unicodedata.normalize("NFKD", value)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = text.lower()
text = re.sub(r"[^a-z0-9]+", " ", text)
return " ".join(text.split())
def _space_text_bucket(wall):
texts = []
for key in ("AdjacentSpaceNames", "AdjacentSpaceHints"):
values = wall.get(key) or []
for value in values:
norm = _normalize_text(value)
if norm:
texts.append(norm)
return list(dict.fromkeys(texts))
def _contains_keyword(text, keyword):
return re.search(rf"\b{re.escape(keyword)}\b", text) is not None
def _classify_wall_space_context(wall):
texts = _space_text_bucket(wall)
has_service = False
has_general = False
for text in texts:
if any(_contains_keyword(text, kw) for kw in SERVICE_SPACE_KEYWORDS):
has_service = True
if any(_contains_keyword(text, kw) for kw in GENERAL_SPACE_KEYWORDS):
has_general = True
return {
"has_space_links": bool(wall.get("HasSpaceBoundary")),
"has_service": has_service,
"has_general": has_general,
}
def rule_min_thickness(walls, min_mm=100):
out = []
for w in walls:
t = _coerce_float(w.get("Thickness_mm"))
gid, name = _wall_label(w)
if t is None:
out.append(f"[???] IfcWall {gid} {name}: thickness unknown (need Thickness_mm)")
elif t < float(min_mm):
out.append(f"[FAIL] IfcWall {gid} {name}: thickness={t:.1f}mm < {float(min_mm):.1f}mm")
else:
out.append(f"[PASS] IfcWall {gid} {name}: thickness={t:.1f}mm >= {float(min_mm):.1f}mm")
return out
def rule_min_height(walls, min_height_mm=2500):
out = []
for w in walls:
h = _coerce_float(w.get("Height_mm"))
gid, name = _wall_label(w)
if h is None:
out.append(f"[???] IfcWall {gid} {name}: height unknown (need Height_mm)")
elif h < float(min_height_mm):
out.append(f"[FAIL] IfcWall {gid} {name}: height={h:.1f}mm < {float(min_height_mm):.1f}mm")
else:
out.append(f"[PASS] IfcWall {gid} {name}: height={h:.1f}mm >= {float(min_height_mm):.1f}mm")
return out
def rule_min_height_by_space_use(walls, min_general_mm=2500, min_service_mm=2200):
out = []
for w in walls:
h = _coerce_float(w.get("Height_mm"))
gid, name = _wall_label(w)
ctx = _classify_wall_space_context(w)
if h is None:
out.append(f"[???] IfcWall {gid} {name}: height unknown (need Height_mm)")
continue
if ctx["has_general"]:
threshold = float(min_general_mm)
reason = "general-space context"
elif ctx["has_service"]:
threshold = float(min_service_mm)
reason = "service-space context (kitchen/bath/corridor)"
else:
if not ctx["has_space_links"]:
if h >= float(min_general_mm):
out.append(
f"[PASS] IfcWall {gid} {name}: height={h:.1f}mm >= {float(min_general_mm):.1f}mm "
"(no IfcSpace link; used general limit)"
)
elif h >= float(min_service_mm):
out.append(
f"[???] IfcWall {gid} {name}: height={h:.1f}mm between {float(min_service_mm):.1f}mm "
f"and {float(min_general_mm):.1f}mm (no IfcSpace link to infer room type)"
)
else:
out.append(
f"[FAIL] IfcWall {gid} {name}: height={h:.1f}mm < {float(min_service_mm):.1f}mm "
"(below minimum even for service spaces)"
)
continue
threshold = float(min_general_mm)
reason = "space linked but room type unclear"
if h < threshold:
out.append(f"[FAIL] IfcWall {gid} {name}: height={h:.1f}mm < {threshold:.1f}mm ({reason})")
else:
out.append(f"[PASS] IfcWall {gid} {name}: height={h:.1f}mm >= {threshold:.1f}mm ({reason})")
return out
def rule_max_uvalue(walls, max_u=0.80):
out = []
for w in walls:
u = _coerce_float(w.get("ThermalTransmittance"))
gid, name = _wall_label(w)
if u is None:
out.append(f"[???] IfcWall {gid} {name}: U-value unknown")
elif u > float(max_u):
out.append(f"[FAIL] IfcWall {gid} {name}: U={u:.3f} > {float(max_u):.3f}")
else:
out.append(f"[PASS] IfcWall {gid} {name}: U={u:.3f} <= {float(max_u):.3f}")
return out
def rule_external_uvalue_by_climate_zone(walls, climate_zone="A"):
out = []
zone = str(climate_zone).strip().upper()
if zone not in CLIMATE_ZONE_U_LIMITS:
return [f"[???] Invalid climate zone '{climate_zone}'. Expected one of: A, B, C, D, E."]
limit = CLIMATE_ZONE_U_LIMITS[zone]
for w in walls:
gid, name = _wall_label(w)
ext = _is_true(w.get("IsExternal"))
u = _coerce_float(w.get("ThermalTransmittance"))
if not ext:
out.append(f"[PASS] IfcWall {gid} {name}: climate U-limit not applicable (IsExternal=False)")
continue
if u is None:
out.append(f"[???] IfcWall {gid} {name}: external wall with unknown U-value for climate zone {zone}")
continue
if u > limit:
out.append(f"[FAIL] IfcWall {gid} {name}: U={u:.3f} > U_lim({zone})={limit:.3f}")
else:
out.append(f"[PASS] IfcWall {gid} {name}: U={u:.3f} <= U_lim({zone})={limit:.3f}")
return out
def rule_external_walls_must_have_uvalue(walls):
out = []
for w in walls:
ext = _is_true(w.get("IsExternal"))
u = _coerce_float(w.get("ThermalTransmittance"))
gid, name = _wall_label(w)
if ext and u is None:
out.append(f"[FAIL] IfcWall {gid} {name}: IsExternal=True but U-value missing")
else:
out.append(f"[PASS] IfcWall {gid} {name}: external/U-value OK")
return out
def rule_loadbearing_requires_fire_rating(walls):
out = []
for w in walls:
lb = _is_true(w.get("LoadBearing"))
fr = w.get("FireRating")
gid, name = _wall_label(w)
if not lb:
out.append(f"[PASS] IfcWall {gid} {name}: fire rating check not applicable (LoadBearing=False)")
continue
if fr in (None, "", "Unknown"):
out.append(f"[FAIL] IfcWall {gid} {name}: LoadBearing=True but FireRating missing")
else:
out.append(f"[PASS] IfcWall {gid} {name}: LoadBearing=True with FireRating={fr}")
return out
def rule_space_boundary_linkage(walls):
out = []
for w in walls:
has_boundary = bool(w.get("HasSpaceBoundary"))
count = int(w.get("SpaceBoundaryCount") or 0)
gid, name = _wall_label(w)
if has_boundary:
out.append(f"[PASS] IfcWall {gid} {name}: linked to spaces (IfcRelSpaceBoundary count={count})")
else:
out.append(f"[FAIL] IfcWall {gid} {name}: no IfcRelSpaceBoundary link")
return out
# ----------------------------
# Report + orchestration
# ----------------------------
def make_report(walls, rule_results):
lines = [f"Total walls: {len(walls)}", ""]
lines.extend(rule_results)
return lines
def summarize(lines):
p_count = sum(1 for s in lines if s.startswith("[PASS]"))
f_count = sum(1 for s in lines if s.startswith("[FAIL]"))
u_count = sum(1 for s in lines if s.startswith("[???]"))
return [f"[PASS] count={p_count}", f"[FAIL] count={f_count}", f"[???] count={u_count}"]
def _collect_rule_results(
walls,
min_mm=100,
max_u=0.80,
min_height_mm=2500,
min_service_height_mm=2200,
climate_zone=None,
use_space_aware_height=True,
):
results = []
results += rule_min_thickness(walls, min_mm=min_mm)
if use_space_aware_height:
results += rule_min_height_by_space_use(
walls,
min_general_mm=min_height_mm,
min_service_mm=min_service_height_mm,
)
else:
results += rule_min_height(walls, min_height_mm=min_height_mm)
if climate_zone:
results += rule_external_uvalue_by_climate_zone(walls, climate_zone=climate_zone)
else:
results += rule_max_uvalue(walls, max_u=max_u)
results += rule_external_walls_must_have_uvalue(walls)
results += rule_loadbearing_requires_fire_rating(walls)
results += rule_space_boundary_linkage(walls)
return results
def run_wall_checks(
ifc_path,
min_mm=100,
max_u=0.80,
include_summary=True,
min_height_mm=2500,
min_service_height_mm=2200,
climate_zone=None,
use_space_aware_height=True,
):
model = ifcopenshell.open(ifc_path)
walls = extract_walls(model)
results = _collect_rule_results(
walls,
min_mm=min_mm,
max_u=max_u,
min_height_mm=min_height_mm,
min_service_height_mm=min_service_height_mm,
climate_zone=climate_zone,
use_space_aware_height=use_space_aware_height,
)
lines = make_report(walls, results)
if include_summary:
lines.append("")
lines.extend(summarize(results))
return lines
def run(
ifc_path,
min_mm=100,
max_u=0.80,
include_summary=True,
min_height_mm=2500,
min_service_height_mm=2200,
climate_zone=None,
use_space_aware_height=True,
):
return run_wall_checks(
ifc_path,
min_mm=min_mm,
max_u=max_u,
include_summary=include_summary,
min_height_mm=min_height_mm,
min_service_height_mm=min_service_height_mm,
climate_zone=climate_zone,
use_space_aware_height=use_space_aware_height,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run IFC wall compliance checks (single-file tool).")
parser.add_argument("ifc_path", help="Path to IFC file")
parser.add_argument("--min-mm", type=float, default=100, help="Minimum wall thickness in mm")
parser.add_argument("--max-u", type=float, default=0.80, help="Maximum wall U-value (W/m2K) for custom mode")
parser.add_argument("--min-height-mm", type=float, default=2500, help="General minimum wall height in mm")
parser.add_argument(
"--min-service-height-mm",
type=float,
default=2200,
help="Minimum wall height in service spaces (kitchen/bath/corridor) in mm",
)
parser.add_argument(
"--climate-zone",
default=None,
help="Optional CTE climate zone for external walls (A, B, C, D, E). If set, overrides --max-u.",
)
parser.add_argument(
"--disable-space-aware-height",
action="store_true",
help="Disable room-type-aware height logic and use only --min-height-mm.",
)
parser.add_argument("--no-summary", action="store_true", help="Disable summary counts at end of report")
args = parser.parse_args()
for line in run_wall_checks(
args.ifc_path,
min_mm=args.min_mm,
max_u=args.max_u,
include_summary=not args.no_summary,
min_height_mm=args.min_height_mm,
min_service_height_mm=args.min_service_height_mm,
climate_zone=args.climate_zone,
use_space_aware_height=not args.disable_space_aware_height,
):
print(line)