226 lines
9.3 KiB
Python
226 lines
9.3 KiB
Python
"""Compare 200ft vs 500ft (vs 1000ft) setbacks in terms that matter for
|
|
political argument: how many homes get protected, and which DC sites disappear.
|
|
|
|
Outputs:
|
|
out/comparison_summary.txt plain-text headline numbers
|
|
out/homes_exposure.csv per-dwelling distance to nearest DC patch
|
|
out/marginal_parcels_500.geojson parcels eligible at 200 but NOT at 500
|
|
out/marginal_parcels_1000.geojson parcels eligible at 200 but NOT at 1000
|
|
web/data/comparison.json consumed by the web page for live display
|
|
"""
|
|
from __future__ import annotations
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import geopandas as gpd
|
|
import pandas as pd
|
|
from shapely import make_valid
|
|
from shapely.geometry import MultiPolygon, Polygon
|
|
from shapely.strtree import STRtree
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CACHE = ROOT / "cache"
|
|
OUT = ROOT / "out"
|
|
WEB = ROOT / "web" / "data"
|
|
|
|
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
|
|
RESIDENTIAL_PLACETYPES = {
|
|
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
|
|
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
|
|
}
|
|
# Distance buckets reported in the summary table (ft). 5280 = 1 mile.
|
|
EXPOSURE_BUCKETS = [200, 500, 1000, 1500, 2000, 2640, 5280]
|
|
|
|
|
|
def fix(g):
|
|
if g is None or g.is_empty:
|
|
return g
|
|
return g if g.is_valid else make_valid(g)
|
|
|
|
|
|
def stamp(msg, t0=None):
|
|
now = time.time()
|
|
if t0 is None:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
else:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg} ({now - t0:.1f}s)", flush=True)
|
|
return now
|
|
|
|
|
|
def main() -> int:
|
|
t0 = stamp("loading layers")
|
|
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
|
|
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
|
|
crs = parcels.crs
|
|
|
|
# Eligible (buildable + frontage-validated) GeoJSONs from analyze.py.
|
|
# These are in EPSG:4326; reproject back to EPSG:2274 for distance math.
|
|
elig = {}
|
|
for ft in (200, 500, 1000):
|
|
p = OUT / f"eligible_{ft}ft_buildable.geojson"
|
|
if p.exists() and p.stat().st_size > 100:
|
|
g = gpd.read_file(p).to_crs(crs)
|
|
elig[ft] = g
|
|
else:
|
|
elig[ft] = gpd.GeoDataFrame({"geometry": []}, geometry="geometry", crs=crs)
|
|
stamp("loaded", t0)
|
|
for ft, g in elig.items():
|
|
print(f" buildable@{ft}ft: {len(g)} parcels")
|
|
|
|
# ----- Residential dwellings = address points with residential PLACETYPE
|
|
t1 = stamp("identifying dwellings")
|
|
res_mask = addresses["PLACETYPE"].astype(str).str.upper().isin(RESIDENTIAL_PLACETYPES)
|
|
homes = addresses[res_mask].copy()
|
|
print(f" dwelling address points: {len(homes):,}")
|
|
stamp("done", t1)
|
|
|
|
# ----- For each home, distance (ft) to nearest buildable patch per setback
|
|
summary = {}
|
|
home_dist_cols = {}
|
|
for ft in (200, 500, 1000):
|
|
t1 = stamp(f"computing per-home distance to nearest {ft}ft buildable patch")
|
|
if len(elig[ft]) == 0:
|
|
home_dist_cols[ft] = [float("inf")] * len(homes)
|
|
stamp(" (no eligible patches)", t1)
|
|
continue
|
|
tree = STRtree(elig[ft].geometry.values)
|
|
polys = elig[ft].geometry.values
|
|
dists = []
|
|
for pt in homes.geometry.values:
|
|
# nearest polygon, then exact distance from point to that polygon.
|
|
i = tree.nearest(pt)
|
|
d = polys[i].distance(pt)
|
|
dists.append(d)
|
|
home_dist_cols[ft] = dists
|
|
stamp("done", t1)
|
|
|
|
homes["dist_200_ft"] = home_dist_cols[200]
|
|
homes["dist_500_ft"] = home_dist_cols[500]
|
|
homes["dist_1000_ft"] = home_dist_cols[1000]
|
|
|
|
# ----- Exposure buckets per setback
|
|
for ft in (200, 500, 1000):
|
|
d = homes[f"dist_{ft}_ft"]
|
|
summary[f"setback_{ft}"] = {
|
|
"buildable_patches": int(len(elig[ft])),
|
|
"homes_within_ft": {
|
|
str(b): int((d <= b).sum()) for b in EXPOSURE_BUCKETS
|
|
},
|
|
}
|
|
|
|
# ----- Homes "protected" by tightening from 200 -> 500 (or -> 1000)
|
|
# Definition: home is within X ft of any patch at 200ft setback, but is
|
|
# >= X ft from any patch at 500ft setback. I.e., the patches that pushed
|
|
# the home into the exposure ring are exactly the ones that go away at
|
|
# the tighter setback.
|
|
protection = {}
|
|
for tighter in (500, 1000):
|
|
rows = {}
|
|
for b in EXPOSURE_BUCKETS:
|
|
exposed_at_200 = homes["dist_200_ft"] <= b
|
|
still_exposed = homes[f"dist_{tighter}_ft"] <= b
|
|
rows[str(b)] = int((exposed_at_200 & ~still_exposed).sum())
|
|
protection[f"to_{tighter}"] = {
|
|
"newly_protected_within_ft": rows,
|
|
}
|
|
summary["protection"] = protection
|
|
|
|
# ----- Homes newly protected: within 1000ft of a possible DC at 200ft
|
|
# setback, but >= 1000ft at the tighter setback. The 1000ft threshold is
|
|
# the headline "close-range exposure" distance.
|
|
PROTECT_BUCKET_FT = 1000
|
|
for tighter in (500, 1000):
|
|
exposed_200 = homes["dist_200_ft"] <= PROTECT_BUCKET_FT
|
|
still_exposed = homes[f"dist_{tighter}_ft"] <= PROTECT_BUCKET_FT
|
|
newly = homes[exposed_200 & ~still_exposed].copy()
|
|
# Reproject to EPSG:4326 for the web
|
|
newly_web = newly.to_crs("EPSG:4326")
|
|
keep_cols = ["FULLADDR", "PLACETYPE", "dist_200_ft", f"dist_{tighter}_ft"]
|
|
newly_web = newly_web[[c for c in keep_cols if c in newly_web.columns] + ["geometry"]]
|
|
out_p = OUT / f"homes_protected_{tighter}.geojson"
|
|
if out_p.exists():
|
|
out_p.unlink()
|
|
if len(newly_web):
|
|
newly_web.to_file(out_p, driver="GeoJSON")
|
|
print(f" homes newly protected -> {tighter}ft (within {PROTECT_BUCKET_FT}ft): {len(newly_web)}")
|
|
|
|
# ----- Marginal parcels: eligible at 200 but lost at 500 / 1000
|
|
e200 = elig[200]
|
|
for tighter in (500, 1000):
|
|
et = elig[tighter]
|
|
tighter_ids = set(et["GISLINK"].dropna().astype(str).tolist()) if "GISLINK" in et.columns else set()
|
|
if "GISLINK" not in e200.columns:
|
|
marginal = gpd.GeoDataFrame(columns=e200.columns)
|
|
else:
|
|
marginal = e200[~e200["GISLINK"].astype(str).isin(tighter_ids)].copy()
|
|
out_p = OUT / f"marginal_parcels_{tighter}.geojson"
|
|
if out_p.exists():
|
|
out_p.unlink()
|
|
if len(marginal):
|
|
marginal.to_file(out_p, driver="GeoJSON")
|
|
print(f" marginal at -> {tighter}ft: {len(marginal)} parcels lost")
|
|
summary[f"marginal_to_{tighter}"] = int(len(marginal))
|
|
|
|
# ----- Write outputs
|
|
OUT.mkdir(exist_ok=True)
|
|
homes_out = homes[["FULLADDR", "PLACETYPE", "dist_200_ft", "dist_500_ft", "dist_1000_ft"]].copy()
|
|
for c in ("dist_200_ft", "dist_500_ft", "dist_1000_ft"):
|
|
homes_out[c] = homes_out[c].round(1)
|
|
homes_out.to_csv(OUT / "homes_exposure.csv", index=False)
|
|
print(f" wrote out/homes_exposure.csv ({len(homes_out):,} rows)")
|
|
|
|
# Plain-text headline
|
|
with open(OUT / "comparison_summary.txt", "w") as f:
|
|
f.write("Johnson City data-center setback comparison\n")
|
|
f.write(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
f.write(f"Dwelling address points considered: {len(homes):,}\n\n")
|
|
f.write("Buildable-subdividable patches:\n")
|
|
for ft in (200, 500, 1000):
|
|
f.write(f" {ft:>4} ft setback: {summary[f'setback_{ft}']['buildable_patches']:>4} patches\n")
|
|
f.write("\nHomes within distance D of any potential data center:\n")
|
|
f.write(f" {'D (ft)':>8} | {'200ft':>8} | {'500ft':>8} | {'1000ft':>8}\n")
|
|
f.write(" " + "-" * 44 + "\n")
|
|
for b in EXPOSURE_BUCKETS:
|
|
r = [summary[f"setback_{ft}"]["homes_within_ft"][str(b)] for ft in (200, 500, 1000)]
|
|
f.write(f" {b:>8,} | {r[0]:>8,} | {r[1]:>8,} | {r[2]:>8,}\n")
|
|
f.write("\nHomes NEWLY PROTECTED by tightening setback from 200ft:\n")
|
|
f.write(f" {'D (ft)':>8} | {'200->500':>10} | {'200->1000':>10}\n")
|
|
f.write(" " + "-" * 36 + "\n")
|
|
for b in EXPOSURE_BUCKETS:
|
|
a = protection["to_500"]["newly_protected_within_ft"][str(b)]
|
|
c = protection["to_1000"]["newly_protected_within_ft"][str(b)]
|
|
f.write(f" {b:>8,} | {a:>10,} | {c:>10,}\n")
|
|
f.write(f"\nMarginal DC sites lost by tightening 200 -> 500: {summary['marginal_to_500']}\n")
|
|
f.write(f"Marginal DC sites lost by tightening 200 -> 1000: {summary['marginal_to_1000']}\n")
|
|
print(f" wrote out/comparison_summary.txt")
|
|
print("\n--- comparison_summary.txt ---")
|
|
print((OUT / "comparison_summary.txt").read_text())
|
|
|
|
# JSON for the web page
|
|
WEB.mkdir(parents=True, exist_ok=True)
|
|
with open(WEB / "comparison.json", "w") as f:
|
|
json.dump({
|
|
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"total_homes": int(len(homes)),
|
|
"buckets_ft": EXPOSURE_BUCKETS,
|
|
**summary,
|
|
}, f, indent=2)
|
|
print(f" wrote web/data/comparison.json")
|
|
|
|
# Copy comparison artifacts into web/
|
|
for stem in ("marginal_parcels_500", "marginal_parcels_1000",
|
|
"homes_protected_500", "homes_protected_1000"):
|
|
src = OUT / f"{stem}.geojson"
|
|
if src.exists():
|
|
dst = WEB / src.name
|
|
dst.write_bytes(src.read_bytes())
|
|
print(f" copied -> web/data/{src.name}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|