Spaces:
Running
Running
| """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) | |