← Open the map · Neighborhood Intel · System notes · Oct 2026

How the data gets from government portals to the map

Collectors fetch public data on a schedule and write it to a local file store. When someone clicks the map, the report is built only from that store, so a lookup never waits on a slow portal. This page walks through each step.

The whole pipeline

There are two halves, and the dashed line in the middle is the most important idea in the system. Everything to its left runs on a timer and talks to the outside world. Everything to its right runs when a user asks a question, and only reads files on disk.

SCHEDULED · TALKS TO THE INTERNET PER REQUEST · READS DISK ONLY Public sources Geofabrik OSM CPCB CAAQMS UDISE+ India-WRIS RERA Karnataka Delhi GSDL ArcGIS DJB · DERC PDFs BESCOM · KSP cron · 5 cadences Collectors collect() fetch + parse → GeoJSON Store data/store/ <city>/<source>/ *.json, *.jsonl Raw cache data/cache/ PBF, PDF, XLSX Engine report(lat,lon) metrics(loc) + verdicts Map UI Leaflet HTTP API /api/report CLI ni report
Portals are slow, rate-limited or down (Overpass 502s, data.gov.in 429s, BDA timeouts). Keeping them on the left side of the line means a broken portal makes data older, never makes a lookup slow.

One adapter, two halves

Each data source is a small Python class under ni/sources/. It has two methods that never run at the same time. collect() runs on the schedule and writes files. metrics(loc) runs for each lookup and reads them back. The store between them is the only thing they share.

Cities switch adapters on in cities/<slug>.toml. National adapters (OSM, CPCB, UDISE+, CGWB) need only config to work in a new city. State adapters cover one state's portals.

RUNS ON SCHEDULE RUNS PER LOOKUP collect() 1 download or query the portal 2 parse HTML, XML, PDF, PBF 3 put() or append() to store Store GeoJSON files + manifest + history metrics(loc) 1 layer(): features in metres 2 nearest / within / containing 3 return Metric with verdict
Adapters register themselves with @register. build_sources(city) turns each [[sources]] entry in the city's TOML into an instance.

Collecting on a schedule

Every source declares how often its data actually changes, and cron runs one cadence at a time. Air quality is a live feed, so it runs hourly. School and groundwater data change a few times a year, so they run monthly. python -m ni cron prints these lines; they are not installed automatically.

Hourly
5 * * * *
cpcb_air BLRDEL
Daily
20 3 * * *
rera_karnataka BLRtpddl_outages DEL
Weekly
40 3 * * 0
osm BLRDELinfra_registry BLRDEL
Monthly
0 4 1 * *
udise_schools BLRDEL cgwb_groundwater BLRDEL bbmp_flood_spots BLR cctv_osm BLR ksp_police BLR bescom BLR delhi_police DEL delhi_doe_results DEL delhi_flood DEL djb_supply DEL derc_sop DEL dda_zone DEL
Yearly
0 5 1 1 *
iisc_groundwater BLRcrime_city_stats BLRDEL
BLR BengaluruDEL Delhi
# one cron line per cadence; each runs every city's sources on that cadence
5 * * * *   cd neighborhood-intel && python -m ni collect --all --refresh hourly  >> data/collect.log 2>&1
20 3 * * *  cd neighborhood-intel && python -m ni collect --all --refresh daily   >> data/collect.log 2>&1
# … weekly, monthly, yearly follow the same shape

The weekly OSM job downloads the Geofabrik extract for the region (about 900 MB across both zones), skips the download if the local copy is under 20 hours old, and streams it through osmium to cut out about 20 layers: metro stations, hospitals, parks, landfills, runways and so on.

How data is stored

The store is plain files: one folder per city, one folder per source, one JSON file per dataset. There is no database. A collector writes in one of two ways, depending on whether the source keeps its own history.

Snapshot · put() source already keeps history; we keep the latest copy hospital.json last week hospital.json replaced whole manifest.json "hospital": { fetched_at, count, as_of, source_url } History · append() source only shows "now"; we build the history {fetched_at: 13:05, stations: …} {fetched_at: 14:05, stations: …} {fetched_at: 15:05, stations: …} + {fetched_at: 16:05, …} new line cpcb_air/snapshots.jsonl hourly PM2.5 → a 365-day mean per station
CPCB and KSNDMC only publish the current reading, and TPDDL only lists upcoming outages. If we don't log them ourselves, the history is gone. tpddl_outages/log.jsonl is de-duplicated by district, start time and area.
data/
├── store/                    # 72 MB · what reports read
│   ├── bengaluru/
│   │   ├── osm/              hospital.json  metro_station.json  …  manifest.json
│   │   ├── cpcb_air/         stations.json  snapshots.jsonl  manifest.json
│   │   └── rera_karnataka/   projects.json  manifest.json
│   └── delhi/
│       └── delhi_flood/      stages.json  floodplain.json  …
├── cache/                    # 888 MB · raw downloads, kept so re-parsing is cheap
│   └── osm/                  asia_india_southern-zone.osm.pbf  …
└── collect.log               # one JSON line per source per run

