Level Mapping & Z-Axis Logic

Level mapping is the process of collapsing a continuous stream of elevation samples into a small set of discrete, logically indexed floor levels that an indoor routing graph can traverse. It sits inside the Indoor Mapping Architecture & Standards framework as the vertical-topology stage: the point where raw survey geometry stops being a cloud of Z-coordinates and becomes addressable floors with stable integer indices. Outdoor GIS treats elevation as a continuous attribute; indoor wayfinding treats it as a primary key. Get the Z-axis wrong and every downstream concern — graph connectivity, POI placement, vertical transitions — inherits the error.

Problem Statement: Continuous Elevation Is Not a Floor Level

A building does not announce its floors. What a survey hands you is a population of Z-values — slab tops, finished-floor heights, ceiling fixtures, mezzanine decks, ramp midpoints — scattered along a vertical axis with measurement noise on top. The job of this stage is to answer one question deterministically: which floor level does each point belong to, and what integer should name it?

The symptom of getting this wrong is unmistakable in production. A routing engine reports a user “between floors” because a ramp sample landed halfway between two clusters. A POI for a third-floor café renders on a phantom floor that exists only because a suspended light fixture survived noise filtering. Two adjacent buildings on a campus disagree on what “Level 2” means because one anchored Z to a geodetic datum and the other to a local benchmark. Each of these is a level-mapping defect, not a rendering or routing bug — which is why the fix belongs here, before any edge is ever drawn.

Because the Z-index this stage emits becomes the floor key that POIs are joined against, a misassigned level silently corrupts POI Taxonomy & Classification downstream: asset metadata attaches to the wrong floor, and multi-tenant campuses end up with duplicated or orphaned points. The discretization here is the contract every later stage trusts.

From a noisy elevation scatter to a routable multi-level graph A vertical Z axis on the left holds a one-dimensional scatter of elevation samples grouped in three dense horizontal bands at roughly 0.0, 3.2 and 6.5 metres, with several stray outlier points between and beyond the bands. An arrow leads into a vertical stack of three processing stages: IQR filter, hierarchical (Ward) clustering, and Z-index assignment. The output, on the right, is three discrete floor slabs — Ground indexed 0 at z = 0.0 m, Level 1 indexed 1 at z = 3.2 m, and Level 2 indexed 2 at z = 6.5 m — each a node carrying its integer index. Stair, elevator and ramp connectors join the nodes as bidirectional edges, forming a small multi-level routing graph. Filter → cluster → index: noisy elevations become routable floor levels Raw Z scatter (noisy) Z (m) 6.5 3.2 0.0 ✕ = outliers (fixtures, drains) IQR filter strip non-surface noise Hierarchical clustering gaps define floor bands Z-index assign sort by mean → 0,1,2 Discrete routable floor levels Level 2 · z = 6.5 m Level 1 · z = 3.2 m Ground · z = 0.0 m 2 1 0 stair elevator ramp

Prerequisites & Dependencies

Before implementing this stage, the following must already hold:

  • Aligned vertical datum. All elevation samples must share one origin. Raw BIM exports, LiDAR point clouds, and CAD floor plans rarely agree, so reconcile them to a consistent metric datum first. The horizontal counterpart of this guarantee lives in Indoor Coordinate Reference Systems; the same orthogonal, metre-based frame that anchors X/Y must anchor Z, or clustering tolerances expressed in metres are meaningless.
  • Extracted Z-values. You need a flat array of elevations, not raw CAD entities. Pulling clean Z-coordinates out of DWG/DXF — where elevations are often stored as relative block offsets rather than absolute heights — is its own task, covered in Converting CAD elevations to indoor Z-levels.
  • Python libraries. numpy for sample arrays, scipy.cluster.hierarchy for floor discretization, and networkx for the multi-level routing graph. Optional: scikit-learn for AgglomerativeClustering on large campuses, and laspy/open3d if you downsample point clouds upstream.
  • Connector inventory. A list of vertical connectors (stairs, elevators, ramps, escalators) expressed as floor-index pairs. Geometry detection for the passable edges those connectors imply is handled by Wall & Door Detection Algorithms upstream; this stage only needs to know which floor levels each connector joins.

