POI Taxonomy & Classification: A Production Pipeline

A point-of-interest (POI) taxonomy is the semantic contract that turns raw spatial exports into routable, queryable assets, and it is one of the load-bearing stages of the Indoor Mapping Architecture & Standards reference — the classification a POI carries directly sets its routing weight, its accessibility eligibility, and its search relevance. Get the schema wrong and the routing graph degrades into heuristic guesswork: suboptimal paths, broken accessibility routing, and POIs that exist on the map but cannot be reached. This page covers the implementation pipeline for automated classification, spatial validation, and graph-ready attribute mapping.

The Problem: Free-Text Categories That Silently Break Routing

The failure this pipeline prevents rarely throws an exception. A facilities CMDB exports a room labelled Conf Rm 3 (huddle), the next nightly sync renames it Huddle / Conference, and a third source calls it MTG-3. None of these crash anything — they just resolve to three different taxonomy leaves, so the routing graph attaches three different traversal costs to the same physical space, and an accessibility filter that keys on the string Conference silently drops the room the night someone abbreviates it.

The symptom reaches users as routing that is plausibly wrong. A wayfinding engine routes a wheelchair user up a stairwell because a misclassified vertical-circulation node inherited the default corridor weight instead of the elevator weight. A search for “nearest restroom” returns a janitorial closet because both were flattened to a generic service category. The root cause is always the same: each upstream source — CAD, BIM, a CMDB, a space-management tool — ships its own free-text categories with its own spelling, depth, and delimiter, and nothing forces them onto a single controlled vocabulary before distance-based logic runs. A POI taxonomy is the contract that does exactly that, and this pipeline is its enforcement layer.

Prerequisites & Dependencies

Before implementing the classifier, pin the libraries and agree the assumptions the rest of the pipeline depends on:

  • pydantic (v2) — strict, typed schema enforcement at ingestion. Validation runs once, at the boundary, so no downstream stage re-checks types it could have inherited.
  • geopandas / shapely — spatial joins and geometry predicates (within, intersects) for binding each POI to a floor footprint and flagging spatial outliers.
  • A defined coordinate contract. Every POI must already sit in a metric, orthogonal, survey-anchored frame. Geometry only reaches this stage after it has been projected into a consistent Indoor Coordinate Reference System; classifying coordinates that have not been normalized produces spatial joins that pass in CAD units and fail in metres.
  • Source attributes already mapped. The raw room/space attributes this pipeline classifies come from the attribute mapping from blueprints step — this stage assumes block names and CAD layers have already been lifted into structured fields, not that it is parsing drawing text itself.

The output contract is a FeatureCollection of POIs, each carrying an immutable identifier, an ordered classification path, a numeric routing_weight, and an accessibility flag — the same GeoJSON envelope used everywhere else in the reference so the routing graph consumes one shape, not several.

Architecture: One Controlled Vocabulary, Three Tiers

The taxonomy is a deterministic three-tier hierarchy that maps cleanly onto routing-graph nodes. Each tier narrows the previous one, and only the leaf tier (L3) corresponds to a routable entity:

  • L1 (Domain) — high-level facility context: Healthcare, Corporate, Retail, Transit.
  • L2 (Category) — functional grouping: Clinical, Administrative, Amenity, Circulation.
  • L3 (Type) — the discrete, routable entity: Exam_Room, Restroom, Elevator, Security_Checkpoint.

Every POI resolves to exactly one L3 leaf. Multi-parent membership is expressed with attribute tags, never with a second path, because a node that lives under two parents makes an A*/Dijkstra search double-count its edges or invent phantom corridors. The depth and granularity trade-offs — why three tiers beats five in production — are worked through in best practices for indoor POI taxonomy; this page implements the pipeline that enforces whatever vocabulary that design fixes.

POI classification pipeline as a left-to-right data flow A raw POI export carrying free-text categories and CAD geometry enters on the left. It passes through four chained stages: stage 1 normalizes the legacy label into a canonical L1/L2/L3 path; stage 2 validates it against the frozen pydantic schema and controlled vocabulary; stage 3 assigns a routing_weight scaled by the accessibility factor; stage 4 spatially binds each POI with a within() test against the floor footprint. The accepted track converges into a GeoJSON FeatureCollection carrying poi_id, classification_path, routing_weight and the accessibility flag, which fans out to three consumers: the routing graph, the search index, and the accessibility filter. Records that fail validation, fall outside the weight range, or land outside the footprint branch downward into a rejection log that records the row index and reason and is never dropped silently. Deterministic classification pipeline: free-text in, one routing-ready FeatureCollection out accept Raw POI export free-text category + CAD geometry 1 · Normalize legacy label map → L1 / L2 / L3 2 · Validate pydantic (frozen) controlled vocab 3 · Assign weight routing_weight × accessibility 4 · Spatial bind within() floor footprint gate GeoJSON FeatureCollection poi_id · classification_path routing_weight · a11y flag ValidationError weight out of range outside footprint Rejection log + alert logged with row index + reason — never dropped silently one shape, three consumers Routing graph traversal cost + ADA Search index nearest-X relevance Accessibility filter wheelchair / stroller

Each L3 type carries a standardized attribute payload the routing graph consumes to compute traversal cost, enforce ADA/WCAG constraints, and manage fallback priority. The mandatory fields are:

Field Type Notes
poi_id str Deterministic, immutable across sync cycles (see Performance notes)
classification_path list[str] Ordered [L1, L2, L3]; exactly three elements
is_accessibility_compliant bool Drives wheelchair / stroller routing eligibility
operational_hours str ISO 8601 schedule or 24/7
routing_weight float 0.11.0; 1.0 = standard corridor traversal cost

Recommended optional fields — capacity, requires_badge_access, maintenance_status, floor_level — let the graph express access control and per-floor binding without inflating the core schema.

Step-by-Step Implementation

The module below takes a raw export to a graph-ready FeatureCollection. Each step is runnable on its own; together they form the ingestion pipeline.

1. Declare the schema as a typed contract

Treat the taxonomy as infrastructure-as-code. A frozen pydantic model makes the vocabulary explicit, reviewable, and enforced at the boundary — the controlled vocabulary lives in the validator, so an unknown L1/L2 value is rejected, never coerced.

import logging
import uuid
from typing import List, Optional

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

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

ALLOWED_L1 = {"Healthcare", "Corporate", "Retail", "Transit"}
ALLOWED_L2 = {"Clinical", "Administrative", "Amenity", "Circulation"}


class POIAttributes(BaseModel):
    """Immutable, validated attribute payload for a single routable POI."""

    model_config = ConfigDict(frozen=True)

    poi_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    classification_path: List[str] = Field(min_length=3, max_length=3)
    is_accessibility_compliant: bool
    operational_hours: str
    routing_weight: float = Field(ge=0.1, le=1.0)
    capacity: Optional[int] = None
    requires_badge_access: bool = False
    maintenance_status: str = "active"

    @field_validator("classification_path")
    @classmethod
    def validate_taxonomy(cls, path: List[str]) -> List[str]:
        if path[0] not in ALLOWED_L1:
            raise ValueError(f"Invalid L1 domain: {path[0]!r}")
        if path[1] not in ALLOWED_L2:
            raise ValueError(f"Invalid L2 category: {path[1]!r}")
        return path

2. Normalize legacy classification paths

Upstream sources disagree on delimiter and depth. The normalizer coerces strings and lists into a canonical [L1, L2, L3], and a fallback map rescues known legacy labels from older CAD/CMDB exports so a single renamed leaf does not silently fall through to the reject log.

def normalize_classification_path(raw_path: "str | List[str]") -> List[str]:
    """Coerce a raw taxonomy value into a canonical [L1, L2, L3] path."""
    if isinstance(raw_path, str):
        parts = [p.strip() for p in raw_path.split("/") if p.strip()]
    else:
        parts = [str(p).strip() for p in raw_path if p]

    # Rescue known legacy leaf labels from older exports.
    legacy_leaf_map = {
        "Restroom": ["Corporate", "Amenity", "Restroom"],
        "Elevator_Core": ["Corporate", "Circulation", "Elevator"],
        "Patient_Room": ["Healthcare", "Clinical", "Exam_Room"],
        "Security": ["Corporate", "Administrative", "Security_Checkpoint"],
    }
    if len(parts) == 3:
        return parts
    leaf = parts[-1] if parts else ""
    if leaf in legacy_leaf_map:
        logger.info("Rescued legacy leaf %r -> %s", leaf, legacy_leaf_map[leaf])
        return legacy_leaf_map[leaf]
    return parts  # left as-is; schema validation will reject if still malformed

3. Assign a routing weight from the leaf type

Traversal cost is a function of the L3 type and accessibility eligibility. Compliant routes are made cheaper so the pathfinder prefers them for users who need them, without removing the alternatives.

def assign_routing_weight(l3_type: str, is_accessible: bool) -> float:
    """Map an L3 type + accessibility flag to a 0.1-1.0 traversal cost."""
    base_weights = {
        "Corridor": 1.0, "Elevator": 0.9, "Escalator": 0.8,
        "Stairwell": 0.6, "Amenity": 0.5, "Restroom": 0.4,
        "Security_Checkpoint": 0.3, "Exam_Room": 0.2,
    }
    weight = base_weights.get(l3_type, 0.7)  # conservative default for unknowns
    # Accessibility-eligible routes get a lower cost so the pathfinder prefers them.
    return round(weight * 0.85 if is_accessible else weight, 2)

4. Validate and classify the ingestion batch

The batch driver wires the steps together: normalize, classify, weight, then validate against the schema. Rejected records are logged with their index — never dropped silently — so the data-engineering team can trace every failure back to a source row.

import geopandas as gpd


def validate_and_classify_pois(raw_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Validate, classify, and weight a raw POI batch into a graph-ready frame."""
    accepted: list[dict] = []
    rejected = 0

    for idx, row in raw_gdf.iterrows():
        try:
            raw_path = row.get("classification_path", row.get("category", []))
            path = normalize_classification_path(raw_path)
            leaf = path[-1] if path else "Unknown"
            is_acc = bool(row.get("is_accessibility_compliant", False))

            poi = POIAttributes(
                classification_path=path,
                is_accessibility_compliant=is_acc,
                operational_hours=row.get("operational_hours", "24/7"),
                routing_weight=assign_routing_weight(leaf, is_acc),
                capacity=row.get("capacity"),
                requires_badge_access=bool(row.get("requires_badge_access", False)),
                maintenance_status=row.get("maintenance_status", "active"),
            )
            accepted.append({**poi.model_dump(), "geometry": row.geometry})
        except ValidationError as exc:
            rejected += 1
            logger.warning("Schema rejection at row %s: %s", idx, exc.errors()[0]["msg"])
        except Exception as exc:  # noqa: BLE001 - log and continue ingestion
            rejected += 1
            logger.error("Unexpected error at row %s: %s", idx, exc)

    if not accepted:
        raise RuntimeError("Pipeline halted: 0 valid POIs ingested.")

    logger.info("Classification complete. accepted=%d rejected=%d", len(accepted), rejected)
    crs = raw_gdf.crs or "EPSG:3857"
    return gpd.GeoDataFrame(accepted, crs=crs)

5. Bind to the floor footprint and emit GeoJSON

Classification alone does not guarantee routability. Each POI must fall inside its floor footprint and clear restricted zones (mechanical rooms, server closets) before it earns a graph node. The spatial gate uses shapely’s within predicate; the result serializes to the standard FeatureCollection envelope.

from shapely.geometry import Polygon


def bind_and_export(classified: gpd.GeoDataFrame, footprint: Polygon) -> dict:
    """Drop POIs outside the floor footprint, then emit a GeoJSON FeatureCollection."""
    inside_mask = classified.within(footprint)
    outside = int((~inside_mask).sum())
    if outside:
        logger.warning("Dropping %d POIs outside the floor footprint", outside)

    bound = classified.loc[inside_mask]
    if bound.empty:
        raise RuntimeError("No POIs fall within the supplied footprint.")

    logger.info("Spatially bound %d POIs to footprint", len(bound))
    return bound.__geo_interface__  # GeoJSON FeatureCollection

A bound POI serializes to a Feature whose properties carry the full classification contract:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "Point", "coordinates": [125.5, 88.2, 0.0] },
      "properties": {
        "poi_id": "L02-RM-1184",
        "classification_path": ["Healthcare", "Clinical", "Exam_Room"],
        "is_accessibility_compliant": true,
        "operational_hours": "06:00-22:00",
        "routing_weight": 0.17,
        "requires_badge_access": false,
        "maintenance_status": "active"
      }
    }
  ]
}

Edge Cases & Gotchas

Classification bugs surface as routing anomalies and missing search results, not exceptions. The patterns below cover the failures that reach production most often.

Symptom Root cause Diagnostic step Resolution
Wheelchair routes sent up stairwells is_accessibility_compliant inverted during CSV/JSON parse Cross-tab routing_weight vs flag per L3 type Re-derive the flag at source; re-weight the batch
Restroom search returns a closet Distinct spaces flattened to one L3 leaf Count POIs per leaf; look for over-loaded leaves Split the leaf; tag variants instead of merging
Path through a wall or mechanical room POI geometry outside footprint, never spatially bound ~classified.within(footprint) count > 0 Run step 5; flag outliers for manual correction
Whole batch rejected after a sync Upstream changed delimiter or dropped L2/L3 column Compare path cardinality to last good snapshot Reject batches where len(path) < 3; alert pipeline
Routing weight ignored routing_weight outside 0.11.0 clipped to default ~weights.between(0.1, 1.0) Fix source; clip to bounds as a stopgap

A particularly nasty variant is classification drift across sync cycles: a path degrades from ["Healthcare", "Clinical", "Exam_Room"] to ["Healthcare", "Exam_Room", ""] because the source dropped a column, and the empty trailing element passes a naive length check while breaking the leaf lookup. Always validate that no path element is empty, not merely that there are three of them.

Validation Output

The contract is verifiable, so gate it before any graph is built. The audit below confirms every weight is in range and every path resolves to a known leaf — deploy it in CI alongside the rest of the CI gating for map updates so a malformed taxonomy can never reach a published map.

def audit_classified_batch(classified: gpd.GeoDataFrame) -> bool:
    """Assert routing weights and classification paths are well-formed."""
    bad_weight = classified[~classified["routing_weight"].between(0.1, 1.0)]
    empty_leaf = classified[classified["classification_path"].apply(lambda p: "" in p)]

    if not bad_weight.empty:
        logger.error("Out-of-range routing_weight on %d POIs", len(bad_weight))
        return False
    if not empty_leaf.empty:
        logger.error("Empty path element on %d POIs (drift)", len(empty_leaf))
        return False

    logger.info("Batch audit passed: %d POIs valid", len(classified))
    return True

A correct run prints INFO: Batch audit passed: 1184 POIs valid. A batch that suffered the column-drop drift prints ERROR: Empty path element on 37 POIs (drift) and returns False, failing the build before the broken classification reaches the routing graph. The signature is unambiguous: an empty-leaf error points at the source schema, an out-of-range weight error points at the weight assignment, and the two never get confused.

Performance & Scale Notes

The classification arithmetic is trivial; the costs are validation overhead and identifier stability. Two practices dominate at scale:

  • Deterministic identifiers, not random UUIDs. Derive poi_id from stable source attributes — f"{floor_level}-{room_number}" — rather than uuid4(). A deterministic id makes the nightly sync an idempotent upsert: the same room keeps the same node across runs, so the routing graph and any cached paths stay valid. Random ids reshuffle node membership every sync and invalidate every downstream cache.
  • Batch the validation. For facilities above ~10,000 POIs, validating row-by-row through the pydantic model is the bottleneck. Validate with a TypeAdapter(list[POIAttributes]) over the whole batch, or pre-filter obvious failures with vectorised pandas masks before constructing models — either keeps the hot path inside C loops and cuts ingestion latency substantially.

Attach a topology hash to the exported FeatureCollection and only re-run classification when the source geometry or the vocabulary actually changes; the classified output then merges per floor level and feeds the routing graph and its resilient fallback routing architectures, which assume every POI in the stack shares one taxonomy. Maintain an audit log of every rejection and weight override — facilities compliance teams require that traceability for ADA routing claims and emergency egress mapping.

Frequently Asked Questions

Why exactly three tiers — why not a deeper, more expressive hierarchy?

Depth is routing latency and ambiguity, not richness. Every extra tier multiplies the leaf space a pathfinder and a search index must reason over, and deep hierarchies invite the same physical space to be classified two different ways. Three tiers (domain, category, type) is enough to drive weighting, accessibility filtering, and search while keeping each POI on a single leaf. Express everything else — wheelchair access, badge requirements, department ownership — as attribute tags on the leaf, not as more tiers.

How do I stop accessibility flags from inverting during ingestion?

Treat the flag as a typed boolean at the schema boundary, never as a truthy string. A CSV cell of "false" is a non-empty string and evaluates truthy, which is exactly how a compliant route becomes non-compliant. Coerce explicitly (value.strip().lower() == "true") before the pydantic model sees it, and add the cross-tab audit from the Edit Cases table so an inverted flag shows up as elevators priced like stairwells before it reaches a user.

Should a POI ever belong to two classification paths?

No. A second path makes an A*/Dijkstra search double-count the node’s edges or fabricate phantom corridors, because the same geometry resolves to two graph nodes. When a space genuinely serves two functions — a cafeteria that is also an emergency assembly point — keep one leaf and add a tag (assembly_point: true). Tags are queryable without forking the routing topology.

How do classification and coordinates stay in agreement?

Bind both to the same frame before either is built. If POIs are classified in one coordinate space and the routing graph is built in another, the spatial within join in step 5 passes or fails depending on units, and proximity queries drift intermittently. Register POI geometry through the same Indoor Coordinate Reference System the graph uses, then classify — never the reverse.

This page is part of the Indoor Mapping Architecture & Standards section of the Indoor Mapping & Wayfinding Automation reference.