[object Object]

← back to Nationalrealestate

parcels: add Fulton County GA / Atlanta (13121) — dedicated rich full-universe adapter

f01d750d4d0d8309ac953ca715520a1f9040c212 · 2026-07-28 07:06:11 -0700 · Steve Abrams

Dedicated fulton_ga.ts over the keyless Fulton County DoIT/GIS-hosted Tax_Parcels
FeatureServer (Board of Assessors tax digest, ~373,004 parcels, TaxYear 2026).
Coverage: owner 99.98%, property address assembled from structured components 100%,
land-use code 100%, living units 86%, land acres. No market value / year-built /
building sqft / beds-baths / sale-or-deed feed in this open view -> parcel-detail-only
(Bexar/Salt Lake precedent): zero parcel_event rows, never a fabricated sale.
Registered 'fulton' in engine.ts, documented in PARCEL-COVERAGE.md. Verified
end-to-end vs local usre DB (bounded 2-page run, owner/addr/use 100%, geo in Atlanta
bounds, 0 events, 0 parcel_source leak), tsc exit 0, test rows purged. Applies the
Wake/Tarrant/Bexar DTD-B precedent (dedicated *.ts, no re-panel). Full 373k backfill +
Kamatera prod deploy remain Steve-gated and NOT executed this pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f01d750d4d0d8309ac953ca715520a1f9040c212
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 07:06:11 2026 -0700

    parcels: add Fulton County GA / Atlanta (13121) — dedicated rich full-universe adapter
    
    Dedicated fulton_ga.ts over the keyless Fulton County DoIT/GIS-hosted Tax_Parcels
    FeatureServer (Board of Assessors tax digest, ~373,004 parcels, TaxYear 2026).
    Coverage: owner 99.98%, property address assembled from structured components 100%,
    land-use code 100%, living units 86%, land acres. No market value / year-built /
    building sqft / beds-baths / sale-or-deed feed in this open view -> parcel-detail-only
    (Bexar/Salt Lake precedent): zero parcel_event rows, never a fabricated sale.
    Registered 'fulton' in engine.ts, documented in PARCEL-COVERAGE.md. Verified
    end-to-end vs local usre DB (bounded 2-page run, owner/addr/use 100%, geo in Atlanta
    bounds, 0 events, 0 parcel_source leak), tsc exit 0, test rows purged. Applies the
    Wake/Tarrant/Bexar DTD-B precedent (dedicated *.ts, no re-panel). Full 373k backfill +
    Kamatera prod deploy remain Steve-gated and NOT executed this pass.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 PARCEL-COVERAGE.md              |   1 +
 src/ingest/parcels/engine.ts    |   1 +
 src/ingest/parcels/fulton_ga.ts | 165 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 167 insertions(+)

