lawn-estimator-dev / docs /adding-a-region.md
TempuraML's picture
docs: add 'adding a new region' runbook
28e0ff1
|
Raw
History Blame Contribute Delete
5.57 kB

Adding a New Region (County)

The estimator is region-agnostic: each area you cover is one RegionConfig in src/lawn_estimator/regions.py that wires concrete data-source adapters behind the ports in src/lawn_estimator/sources/base.py. Adding a county is finding a handful of public endpoints and writing a ~20-line build_<county>() function β€” no core pipeline changes.

Resolution tries each registered region's geocoder in order; the first hit pins that region for the whole run (falling back to validated Google + parcel-pinning if no county address layer matches).


What to gather

Required (4)

# Source What it is / how to find it Used for
1 Address points An Esri Address_Points-style FeatureServer/MapServer layer with a full-address field (usually FULLADDR). Search the county's ArcGIS portal (<county>.gov/gis, data.<county>...opendata.arcgis.com). Free, keyless geocoding that lands on the building (so the parcel lookup always hits). Optional β€” a region can geocode via Google only, but that costs API calls and is less precise.
2 Parcels An Esri parcel FeatureServer (polygon) supporting point-in-polygon queries (Query capability). Plus a field map: which attribute is the parcel id, the site address, and year-built (if any). The legal boundary + the year-built vintage guard.
3 LiDAR Almost always USGS via The National Map β€” national coverage. Query tnmaccess.nationalmap.gov/api/v1/products (datasets=Lidar Point Cloud (LPC), a bbox over the area) and note the dataset title prefix + flight year. A local county LiDAR bucket only if it's newer/denser (e.g. Douglas's 2022). Ground points β†’ the high-confidence estimate (with imagery-only fallback when absent).
4 Local CRS The correct UTM zone (EPSG). Eastern Nebraska = EPSG:26914 (14N); western Iowa / across the Missouri = EPSG:26915 (15N). Area math + LiDAR/point projection.

Optional (graceful fallback if absent)

# Source Absent β†’ fallback
5 Street / road centerlines (Esri polyline layer) Street-aware right-of-way extension (Phase 3.5). Absent β†’ extend-all + neighbor subtraction (per-extension sizes still reported so the user can hand-correct).
6 Ortho imagery tile service (county aerials) Crisp display layer. Absent β†’ visualizations render on Google satellite.

The two counties we ship today (worked examples)

Douglas County, NE (build_douglas)

  • Address points: dcgis.org/server/rest/services/vector/Address_Points/FeatureServer/0 (FULLADDR)
  • Parcels: dcgis.org/server/rest/services/vector/Parcels_public/FeatureServer/0 β€” field map {object_id: OBJECTID, site_address: PROPERTY_A, year_built: BLDG_YRBLT, bldg_sqft: BLDG_SF}
  • LiDAR: county 2022 QL1 on public S3 (DouglasS3LidarSource, vintage 2022, ~8 pts/mΒ²)
  • Streets: .../vector/Street_Centerlines/FeatureServer/0 Β· Ortho: 2025 county tiles Β· CRS: 26914

Sarpy County, NE (build_sarpy)

  • Address points: geodata.sarpy.gov/arcgis/rest/services/Cadastral/LandRecordsDynamic/MapServer/2 (FULLADDR)
  • Parcels: services.arcgis.com/OiG7dbwhQEWoy77N/.../Sarpy_Parcels_WFL1/FeatureServer/0 β€” field map {object_id: OBJECTID, site_address: SITEADDRESS} (no year-built field β†’ new construction rides the RGB-only fallback instead of a vintage warning)
  • LiDAR: UsgsTnmLidarSource(["NE_Eastern_Nebraska_UA_LiDAR_2016"], vintage_year=2016) (~2 pts/mΒ²)
  • Streets: .../LandRecordsDynamic/MapServer/3 (Road Centerlines) Β· Ortho: none (Google display) Β· CRS: 26914

Note how the field map absorbs schema differences (Douglas PROPERTY_A vs Sarpy SITEADDRESS) so the pipeline only ever sees canonical keys.


Steps

  1. Find endpoints 1–4 on the county's ArcGIS portal (~30 min). Verify the parcel layer's ?f=json shows "capabilities": "Query" and lists the fields you'll map.
  2. Confirm LiDAR with a TNM bbox query over a known address; record the dataset prefix + year. (If TNM has nothing, the region still works on RGB-only.)
  3. Write build_<county>(session, config) -> RegionConfig in regions.py, reusing EsriAddressPointsGeocoder, EsriParcelSource (with your field map), UsgsTnmLidarSource (or a county source), and optional streets/ortho.
  4. Append it to REGION_BUILDERS.
  5. Validate (the "quality LOOK"): run 3–5 known addresses. Confirm they resolve to the right region + parcel, LiDAR produces sane ground points (or cleanly falls back), and the visualization aligns (red points on roofs, green on lawn). Spot-check estimates against the RGB vegetation baseline.
  6. Regression: re-run the Douglas QA batch β€” it must stay byte-identical (adding a region must never perturb existing ones).

Gotchas learned the hard way

  • Gov ArcGIS servers flake with transient 5xx. All Esri/TNM queries go through http_get_with_retry; resolve_region degrades to Google on sustained errors.
  • Municipality labels for unincorporated addresses can be the county name; the geocoder prefers the city the user typed.
  • Attached housing (duplex/townhome) has one parcel per unit β€” measuring only the queried address's parcel is correct, not a bug.
  • LiDAR vintage is per-source, not per-region β€” a newer flight is a one-line swap.
  • UTM zone: crossing the Missouri into Iowa means EPSG:26915, not 26914.