Handling Mezzanines and Split Levels
The hard cases of Level Mapping & Z-Axis Logic. Full storeys are straightforward; the elevation clusters that are not full storeys are where a level index goes wrong, and where getting it wrong renumbers everything above.
Five Things That Look Like Levels
Clustering elevations produces one cluster per horizontal plane with substantial geometry on it, and only some of those planes are storeys. Spacing alone — the distance to the plane below — cannot separate them, because a mezzanine, a split level and a ceiling void all sit at fractions of the storey pitch.
Plate extent is the discriminator. A full storey covers essentially the whole floor plate. A mezzanine covers a fraction of it. A split level appears as two clusters each covering about half. A ceiling void covers the whole plate, which is why extent alone is not enough either — and why vertical links are the third measurement.
Mezzanine or Split Level
The distinction that matters most is between a mezzanine and a split level, because they get different indices.
A mezzanine is an intermediate floor: it sits between two storeys, covers part of the plate, and is reached by its own stair. It gets a fractional index — 0.5, 1.5 — which keeps it ordered correctly between the storeys it sits between without renumbering them.
A split level is one storey built at two heights, typically because the site slopes or two building phases met. Both halves cover about half the plate, they are joined by a ramp or a few steps, and to everyone in the building they are the same floor. They share one integer index, and the height difference is carried as geometry rather than as a level distinction.
Getting this backwards is visible immediately: a split level indexed as two levels doubles the building’s floor count and puts half of every storey on a floor that does not exist in the lifts.
Minimal Working Example
import logging
import statistics
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Cluster:
elevation_m: float
plate_fraction: float # area covered / the building's modal plate area
has_doors: bool
has_stair: bool
service_only: bool
def classify(clusters: list[Cluster], *, mezz_max: float = 0.55,
split_tol: float = 0.15) -> list[tuple[Cluster, float | None]]:
"""Assign a level index to each cluster, or None where it is not a level."""
if len(clusters) < 2:
raise ValueError("need at least two clusters to establish a storey pitch")
ordered = sorted(clusters, key=lambda c: c.elevation_m)
gaps = [b.elevation_m - a.elevation_m for a, b in zip(ordered, ordered[1:])]
pitch = statistics.median([g for g in gaps if g > 0.5]) if gaps else 3.6
out: list[tuple[Cluster, float | None]] = []
index, i = 0.0, 0
while i < len(ordered):
c = ordered[i]
if not c.has_doors and not c.has_stair:
out.append((c, None)) # a void or a ceiling
logger.info("%.2f m: no access — not a level", c.elevation_m)
i += 1
continue
nxt = ordered[i + 1] if i + 1 < len(ordered) else None
if (nxt and abs(nxt.elevation_m - c.elevation_m) < 0.6 * pitch
and abs(c.plate_fraction - 0.5) < split_tol
and abs(nxt.plate_fraction - 0.5) < split_tol):
out.append((c, index)) # split level: one index, two halves
out.append((nxt, index))
logger.info("%.2f/%.2f m: split level -> %+g", c.elevation_m, nxt.elevation_m, index)
index += 1.0
i += 2
continue
if c.plate_fraction <= mezz_max and out:
frac = out[-1][1]
out.append((c, (frac if frac is not None else index - 1) + 0.5))
logger.info("%.2f m: mezzanine -> %+g", c.elevation_m, out[-1][1])
i += 1
continue
out.append((c, index))
index += 1.0
i += 1
return out
The loop advances by two for a split level, which is what keeps the integer index from being consumed twice. Everything else — mezzanine, void, plant deck — advances by one, and only a full storey increments the index.
Common Errors & Fixes
Every floor above the mezzanine is off by one. The mezzanine was given an integer index. Fractional indices exist precisely to avoid this, and the assertion that catches it is that the building’s integer indices form a contiguous run.
A split-level building has twice as many floors as it should. The two halves were classified independently. Both the spacing and the plate fraction have to be checked together — two clusters at half the pitch, each covering half the plate, are one floor.
A plant deck appears in the public floor picker. It was indexed correctly and not marked
service-only. The index is a geometric fact; whether a level is publicly routable is a
space_class and profile question, handled by
POI taxonomy and
accessible routing profiles.
The level field truncates 0.5 to 0. The schema declares level as an integer. It has to be a
number end to end — envelope, tiles, API and client — which is a
schema versioning question as much as a
level-mapping one.
Integration Point
This classification runs inside Level Mapping & Z-Axis Logic, after elevation clustering and before indices are published. Its output is consumed by everything that filters on level: the tile level filter, the vertical edges in routing graph construction, and the level elevation table that floor-level detection snaps barometric altitudes to.
That last consumer is the one that makes fractional indices load-bearing rather than cosmetic: a mezzanine missing from the elevation table pulls users standing on it to the floor above or below, and they flip between the two as they walk.
Frequently Asked Questions
Why 0.5 rather than a separate level list?
Because every consumer already sorts by the level index, and a fractional value sorts correctly with no special handling. Introducing a parallel notion of intermediate levels means every floor picker, every level filter and every vertical-edge query needs to know about it, and any that does not will silently omit mezzanines. A float that sorts between 0 and 1 requires nothing new anywhere, which is why the only real requirement is that the field is typed as a number rather than an integer throughout the stack.
How do I detect a split level automatically?
Two clusters at well under a full storey apart, each covering roughly half the plate, with a ramp or a short stair between them. All three conditions matter: two clusters close together with one covering the whole plate is a mezzanine over a full floor, and two half-plates far apart are two genuine storeys in a stepped building. Where the automatic classification is uncertain, recording it as ambiguous and asking a human is far better than guessing, because the consequence of getting it wrong is the whole building being renumbered.
What about buildings that skip floor 13?
Keep the geometric index contiguous and carry the display name separately. The level index is an ordinal used for ordering, filtering and vertical adjacency, and making it skip a number breaks the assumption that consecutive indices are physically adjacent — which vertical-edge construction relies on. The name shown to users is a different field, populated from the building’s own signage, and can skip 13, use letters, or call the ground floor whatever the estate calls it.
Related
- Level Mapping & Z-Axis Logic — the clustering stage this classification completes.
- Converting CAD Elevations to Indoor Z-Levels — how the elevation clusters are produced in the first place.
- Mapping IFC Storeys to Indoor Levels — the same problem when the source declares its storeys explicitly.
This page is a companion to Level Mapping & Z-Axis Logic, part of the Indoor Mapping Architecture & Standards section.