Best Practices for Indoor POI Taxonomy
This page covers the design decision that decides whether an indoor POI taxonomy stays routable — how deep to make the hierarchy and what to express as attributes instead — and it sits under POI Taxonomy & Classification within the broader Indoor Mapping Architecture & Standards reference. The classification pipeline enforces whatever vocabulary you give it; this page is about giving it a vocabulary that does not quietly fracture the routing graph.
Concept Definition
An indoor POI taxonomy is a controlled, finite vocabulary of point-of-interest types arranged as a strict tree, where every physical space resolves to exactly one leaf and every leaf maps to one routable entity. “Best practice” here is not about being expressive — it is about three measurable properties that keep the taxonomy compatible with distance-based pathfinding:
- Bounded depth. A three-tier model — domain (
Corporate,Healthcare,Transit), category (Circulation,Amenity,Clinical), type (Elevator,Restroom,Exam_Room) — is enough to drive routing weight, accessibility filtering, and search. Each extra tier multiplies the leaf space a pathfinder and a search index must reason over without adding routing information. - Single-leaf resolution. A POI belongs to one and only one classification path. Multi-parent membership makes an A*/Dijkstra search double-count a node’s edges or fabricate phantom corridors, because the same geometry resolves to two graph nodes.
- Attributes, not tiers, for cross-cutting facts. Wheelchair access, badge requirements, gender-neutral status, and department ownership are tags on a leaf, never new branches.
category: "Restroom"withattributes: {wheelchair_accessible: true, gender_neutral: false}stays one leaf;Accessible_Unisex_Restroomforks the tree and bloats the leaf space combinatorially.
The anti-pattern these rules prevent is over-classification: a taxonomy so granular that semantically identical spaces land on different leaves, so the routing graph attaches different traversal costs to the same kind of space and search relevance scatters. Over-classification rarely throws an exception — it surfaces as routing that is plausibly wrong.
Minimal Working Example
The technique that operationalizes these rules is a granularity audit: before a taxonomy is published, assert that no path exceeds the allowed depth and that no leaf has more than one parent. The self-contained routine below takes the parent/child edges of a candidate taxonomy and returns the two failure classes that break routing — paths that are too deep, and leaves that resolve under multiple parents.
import logging
from collections import defaultdict
from typing import Iterable
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("poi_taxonomy_audit")
def audit_taxonomy_shape(
edges: Iterable[tuple[str, str]], max_depth: int = 3
) -> dict[str, list[str]]:
"""Flag taxonomy leaves that are too deep or have >1 parent.
`edges` are (parent, child) pairs; `max_depth` is the allowed tier count.
Returns the offending leaves keyed by failure class.
"""
parents: dict[str, list[str]] = defaultdict(list)
try:
for parent, child in edges:
parents[child].append(parent)
except ValueError as exc: # malformed edge tuple
logger.error("Edge list is not (parent, child) pairs: %s", exc)
raise
multi_parent = [c for c, ps in parents.items() if len(ps) > 1]
def depth(node: str, seen: frozenset[str] = frozenset()) -> int:
if node in seen: # cycle guard
return max_depth + 1
ps = parents.get(node)
return 1 if not ps else 1 + max(depth(p, seen | {node}) for p in ps)
too_deep = [c for c in parents if depth(c) > max_depth]
issues = {"multi_parent": multi_parent, "too_deep": too_deep}
logger.info("Audited %d nodes: %s", len(parents), {k: len(v) for k, v in issues.items()})
return {k: v for k, v in issues.items() if v}
Run it on the edge export before classification; a non-empty result means the design is wrong, not the data, so fix the vocabulary rather than patching individual rows downstream.
Parameter & Threshold Reference
| Parameter | Type | Default / Threshold | Notes |
|---|---|---|---|
max_depth |
int |
3 |
Tier count: domain → category → type. > 3 rarely adds routing information. |
| Parents per leaf | int |
1 (exact) |
More than one parent forks the node in the routing graph. |
| Leaves per category | int |
≤ 12 recommended |
A category exploding past ~12 leaves usually signals attributes masquerading as types. |
classification_path length |
list[str] |
exactly 3, no empty element |
An empty trailing element passes a naive length check but breaks leaf lookup. |
| Cross-cutting facts | attribute tags | not tiers | Accessibility, badge access, hours → key/value payload on the leaf. |
| Leaf naming | str |
Snake_Case noun, no modifiers |
Restroom, not Accessible_Unisex_Restroom; modifiers belong in attributes. |
routing_weight |
float |
0.1–1.0 |
Derived from the leaf type, not encoded in its name. |
Common Errors & Fixes
AssertionError: multi_parent leaves = ['Elevator_Lobby'] — a space was placed under two categories (often Circulation and Amenity) to make it findable from both. The audit catches it, but the fix is design, not code: keep the single most routing-relevant parent (Circulation) and add a queryable tag (is_amenity_adjacent: true) so search still finds it without forking the graph node.
Routing weights that disagree for identical spaces — two huddle rooms resolve to Conference_Small and Huddle, so the routing graph assigns them different traversal costs even though they are the same kind of space. The root cause is leaf granularity drifting per source. Collapse near-duplicate leaves and express the distinction as an attribute:
def collapse_leaves(leaf: str, alias_map: dict[str, str]) -> str:
"""Fold near-duplicate leaf labels onto one canonical type."""
canonical = alias_map.get(leaf.strip(), leaf.strip())
if canonical != leaf:
logger.info("Collapsed leaf %r -> %r", leaf, canonical)
return canonical
# alias_map = {"Huddle": "Conference_Room", "Conf_Rm": "Conference_Room"}
KeyError on leaf lookup after a sync — a path degraded from ["Healthcare", "Clinical", "Exam_Room"] to ["Healthcare", "Exam_Room", ""] because an upstream column was dropped, and the empty trailing element slipped past a length-only check. Validate that no path element is empty, not merely that there are three of them, so the broken row is rejected at the boundary instead of crashing the leaf lookup later.
Integration Point
This design step sits upstream of everything that consumes a POI. The vocabulary you settle here is the controlled list the POI Taxonomy & Classification pipeline freezes into its pydantic validator, and the leaf types you keep shallow become the nodes the routing graph weights. Because every leaf binds to geometry, the taxonomy only behaves once each POI sits in a metric frame established by a consistent Indoor Coordinate Reference System — classify before that and the spatial within bind that gates routability passes or fails by units. Downstream, the resilient Fallback Routing Architectures assume every POI shares one taxonomy when they prune restricted edges, and the published vocabulary travels in the same FeatureCollection envelope defined by JSON Schema Design for Indoor Maps so SDKs read one shape. Wire the granularity audit into CI Gating for Map Updates so an over-classified taxonomy fails the build before it reaches a published map.
Frequently Asked Questions
How large should the tier-1 class enumeration be?
Small enough that every consumer can exhaustively handle it — around eight to ten values. Tier 1 exists so that routing can switch on space_class without a default branch, which is only safe if the set is closed and rarely changes. Every value you add is a value that every client, tile style and routing profile must learn, so the bar for admission is that routing behaviour genuinely differs: a stair and an elevator route differently and both deserve a class, whereas a meeting room and an office route identically and belong in tier 2. If you find yourself wanting an eleventh class, the question to ask is what would break if it were a tier-2 category instead; usually the answer is nothing.
What happens to categories a building uses that the taxonomy does not have?
They are quarantined, not invented. The space keeps its tier-1 class, so it stays routable and renderable, its category is set to null, and the original source string is retained alongside it for curation. That gives you three things a guess would not: the space still works, the unresolved count is a number you can gate a publish on, and the curator sees the real term rather than someone’s approximation of it. Once a term appears in several buildings it is either added to tier 2 or given an alias to an existing category, and the alias table absorbs it permanently. The anti-pattern is mapping an unknown term to the nearest-looking category at ingest time, because that guess becomes indistinguishable from a curated value the moment it is published.
Does the taxonomy need to match IMDF or IndoorGML?
It needs to map onto them cleanly, which is not the same as matching them. Publishing to Apple Maps requires IMDF’s closed category enumeration, and an internal taxonomy that mirrors IMDF exactly would be constrained by someone else’s model for every internal use as well. The practical arrangement is to keep an internal taxonomy shaped by your own routing and search needs, and maintain an explicit mapping table to each external standard at the publishing boundary — which is exactly where the Apple Maps IMDF integration does its work. What the internal taxonomy must guarantee is that such a mapping is possible: if two internal categories collapse to one external one that is fine, but an internal category with no external equivalent at all is a publishing problem you want to discover during design rather than at submission.
Related
- POI Taxonomy & Classification
- Defining Indoor CRS for Multi-Building Campuses
- Converting CAD Elevations to Indoor Z-Levels
This page sits under the POI Taxonomy & Classification collection, part of the Indoor Mapping Architecture & Standards reference.