Heizlast Quality Check
Purpose
Real-world IFC models have sharp edges — models with "OKFF" vs. "OKRD" storey duplication, thin shaft walls sitting inside room AABBs, and openings tied to the wrong storey. These don't always make the extractor crash; they silently skew the heating load. The quality check runs a battery of structural tests over the heizlast envelope and surfaces anything suspicious so the user can decide whether it's a modelling quirk to accept or a bug to chase.

Where it lives
| File | Purpose |
|---|---|
tools/_heizlast_quality.py |
Check definitions + the run_all_checks(ifc_path) entry point |
app.py :: /heizlast_quality/<filename> |
GET endpoint (200 if cache is fresh, 202 + heizlast job otherwise) |
templates/viewer.html (Heizlast view) |
"Qualitätscheck" button + expandable report panel |
tests/test_heizlast_quality_{unit,e2e,ui}.py |
10 unit + 5 e2e + 2 Playwright |
Checks
Each issue has a check_id, level (error / warn / info), title,
description, count, up to 10 samples, and a fix_hint.
| Check | Level | What it catches | Fix hint |
|---|---|---|---|
zero_wall_rooms |
error | Heated rooms with zero wall surfaces (balconies / terraces filtered by PredefinedType=EXTERNAL or name patterns) |
Room AABB doesn't touch any wall face — check placement and Z-offset |
self_adjacency |
error | Wall where adj_space_gid == owner_space_gid — a shaft/partition that slipped past the self-overlap filter in _compute_wall_sub_ranges |
Non-axis-aligned wall geometry; refine _face_bounding_segments |
slab_double_count |
error | Same (slab, room, surface_type) triple emitted twice | Shrink slab Z-tolerance or guard against floor+ceiling matches on the same thin slab |
missing_wall_openings |
error | Opening declared via FillsVoids but absent from the envelope |
Opening position falls outside wall sub-ranges — verify local-frame transform |
wall_area_divergence |
warn | ΣWallArea diverges >50% from Qto_SpaceBaseQuantities.GrossWallArea |
Relax Z-tolerance when rooms and walls sit on different storey layers (OKFF vs. OKRD) |
opening_storey_mismatch |
info | Door/window's own storey differs from its parent wall's storey | Data quirk; UI filters should inherit parent-wall storey for openings |
zero_volume_rooms |
error | IfcSpace with no volume + no surfaces | Re-export with geometry + Qto; or exclude via usage_type |
orphan_adjacencies |
warn | Wall's adj_space_gid points to a room not in the envelope |
Force recompute (?force=1); check space GlobalId stability |
Cache version bump
The extraction-cache format got a schema_version = 2 bump on
2026-04-23 (self-adjacency fix). Existing <stem>.heizlast.json /
<stem>.room_network.json files with schema_version = 1 are detected
as stale on the next load and regenerate automatically. User-entered
surface_overrides survive — they're keyed on stable surface_id
strings and the cache-load path preserves them across the version bump.
If a model's bundle was never regenerated after the fix, the quality check may surface a non-zero self_adjacency count on first open. Hitting "Neu berechnen" once clears it.
API shape
GET /heizlast_quality/<filename>?project=<name>
- 200 with:
json { "issues": [ { "check_id": "self_adjacency", "level": "error", "title": "...", "description": "...", "count": 0, "samples": [], "fix_hint": "..." }, … ], "meta": { "room_count": 5, "wall_surfaces": 34, "opening_surfaces": 2, "slab_surfaces": 10, "extraction_warnings": 0, "totals_by_level": {"error": 0, "warn": 0, "info": 0} } } - 202 +
{pending, job_id, operation: "heizlast"}when the heizlast cache is stale — the user polls the heizlast job, then re-issues the quality-check GET. - 404 if the IFC doesn't exist.
Running checks from Python
from tools._heizlast_quality import run_all_checks
report = run_all_checks("uploads/my-project/versions/v1/arch.ifc",
use_cache=True) # use_cache=False forces re-extract
for issue in report["issues"]:
if issue["count"]:
print(f"[{issue['level']}] {issue['check_id']} = {issue['count']}")
Extending
New check function signature:
def check_something(envelope: dict, model) -> QualityIssue:
...
Register it in run_all_checks. Return count = 0 + level = "info" for
the clean-bill case (so totals_by_level stays accurate).