Validating IMDF Archives in Python

Part of Indoor Map Data Standards. The IMDF validator answers one question — is this archive well formed — and every failure that reaches a user answers a different one. This page covers both halves and the diff that sits between them.

Structural and Semantic Validation

What the IMDF validator checks and what it leaves to you A table of eight checks. The IMDF validator itself catches missing required files, schema non-conformance, categories outside the closed enumerations, and referential integrity between features. Four further checks are the publisher's responsibility: that geometry lies within its declared level, that level ordinals match the building's signage, that identifiers are stable against the previous publish, and that every occupant has an anchor. Each of those, if missed, produces a visible defect at the consumer rather than a rejection at submission. Four checks the tool does, four it cannot Check Caught by Symptom if missed Required files present ● the validator archive rejected at submission Schema conformance ● the validator archive rejected at submission Category in enum ● the validator archive rejected at submission Referential integrity ● the validator orphan features, silently dropped Geometry inside its level △ you △ a unit floating outside the venue Ordinals match signage △ you △ floor picker disagrees with the lifts Ids stable vs. last publish △ you △ every feature looks new Occupants anchored △ you △ tenants render at the venue centroid The validator is necessary and stops at structure; the bottom four are semantic.

A validator pass is not a correctness proof. Everything it checks is structural; every failure mode that reaches a user is semantic and has to be checked on the way out.

The IMDF validator checks structure: that the archive contains the required files, that each conforms to its schema, that categories come from the closed enumerations, and that references between features resolve. All of that is necessary and none of it is sufficient, because an archive can satisfy every structural rule while describing the wrong building.

The four semantic checks below the line are the ones worth writing yourself:

  • Containment. A unit whose geometry lies outside its level footprint is structurally fine and visibly wrong. It usually means a reprojection ran on some features and not others.
  • Ordinals. IMDF level.ordinal is signed with 0 at ground, and it should agree with the numeric part of the level’s own name. When it does not, the consumer’s floor picker disagrees with the building’s lift buttons.
  • Identifier stability. Covered below; the single most consequential of the four.
  • Occupant anchors. An occupant without an anchor referencing a unit renders at the venue centroid, so every tenant in the building appears in the lobby.
The four stages of validating an IMDF archive before submission A four-stage pipeline. The archive is built from the internal envelope, producing a zip. Structural validation checks required files, schema conformance and category enumerations. Semantic validation then checks geometry containment, level ordinals and occupant anchors. A final diff compares identifiers against the previous publish. Only an archive clearing all four is submittable. Structure, then meaning, then what changed a zip structurally valid semantically sound 1 Build archive from the envelope 2 Structure files, schema, enums 3 Semantics containment, ordinals, anchors 4 Diff ids vs. the last publish submittable

The diff stage is the one teams add last and miss most. An archive can be perfectly valid and still tell the consumer that every room in the venue was deleted and recreated.

Minimal Working Example

import json
import logging
import zipfile
from dataclasses import dataclass, field

from shapely.geometry import shape

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

REQUIRED = {"manifest.json", "address.geojson", "venue.geojson", "level.geojson",
            "unit.geojson", "opening.geojson"}


@dataclass
class Findings:
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)

    def ok(self) -> bool:
        return not self.errors


def validate_archive(path: str) -> Findings:
    """Structural + semantic checks on an IMDF archive, before it is submitted."""
    f = Findings()
    try:
        with zipfile.ZipFile(path) as z:
            names = {n.split("/")[-1] for n in z.namelist()}
            docs = {n: json.loads(z.read(n)) for n in z.namelist() if n.endswith(".geojson")}
    except (OSError, zipfile.BadZipFile, json.JSONDecodeError) as exc:
        f.errors.append(f"archive unreadable: {exc}")
        return f

    for missing in REQUIRED - names:
        f.errors.append(f"missing required file: {missing}")

    levels = {ft["id"]: ft for d in docs.values() if isinstance(d, dict)
              for ft in d.get("features", []) if ft.get("feature_type") == "level"}
    units = [ft for d in docs.values() if isinstance(d, dict)
             for ft in d.get("features", []) if ft.get("feature_type") == "unit"]

    # semantic 1: every unit sits inside the level it claims
    for unit in units:
        lvl = levels.get(unit["properties"].get("level_id"))
        if lvl is None:
            f.errors.append(f"unit {unit['id']} references an unknown level")
            continue
        if not shape(lvl["geometry"]).buffer(1e-7).contains(shape(unit["geometry"])):
            f.errors.append(f"unit {unit['id']} lies outside level {lvl['id']}")

    # semantic 2: ordinals should agree with the level's own name
    for lvl in levels.values():
        name = ((lvl["properties"].get("name") or {}).get("en") or "")
        digits = "".join(c for c in name if c.isdigit() or c == "-")
        if digits and int(digits) != lvl["properties"].get("ordinal"):
            f.warnings.append(f"level {lvl['id']}: name {name!r} vs ordinal "
                              f"{lvl['properties'].get('ordinal')}")

    logger.info("validation: %d error(s), %d warning(s)", len(f.errors), len(f.warnings))
    return f

