Skip to content

feat #10: add validated administrative area lookup with boundary polygons - #11

Open
manjudr wants to merge 12 commits into
developmentfrom
feat/area-gazetteer-tool
Open

feat #10: add validated administrative area lookup with boundary polygons#11
manjudr wants to merge 12 commits into
developmentfrom
feat/area-gazetteer-tool

Conversation

@manjudr

@manjudr manjudr commented Sep 3, 2026

Copy link
Copy Markdown
Member

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": "LGD", "areaCode": "466", "areaLevel": "District" }

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,097 rows across Country, State, District, Block and PostalCode.
  • 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. 21 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).

Status

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

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

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
11 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 validate.py found, all fixed

  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.

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
    ~18 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
  • 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.

Resolves an OpenAgriNet AdministrativeAreaReference (codeScheme, areaCode,
areaLevel) to a name, parent chain and coordinate via a cached CSV snapshot,
so coverageAreas resolution needs no live API or database.

Two stages:
  build_gazetteer.py  pulls LGD codes and names (GODL-India) into a dated
                      snapshot: 25,097 rows across Country, State, District,
                      Block and PostalCode.
  join_geometry.py    joins published boundary layers onto those LGD codes
                      and fills latitude, longitude, bbox and provenance.

Boundary geometry has two selectable sources. Survey of India is the default
because it is openly licensed; --source lgd uses BharatMaps geometry, which
resolves Block level as well but is not openly licensed. Every row records
which layer supplied its point, and the manifest records the licence.

The join keys on LGD codes only, never names: 117 areas join on a stable code
while the boundary layer spells the name differently. Points are area-weighted
centroids with an interior-point fallback for concave shapes; unmatched rows
inherit their parent's point, marked as such in point_method.

Standalone build-time utility. Adds no Go package and is not imported by the
adapter runtime. Stdlib Python only.

See tools/gazetteer/README.md for execution steps, per-source detail, the
stored schema, measured coverage and known data-quality outliers.
Reduce the README to what an operator needs: the purpose, the single refresh
command, and an appendix stating which source fills which columns. 422 lines
down to 138.

Also fix a defect the rewrite surfaced while verifying the refresh command:
build_gazetteer.py caught only HTTPError, so a dropped TLS connection during
a download produced a raw traceback instead of a message. Both stages now
catch URLError, retry transient failures with backoff, and remove any partial
file. HTTP status errors are still fatal without retry, since a 404 means the
asset is genuinely not published.
Adds the built lookup table so consumers can use it without running the
pipeline: 25,097 rows resolving Country, State, District, Block and PostalCode
to a name, parent and coordinate. Geometry is from Survey of India (openly
licensed); every row records its source layer in geometry_source.

The download cache (~500 MB) and the optional boundaries.geojsonl export
(335 MB) remain gitignored, as both are reproducible from the scripts.
"Gazetteer" is the standard geospatial term but needs looking up, so the tool is
renamed to say what it does. The word is removed everywhere rather than only in
the directory name, so nothing is left half-renamed:

  tools/gazetteer/          -> tools/area-lookups/
  build_gazetteer.py        -> build_areas.py
  data/gazetteer/           -> data/areas/
  gazetteer.csv             -> areas.csv
  --gazetteer               -> --areas

No behaviour change. Verified the pipeline still runs: 25,097 rows, State 36/36,
District 728/784, containment 764/764 clean.
Full refresh re-run with the renamed scripts, which also verifies stage 1's
download path end to end. manifest.json now records build_areas.py as its
generator.
The rename commit staged the path moves but not the in-file edits, leaving
join_geometry.py defaulting to the old data/gazetteer path and still exposing
--gazetteer. This carries the actual content: renamed flags, defaults, docstrings
and User-Agent.
@manjudr manjudr changed the title feat #10: add administrative area gazetteer for coverageAreas resolution feat #10: add administrative area lookup for coverageAreas resolution Sep 3, 2026
A coded coverageArea is an area, not a point, so resolving one to a centroid
cannot answer whether a location falls inside it. Add areas.geojsonl: one
simplified outline per area, keyed identically to areas.csv, with has_polygon
in the CSV so a consumer can tell an outline exists without opening it.
Simplification is tolerance-scaled per part, holding the district layer to
11 MB against 460 MB at full fidelity for a 0.23% worst-case area error.