How Z-Axis Normalization Works

The approach is a three-stage reduction: filter, cluster, index. Each stage narrows the data and hardens the guarantees the next stage can assume.

  1. Noise filtering removes samples that are not walking surfaces or structural slabs — ceiling fixtures, floor drains, transient obstructions — so they cannot pull a floor centroid off true.
  2. Hierarchical clustering groups the surviving samples into vertical bands. A distance threshold tuned to real floor-to-floor clearance decides where one floor level ends and the next begins.
  3. Z-index assignment maps each band to a sequential integer (0, 1, 2…). That integer is the floor key the routing graph, the POI join, and the client SDK all share.

Clustering is the load-bearing choice. Fixed-width binning fails the moment a building has irregular storey heights — a tall lobby, a low mezzanine, a double-height atrium. Hierarchical (Ward) linkage instead lets the gaps in the data define the boundaries, which is exactly how a human reads a section drawing. DBSCAN with a vertical eps is the alternative when sample density varies wildly across floors.

Z-axis normalization data flow: ndarray to routing graph Five stages connected left to right by arrows. Stage one: raw elevations, a NumPy ndarray. Stage two: IQR filter. Stage three: Ward clustering, using fcluster with a distance threshold; a callout notes the threshold equals the floor-to-floor clearance. Stage four: a Z-index map of the integers zero, one and two. Stage five: a MultiDiGraph holding floor nodes and vertical connector edges. Each stage hardens the data guarantees the next stage relies on. filter → cluster → index → graph Raw elevations np.ndarray IQR filter strip outlier returns Ward clustering fcluster · distance Z-index map { 0, 1, 2 } MultiDiGraph floor nodes + vertical edges threshold = floor-to-floor clearance

Step-by-Step Implementation

The pipeline below is engineered for batch processing inside automated GIS workflows. Every function carries typed signatures, emits a logging call, and handles the failure modes most likely to abort a real run. Start from a shared configuration and import block.

import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple

import networkx as nx
import numpy as np
from scipy.cluster.hierarchy import fcluster, linkage

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("z_axis")


@dataclass
class ZLevelConfig:
    vertical_tolerance: float = 0.35  # metres; max within-floor cophenetic distance
    min_cluster_size: int = 5
    outlier_iqr_factor: float = 1.5

Step 1 — Ingest and Filter Elevation Noise

Raw elevation data carries measurement noise, structural protrusions, and temporary obstructions. Before clustering, isolate true walking surfaces and slabs with robust statistical filtering. Interquartile-range (IQR) clipping removes the ceiling fixtures, suspended lighting, and floor drains that would otherwise drag a floor centroid off true. The function raises on the one failure that makes everything downstream meaningless — too few valid samples to define any floor level at all.

def filter_elevation_noise(
    z_values: np.ndarray, iqr_factor: float = 1.5, min_valid: int = 5
) -> np.ndarray:
    """Remove elevation outliers using IQR clipping.

    Returns the surviving samples; raises ValueError when too few remain
    to define even a single floor level.
    """
    if z_values.size == 0:
        raise ValueError("Empty elevation array passed to noise filter.")

    q75, q25 = np.percentile(z_values, [75, 25])
    iqr = q75 - q25
    lower, upper = q25 - iqr_factor * iqr, q75 + iqr_factor * iqr
    filtered = z_values[(z_values >= lower) & (z_values <= upper)]

    if filtered.size < min_valid:
        raise ValueError(
            f"Only {filtered.size} samples survived IQR filtering "
            f"(need >= {min_valid}); check the input datum and units."
        )
    logger.info("Noise filter: %d -> %d samples", z_values.size, filtered.size)
    return filtered

For point-cloud inputs, downsample with a voxel grid (typically 0.5 m) before this step so dense returns do not dominate the IQR statistics — this also caps clustering cost, addressed in the performance notes below.

