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
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
unitwhose geometry lies outside itslevelfootprint is structurally fine and visibly wrong. It usually means a reprojection ran on some features and not others. - Ordinals. IMDF
level.ordinalis 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
occupantwithout ananchorreferencing aunitrenders at the venue centroid, so every tenant in the building appears in the lobby.
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
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.
Related
- Indoor Map Data Standards — where validation sits in the publishing path.
- IMDF vs. IndoorGML for Indoor Map Interchange — what the archive being validated is competing with.
- Integrating Indoor Maps with Apple Maps (IMDF) — the consumer that will read the archive once it passes.
This page is a companion to Indoor Map Data Standards, part of the Indoor Mapping Architecture & Standards section.