cities/
├── bengaluru.toml            # sources, declared gaps, sample points
└── bengaluru/infra_registry.json   # hand-curated, checked into git

Long crawls, in slices

Two sources are too large to fetch in one run without hammering the portal. They keep a list of what is already done in the store and take the next slice each run. The areas we use for sample points go first.

UDISE+ schools 25 pincodes per run · sample-area pincodes first · up to 300 schools each run 1 · priority run 2 run 3 this run queued RERA Karnataka geocoding 200 project detail pages per run · newest first runs 1–N this run queued · 4,434 projects in all
Until an area is fully crawled, its metrics stay hidden. A half-crawled pincode would otherwise show "2 schools nearby" when there are 40.

Serving a report

A click on the map sends one request. The engine works out which city the point is in from the city bounding boxes, asks every enabled adapter for its metrics, adds the declared gaps, and returns one JSON report. Nothing in this path touches the network.

Browser API · FastAPI Engine Adapters × N Store cache GET /api/report report(lat, lon) city_for(): bbox match a city no city → 404 metrics(loc) each enabled source layer(dataset) hit: Layer already in memory miss: load JSON → metres Layer nearest, within, containing Metric[] + verdicts + declared gaps report JSON 200 · JSON render panel + map pins
Each adapter is wrapped on its own. If one dataset hasn't been collected yet, the report still returns, with an entry in errors such as "not collected yet". The search box suggests RERA-registered projects as you type, by project or builder name, from our own store (/api/suggest), so search makes no outside calls.

Spatial queries are fast because each layer is projected once from latitude and longitude into metres around the city centre, then kept as a numpy array of shapely geometries. One lookup is a vectorised distance or containment test over that array.

How updates reach users

The API keeps parsed layers in memory, so a new collection has to replace them without a restart. The cache key is the file's path plus its last-modified time. When cron rewrites a file, its mtime changes and the next request misses the cache and loads the new data.

02:00 · request key (stages.json, t1) miss → load and keep 04:00 · monthly cron rewrites stages.json mtime t1 → t2 04:05 · request key (stages.json, t2) miss → fresh data requests here reuse (t1) · ~10 ms no restart needed · the old (t1) entry ages out of the 256-entry LRU cache
Every metric also carries as_of, the date of the observation itself, which is separate from when we fetched it. A five-year-old school census fetched yesterday still shows its census year.

What the user sees

Every number in a report carries a reliability verdict alongside its value, source and date. The verdict is set by the adapter, based on audits of how well that source holds up at that place.

Show

Spot-checked and precise

Measured at the point, or a place-level fact we've verified against the ground. Listed under verified_metrics in the city's TOML.

Distance to nearest metro station · RERA delay record · police jurisdiction
Caveat

Useful, with a stated limit

Shown with a sentence saying what the number can't tell you.

Air quality from ~10 monitors in Bengaluru · undated BBMP flood spots
Don't show

Public data can't say

Declared as [[gaps]], so the report says so plainly instead of guessing.

Station-level crime · locality power cuts · private-school results

Granularity

Each metric also says how local it really is, so a city-wide average isn't mistaken for a fact about one street.

pointthe thing itself: a school, a station
hyperlocalcounted in a radius around the home
wardward, police station, pincode, feeder
districttaluk, groundwater block
cityone figure for the city
regioncoarse model grid, tens of km
{
  "key": "metro_nearest",
  "label": "Nearest metro station",
  "value": 640,
  "unit": "m",
  "source": "OpenStreetMap",
  "source_url": "https://download.geofabrik.de/…",
  "granularity": "point",
  "as_of": "2026-10-16",
  "verdict": "show",
  "caveat": "",
  "topic": "connectivity",
  "points": [{ "lat", "lon", "name", "distance_m" }]
}
  • value + unit · the number itself, or null for a gap
  • source + source_url · where it came from, linkable
  • as_of · when the thing was observed, not when we fetched it
  • verdict + caveat · whether to trust it, and what it can't tell you
  • points · the actual features counted, drawn as pins on the map

Example values are illustrative.

When a source breaks

Government portals fail often. The pipeline is built so that one failure makes one source's data older and leaves everything else alone.

One portal down

collect() runs inside its own try block. A failure is logged with status error and the next source runs. The previous file stays in the store, so users keep seeing the last good data with its date.

Interrupted download

Files download to *.part and are renamed only when complete. A half-finished file never replaces a good one.

Never collected

The report still returns. That adapter's metrics are missing and the errors list says "not collected yet".

Half-finished crawl

Metrics for areas not yet crawled stay hidden until the crawl reaches them.

Being a polite client

Every request identifies itself as neighborhood-intel/0.2 (non-commercial research). Large crawls are sliced across runs. Sources behind captchas, tokens or personal data (KSP FIR search, BMRCL API) are deliberately not automated.