Add validate.py as a third stage. It reimplements point-in-polygon rather than
importing it, so a bug in the join cannot pass by agreeing with itself, and it
exits non-zero to gate a refresh. It found four defects in the CSV, all fixed
here:

- 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, so walking a
  parent chain resolved the wrong area. Add parent_level.
- Hairline slivers inflated bounding boxes. A 0.1 m2 splinter stretched
  Hailakandi 41 km into a neighbour and a 1 m2 spike pulled Kancheepuram's
  western edge out 16 km, both of which would make a bbox prefilter match
  points well outside the area. Parts are now floored on area, not extent.
- Genuine small parts were dropped by simplification, silently losing extent.
  The tolerance now backs off rather than discarding a part.
- Sundaragada's point sat outside its own simplified outline, and 62 child
  Blocks had already inherited the pre-correction coordinate. Points are
  re-derived from the shipped outline before inheritance runs.

Both sources and every flag combination now pass validation.
@manjudr

manjudr commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Update: polygons added, and the CSV is now validated

A coded coverageArea is an area, not a point. Resolving one to a centroid cannot answer whether a location falls inside it, so this adds the outlines and a validation stage.

New artifact

areas.geojsonl — one simplified Polygon/MultiPolygon per area, keyed on the same (code_scheme, area_code, area_level) as the CSV.

default (soi) --source lgd
Polygons 764 (State, District) 6,924 (+ Block)
Size 11 MB 34.5 MB
Worst area error 0.23% 0.83%
Worst bbox shift 102 m 130 m

Full fidelity would be ~460 MB. The tolerance is scaled per part rather than fixed, because a flat 110 m costs 2% of Lakshadweep's 30 km² while being negligible on a 7,000 km² district; that alone cut worst-case area error from 2.14% to 0.23%.

has_polygon in the CSV says whether an outline exists, so a consumer never has to open the second file to find out.

New stage: validate.py

21 checks, exits non-zero so a refresh is gated on it. It reimplements point-in-polygon rather than importing it, so a bug in the join cannot pass by agreeing with itself.

python3 build_areas.py && python3 join_geometry.py && python3 validate.py

