API Endpoints
Overview
All endpoints are served by Flask (app.py). The web UI and 3D viewer
communicate with the backend exclusively through these JSON APIs.
Cache-or-enqueue contract (heavy GETs)
Every endpoint below that touches IFC geometry (/spaces, /connection_graph,
/wall_zones, /roombook, /heizlast) follows a single contract:
- Cache fresh →
200with the result body (fast path, inline). - Cache missing or stale →
202with{"pending": true, "job_id": "...", "operation": "..."}. The client pollsGET /jobs/<job_id>untilstatus == "completed", then re-fetches the original URL — which is now a cache hit and returns200. ?force=1skips the cache check and always enqueues.
A single dispatcher drains queued jobs in FIFO order (one at a time), so a cold open never blocks the Flask request thread and never freezes the browser.
The viewer's fetchOrJob(url) helper in templates/viewer.html encapsulates
this flow; every frontend consumer uses it.
Endpoints
GET /
Main analysis page. Lists uploaded files and available tools.
POST /upload
Upload an IFC file.
- Body: multipart/form-data with field file
- Returns: {"filename": "test-1.ifc"}
DELETE /delete/<filename>
Remove an uploaded file.
- Returns: {"ok": true}
GET /uploads/<filename>
Serve raw IFC file (used by web-ifc in the browser).
POST /analyze
Run an analysis tool.
- Body: {"filename": "test-1.ifc", "tool": "element_summary"}
- Returns: tool result dict ({title, summary, columns, rows, ...})
GET /spaces/<filename>
Space bounding boxes + IFC world bbox for coordinate mapping. - Follows the cache-or-enqueue contract (200 cache hit, 202 + job otherwise). - Returns on 200:
{
"ifcWorldMin": [0.0, 0.0, -0.18],
"ifcWorldMax": [6.45, 10.25, 0.0],
"spaces": [
{"expressID": 46, "name": "1", "longName": "Raum 1", "globalId": "...",
"bbox": {"min": [0.3, 5.3, 0.0], "max": [6.3, 10.1, 2.438]}}
]
}
- Used by viewer for: space container rendering + coordinate mapping detection
GET /connection_graph/<filename>
Connection point graph for all building elements.
- Follows the cache-or-enqueue contract. Query params: force=1 (optional).
- Returns on 200: {nodes, edges, stats} (see 02_connection_graph.md)
- Cached: saves to <stem>.connection_graph.json.
GET /wall_zones/<filename>?mode=normal
DIN 18015-3 installation zones for wall and ceiling surfaces.
- Follows the cache-or-enqueue contract. Query params: mode=normal|false_ceiling|false_floor, force=1.
- Returns on 200: {walls, ceilings, mode, stats} (see 04_wall_zones.md)
- ceilings: per-IfcSpace ceiling zones (ZD-r, ZD-t, dead zones, or false ceiling fly zone)
- Cached: saves to <stem>.wall_zones_<mode>.json per mode; each mode is a
distinct job in the queue.
GET /storeys/<filename>
Building storeys with contained element express IDs.
- Query params: project=<name> (optional)
- Returns:
{
"storeys": [
{"expressID": 25, "name": "EG", "longName": "Erdgeschoss",
"elevation": 0.0,
"elementIDs": [260, 395, 453, 83, 180]}
]
}
elementIDsincludes: directly contained elements, space IDs decomposed from storey, and elements within those spaces- Sorted by elevation ascending
- Used by viewer for storey filter panel
POST /jobs
Manually start a background job (used by the MEP Routing button and the
Connection Graph / Wall Zones recalculate buttons). Most consumers never call
this directly — the heavy GETs above enqueue jobs themselves via the cache-or-
enqueue contract.
- Body: {"operation": "connection_graph|wall_zones|mep_routing|spaces|roombook|heizlast", "filename": "...", "project": "...", "mode": "..."}
- Returns: {"job_id": "uuid"}
- Deduplication: if a matching job (same operation + params) is already queued
or running, returns the existing job_id instead of enqueueing a duplicate.
GET /jobs
List all jobs. Optional filters: ?filename=&project=. Order: running, then
queued (FIFO by queue_position), then finished (newest first).
GET /jobs/<id>
Poll job status.
- Returns: {"status": "queued|running|completed|failed", "current_phase": "...",
"phases_completed": ["..."], "total_phases": 6, "elapsed_s": 12.5,
"runtime_s": 4.8, "queue_position": 2, "error": null}
- queue_position is null once the job leaves the queue; runtime_s counts
only actual execution time, while elapsed_s includes queue wait.
GET /jobs/<id>/result
Fetch completed job result data. For roombook and heizlast the response is
sourced from job.result so transient fields like dropped_override_ids are
preserved; for other operations it reads from the on-disk cache.
GET /has_cache/<cache_type>/<filename>
Check if cached computation results exist.
- cache_type: connection_graph, wall_zones, spaces, or roombook
- Returns: {"exists": true|false}
- Used by viewer on page load to show/hide action buttons
GET /project-info/<project>
Project metadata, file sizes, and cache flags in a single response. Used by the
Detail view to populate file sizes + cache dots without serialising a HEAD per
file and a /has_cache/ call per cache key (which otherwise queue behind the
large IFC download on the browser's connection pool).
- Returns:
{
"name": "jella-haase",
"files": [{"filename": "Sella-Hasse-Str3.ifc", "role": "architectural", "size": 48234567}],
"caches": {"spaces": true, "connection_graph": true, "wall_zones": true, "roombook": false}
}
GET /roombook/<filename>?project=<name>&force=1
Roombook for an IFC: IFC-extracted fields keyed by GlobalId plus persisted manual overrides, plus the field specs that drive the frontend renderer. - Follows the cache-or-enqueue contract. - Returns on 200:
{
"schema_version": 2,
"ifc_fingerprint": "1776577853_1257250",
"rooms_ifc": {"<gid>": {"name": "1", "long_name": "Raum 1", "purpose": "",
"storey": "OG1", "volume": 80.64}},
"overrides": {"<gid>": {"usage_type": "Wohnraum", "design_temp": 20.0}},
"field_specs": [
{"key": "name", "type": "text", "label": "Name"},
{"key": "usage_type", "type": "enum", "label": "Nutzung",
"options": ["Wohnraum", "Schlafraum", "Küche", "Bad", "WC", "Flur",
"Treppenhaus", "Büro", "Abstellraum", "Unbeheizt"]},
{"key": "design_temp", "type": "number", "label": "θ_int (°C)",
"step": 0.5, "derive_from_usage_type": true},
// ...
],
"dropped_override_ids": []
}
force=1re-extracts from IFC and prunes overrides whose GlobalId no longer exists; any pruned IDs appear indropped_override_ids.field_specsdrives the typed-input rendering inviewer.html— backend is the single source of truth for the roombook schema.
POST /roombook/<filename>/override?project=<name>
Persist a manual override.
- Body: {"global_id": "...", "field": "<any key from field_specs>", "value": "..."}
- Number fields coerced via float(); enum fields validated against options;
text fields stored as-is. Empty string or null clears the field; empty entries
are dropped. 400 on unknown field or invalid enum value.
- Returns the updated roombook state.
POST /roombook/<filename>/recalculate?project=<name>
Force re-extraction from IFC. Always returns 202 + {job_id}; the frontend waits
for the job to complete and then fetches /jobs/<id>/result to receive the
bundle (including the transient dropped_override_ids list). Overrides matching
an existing GlobalId are kept; orphans are pruned and surfaced via
dropped_override_ids. Override keys that no longer appear in ROOM_FIELDS are
also pruned.
GET /heizlast_presets/<filename>?project=<name>
Per-model Heizlast project parameters. Auto-creates <stem>.heizlast_presets.json
with DIN defaults on first call.
- Returns:
{
"schema_version": 1,
"theta_e": -12.0,
"n_50": 3.0,
"delta_u_tb": 0.10,
"shielding": "normal",
"reheat_enabled": false,
"reheat_factor_wm2": 11.0,
"u_defaults": {
"wall_external": 0.28, "wall_internal": 1.50, "wall_to_unheated": 1.00,
"window": 1.30, "door_external": 1.80, "door_internal": 3.00,
"floor_ground": 0.35, "floor_internal": 1.80, "roof": 0.20,
"ceiling_to_unheated": 0.50
}
}
POST /heizlast_presets/<filename>?project=<name>
Partial update. Only the keys you pass are written; others remain unchanged.
- Body (any subset): {"theta_e": -14.0, "u_defaults": {"wall_external": 0.24}}
- Returns the full updated presets.
GET /heizlast/<filename>?project=<name>&force=1
Full DIN EN 12831 Heizlast bundle: extracted envelope + inputs snapshot + per-room results + building total. - Follows the cache-or-enqueue contract. Fast-path returns 200 only when both the envelope and the roombook caches are fresh; otherwise enqueues. - Returns on 200:
{
"schema_version": 1,
"ifc_fingerprint": "...",
"roombook_fingerprint": "...",
"presets_fingerprint": "...",
"surfaces_by_room": {
"<gid>": [
{"surface_id": "<wall_gid>__face_max", "surface_type": "wall",
"area_effective": 29.12, "u_value_effective": 3.49, "u_source": "pset",
"adjacency_effective": "external", "fk_effective": 1.0, "phi_w": 3249.0,
"source_element_type": "IfcWall", "source_name": "...", ...}
]
},
"results_by_room": {"<gid>": {"heated": true, "theta_int": 20.0, "volume_m3": 70.2,
"phi_T_w": 15117, "phi_V_w": 439, "phi_RH_w": 0,
"phi_HL_w": 15556}},
"building_total": {"phi_HL_w": 24426, "phi_HL_kw": 24.43,
"heated_volume_m3": 161.28, "heated_area_m2": 57.6,
"heated_room_count": 2},
"room_inputs": {"<gid>": {"usage_type": "Wohnraum", "theta_int": 20,
"ach_min": 0.5, "volume": 70.2, "shielding": "normal",
"heated": true}},
"presets_snapshot": { ... same shape as /heizlast_presets ... },
"rooms_ifc": { ... same as /roombook ... },
"surface_overrides": {},
"warnings": [],
"dropped_override_ids": []
}
force=1re-extracts the geometric envelope from the IFC and drops orphan surface overrides (surface IDs that no longer exist).
POST /heizlast/<filename>/surface-override?project=<name>
Persist a per-surface override.
- Body: {"surface_id": "<wall_gid>__face_max", "field": "u_value", "value": 0.24}
- Valid fields: area_m2, u_value, fk, exposed_perimeter_m (numbers);
adjacency (one of external, roof, ground, to_heated, to_unheated).
- exposed_perimeter_m only affects ground-adjacency floor surfaces (it feeds
the EN ISO 13370 U_bf computation). Setting it on other surfaces is stored
but ignored by the compute.
- Empty string/null clears the field; empty entries are dropped.
- Returns the recomputed full Heizlast bundle (same shape as GET).
POST /heizlast/<filename>/surface-writeback?project=<name>
Explicit per-field writeback-to-IFC flag for one surface. Drives the → IFC
column in the Heizlast UI and the export-review flow.
- Body: {"surface_id": "<sid>", "field": "u_value", "enabled": true}
- Valid fields: u_value, area_m2, adjacency, fk.
(exposed_perimeter_m is deliberately excluded — it's a derived geometric
input, not a quantity architects read back through standard IFC Psets.)
- enabled: null clears the explicit flag and reverts to the default rule:
auto-tick iff an override exists for that field, plus auto-tick u_value
when u_source == "iso13370". Explicit true/false always wins.
- Persisted under surface_overrides[sid].write_to_ifc[field].
- Returns the recomputed full Heizlast bundle (same shape as GET).
POST /heizlast/<filename>/recalculate?project=<name>
Force re-extraction of the geometric envelope. Always returns 202 + {job_id};
same semantics as GET /heizlast/<filename>?force=1.
GET /heizlast/<filename>/export.csv?project=<name>
Flat CSV dump of the current Heizlast bundle. Two sections separated by a blank
line: Räume (one row per room: Geschoss, Name, Nutzung, θ_int, V, Φ_T/V/RH/HL,
GlobalId …) and Flächen (one row per surface: Raum, Typ, Quell-Element,
Fläche, U, U-Quelle, Adjazenz, Nachbar, f_k, Φ, surface_id).
- Follows the cache-or-enqueue contract — returns 200 with the CSV body on a
fresh cache, 202 + {pending, job_id, operation} when the cache is stale.
- Content-Disposition carries the filename {project}_{stem}_heizlast_{YYYYMMDD}.csv.
GET /heizlast/<filename>/export.xlsx?project=<name>
Three-sheet XLSX workbook (requires openpyxl):
- Projekt — label/value rows for presets + building total (θ_e, n₅₀, ΔU_TB,
Φ_HL kW/W, beheiztes Volumen, Nutzfläche, Anzahl Räume).
- Räume — one row per room with the same columns as the CSV Räume section.
- Flächen — one row per surface; first row frozen for scroll-through.
- Same cache-or-enqueue contract as CSV. Content-Disposition filename ends .xlsx.
GET /heizlast/<filename>/print?project=<name>
Print-optimized HTML page (landscape A4, @media print inverts to white-on-white).
Users open it in a new tab and hit the browser's print dialog to save as PDF.
- Sections: title header, Gebäude-Gesamtergebnis (4-stat grid), Eingabedaten
(presets), then one Geschoss section per storey with a room-block per
room containing room-level stats + a surface-detail table. Every storey begins
on a new page (page-break-before: always).
- Same cache-or-enqueue contract. Returns HTML (not JSON).
Heizflächenauslegung (Sizing)
See Heizflächenauslegung for the feature overview. Every sizing response and export carries a Richtwerte disclaimer — these are not verbindliche Planungswerte.
GET /sizing/<filename>?project=<name>&force=1
Full sizing bundle. Synchronous (pure arithmetic; no job queue).
- Returns 200 + a dict keyed by IfcSpace.GlobalId:
json
{
"schema_version": 1,
"ifc_fingerprint": "...",
"heizlast_fingerprint": "...",
"presets_fingerprint": "...",
"disclaimer": "Richtwerte nach vereinfachter EN 442 / EN 1264 …",
"presets_sizing": { "theta_v_c": 55.0, "theta_r_c": 45.0, ... },
"catalog": { "types": ["Type 11", "Type 22", …],
"grid": {"heights_mm": [...], "lengths_mm": [...]},
"source_label": "Richtwerte — keine Herstellerdaten" },
"rooms": {
"<GlobalId>": {
"heated": true,
"room_context": {"name": "…", "storey": "…", "theta_int_c": 20.0,
"phi_HL_w": 2140.0, "floor_area_m2": 23.8, …},
"candidates": [
{ "id": "<uuid>", "label": "Heizkörper", "type": "radiator",
"is_active": true, "is_shipped": true,
"params": {"catalog_key":"Type 22","height_mm":600,"length_mm":1000,
"exponent_override":null,"theta_v_override_c":null,…},
"result": { "phi_delivered_w": 2280.0, "coverage_pct": 106.5,
"covers_load": true, "warnings": [],
"details": {"delta_T_ln": 49.83, "f_corr": 1.0,
"k_m_w": 2280, "n": 1.3, …},
"disclaimer": "Richtwerte …" }
}
],
"active_candidate_id": "<uuid|null>"
}
},
"dropped_candidate_ids": []
}
- First-visit heated rooms get one auto-synthesized default candidate using
the preset preferred_type. Unheated rooms get an empty candidate list.
- Once a user has touched a room (any CRUD), the entry is preserved even when
empty — the server never re-synthesizes defaults over explicit deletes.
GET /sizing/<filename>/catalog?project=<name>
Radiator catalog (types + dimension grid + Richtwerte label).
{ "types": [{"key":"Type 22","n":1.3,"K_m_per_m2_w":1960,"depth_mm":100,…},…],
"grid": {"heights_mm": [300,400,…,900], "lengths_mm": [400,…,3000]},
"source_label": "Richtwerte — keine Herstellerdaten. …" }
POST /sizing/<filename>/candidate?project=<name>
Create a candidate.
- Body: {"global_id": "<gid>", "type": "radiator" | "ufh" | "wall" | "ceiling" | "towel_rail", "label"?: "…", "params"?: {…}}
- Returns 201 + full bundle. Stub types (wall/ceiling/towel_rail) are
stored but their results carry "Noch nicht implementiert" warnings and
is_shipped: false — the UI can render them disabled.
- 400 on unknown type, 404 on unknown global_id.
PATCH /sizing/<filename>/candidate/<cid>?project=<name>
Update one field of a candidate.
- Body: {"global_id": "<gid>", "field": "params.<name>" | "label" | "type",
"value": <coerced>}. Empty value clears.
- Returns 200 + full bundle; 400 on unknown field/type/value, 404 on unknown gid/cid.
DELETE /sizing/<filename>/candidate/<cid>?project=<name>
- Body:
{"global_id": "<gid>"} - Returns
200+ full bundle. If the deleted candidate was active, the room'sactive_candidate_idis cleared (or promoted to the next remaining candidate).
POST /sizing/<filename>/active?project=<name>
Set or clear the active candidate.
- Body: {"global_id": "<gid>", "candidate_id": "<uuid>" | null}
- Returns 200 + full bundle.
Sizing in the presets payload
The global system temperatures, UFH caps, and radiator defaults live inside the
existing /heizlast_presets endpoint under a nested sizing key (schema v2):
{ "schema_version": 2,
"theta_e": -12.0, ...,
"sizing": {
"theta_v_c": 55.0, "theta_r_c": 45.0,
"preferred_type": "ufh",
"radiator_default_type": "Type 22", "radiator_default_exponent": 1.3,
"ufh_cap_living_w_m2": 100.0, "ufh_cap_bath_w_m2": 175.0,
"ufh_active_area_fraction": 0.9 } }
POST accepts partial nested patches: {"sizing": {"theta_v_c": 40}}. Unknown
keys inside sizing are rejected (strict schema — prevents silent drift).
v1 files on disk are migrated on first read.
Sizing in the heizlast exports
Default-enabled in CSV/XLSX/Print. Append ?include_sizing=0 to suppress the
Auslegung section in CSV/XLSX (the print template renders it unconditionally).
GET /has_cache/sizing/<filename>?project=<name>
Extension of the existing has_cache endpoint — {"exists": true|false}.
GET /climate/de
German-municipality lookup for the Presets "Standort" card. Small static dataset (~60 cities) with Norm-Außentemperatur (θ_e) per DWD TRY-region midpoint. - Returns:
{
"entries": [
{"plz_prefix": "80", "name": "München (Mitte)", "theta_e": -14.0, "try_region": "TRY 13"},
{"plz_prefix": "20", "name": "Hamburg", "theta_e": -10.0, "try_region": "TRY 3"},
…
],
"count": 60,
"note": "…caveats about precision and source…"
}
- Entries are sorted alphabetically by
name. PLZ prefix is the first 2 digits of the postal code. Accuracy is ±1–2 K — the user's manual θ_e override on the Presets form remains authoritative. Source: DWD Testreferenzjahr (TRY 15-region) midpoints; not the DIN EN 12831 Beiblatt (copyright).
Caching Strategy
Computed results are saved as JSON files alongside the IFC in uploads/:
| Endpoint | Cache File | Regeneration |
|---|---|---|
/connection_graph/ |
<stem>.connection_graph.json |
Auto on first request, or via tool |
/wall_zones/?mode=X |
<stem>.wall_zones_X.json |
Auto on first request |
/spaces/ |
<stem>.spaces.json |
Auto, invalidated when IFC mtime+size changes |
/roombook/ |
<stem>.roombook.json |
Auto, invalidated when IFC mtime+size changes; force=1 to rebuild and prune orphan overrides |
/heizlast_presets/ |
<stem>.heizlast_presets.json |
Auto-created on first GET with DIN defaults; POSTs persist partial updates |
/heizlast/ |
<stem>.heizlast.json |
Envelope re-extracted when IFC fingerprint changes or force=1; arithmetic runs fresh every GET |
/sizing/ |
<stem>.sizing.json |
Candidates persist across Heizlast changes; orphan GIDs are pruned on next write and surfaced in dropped_candidate_ids |
To force recomputation: delete the cached JSON file and reload the viewer.