diff --git a/PARCEL-COVERAGE.md b/PARCEL-COVERAGE.md
index d4f2d07..a57e73d 100644
--- a/PARCEL-COVERAGE.md
+++ b/PARCEL-COVERAGE.md
@@ -21,6 +21,7 @@ migration 004, adapters in `src/ingest/parcels/`).
 | Cook, IL — Chicago (17031) | 1,863,530 | Assessor Socrata: universe `pabr-t5kh` (2026, lat/lng+class) + addresses/owner `3723-97qp` (2026) + certified ASSESSED values `uzyt-m557` (2025) + res characteristics `x54s-btds` (2026) (`ingest:parcels cook`) | re-run adapter (annual cycle) | Details (beds/baths/sqft/yr for 1.1M res pins), Tax (2025 certified **assessed** value — 10%/25% of market, labeled), **Ownership (owner/taxpayer name + mailing)**, Map (parcel lat/lng); no zoning (municipal), no sale feed |
 | Tarrant, TX — Fort Worth (48439) | ~714,982 | TAD appraisal roll via City-of-Fort-Worth hosted `Parcels_Public_Vview` FeatureServer (keyless ArcGIS, `COUNTYNAME='Tarrant'` filter; `ingest:parcels tarrant`) | re-run adapter (remote-runnable, no GDAL) | Details (sqft/living-area 89%, year-built 90%, state use code), **Ownership (owner 100%)**, site address + real city name, LAND+IMPR + **TRUE MARKET value (99%)** + appraised value, **Property History: deed/ownership-change DATE (98%, no price in this feed) → parcel_event `deed` rows w/ amount NULL**, Map (polygon centroid). **No beds/baths or sale PRICE** (TAD withholds them in the public view). Feed sentinel `12-31-1900` (~18k, "no deed on record") dropped. The TAD county's own `gis.tad.org` 000s to plain curl — this CFW-hosted public view is the reachable authoritative copy |
 | Bexar, TX — San Antonio (48029) | ~721,005 | BCAD appraisal roll via City-of-San-Antonio open-data hosted `BCAD_Parcels` FeatureServer (keyless ArcGIS, `PropID>0` filter, `outSR=4326` server-side reproject; `ingest:parcels bexar`) | re-run adapter (remote-runnable, no GDAL) | Details (living-area sqft 87%, state use code, land sqft, legal acreage), **Ownership (owner 99%)**, `Situs` **property** address parsed → street+city+zip (99%; the mailing `addr_*` fields are the owner's, so we read situs instead), Map (polygon centroid, WGS84). **No appraised value / year-built / beds-baths / sale-or-deed feed** in this CoSA-hosted BCAD open view → parcel-DETAIL-only (like Salt Lake); **zero parcel_event rows** (no fabricated sales). The authoritative `maps.bcad.org` PAMapSearch service DOES carry appraised value + owner but blocks bulk paging (no offset/orderBy) — so the pageable CoSA layer is the full-universe backfill source (Tarrant precedent) |
+| Fulton, GA — Atlanta (13121) | ~373,004 | Board of Assessors tax digest via Fulton County DoIT/GIS hosted `Tax_Parcels` FeatureServer (keyless ArcGIS, `ParcelID<>''` filter, `outSR=4326`; `ingest:parcels fulton`) | re-run adapter (remote-runnable, no GDAL) | Details (living/dwelling units `LivUnits` 86%, land-use `LUCode` 100%, class code, land acres), **Ownership (owner 99.98%)**, property address assembled from the structured `AddrNumber`/`AddrPreDir`/`AddrStreet`/`AddrSuffix`/`AddrPosDir`/unit components (100%), tax year 2026, Map (polygon centroid, WGS84). **No market/assessed value / year-built / building sqft / beds-baths / sale-or-deed feed** in this open assessor view → parcel-DETAIL-only (like Bexar/Salt Lake); **zero parcel_event rows** (no fabricated sales). The layer serviceDescription self-identifies as the Fulton Board of Assessors annual tax digest. No separate city column (county spans Atlanta + ~14 municipalities) — city left null rather than guessed |
 
 ## Probed — gated or absent
 
diff --git a/src/ingest/parcels/engine.ts b/src/ingest/parcels/engine.ts
index 0108f96..41ae6c0 100644
--- a/src/ingest/parcels/engine.ts
+++ b/src/ingest/parcels/engine.ts
@@ -16,6 +16,7 @@ const ADAPTERS: Record<string, () => Promise<{ run: () => Promise<{ upserted: nu
   wake: async () => ({ run: (await import('./wake_nc.ts')).ingestWake }),
   tarrant: async () => ({ run: (await import('./tarrant_tx.ts')).ingestTarrant }),
   bexar: async () => ({ run: (await import('./bexar_tx.ts')).ingestBexar }),
+  fulton: async () => ({ run: (await import('./fulton_ga.ts')).ingestFulton }),
   saltlake: async () => ({ run: (await import('./saltlake_ut.ts')).ingestSaltLake }),
   sandiego: async () => { const m = await import('./sandiego_ca.ts'); return { run: () => m.ingestSanDiego('poway') }; },
   'sandiego-county': async () => { const m = await import('./sandiego_ca.ts'); return { run: () => m.ingestSanDiego('county') }; },
diff --git a/src/ingest/parcels/fulton_ga.ts b/src/ingest/parcels/fulton_ga.ts
new file mode 100644
index 0000000..2cb36fd
--- /dev/null
+++ b/src/ingest/parcels/fulton_ga.ts
@@ -0,0 +1,165 @@
+/**
+ * Fulton County GA (Atlanta, FIPS 13121) parcel ingest — dedicated full-universe
+ * adapter (applies the Wake/Miami-Dade/Tarrant/Bexar DTD verdict B precedent: a
+ * rich full-universe keyless ArcGIS county gets a dedicated adapter, not the
+ * sale-cursor generic one, so the real OWNER + physical-detail fields are
+ * preserved).
+ *
+ * Source: the Fulton County Dept. of IT / GIS Division hosted "Tax Parcels"
+ * FeatureServer (built from the Fulton County Board of Assessors tax digest;
+ * keyless, maxRecordCount 1000, supportsPagination, $0):
+ *   https://services1.arcgis.com/AQDHTHDrZzfsFsB5/arcgis/rest/services/Tax_Parcels/FeatureServer/0
+ * The serviceDescription self-identifies as "the Fulton County Board of
+ * Assessor's ... annual tax digest." The layer serves outSR=4326 server-side, so
+ * parcel centroids come back as clean WGS84 lat/lng.
+ *
+ * Coverage (373,004-parcel universe, TaxYear 2026 — current): Owner (99.98%,
+ * 372,938), Address (100% — assembled from the structured AddrNumber/PreDir/
+ * Street/Suffix/PosDir/UnitType/Unit components, which is cleaner than the flat
+ * `Address` string), LUCode (land-use code, 100%), ClassCode, LivUnits
+ * (living/dwelling units, 86%), LandAcres, Neighborhood.
+ *
+ * This Fulton assessor open view carries NO market/assessed value, NO
+ * year-built, NO building sqft, NO beds/baths, and NO sale/deed feed. Because
+ * there is no priced-sale feed here, this county is parcel-DETAIL-only (like
+ * Bexar TX / Salt Lake UT): zero parcel_event rows are written — never a
+ * fabricated sale.
+ *
+ * source_id = ParcelID (the Fulton Board of Assessors parcel account id, e.g.
+ * "07 410001590187"). We keep the raw ParcelID including its embedded space (it
+ * is the canonical key on the Fulton digest); rows with a blank ParcelID are
+ * skipped.
+ *
+ * Run: NODE_OPTIONS=--max-old-space-size=4096 npm run ingest:parcels fulton
+ */
+import { pool } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+import { upsertParcels, registerParcelSource, normAddress, type ParcelUpsertRow } from './upsert.ts';
+
+const FIPS = '13121';
+const LAYER = 'https://services1.arcgis.com/AQDHTHDrZzfsFsB5/arcgis/rest/services/Tax_Parcels/FeatureServer/0';
+const WHERE = "ParcelID IS NOT NULL AND ParcelID <> ''";
+const PAGE = 1000; // layer maxRecordCount is 1000
+
+const s = (v: unknown): string | null => { const t = v == null ? '' : String(v).trim(); return t ? t : null; };
+const num = (v: unknown): number | null => { const x = Number(v); return Number.isFinite(x) && x !== 0 ? x : null; };
+
+/**
+ * Assemble a clean street line from the structured Fulton address components,
+ * in USPS-ish order: "<num> <predir> <street> <suffix> <posdir> <unittype> <unit>".
+ * We prefer the components over the flat `Address` field because they are
+ * separately reliable; fall back to the flat `Address` if no components exist.
+ */
+function buildStreet(a: any): string | null {
+  const parts = [a.AddrNumber, a.AddrPreDir, a.AddrStreet, a.AddrSuffix, a.AddrPosDir, a.AddrUntTyp, a.AddrUnit]
+    .map(s).filter(Boolean);
+  const assembled = parts.join(' ').replace(/\s+/g, ' ').trim();
+  // Fulton uses a literal "0" AddrNumber for unaddressed land parcels; when the
+  // number is "0" and there's a real street, drop the leading 0 so the address
+  // isn't "0 GULLATT RD" (keep the street though — it's still location info).
+  const cleaned = assembled.replace(/^0\s+/, '').trim();
+  return cleaned || s(a.Address);
+}
+
+/** plain vertex-average of ring[0] — NOT a true polygon centroid (fine for a map
+ *  pin; multipart parcels only get the first ring). Geometry is already WGS84
+ *  because we request outSR=4326. */
+function centroid(geom: any): [number | null, number | null] {
+  const ring = geom?.rings?.[0]; if (!Array.isArray(ring) || !ring.length) return [null, null];
+  let sx = 0, sy = 0; for (const [x, y] of ring) { sx += x; sy += y; }
+  return [+(sx / ring.length).toFixed(6), +(sy / ring.length).toFixed(6)];
+}
+
+const OUT = ['ParcelID', 'TaxYear', 'Address', 'AddrNumber', 'AddrPreDir', 'AddrStreet',
+  'AddrSuffix', 'AddrPosDir', 'AddrUntTyp', 'AddrUnit', 'Owner', 'LUCode', 'ClassCode',
+  'ExCode', 'LivUnits', 'LandAcres', 'NbrHood', 'Subdiv'].join(',');
+
+async function fetchPage(offset: number): Promise<any[]> {
+  const u = new URL(LAYER + '/query');
+  u.searchParams.set('where', WHERE);
+  u.searchParams.set('outFields', OUT);
+  u.searchParams.set('orderByFields', 'OBJECTID ASC');
+  u.searchParams.set('resultOffset', String(offset));
+  u.searchParams.set('resultRecordCount', String(PAGE));
+  u.searchParams.set('returnGeometry', 'true');
+  u.searchParams.set('outSR', '4326');
+  u.searchParams.set('f', 'json');
+  let lastErr: any;
+  for (let a = 0; a < 3; a++) {
+    try {
+      const res = await fetch(u, { headers: { 'User-Agent': 'Mozilla/5.0 (usre-parcel-ingest)' }, signal: AbortSignal.timeout(120_000) });
+      if (!res.ok) throw new Error(`fulton ${res.status}: ${(await res.text()).slice(0, 140)}`);
+      const j: any = await res.json();
+      if (j.error) throw new Error(`fulton error: ${JSON.stringify(j.error).slice(0, 140)}`);
+      return j.features || [];
+    } catch (e) { lastErr = e; if (a < 2) await new Promise(r => setTimeout(r, 2000 * (a + 1))); }
+  }
+  throw lastErr;
+}
+
+export async function ingestFulton(): Promise<{ upserted: number }> {
+  const runId = await openRun('parcel_fulton_ga', LAYER);
+  try {
+    let offset = 0, seen = 0, skipped = 0, upserted = 0;
+    for (;;) {
+      const feats = await fetchPage(offset);
+      if (!feats.length) break;
+      const batch: ParcelUpsertRow[] = [];
+      for (const f of feats) {
+        const a = f.attributes || {};
+        const pid = s(a.ParcelID);
+        if (!pid) { skipped++; continue; }
+        const owner = s(a.Owner);
+        const street = buildStreet(a);
+        // skip pure ghost polygons (no owner AND no street) so we don't store bare geometry rows
+        if (!owner && !street) { skipped++; continue; }
+        seen++;
+        const [lng, lat] = centroid(f.geometry);
+        const norm = street ? normAddress(street) : null;
+        batch.push({
+          county_fips: FIPS, source_id: pid,
+          address: norm, norm_address: norm,
+          // Fulton's tax-parcel view has no separate city column (the county
+          // spans Atlanta + ~14 other municipalities); leave city null rather
+          // than guess it from the neighborhood code.
+          city: null, zip: null,
+          lat, lng,
+          year_built: null, sqft: null, beds: null, baths: null,
+          units: num(a.LivUnits),
+          use_desc: s(a.LUCode),
+          land_value: null, improvement_value: null, total_value: null,
+          tax_year: s(a.TaxYear), owner_name: owner, zoning: null,
+          // no priced-sale / deed feed in this Fulton assessor open view — leave
+          // both sale fields NULL (never fabricate a sale date or price).
+          last_sale_date: null, last_sale_price: null,
+          extra: JSON.stringify({
+            class_code: s(a.ClassCode) || undefined,
+            ex_code: s(a.ExCode) || undefined,
+            land_acres: num(a.LandAcres) || undefined,
+            neighborhood: s(a.NbrHood) || undefined,
+            subdivision: s(a.Subdiv) || undefined,
+            note: 'Fulton County GA (Board of Assessors tax digest) via Fulton DoIT GIS Tax_Parcels view — owner + structured address + land-use code + living units + land acres; no market value / year-built / building sqft / beds-baths / sale-or-deed feed in this open view',
+          }),
+        });
+      }
+      upserted += await upsertParcels(batch);
+      offset += feats.length;
+      if (seen % 100000 < feats.length) console.log(`[fulton] ${upserted} upserted, ${skipped} stubs skipped`);
+      if (feats.length < PAGE) break;
+    }
+    if (upserted < 340000) throw new Error(`only ${upserted} Fulton parcels (expected ~373k) — layer/paging/filter drift?`);
+
+    const n = await registerParcelSource(FIPS, LAYER,
+      `Fulton County GA (Atlanta) Board of Assessors tax digest via Fulton DoIT GIS Tax_Parcels view — ${upserted} parcels: owner (99.98%), property address assembled from structured components (100%), land-use code (LUCode, 100%), class code, living/dwelling units (LivUnits, 86%), land acres, neighborhood, Map (polygon centroid, WGS84 via outSR). TaxYear 2026 (current). No market/assessed value, year-built, building sqft, beds/baths, or sale/deed feed in this open assessor view (parcel-detail-only, like Bexar/Salt Lake — zero fabricated events). ${skipped} placeholder/geometry-only stubs skipped.`);
+    await closeRun(runId, 'ok', { upserted, skipped, notes: `Fulton GA: ${upserted} parcels, ${skipped} stubs skipped` });
+    console.log(`[fulton] ok: ${upserted} parcels, ${skipped} stubs (registry ${n})`);
+    return { upserted };
+  } catch (e: any) {
+    await closeRun(runId, 'failed', { notes: String(e.message || e).slice(0, 500) });
+    throw e;
+  }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  ingestFulton().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}

← 994fedb parcels: guard negative sale prices — shared sanitizeSalePri  ·  back to Nationalrealestate  ·  parcels: guard null/placeholder/junk owner names — shared sa 1c33393 →