Step 2 — Cluster Into Discrete Floor Levels

Continuous Z-values are discretized into logical floor levels with Ward hierarchical clustering. The tolerance is a real, physical quantity: the maximum vertical spread allowed within one floor level, which should sit well below the building’s smallest floor-to-floor clearance (typically 2.8–4.5 m for office and retail, 1.8–2.5 m for mezzanines). Clusters smaller than min_size are dropped and their members flagged invalid rather than promoted to a phantom floor.

def cluster_floors(
    z_values: np.ndarray, tolerance: float = 0.35, min_size: int = 5
) -> Tuple[np.ndarray, Dict[int, np.ndarray]]:
    """Discretize continuous Z-values into 0-based floor-level clusters.

    Returns (per-sample floor index with -1 for dropped samples,
    {floor_index: member elevations}).
    """
    if z_values.size < min_size:
        raise ValueError(
            f"Need >= {min_size} samples to cluster, got {z_values.size}."
        )

    linkage_matrix = linkage(z_values.reshape(-1, 1), method="ward")
    labels = fcluster(linkage_matrix, t=tolerance, criterion="distance")

    # Order labels by mean elevation so index 0 is the lowest floor level.
    order = sorted(np.unique(labels), key=lambda lbl: z_values[labels == lbl].mean())
    label_to_idx = {lbl: idx for idx, lbl in enumerate(order)}
    z_indices = np.array([label_to_idx[lbl] for lbl in labels])

    floor_members: Dict[int, np.ndarray] = {}
    for idx in range(len(order)):
        mask = z_indices == idx
        if int(mask.sum()) >= min_size:
            floor_members[idx] = z_values[mask]
        else:
            logger.warning("Cluster %d dropped (size %d < %d)", idx, int(mask.sum()), min_size)
            z_indices[mask] = -1

    logger.info("Clustering: %d candidate bands -> %d floor levels", len(order), len(floor_members))
    return z_indices, floor_members

Sorting clusters by mean elevation before assigning indices is what makes index 0 reliably the lowest floor level across every building on a campus — without it, fcluster label order is arbitrary and “Level 2” drifts between datasets.

Step 3 — Assign Z-Indices and Build the Routing Graph

Once floor levels are fixed, each becomes a node in a MultiDiGraph, keyed by its integer Z-index and tagged with its centroid elevation. Vertical connectors become directed edges so the routing graph can honour one-way escalators and elevator call logic. Edges are added in both directions only for bidirectional connector types; the caller controls directionality through the connector tuples.

def build_multilevel_graph(
    floor_members: Dict[int, np.ndarray],
    vertical_connectors: Optional[List[Tuple[int, int, str]]] = None,
) -> nx.MultiDiGraph:
    """Construct a routable multi-level graph from clustered floor levels."""
    graph = nx.MultiDiGraph()

    for floor_idx, elevations in floor_members.items():
        graph.add_node(
            floor_idx,
            floor_id=floor_idx,
            z_centroid=float(np.median(elevations)),
            sample_count=int(elevations.size),
        )

    bidirectional = {"stair", "elevator", "ramp"}
    for src, dst, conn_type in vertical_connectors or []:
        if src not in graph or dst not in graph:
            logger.warning("Connector %s skips missing floor level (%s -> %s)", conn_type, src, dst)
            continue
        graph.add_edge(src, dst, type=conn_type, weight=1.0)
        if conn_type in bidirectional:
            graph.add_edge(dst, src, type=conn_type, weight=1.0)

    logger.info("Routing graph: %d floor nodes, %d connector edges",
                graph.number_of_nodes(), graph.number_of_edges())
    return graph

Step 4 — Orchestrate the End-to-End Pass

The orchestrator chains the three stages and returns a routing graph ready for an indoor wayfinding engine. A single entry point keeps the contract narrow: raw elevations and a connector inventory in, a validated multi-level routing graph out.

