Skip to content

Resolve coded coverageAreas to geometry via a cached, validated area lookup #10

Description

@manjudr

Problem

coverageAreas in the OpenAgriNet schema packs is a oneOf between a coded
AdministrativeAreaReference and a Beckn GeoJSONGeometry. Publishers prefer
the coded form because it is short and stable:

{ "codeScheme": "ISO-3166-2", "areaCode": "IN-KA", "areaLevel": "State" }

Consumers that need to draw a map, run a distance filter, or test whether a
location falls inside a provider's coverage need real geometry. There is no
lookup that turns one into the other, and doing it at request time would add a
runtime dependency on a government API.

An ONIX plugin will do this denormalization at publish time, reading a cached
snapshot. That makes the snapshot's correctness the gating concern: a wrong
coordinate here becomes wrong geometry in a published catalog.

Approach

A refreshable snapshot, built by three stdlib-Python scripts under
tools/area-lookups/. Build-time utility only: not a plugin, not part of the
adapter runtime, adds no Go package.

cd tools/area-lookups
python3 build_areas.py && python3 join_geometry.py && python3 validate.py
  • build_areas.py pulls LGD codes and names (GODL-India) into a dated
    snapshot: 25,140 rows across Country, State, District, Block and PostalCode,
    including the ISO-3166-2 crosswalk.
  • join_geometry.py joins published boundary layers onto those LGD codes,
    filling coordinates and bounding boxes, and writes one simplified outline per
    area.
  • validate.py tries to break the result. 27 checks, exits non-zero, so a
    refresh is gated on it. Point-in-polygon is reimplemented rather than
    imported, so a bug in the join cannot pass by agreeing with itself.

Two output files in data/areas/latest/, joined on the same
(code_scheme, area_code, area_level) key, plus a manifest.json recording
provenance, licence, coverage and every anomaly for the run:

File Holds
areas.csv one row per area: identity, parent, a representative point, a bounding box
areas.geojsonl one simplified Polygon/MultiPolygon per area, for containment tests

A coded area is an area, not a point, so the outline is the part that answers
containment. The point remains useful for map pins and distance sorting, and
has_polygon lets a consumer tell from the CSV alone whether an outline exists.

The join keys on LGD codes only, never names — 117 areas join on a stable code
while the boundary layer spells the name differently (LGD 466 is Ahilyanagar;
every boundary layer still says Ahmednagar).

What the plugin has to implement

Deliberately almost nothing. One exact-match lookup per file:

key = (codeScheme, areaCode, areaLevel)
row = areas_csv[key]
if row.has_polygon: return areas_geojsonl[key]   # already RFC 7946
else:               return Point(row.longitude, row.latitude)

Two rules it does have to respect:

  • Infer a missing areaLevel only where the scheme permits it. areaLevel
    is optional in the schema. ISO-3166-1 is always Country, ISO-3166-2
    always State and IN-PIN always PostalCode, so those are safe. LGD is not:
    765 codes name a State, a District and a Block at once, so a level-less LGD
    reference must fail loudly rather than resolve to whichever row is found first.
  • Check point_method before publishing a fallback point. centroid and
    interior_grid are the area's own geometry; inherited:<level> is an
    ancestor's coordinate, and publishing that as the area's location is a
    factual error. That branch covers 68% of rows.

Denorm should append a geometry item beside the coded one rather than replace
it, since each coverageAreas item is a oneOf and the code is the stable,
auditable part.

ISO-3166-2 resolution

Found by walking through a real event rather than by testing: every example in
the OpenAgriNet schema packs that names a State uses ISO-3166-2, never LGD,
and the snapshot originally carried zero ISO-3166-2 rows. Karnataka
existed only as LGD/29/State, so {"codeScheme": "ISO-3166-2", "areaCode": "IN-KA"} matched nothing and the plugin would have published no geometry for
the specs' own examples.

The snapshot now carries the ISO 3166-2:IN crosswalk: 36 current codes covering
every State and Union Territory, plus the 7 ISO has withdrawn but publishers
still send. IN-TG was retired in favour of IN-TS in November 2023 and the
specs were still using it (fixed separately in network-specs).

Alias rows hold their own copy of the point, box and outline rather than a
reference, which is what keeps the plugin to a single lookup. The same_as
column records provenance and lets validate.py prove the copy has not
drifted; nothing needs to read it.

ISO assigns codes to States and Union Territories only, so District and Block
stay LGD-only. There is nothing below State to translate.

The cost is 43 extra rows and 2.9 MB of repeated state outlines. Code scanning
every feature for containment should skip rows with a non-empty same_as, or
it will report the same state twice.

Status

Implemented in PR #11 on feat/area-gazetteer-tool, with the built snapshot
committed. Full refresh verified end to end from a deleted snapshot; both
sources and every flag combination pass validation.

Level Rows soi points soi polygons lgd points lgd polygons
State 79 79 79 79 79
District 784 728 728 771 771
Block 7,092 0 0 6,117 6,117
PostalCode 17,184 0 0 0 0

State counts include the 43 ISO-3166-2 alias rows, which carry their state's
geometry at every setting.

No row is left without a coordinate; anything unjoined inherits its parent's
point, marked in point_method. Outlines are only ever present for an area's
own geometry, never inherited.

Simplification is tolerance-scaled per part, which holds the default snapshot
to 14.3 MB against ~460 MB at full fidelity for a 0.23% worst-case area error.
A flat tolerance cost 2.14%, because 110 m is negligible on a 7,000 km²
district but consumes 2% of Lakshadweep's 30 km².

Defects found and fixed

Four found by validate.py:

  1. parent_code was ambiguous. An LGD code is unique within a level, not
    across levels: 765 codes name a State, a District and a Block at once —
    code 35 is Andaman & Nicobar Islands, Kapurthala, and Baramulla. Walking a
    parent chain resolved the wrong area. Added a parent_level column.
  2. Hairline slivers inflated bounding boxes. A 0.1 m² splinter stretched
    Hailakandi's bbox 41 km into a neighbouring district; a 1 m² spike pulled
    Kancheepuram's western edge out 16 km. Either makes a bbox prefilter match
    points well outside the area. Parts are now floored on area, not extent —
    these slivers have a large extent and no area, which is why an extent-based
    check missed them.
  3. Genuine small parts were dropped by simplification, silently losing real
    extent. The tolerance now backs off rather than discarding a part.
  4. A point fell outside its own simplified outline (Sundaragada), and 62
    child Blocks had already inherited the pre-correction coordinate. Points are
    now re-derived from the shipped outline before inheritance runs.

One found by walking a real event through the lookup by hand:

  1. No ISO-3166-2 rows existed at all, so the scheme every example
    actually uses resolved to nothing. See above.

validate.py grew 6 checks for the alias rows, each confirmed to fail when
broken deliberately: self-reference, dangling same_as, cyclic chains,
geometry that differs from its target, an alias left without a coordinate, and
has_polygon=true with no feature under the alias's own key.

Open decision — geometry source licence

Two selectable sources, and this needs a call before derived coordinates are
published:

  • --source soi (default) — Survey of India, openly licensed. Cannot
    resolve Block at all: SOI_Subdistricts carries no LGD field.
  • --source lgd — BharatMaps / NIC. Resolves Block (6,117 points and
    outlines), but the upstream is not openly licensed.

The manifest records the licence per run and geometry_source records the
originating layer per row, so the choice is auditable either way.

Follow-ups

  • Decide the geometry source licence question above
  • Decide how consumers read the snapshot (cached by the ONIX plugin, or
    published as a release asset — areas.csv plus areas.geojsonl is
    ~21 MB per refresh and dated snapshots accumulate in git history)
  • Adjudicate the 7 parent-containment outliers reported under --source lgd
    (2 genuine upstream errors, 3 knock-ons, 2 correct Puducherry exclaves —
    reported, never auto-corrected)
  • PostalCode has no boundary source anywhere. Those 17,184 rows (68% of the
    table) keep inheriting district points and will never get an outline
  • Some urban PIN codes are missing outright, not merely without geometry.
    The source is pincode_villages, so a PIN code with no village mapping
    never appears: 560003 and 560004 are present, 560001 is not
  • Build the ONIX plugin that reads this snapshot and denormalizes
    coverageAreas at publish time

See tools/area-lookups/README.md for the refresh command and the per-source
column appendix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions