Spaces:
Running
Running
File size: 5,565 Bytes
28e0ff1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | # 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.
|