def normalize_z_pipeline(
    raw_elevations: np.ndarray,
    config: ZLevelConfig = ZLevelConfig(),
    vertical_connectors: Optional[List[Tuple[int, int, str]]] = None,
) -> nx.MultiDiGraph:
    """End-to-end Z-axis normalization: filter -> cluster -> graph."""
    logger.info("Starting Z-axis normalization over %d raw samples", raw_elevations.size)
    filtered = filter_elevation_noise(raw_elevations, config.outlier_iqr_factor, config.min_cluster_size)
    _, floor_members = cluster_floors(filtered, config.vertical_tolerance, config.min_cluster_size)
    return build_multilevel_graph(floor_members, vertical_connectors)


if __name__ == "__main__":
    rng = np.random.default_rng(42)
    samples = np.concatenate([
        rng.normal(0.0, 0.05, 150),   # ground floor level
        rng.normal(3.2, 0.08, 180),   # floor level 1
        rng.normal(6.5, 0.06, 140),   # floor level 2
        rng.uniform(-2.0, 10.0, 30),  # fixtures, drains, transient noise
    ])
    connectors = [(0, 1, "stair"), (1, 2, "elevator"), (0, 2, "ramp")]
    routing_graph = normalize_z_pipeline(samples, vertical_connectors=connectors)
    for node, data in routing_graph.nodes(data=True):
        print(f"Floor {node}: Z-centroid = {data['z_centroid']:.2f} m ({data['sample_count']} samples)")

Edge Cases & Gotchas

Symptom Root cause Diagnostic & resolution
Phantom floor levels detected Tolerance too tight, or ceiling fixtures survived filtering Raise vertical_tolerance to 0.4–0.6 m; confirm IQR clipping removed >95% of non-slab returns; cross-check against the architectural floor schedule.
Vertical drift across datasets Mismatched vertical datums (e.g. NAVD88 vs. local building 0.00) Run a datum transformation before ingestion and anchor to a known benchmark; validate against Indoor Coordinate Reference Systems alignment rules.
Mezzanine merges with main level Low clearance (<1.8 m) or sparse samples Drop vertical_tolerance to 0.25 m and spatially mask the mezzanine footprint before clustering; or switch to DBSCAN with eps=0.2 for irregular geometry.
Routing graph disconnected across floor levels Missing or mislabelled vertical connectors Audit stair/elevator shaft coordinates; confirm each connector tuple references valid floor indices; run a BFS to surface unreachable floor levels.
Floor indices shuffle between runs Relying on raw fcluster label order Sort clusters by mean elevation before index assignment (Step 2) so index 0 is always the lowest floor level.
Y-axis inversion bleeds into Z Source CAD uses a screen-space coordinate convention Confirm the upstream extractor emits a right-handed metric frame; a flipped axis sends “up” the wrong way and clusters invert.

Validation Output

Correctness is checkable, not eyeballed. After a clean run the three centroids should track the building’s storey heights and the routing graph should be strongly connected.

import networkx as nx

EXPECTED_CENTROIDS = [0.0, 3.2, 6.5]  # metres, from the architectural floor schedule

centroids = [d["z_centroid"] for _, d in sorted(routing_graph.nodes(data=True))]
assert len(centroids) == 3, f"expected 3 floor levels, got {len(centroids)}"
for got, want in zip(centroids, EXPECTED_CENTROIDS):
    assert abs(got - want) <= 0.15, f"centroid {got:.2f} m drifts > 0.15 m from {want} m"
assert nx.is_strongly_connected(routing_graph), "vertical transitions leave a floor level unreachable"

A correct run prints centroids within ±0.15 m of the schedule:

INFO: Noise filter: 500 -> 470 samples
INFO: Clustering: 3 candidate bands -> 3 floor levels
INFO: Routing graph: 3 floor nodes, 6 connector edges
Floor 0: Z-centroid = 0.00 m (150 samples)
Floor 1: Z-centroid = 3.20 m (180 samples)
Floor 2: Z-centroid = 6.50 m (142 samples)

