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

A mezzanine and a split level in the same building section A section through a building showing four horizontal planes. Level 0 spans the full width. A mezzanine at half the storey spacing spans only the left third of the plate and is indexed 0.5. Level 1 spans the full width again. Below it, two full-width plates two metres apart are joined by a short ramp: this is a split level, and both halves are level 2 despite their different elevations. Half spacing and part plate is a mezzanine; full plate at two heights is one level 0 0.5 mezzanine — part plate 1 split level: two full plates 2 m apart — both are level 2 full storey mezzanine split level

Two cases that look alike in the elevation data and are not. A mezzanine gets its own fractional index; a split level's two halves share an index, because to a user they are one floor.

Distinguishing five things that look like extra levels A table of five cases. A full storey sits at about one times the storey pitch, covers 85 to 105 percent of the plate, gets an integer index and has stairs and lifts. A mezzanine sits at about half the pitch, covers only 15 to 40 percent of the plate, gets a fractional index and usually has a single stair. A split level sits at 0.3 to 0.6 of the pitch, appears as two plates each covering about half, shares one integer index between them, and is joined by a ramp or a few steps. A plant deck covers 10 to 30 percent, gets an integer index marked service-only, and is reached by a ladder or service stair. A ceiling void sits at about 0.8 of the pitch, covers the whole plate, has no vertical links, and is not a level at all. Three measurements, five cases, no ambiguity What it is Spacing Plate extent Index Vertical links Full storey ~1.0x pitch 85-105% ● integer stairs + lifts Mezzanine ~0.5x pitch △ 15-40% ● fractional one stair, sometimes a lift Split level ~0.3-0.6x pitch 2 x ~50% △ same integer for both a ramp or 3 steps Plant deck varies 10-30% integer, service only ladder or service stair Ceiling void ~0.8x pitch △ 100% △ not a level none Spacing alone confuses three of these; extent and vertical links separate them.

The plate-extent column does most of the work. Spacing alone cannot separate a mezzanine from a split level from a ceiling void; how much of the floor plate the cluster covers separates all three.

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

Classifying a cluster that is not a full storey One decision with four outcomes. A cluster covering part of the plate with a single stair is a mezzanine and takes a fractional index. Two clusters each covering about half the plate are a split level and share one integer index. A cluster with no doors and no stair is not a level — a void or a ceiling. A cluster reachable only by service access is a plant deck, which takes an integer index but is routable only for the service profile. Four outcomes, decided by extent and by what connects to it This elevation cluster is not a full storey. What is it? extent and links decide what spacing cannot part plate, one stair Mezzanine index n + 0.5 two half plates Split level one index, both halves no doors, no stair Not a level void or ceiling service access only Plant deck integer index, service profile only

Doors are the deciding evidence. A plane people can reach has openings onto it; a ceiling void has none, whatever its elevation and extent suggest.

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.

This page is a companion to Level Mapping & Z-Axis Logic, part of the Indoor Mapping Architecture & Standards section.