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:
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.
- 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.
- Genuine small parts were dropped by simplification, silently losing real
extent. The tolerance now backs off rather than discarding a part.
- 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:
- 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
See tools/area-lookups/README.md for the refresh command and the per-source
column appendix.
Problem
coverageAreasin the OpenAgriNet schema packs is aoneOfbetween a codedAdministrativeAreaReferenceand a BecknGeoJSONGeometry. Publishers preferthe 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 theadapter runtime, adds no Go package.
build_areas.pypulls LGD codes and names (GODL-India) into a datedsnapshot: 25,140 rows across Country, State, District, Block and PostalCode,
including the ISO-3166-2 crosswalk.
join_geometry.pyjoins published boundary layers onto those LGD codes,filling coordinates and bounding boxes, and writes one simplified outline per
area.
validate.pytries to break the result. 27 checks, exits non-zero, so arefresh 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 amanifest.jsonrecordingprovenance, licence, coverage and every anomaly for the run:
areas.csvareas.geojsonlPolygon/MultiPolygonper area, for containment testsA 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_polygonlets 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:
Two rules it does have to respect:
areaLevelonly where the scheme permits it.areaLevelis optional in the schema.
ISO-3166-1is always Country,ISO-3166-2always State and
IN-PINalways PostalCode, so those are safe.LGDis 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.
point_methodbefore publishing a fallback point.centroidandinterior_gridare the area's own geometry;inherited:<level>is anancestor'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
coverageAreasitem is aoneOfand 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, neverLGD,and the snapshot originally carried zero
ISO-3166-2rows. Karnatakaexisted only as
LGD/29/State, so{"codeScheme": "ISO-3166-2", "areaCode": "IN-KA"}matched nothing and the plugin would have published no geometry forthe 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-TGwas retired in favour ofIN-TSin November 2023 and thespecs 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_ascolumn records provenance and lets
validate.pyprove the copy has notdrifted; 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, orit will report the same state twice.
Status
Implemented in PR #11 on
feat/area-gazetteer-tool, with the built snapshotcommitted. Full refresh verified end to end from a deleted snapshot; both
sources and every flag combination pass validation.
soipointssoipolygonslgdpointslgdpolygonsState 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'sown 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:parent_codewas ambiguous. An LGD code is unique within a level, notacross 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_levelcolumn.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.
extent. The tolerance now backs off rather than discarding a part.
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:
ISO-3166-2rows existed at all, so the scheme every exampleactually uses resolved to nothing. See above.
validate.pygrew 6 checks for the alias rows, each confirmed to fail whenbroken deliberately: self-reference, dangling
same_as, cyclic chains,geometry that differs from its target, an alias left without a coordinate, and
has_polygon=truewith 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. Cannotresolve Block at all:
SOI_Subdistrictscarries no LGD field.--source lgd— BharatMaps / NIC. Resolves Block (6,117 points andoutlines), but the upstream is not openly licensed.
The manifest records the licence per run and
geometry_sourcerecords theoriginating layer per row, so the choice is auditable either way.
Follow-ups
published as a release asset —
areas.csvplusareas.geojsonlis~21 MB per refresh and dated snapshots accumulate in git history)
--source lgd(2 genuine upstream errors, 3 knock-ons, 2 correct Puducherry exclaves —
reported, never auto-corrected)
table) keep inheriting district points and will never get an outline
The source is
pincode_villages, so a PIN code with no village mappingnever appears: 560003 and 560004 are present, 560001 is not
coverageAreasat publish timeSee
tools/area-lookups/README.mdfor the refresh command and the per-sourcecolumn appendix.