Initial commit: 200feet pipeline, docker packaging, and static site
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""Sweep min-building-footprint thresholds across all setback distances and
|
||||
write a single matrix to out/sweep_matrix.txt. Loads the data once; reuses
|
||||
the buffered residential exclusion and per-parcel buildable polygons across
|
||||
all thresholds. Does NOT touch the canonical eligible_*.csv files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import geopandas as gpd
|
||||
from shapely import make_valid, unary_union
|
||||
from shapely.geometry import MultiPolygon, Polygon
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CACHE = ROOT / "cache"
|
||||
OUT = ROOT / "out"
|
||||
|
||||
# Mirror the constants and helpers from analyze.py.
|
||||
DC_INTERNAL_SETBACK_FT = 50
|
||||
SETBACKS_FT = [200, 500, 1000]
|
||||
BUILDING_THRESHOLDS = [5_000, 20_000, 50_000, 100_000, 250_000, 500_000]
|
||||
|
||||
RESIDENTIAL_DISTRICT_ZONECLASSES = {
|
||||
"R-1", "R-2", "R-2A", "R-2B", "R-2C", "R-3", "R-4", "R-5", "R-6",
|
||||
"RP-2", "RP-3", "RP-4", "RP-5", "RP-6",
|
||||
"RO-1", "RO-2", "A-1", "RM-3", "RM-4", "RM-5",
|
||||
}
|
||||
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
|
||||
RESIDENTIAL_PLACETYPES = {
|
||||
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
|
||||
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
|
||||
}
|
||||
|
||||
|
||||
def fix(g):
|
||||
if g is None or g.is_empty:
|
||||
return g
|
||||
return g if g.is_valid else make_valid(g)
|
||||
|
||||
|
||||
def max_poly(g) -> float:
|
||||
if g is None or g.is_empty:
|
||||
return 0.0
|
||||
if isinstance(g, Polygon):
|
||||
return g.area
|
||||
if isinstance(g, MultiPolygon):
|
||||
return max((p.area for p in g.geoms), default=0.0)
|
||||
try:
|
||||
return max((max_poly(p) for p in g.geoms), default=0.0)
|
||||
except AttributeError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def landuse_res(s) -> bool:
|
||||
if s is None or (isinstance(s, float) and s != s):
|
||||
return False
|
||||
prefix = str(s).split(" - ", 1)[0]
|
||||
return bool({c.strip() for c in prefix.split(":")} & RESIDENTIAL_LANDUSE_CODES)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
t0 = time.time()
|
||||
print("loading layers...", flush=True)
|
||||
zoning = gpd.read_parquet(CACHE / "zoning.parquet")
|
||||
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
|
||||
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
|
||||
zoning["geometry"] = zoning.geometry.apply(fix)
|
||||
parcels["geometry"] = parcels.geometry.apply(fix)
|
||||
parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
|
||||
|
||||
i2 = unary_union(zoning[zoning["ZONECLASS"] == "I-2"].geometry.values)
|
||||
cand_idx = sorted(parcels.sindex.query(i2, predicate="intersects"))
|
||||
cands = parcels.iloc[cand_idx].copy()
|
||||
cands["i2_geom"] = cands.geometry.intersection(i2).apply(fix)
|
||||
cands = cands[~cands["i2_geom"].is_empty].copy()
|
||||
print(f" I-2 candidate parcels: {len(cands):,}")
|
||||
|
||||
res_zones = zoning[zoning["ZONECLASS"].isin(RESIDENTIAL_DISTRICT_ZONECLASSES)]
|
||||
res_landuse = parcels[parcels["LANDUSE"].apply(landuse_res)]
|
||||
res_addr_mask = addresses["PLACETYPE"].astype(str).str.upper().isin(RESIDENTIAL_PLACETYPES)
|
||||
res_addrs = addresses[res_addr_mask]
|
||||
sj = gpd.sjoin(res_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
|
||||
how="inner", predicate="within")
|
||||
res_addr_par = parcels[parcels["GISLINK"].isin(set(sj["GISLINK"].dropna().unique()))]
|
||||
res_union = unary_union(
|
||||
list(res_zones.geometry.values)
|
||||
+ list(res_landuse.geometry.values)
|
||||
+ list(res_addr_par.geometry.values)
|
||||
)
|
||||
print(f" residential union built ({time.time()-t0:.1f}s)")
|
||||
|
||||
# results[(setback_ft, building_sqft)] -> count
|
||||
results = {}
|
||||
strict_counts = {}
|
||||
for ft in SETBACKS_FT:
|
||||
t1 = time.time()
|
||||
excl = res_union.buffer(ft)
|
||||
sindex = gpd.GeoSeries([excl], crs=parcels.crs).sindex
|
||||
strict = 0
|
||||
siteable_per_parcel = []
|
||||
for row in cands.itertuples(index=False):
|
||||
parcel_g = row.geometry
|
||||
i2g = row.i2_geom
|
||||
hits = list(sindex.query(parcel_g, predicate="intersects"))
|
||||
if not hits:
|
||||
strict += 1
|
||||
eroded = fix(i2g.buffer(-DC_INTERNAL_SETBACK_FT))
|
||||
else:
|
||||
remnant = fix(i2g.difference(excl))
|
||||
if remnant.is_empty:
|
||||
siteable_per_parcel.append(0.0)
|
||||
continue
|
||||
eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT))
|
||||
siteable_per_parcel.append(max_poly(eroded))
|
||||
strict_counts[ft] = strict
|
||||
for thr in BUILDING_THRESHOLDS:
|
||||
results[(ft, thr)] = sum(1 for s in siteable_per_parcel if s >= thr)
|
||||
print(f" {ft}ft done strict={strict} ({time.time()-t1:.1f}s)")
|
||||
|
||||
# Pretty matrix
|
||||
OUT.mkdir(exist_ok=True)
|
||||
out_path = OUT / "sweep_matrix.txt"
|
||||
with open(out_path, "w") as f:
|
||||
f.write("Johnson City Data Center site-eligibility sweep\n")
|
||||
f.write(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"I-2 candidate parcels: {len(cands):,}\n")
|
||||
f.write(f"Setbacks: {SETBACKS_FT} (200 = ordinance, others exploratory)\n")
|
||||
f.write(f"DC internal yard setback: {DC_INTERNAL_SETBACK_FT}ft (side/rear)\n")
|
||||
f.write("\nStrict eligibility (entire parcel >= setback from residential):\n")
|
||||
for ft in SETBACKS_FT:
|
||||
f.write(f" {ft:>4} ft: {strict_counts[ft]:>4}\n")
|
||||
f.write("\nSite-able count by min DC BUILDING footprint (sqft) x setback (ft):\n")
|
||||
header = " building sqft |" + "".join(f" {ft:>5} ft" for ft in SETBACKS_FT) + "\n"
|
||||
f.write(header)
|
||||
f.write(" " + "-" * (len(header) - 3) + "\n")
|
||||
for thr in BUILDING_THRESHOLDS:
|
||||
row = f" {thr:>13,} |"
|
||||
for ft in SETBACKS_FT:
|
||||
row += f" {results[(ft, thr)]:>8}"
|
||||
f.write(row + "\n")
|
||||
f.write("\nNotes:\n")
|
||||
f.write(" - Site-able = largest contiguous compliant patch eroded by 50ft\n"
|
||||
" (the DC's own side/rear yard per draft 6.20.3.2.I).\n"
|
||||
" - Counts are NECESSARY-but-not-sufficient: every parcel still\n"
|
||||
" needs BZA special-exception approval.\n")
|
||||
print(f"\nwrote {out_path}")
|
||||
print("\n--- sweep_matrix.txt ---")
|
||||
print(out_path.read_text())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user