The containment check uses a tiny positive buffer on the level geometry because a unit’s boundary is frequently coincident with the level’s, and exact contains is false for a shared edge. A micro-degree of tolerance keeps the check meaningful without admitting genuinely stray units.

Identifier Stability

What identifier strategy does to a consumer's view of each publish Grouped bars over five successive publishes. With randomly generated identifiers, the consumer sees one hundred percent of features as new on every publish, because no identifier survives a rebuild. With version-5 UUIDs derived from the stable internal feature id, the first publish is naturally one hundred percent new and subsequent publishes show only the features that genuinely changed — three, one, six and two percent. Derived identifiers are what make an update an update 0 25 50 75 100 1 2 3 4 5 publish number features seen as new (%) features the consumer sees as NEW (%) with uuid5 from feature_id (%)

Random ids make every publish a full replacement. Occupants attached to the old unit ids are orphaned, and the consumer re-indexes the whole venue — for a change that touched two rooms.

IMDF identifies every feature by UUID, and the consumer treats an identifier it has not seen as a new feature. Generating identifiers randomly at build time therefore tells the consumer, on every publish, that the entire venue was deleted and recreated.

The consequences are worse than the churn. Occupants reference units by id; when the unit ids change, every occupant is orphaned. Any consumer-side state keyed on feature id — a saved place, an indexed search entry, an analytics dimension — is lost.

The fix is a deterministic derivation from something stable, which the internal feature_id already is:

import uuid

NS = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")


def imdf_id(kind: str, feature_id: str) -> str:
    # Same input, same UUID, forever: a UUID v5 is a hash, not a random draw.
    return str(uuid.uuid5(NS, f"{kind}:{feature_id}"))

The namespace must itself be fixed and committed — regenerating it has exactly the same effect as using random ids, and it is an easy thing to lose in a refactor.

Common Errors & Fixes

“Feature is not contained within its level.” The classic reprojection split: some features were transformed and some were not, usually because a code path handled Polygon and missed MultiPolygon. The containment check above catches it before submission.

Occupants render in the lobby. Missing anchors. Every occupant needs an anchor feature whose unit_id points at the room it occupies; without one the consumer falls back to the venue’s display point.

The archive validates and the map shows nothing. Almost always an empty level.geojson, or levels present with no units referencing them. Assert non-empty feature counts per required file rather than trusting the schema, which permits an empty FeatureCollection.

Submission accepted, then rejected on the next publish. Usually an id-namespace change or a category mapping that was loosened. Keeping the previous archive and diffing ids and category distributions against it turns this from a mystery into a two-line report:

def diff_publish(old_ids: set[str], new_ids: set[str]) -> dict[str, int]:
    return {"added": len(new_ids - old_ids), "removed": len(old_ids - new_ids),
            "retained": len(old_ids & new_ids)}

Integration Point

Validation runs at the end of the publishing path in Indoor Map Data Standards and belongs in CI rather than in a submission checklist. Running it on every map change — not only before a submission — means a mapping gap is found by whoever introduced it.

The diff stage connects to the same idea as content-addressed versioning: knowing what changed is what makes a publish safe, and for an external standard the unit of change is the identifier rather than the content hash.

Frequently Asked Questions

Does the official validator catch everything?

It catches everything structural and nothing semantic, which is the distinction worth internalising. Required files, schema conformance, closed enumerations and referential integrity are all checked thoroughly — an archive that passes is well formed. Whether the units sit inside their levels, whether the ordinals match the lift buttons, whether the identifiers are the same ones you published last month: none of that is expressible in a schema, and all of it produces user-visible defects. Treat a validator pass as the beginning of validation rather than the end of it.

How do I test an archive without submitting it?

Load it into a map client and look at it, after the automated checks pass. The archive is a zip of GeoJSON, so unpacking it and adding each file as a source in a MapLibre or Mapbox map takes a few minutes and immediately surfaces the whole class of defects that are obvious to a human and invisible to a validator — a floor that renders somewhere unexpected, units that overlap, a venue in the wrong place. Doing this once per venue rather than once per publish is usually the right cadence.

What should happen when validation fails in CI?

Block the publish and name the failing feature. The value of validating early is entirely in the specificity of the message: “archive invalid” sends someone hunting, while “unit 4f2c-… lies outside level 2” is a five-minute fix. That means collecting all findings rather than raising on the first, and reporting them with the internal feature_id alongside the IMDF UUID, so the engineer can find the source geometry rather than only the published artefact.

This page is a companion to Indoor Map Data Standards, part of the Indoor Mapping Architecture & Standards section.