IFCore Deploy
deploy(prod): 2026-02-21T01:10:43Z
51982d6
"""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