Spaces:
Sleeping
Sleeping
| """Wall compliance rules producing [PASS]/[FAIL]/[???] result lines.""" | |
| import re | |
| import unicodedata | |
| 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", | |
| } | |
| 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 v in values: | |
| norm = _normalize_text(v) | |
| if norm: | |
| texts.append(norm) | |
| # Preserve order, remove duplicates. | |
| return list(dict.fromkeys(texts)) | |
| def _contains_keyword(text, keyword): | |
| # Word boundary matching to avoid partial matches. | |
| 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, | |
| "texts": texts, | |
| } | |
| 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: | |
| # No reliable room-type signal: apply conservative interpretation. | |
| 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 | |