An incorrect run is just as legible: Clustering: 5 candidate bands -> 5 floor levels for a three-storey building means a too-tight tolerance split fixtures into phantom floor levels — exactly the first row of the gotchas table.

When this stage publishes for downstream consumption, emit each floor level as a feature in the same GeoJSON FeatureCollection envelope the rest of the pipeline uses, so the z_index becomes a first-class, joinable property rather than an implicit array position:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": { "z_index": 0, "z_centroid_m": 0.00, "floor_name": "Ground", "sample_count": 150 },
      "geometry": null
    },
    {
      "type": "Feature",
      "properties": { "z_index": 1, "z_centroid_m": 3.20, "floor_name": "Level 1", "sample_count": 180 },
      "geometry": null
    }
  ]
}

That envelope is the contract JSON Schema Design for Indoor Maps validates against before any map artefact ships.

Performance & Scale Notes

Ward linkage is the cost driver. scipy.cluster.hierarchy.linkage is roughly O(n²) in both time and memory on the raw sample count, so a dense LiDAR floor with millions of returns will exhaust RAM long before it finishes. Three levers keep large campuses tractable:

  • Pre-aggregate before clustering. Voxel-downsample point clouds to ~0.5 m and feed the filter the reduced set. Z-axis clustering needs the distribution of elevations, not every return; a 50× reduction changes the centroids by less than a millimetre while cutting linkage cost by orders of magnitude.
  • Swap the estimator at scale. Beyond a few hundred thousand samples, replace linkage/fcluster with sklearn.cluster.AgglomerativeClustering(compute_full_tree=False, distance_threshold=tolerance), which avoids materializing the full dendrogram, or use DBSCAN whose cost scales with neighbourhood density rather than n².
  • Process per building, not per campus. Floor levels never span buildings, so cluster each building independently and assign indices within its own datum. This bounds n, parallelizes trivially across a worker pool, and keeps one building’s noise from perturbing another’s centroids.

The graph itself is cheap: floor counts are tiny (tens of nodes even for a tower), so MultiDiGraph construction and strong-connectivity checks are effectively free relative to clustering. Batch the whole pass per building inside CI so a regression in centroid output fails the build before it reaches users — the gating mechanics live in CI Gating for Map Updates.

FAQ

How do I pick the right clustering tolerance for a building with mixed storey heights?

Set vertical_tolerance to the maximum vertical spread you expect within one floor level (slab-to-finished-floor variation plus survey noise, usually 0.2–0.4 m), never to the floor-to-floor height. Hierarchical clustering finds boundaries in the gaps between floors, so as long as the tolerance stays well below the smallest real clearance — including any low mezzanine — mixed storey heights cluster correctly without per-floor tuning.

Should Z-indices be sequential integers or real elevations?

Both, for different jobs. The integer z_index is the floor key — stable, joinable, and what POIs, the routing graph, and the client SDK all reference. The real z_centroid is metadata for rendering and elevation-aware UI. Routing on raw elevations is fragile because a ramp sample sits between two floors; routing on integer indices is unambiguous.

How do I keep "Level 2" meaning the same building across a multi-building campus?

Cluster each building independently and assign indices within its own aligned datum, then sort clusters by mean elevation so index 0 is always the lowest occupied floor level. Persist a per-building map from z_index to the human floor name. Sharing one global Z-datum across buildings is the common mistake — campus terrain varies, so absolute elevation does not imply the same floor level.

What happens to samples that fall between two floor levels, like ramp midpoints?

Ward clustering assigns each sample to its nearest band, so an isolated ramp midpoint joins whichever floor level it is closest to and contributes negligibly to that centroid. A cluster of ramp samples large enough to exceed min_cluster_size would form its own band — model that ramp explicitly as a vertical connector edge rather than letting it become a phantom floor level.

This page is part of the Indoor Mapping Architecture & Standards section — return there for the end-to-end pipeline this vertical-topology stage feeds.