It found four defects, all fixed in this branch:

  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 simultaneously (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. Both would make a bbox prefilter match points well outside the area. Parts are now floored on area, not extent — the two are different, which is why an extent-based check missed it.
  3. Genuine small parts were dropped by simplification, silently losing real extent. The tolerance now backs off rather than discarding a part.
  4. Sundaragada's point fell outside its own simplified outline, and 62 child Blocks had already inherited the pre-correction coordinate. Points are now re-derived from the shipped outline before inheritance runs.

Both sources and every flag combination (--no-inherit, --simplify 0, --strict) pass.

Two things for review

  • PostalCode has no outline at any setting — 17,184 rows, 68% of the table. No boundary source publishes pincode geometry, so those rows carry their district's point. Polygons fix the levels that have geometry; they don't fix pincodes.
  • Repo size. Committing areas.csv + areas.geojsonl adds ~18 MB per refresh, permanently. Worth deciding whether these should be release assets instead of tracked files before this merges.

The BharatMaps licence question is unchanged: --source lgd is what adds Block polygons, and its upstream is not openly licensed.

@manjudr manjudr changed the title feat #10: add administrative area lookup for coverageAreas resolution feat #10: add validated administrative area lookup with boundary polygons Sep 3, 2026
Every example in the OpenAgriNet schema packs that names a State uses
ISO-3166-2, never LGD, and the lookup carried no ISO-3166-2 rows at all.
An `{"codeScheme": "ISO-3166-2", "areaCode": "IN-KA"}` reference matched
nothing, so the planned ONIX plugin would have published no geometry for
the specs' own examples.

Add the ISO 3166-2:IN crosswalk to stage 1: 36 current codes covering
every State and Union Territory, plus the 7 ISO has withdrawn but
publishers still send. This repository was itself sending IN-TG, retired
in favour of IN-TS in November 2023.

Alias rows hold their own copy of the point, box and outline rather than
a reference, so resolving an ISO code is one exact-match lookup on
(code_scheme, area_code, area_level) and the plugin needs no chain
walking. The new same_as column records provenance and lets the
validator prove the copy has not drifted; nothing has to read it.

Stage 2 fills aliases between joining and inheriting, so an ISO row takes
its state's real geometry instead of an inherited country-level point.
Verified: all 43 fill even under --no-inherit.

validate.py gains 6 checks, each confirmed to fail when broken
deliberately: self-reference, dangling same_as, cyclic chains, geometry
that differs from its target, aliases left without a coordinate, and
has_polygon=true with no feature under the alias's own key. The stats
table gains an alias column so rows still reconcile against
joined + inherited + empty.

Costs 43 rows and 2.9 MB of repeated state outlines. Code scanning every
feature for containment should skip rows with a non-empty same_as.

Refreshed end to end, both sources, all flag combinations pass.
@manjudr

manjudr commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Pushed d561814 — the lookup now resolves ISO-3166-2 state codes.

Why this was needed. Walking a real event through the lookup by hand rather
than testing it: every example in the OpenAgriNet schema packs that names a
State uses ISO-3166-2, never LGD, and this snapshot carried zero
ISO-3166-2 rows. Karnataka existed only as LGD/29/State, so

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

matched nothing. The plugin would have published no geometry for the specs' own
examples.

What changed. The ISO 3166-2:IN crosswalk in build_areas.py: 36 current
codes covering every State and Union Territory, plus the 7 ISO has withdrawn
but publishers still send — IN-TG was retired for IN-TS in November 2023
and network-specs was still emitting it (fixed there in 7263df3).

Alias rows carry their own copy of the point, box and outline rather than a
reference. That is the point of the design: resolving an ISO code stays a
single exact-match lookup on (code_scheme, area_code, area_level), so the
plugin implements no chain walking. The new same_as column is provenance and
validation only — nothing reads it.

fill_aliases() runs between joining and inheriting. Ordering matters: any
later and an ISO state would inherit a country-level point from
ISO-3166-1/IN. All 43 fill even under --no-inherit, which is the evidence
they take real state geometry.

Verified against the refreshed files:

Reference Result
ISO-3166-2 / IN-KA / State MultiPolygon, byte-identical to LGD/29
ISO-3166-2 / IN-MH (no areaLevel) MultiPolygon — inferred, ISO only codes states
ISO-3166-2 / IN-TG (withdrawn) Polygon
LGD / 765 (no areaLevel) refused — ambiguous across levels, by design

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

Rebuilt from a deleted snapshot with the documented chain — exit 0, PASS,
--strict also 0. 25,140 rows / 21 columns / 807 polygons. --source lgd
passes too (6,967 polygons).

Two things to know. The 43 state outlines now appear twice, 2.9 MB of a
14.3 MB file, so code scanning every feature for containment must skip rows
with a non-empty same_as. And the stats table gained an alias column,
because State otherwise read "79 rows, 36 joined, 0 inherited, 0 empty" with 43
rows in no bucket.

Unrelated gap found while testing and logged on #10: IN-PIN/560001 has no row
at all. The source is pincode_villages, so a PIN code with no village mapping
never appears — 560003 and 560004 are present, 560001 is not. Pre-existing, and
distinct from the known "PostalCode has no boundary geometry" item.

The README covered how to refresh the snapshot but not how to consume it,
so the resolution rules that matter lived only in the code: key on three
fields and never on areaName, infer areaLevel only where the scheme
allows it, gate on has_polygon, and reject inherited points.

Replaces the ISO-3166-2 prose with five steps and a three-line example.
.DS_Store and .claude were already ignored, but gitignore patterns match
exact names, so .claude did not cover .claude-flow. Two directories of
per-session scratch were surfacing as untracked; removed, neither held
tracked content.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant