684 lines
32 KiB
Python
684 lines
32 KiB
Python
"""Johnson City Data Center / Bitcoin mine eligibility analysis.
|
|
|
|
NOTE: based on DRAFT ordinance text under consideration (Section 6.20.3.2),
|
|
not enacted law. Treat results as informational only.
|
|
|
|
Setback ordinance (verbatim, 6.20.3.2.I):
|
|
"No Data Center facility shall be located within 200 feet of any residential
|
|
use or district. This includes any zoning district that permits single family
|
|
residences or dwellings, as well as RM-3, RM-4, and RM-5 districts.
|
|
The measurement shall be made from the nearest property line or zoning line
|
|
of the residential use or district, whichever is closer, to the nearest
|
|
property line of the Data Center."
|
|
|
|
Also from 6.20.3.2.I (DC-specific yard setbacks inside the parcel):
|
|
"The minimum front yard setback shall be sixty (60) feet. The minimum side
|
|
and rear setbacks shall be fifty (50) feet."
|
|
These OVERRIDE the I-2 default yard setbacks for a Data Center facility.
|
|
|
|
Data Centers in I-2 are a SPECIAL EXCEPTION (BZA approval required) -- this
|
|
analysis filters on geometric eligibility only; passing the geometric gate is
|
|
necessary but not sufficient for a permit.
|
|
|
|
Interpretation:
|
|
* Data Center is permitted only on I-2 (Heavy Industrial), as special exception.
|
|
* Source of measurement = polygon boundary of EITHER a residential-permitting
|
|
zoning district OR a parcel with a residential use; whichever is closer.
|
|
* Target of measurement = property line of the Data Center parcel.
|
|
* Buffering each source polygon by 200ft and testing parcel-boundary
|
|
intersection captures this exactly (line-to-line distance via buffer set).
|
|
|
|
Three eligibility views per parcel:
|
|
STRICT : whole parcel sits >= 200ft from any residential line.
|
|
(Treats the entire parcel as the "Data Center property line.")
|
|
BUILDABLE: largest contiguous patch inside parcel AND inside I-2 zoning AND
|
|
>= 200ft from any residential line. A developer could subdivide a
|
|
compliant sub-parcel from this footprint. The patch itself is
|
|
potentially developable land.
|
|
SITE-ABLE: BUILDABLE patch eroded inward by the DC's own 50ft side/rear
|
|
yard setback (6.20.3.2.I). What's left is the maximum BUILDING
|
|
footprint achievable on the parcel. This is the metric that
|
|
answers "how big a facility can you actually fit here."
|
|
|
|
Wargame: --exclude-gislinks removes those parcels from the residential
|
|
exclusion set (simulate a buyer demolishing the houses on those parcels).
|
|
|
|
Exploratory wider buffers (500/1000ft) are reported but labeled non-ordinance.
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import geopandas as gpd
|
|
import pandas as pd
|
|
from shapely import make_valid, unary_union
|
|
from shapely.geometry import MultiPolygon, Polygon
|
|
from shapely.ops import polylabel
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CACHE = ROOT / "cache"
|
|
OUT = ROOT / "out"
|
|
OUT.mkdir(exist_ok=True)
|
|
|
|
# Default min largest-contiguous-buildable-patch (sqft). Tunable via --min-sqft.
|
|
# Reference: small container mine ~1-5k sqft; warehouse mine ~20k+ sqft.
|
|
DEFAULT_MIN_USABLE_SQFT = 5000
|
|
|
|
# DC building yard setback inside the parcel (6.20.3.2.I): 60ft front / 50ft
|
|
# side & rear. We erode the buildable polygon by 50ft on all sides (developer
|
|
# will orient the building so the side abutting the road takes the front
|
|
# setback; the difference between 60 and 50 is small relative to parcel size).
|
|
DC_INTERNAL_SETBACK_FT = 50
|
|
|
|
# Strict-eligible parcels under this much I-2 acreage are filtered out (mostly
|
|
# right-of-way slivers in the source data that pass the clearance test
|
|
# trivially because they're a few square feet in size). Tunable via CLI.
|
|
DEFAULT_STRICT_MIN_I2_ACRES = 0.10 # ~4,356 sqft
|
|
|
|
# Minimum building width (ft). The siteable patch must contain an inscribed
|
|
# circle of this diameter so a real building can fit, not just a thin strip.
|
|
# 50ft default = a 50x50 building footprint can fit somewhere in the patch.
|
|
DEFAULT_MIN_BUILDING_WIDTH_FT = 50
|
|
|
|
# JC Subdivision Regs §4-4.1: every subdivided lot must have at least 50 ft
|
|
# of frontage on a public right-of-way (40 ft on cul-de-sac). We approximate
|
|
# road frontage by finding the portion of a parcel's boundary that does NOT
|
|
# lie along another parcel's boundary (roads are the gaps between parcels).
|
|
DEFAULT_MIN_FRONTAGE_FT = 50
|
|
|
|
# Half-width of a typical road right-of-way. The road centerline file is a
|
|
# LineString through the middle of each road; a parcel that fronts the road
|
|
# has its boundary roughly ROW_HALF_WIDTH_FT from the centerline. We treat any
|
|
# parcel-boundary segment within this distance of a public road centerline as
|
|
# road frontage. Set generously (50ft) to catch wider feeder/collector ROWs
|
|
# that can be 80-100ft total (half-width 40-50ft).
|
|
ROW_HALF_WIDTH_FT = 50
|
|
|
|
# Road classes / drive types treated as non-public (excluded from frontage).
|
|
NONPUBLIC_ROAD_CLASSES = {"Private", "Retired"}
|
|
|
|
# Zoning districts that satisfy the ordinance trigger: "any zoning district
|
|
# that permits single family residences or dwellings, as well as RM-3, RM-4,
|
|
# and RM-5 districts."
|
|
#
|
|
# The "as well as RM-3/RM-4/RM-5" carve-in implies the preceding clause is
|
|
# narrow (SFR-permitting only) -- if "or dwellings" meant any dwelling, the
|
|
# RM-3/4/5 enumeration would be surplusage. So we include only districts that
|
|
# permit single-family residences, plus the explicit RM-3/4/5.
|
|
#
|
|
# MX and MX-1 EXCLUDE single-family detached dwellings by ordinance and are
|
|
# not named in the carve-in, so they are NOT residential districts here.
|
|
# (Any actual dwelling on MX/MX-1 land is still captured via the residential
|
|
# USE path -- LANDUSE codes and address-point joins.)
|
|
RESIDENTIAL_DISTRICT_ZONECLASSES = {
|
|
# Residential districts (SFR permitted by-right)
|
|
"R-1", "R-2", "R-2A", "R-2B", "R-2C", "R-3", "R-4", "R-5", "R-6",
|
|
# Planned Residential (SFR permitted)
|
|
"RP-2", "RP-3", "RP-4", "RP-5", "RP-6",
|
|
# Residential-Office hybrid (SFR permitted)
|
|
"RO-1", "RO-2",
|
|
# Agricultural (farm SFR permitted)
|
|
"A-1",
|
|
# Manufactured home parks (explicitly named in ordinance carve-in)
|
|
"RM-3", "RM-4", "RM-5",
|
|
}
|
|
|
|
# Parcel LANDUSE codes indicating an existing residential use.
|
|
# 11 Household Units; 12 Group Quarters; 13 Residential Hotels; 14 Mobile Home Parks.
|
|
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
|
|
|
|
# Parcel LANDUSE codes that disqualify a parcel from being a data-center site
|
|
# regardless of zoning -- linear infrastructure that physically can't host a
|
|
# building (active rail right-of-way is the main one). 41 = rail transportation.
|
|
NON_BUILDABLE_LANDUSE_CODES = {"41"}
|
|
|
|
# Owner-name patterns that indicate railroad-owned right-of-way even when
|
|
# LANDUSE is unset. Case-insensitive substring match on the OWNER field.
|
|
NON_BUILDABLE_OWNER_KEYWORDS = (
|
|
"RAILROAD", "RAILWAY", " RY ", " RY,", " RY.", " R R ", " RR ",
|
|
"CSX TRANSPORTATION", "NORFOLK SOUTHERN", " R/R",
|
|
)
|
|
|
|
# Address PLACETYPE values indicating a residence at that point.
|
|
RESIDENTIAL_PLACETYPES = {
|
|
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
|
|
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
|
|
}
|
|
|
|
# Address PLACETYPE values that explicitly mark a parcel as currently vacant.
|
|
# When the address dataset (which is kept current for 911/utility purposes)
|
|
# marks a parcel vacant but its LANDUSE code is stale "household units" from
|
|
# the last assessment, we trust the address. Avoids treating long-cleared
|
|
# industrial lots as residential exclusions.
|
|
VACANT_PLACETYPES = {"VACANT LOT", "VACANT", "VACANT UNIT"}
|
|
|
|
# Buffers reported. 200 is the ordinance threshold; 500 / 1000 are exploratory.
|
|
SETBACKS_FT = [200, 500, 1000]
|
|
ORDINANCE_SETBACK_FT = 200
|
|
|
|
|
|
def stamp(msg: str, start: float | None = None) -> float:
|
|
now = time.time()
|
|
if start is None:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
else:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg} ({now - start:.1f}s)", flush=True)
|
|
return now
|
|
|
|
|
|
def fix(geom):
|
|
if geom is None or geom.is_empty:
|
|
return geom
|
|
if not geom.is_valid:
|
|
return make_valid(geom)
|
|
return geom
|
|
|
|
|
|
def _inscribed_radius(geom, tolerance: float = 5.0) -> float:
|
|
"""Largest inscribed circle radius (ft) across all polygon parts of geom."""
|
|
if geom is None or geom.is_empty:
|
|
return 0.0
|
|
polys = []
|
|
if isinstance(geom, Polygon):
|
|
polys = [geom]
|
|
elif isinstance(geom, MultiPolygon):
|
|
polys = list(geom.geoms)
|
|
else:
|
|
try:
|
|
for g in geom.geoms:
|
|
if isinstance(g, Polygon):
|
|
polys.append(g)
|
|
elif isinstance(g, MultiPolygon):
|
|
polys.extend(g.geoms)
|
|
except AttributeError:
|
|
return 0.0
|
|
best = 0.0
|
|
for p in polys:
|
|
if p.is_empty or p.area < 1.0:
|
|
continue
|
|
try:
|
|
pt = polylabel(p, tolerance=tolerance)
|
|
r = p.exterior.distance(pt)
|
|
if r > best:
|
|
best = r
|
|
except Exception:
|
|
continue
|
|
return best
|
|
|
|
|
|
def _write_gjs(gdf, path: Path) -> None:
|
|
"""Reproject a GeoDataFrame to EPSG:4326 and write as GeoJSON."""
|
|
gdf = gdf.to_crs("EPSG:4326")
|
|
if path.exists():
|
|
path.unlink()
|
|
gdf.to_file(path, driver="GeoJSON")
|
|
print(f" wrote {path.relative_to(ROOT)} ({len(gdf):,} features, {path.stat().st_size/1024:.1f} KB)")
|
|
|
|
|
|
def _max_polygon_area(geom) -> float:
|
|
"""Return area (sqft) of the single largest contiguous polygon piece."""
|
|
if geom is None or geom.is_empty:
|
|
return 0.0
|
|
if isinstance(geom, Polygon):
|
|
return geom.area
|
|
if isinstance(geom, MultiPolygon):
|
|
return max((g.area for g in geom.geoms), default=0.0)
|
|
# GeometryCollection etc.
|
|
try:
|
|
polys = [g for g in geom.geoms if isinstance(g, (Polygon, MultiPolygon))]
|
|
return max((_max_polygon_area(p) for p in polys), default=0.0)
|
|
except AttributeError:
|
|
return 0.0
|
|
|
|
|
|
def landuse_is_residential(landuse: str | None) -> bool:
|
|
if landuse is None or pd.isna(landuse):
|
|
return False
|
|
prefix = str(landuse).split(" - ", 1)[0]
|
|
codes = {c.strip() for c in prefix.split(":") if c.strip()}
|
|
return bool(codes & RESIDENTIAL_LANDUSE_CODES)
|
|
|
|
|
|
def landuse_is_non_buildable(landuse: str | None) -> bool:
|
|
if landuse is None or pd.isna(landuse):
|
|
return False
|
|
prefix = str(landuse).split(" - ", 1)[0]
|
|
codes = {c.strip() for c in prefix.split(":") if c.strip()}
|
|
return bool(codes & NON_BUILDABLE_LANDUSE_CODES)
|
|
|
|
|
|
def owner_is_railroad(owner: str | None) -> bool:
|
|
if owner is None or pd.isna(owner):
|
|
return False
|
|
s = f" {str(owner).upper()} "
|
|
return any(kw in s for kw in NON_BUILDABLE_OWNER_KEYWORDS)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument(
|
|
"--exclude-gislinks", nargs="*", default=[],
|
|
help="GISLINKs of parcels to treat as NON-residential (wargame mode).",
|
|
)
|
|
ap.add_argument(
|
|
"--drop-a1", action="store_true",
|
|
help="Sensitivity test: do NOT count A-1 (Agricultural) as a residential district.",
|
|
)
|
|
ap.add_argument(
|
|
"--min-sqft", type=float, default=DEFAULT_MIN_USABLE_SQFT,
|
|
help=f"Min largest-contiguous BUILDABLE patch (sqft) to report. "
|
|
f"Default {DEFAULT_MIN_USABLE_SQFT}.",
|
|
)
|
|
ap.add_argument(
|
|
"--min-building-sqft", type=float, default=None,
|
|
help="If set, also require SITE-ABLE area (buildable patch after DC's "
|
|
"own 50ft side/rear setback) >= this many sqft. Use this to filter "
|
|
"for parcels where an actual building of this size can fit.",
|
|
)
|
|
ap.add_argument(
|
|
"--out-suffix", type=str, default="",
|
|
help="Suffix appended to output filenames so sweeps don't clobber the "
|
|
"canonical CSVs. Example: --out-suffix _scenarioA -> "
|
|
"eligible_200ft_buildable_scenarioA.csv",
|
|
)
|
|
ap.add_argument(
|
|
"--no-geojson", action="store_true",
|
|
help="Skip GeoJSON outputs (write CSVs only). Default writes GeoJSON "
|
|
"for map rendering, reprojected back to EPSG:4326 (lon/lat).",
|
|
)
|
|
ap.add_argument(
|
|
"--min-width-ft", type=float, default=DEFAULT_MIN_BUILDING_WIDTH_FT,
|
|
help=f"Minimum width (ft) of an inscribed building footprint in the "
|
|
f"siteable patch. Default {DEFAULT_MIN_BUILDING_WIDTH_FT} ft. "
|
|
f"Set to 0 to disable shape filtering.",
|
|
)
|
|
ap.add_argument(
|
|
"--strict-min-i2-acres", type=float, default=DEFAULT_STRICT_MIN_I2_ACRES,
|
|
help=f"Strict-eligible parcels must have at least this much I-2 acreage "
|
|
f"(filters out ROW slivers). Default {DEFAULT_STRICT_MIN_I2_ACRES}.",
|
|
)
|
|
ap.add_argument(
|
|
"--min-frontage-ft", type=float, default=DEFAULT_MIN_FRONTAGE_FT,
|
|
help=f"Minimum public-road frontage (ft) per JC Subdivision Reg §4-4.1. "
|
|
f"Default {DEFAULT_MIN_FRONTAGE_FT}. Set to 0 to disable.",
|
|
)
|
|
args = ap.parse_args()
|
|
exclude_gislinks = set(args.exclude_gislinks)
|
|
min_sqft = args.min_sqft
|
|
min_building_sqft = args.min_building_sqft
|
|
out_suffix = args.out_suffix
|
|
write_geojson = not args.no_geojson
|
|
min_width_ft = args.min_width_ft
|
|
strict_min_i2_acres = args.strict_min_i2_acres
|
|
min_radius_ft = min_width_ft / 2.0
|
|
min_frontage_ft = args.min_frontage_ft
|
|
|
|
res_district_classes = set(RESIDENTIAL_DISTRICT_ZONECLASSES)
|
|
if args.drop_a1:
|
|
res_district_classes -= {"A-1"}
|
|
|
|
# Wipe stale per-setback outputs with the matching suffix so missing tiers
|
|
# (e.g., strict=0 at 500ft) don't leave behind files from previous runs.
|
|
for ft in SETBACKS_FT:
|
|
for tier in ("strict", "buildable", "siteable"):
|
|
for ext in (".csv", ".geojson"):
|
|
p = OUT / f"eligible_{ft}ft_{tier}{out_suffix}{ext}"
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
t0 = stamp("loading cached layers")
|
|
zoning = gpd.read_parquet(CACHE / "zoning.parquet")
|
|
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
|
|
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
|
|
roads = gpd.read_parquet(CACHE / "roads.parquet")
|
|
stamp(f"loaded zoning={len(zoning):,} parcels={len(parcels):,} "
|
|
f"addresses={len(addresses):,} roads={len(roads):,}", t0)
|
|
assert zoning.crs == parcels.crs == addresses.crs == roads.crs
|
|
|
|
# Public roads only -- private drives and retired segments don't count
|
|
# toward subdivision-eligible road frontage.
|
|
public_road_mask = (
|
|
~roads["ROADCLASS_PW"].astype(str).isin(NONPUBLIC_ROAD_CLASSES)
|
|
& roads["PRIVDRIVETYPE"].isna()
|
|
)
|
|
public_roads = roads[public_road_mask].copy()
|
|
print(f" public roads: {len(public_roads):,} segments of {len(roads):,}")
|
|
|
|
t1 = stamp("fixing invalid geometries")
|
|
zoning["geometry"] = zoning.geometry.apply(fix)
|
|
parcels["geometry"] = parcels.geometry.apply(fix)
|
|
parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
|
|
stamp("done", t1)
|
|
|
|
# ---- I-2 footprint ----
|
|
t1 = stamp("building I-2 footprint")
|
|
i2_zones = zoning[zoning["ZONECLASS"] == "I-2"].copy()
|
|
i2_union = unary_union(i2_zones.geometry.values)
|
|
print(f" I-2 zones: {len(i2_zones)}; total acres: {i2_union.area/43560:.1f}")
|
|
stamp("done", t1)
|
|
|
|
# ---- Candidate parcels: any parcel intersecting any I-2 zone ----
|
|
t1 = stamp("identifying parcels intersecting I-2")
|
|
parcels_sindex = parcels.sindex
|
|
cand_idx = sorted(parcels_sindex.query(i2_union, predicate="intersects"))
|
|
candidates = parcels.iloc[cand_idx].copy()
|
|
candidates["i2_geom"] = candidates.geometry.intersection(i2_union).apply(fix)
|
|
candidates = candidates[~candidates["i2_geom"].is_empty].copy()
|
|
candidates["parcel_sqft"] = candidates.geometry.area
|
|
candidates["i2_sqft"] = candidates["i2_geom"].area
|
|
candidates["parcel_acres"] = candidates["parcel_sqft"] / 43560.0
|
|
candidates["i2_acres"] = candidates["i2_sqft"] / 43560.0
|
|
n_intersect = len(candidates)
|
|
print(f" parcels intersecting I-2: {n_intersect:,}")
|
|
|
|
# Drop parcels that are active rail right-of-way or other linear
|
|
# infrastructure that can't host a building (regardless of geometric
|
|
# eligibility). Identified by LANDUSE code 41 or railroad-style owner.
|
|
nb_lu_mask = candidates["LANDUSE"].apply(landuse_is_non_buildable)
|
|
nb_owner_mask = candidates["OWNER"].apply(owner_is_railroad)
|
|
nb_mask = nb_lu_mask | nb_owner_mask
|
|
if nb_mask.any():
|
|
dropped = candidates[nb_mask][["GISLINK", "ADDRESS", "OWNER", "LANDUSE"]]
|
|
print(f" dropping {nb_mask.sum()} non-buildable infrastructure parcel(s):")
|
|
for _, r in dropped.iterrows():
|
|
print(f" {r['GISLINK']!s:<22} {r['ADDRESS']!s:<30} {r['OWNER']!s:<32} {r['LANDUSE']!s}")
|
|
candidates = candidates[~nb_mask].copy()
|
|
print(f" buildable-eligible candidates: {len(candidates):,}")
|
|
stamp("done", t1)
|
|
|
|
# ---- Road frontage per parent parcel (authoritative centerlines) ----
|
|
# For each parcel, frontage = length of parcel boundary within
|
|
# ROW_HALF_WIDTH_FT of any PUBLIC road centerline. The centerline file is
|
|
# a LineString through the middle of each road; a parcel boundary roughly
|
|
# at the ROW edge sits ~25-35 ft from the centerline.
|
|
t1 = stamp("computing road frontage from centerlines")
|
|
roads_sindex = public_roads.sindex
|
|
road_geoms = public_roads.geometry.values
|
|
road_frontage_geoms = []
|
|
parent_frontage_ft_list = []
|
|
for row in candidates.itertuples(index=False):
|
|
pg = row.geometry
|
|
nearby_ids = list(roads_sindex.query(pg.buffer(ROW_HALF_WIDTH_FT + 5)))
|
|
if not nearby_ids:
|
|
road_frontage_geoms.append(None)
|
|
parent_frontage_ft_list.append(0.0)
|
|
continue
|
|
corridor = unary_union([road_geoms[i] for i in nearby_ids]).buffer(ROW_HALF_WIDTH_FT)
|
|
front = pg.boundary.intersection(corridor)
|
|
if front.is_empty:
|
|
road_frontage_geoms.append(None)
|
|
parent_frontage_ft_list.append(0.0)
|
|
else:
|
|
road_frontage_geoms.append(front)
|
|
parent_frontage_ft_list.append(round(front.length, 1))
|
|
candidates["road_frontage_geom"] = road_frontage_geoms
|
|
candidates["parent_frontage_ft"] = parent_frontage_ft_list
|
|
n_with = (candidates["parent_frontage_ft"] >= min_frontage_ft).sum()
|
|
print(f" parents with >= {int(min_frontage_ft)} ft public road frontage: "
|
|
f"{n_with}/{len(candidates)}")
|
|
stamp("done", t1)
|
|
|
|
inv_cols = ["GISLINK", "ADDRESS", "OWNER", "OWNER2", "ZONECLASS",
|
|
"LANDUSE", "PROPTYPE", "parcel_acres", "i2_acres"]
|
|
candidates[[c for c in inv_cols if c in candidates.columns]] \
|
|
.sort_values("i2_acres", ascending=False) \
|
|
.to_csv(OUT / "i2_parcels.csv", index=False)
|
|
print(f" wrote out/i2_parcels.csv")
|
|
|
|
if write_geojson:
|
|
# Static layers (independent of setback): I-2 zoning polygons and the
|
|
# full candidate-parcel set. Written once per run; suffix omitted so
|
|
# the map can always rely on these names.
|
|
_write_gjs(i2_zones[["ZONECLASS", "ZONEDESC", "geometry"]].copy(),
|
|
OUT / "i2_zones.geojson")
|
|
parcels_layer = candidates[[c for c in inv_cols if c in candidates.columns] + ["geometry"]].copy()
|
|
_write_gjs(parcels_layer, OUT / "i2_parcels.geojson")
|
|
|
|
# ---- Residential exclusion universe ----
|
|
t1 = stamp("building residential exclusion set")
|
|
zc = zoning["ZONECLASS"].fillna("").astype(str)
|
|
res_zone_mask = zc.isin(res_district_classes)
|
|
res_zones = zoning[res_zone_mask].copy()
|
|
print(f" residential district polygons : {len(res_zones):,} "
|
|
f"({sorted(res_zones['ZONECLASS'].unique())})")
|
|
|
|
# Address points: classify into residential / vacant for ground-truth
|
|
# signals that override stale LANDUSE codes.
|
|
addr_pt = addresses["PLACETYPE"].astype(str).str.upper()
|
|
res_addrs = addresses[addr_pt.isin(RESIDENTIAL_PLACETYPES)].copy()
|
|
vacant_addrs = addresses[addr_pt.isin(VACANT_PLACETYPES)].copy()
|
|
|
|
res_addr_in_parcels = gpd.sjoin(
|
|
res_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
|
|
how="inner", predicate="within",
|
|
)
|
|
res_addr_gislinks = set(res_addr_in_parcels["GISLINK"].dropna().unique())
|
|
|
|
vac_addr_in_parcels = gpd.sjoin(
|
|
vacant_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
|
|
how="inner", predicate="within",
|
|
)
|
|
vac_addr_gislinks = set(vac_addr_in_parcels["GISLINK"].dropna().unique())
|
|
|
|
# Override set: parcels whose address record explicitly says VACANT and has
|
|
# no residential address point. These trump a stale residential LANDUSE.
|
|
landuse_override_gislinks = vac_addr_gislinks - res_addr_gislinks
|
|
|
|
# (B) Residential LANDUSE -- but exclude parcels overridden by addresses.
|
|
res_landuse_mask = parcels["LANDUSE"].apply(landuse_is_residential) \
|
|
& ~parcels["GISLINK"].isin(landuse_override_gislinks)
|
|
res_landuse_parcels = parcels[res_landuse_mask].copy()
|
|
n_overridden = (parcels["LANDUSE"].apply(landuse_is_residential)
|
|
& parcels["GISLINK"].isin(landuse_override_gislinks)).sum()
|
|
print(f" parcels w/ residential LANDUSE : {len(res_landuse_parcels):,} "
|
|
f"(dropped {n_overridden} as VACANT per address record)")
|
|
|
|
# (C) Parcels containing a residential address point.
|
|
res_addr_parcels = parcels[parcels["GISLINK"].isin(res_addr_gislinks)].copy()
|
|
print(f" parcels containing a dwelling : {len(res_addr_parcels):,}")
|
|
|
|
if exclude_gislinks:
|
|
before_l = len(res_landuse_parcels); before_a = len(res_addr_parcels)
|
|
res_landuse_parcels = res_landuse_parcels[~res_landuse_parcels["GISLINK"].isin(exclude_gislinks)]
|
|
res_addr_parcels = res_addr_parcels[~res_addr_parcels["GISLINK"].isin(exclude_gislinks)]
|
|
print(f" wargame excluded {len(exclude_gislinks)} GISLINKs "
|
|
f"(LANDUSE {before_l}->{len(res_landuse_parcels)}, "
|
|
f"addr {before_a}->{len(res_addr_parcels)})")
|
|
|
|
res_geoms = (
|
|
list(res_zones.geometry.values)
|
|
+ list(res_landuse_parcels.geometry.values)
|
|
+ list(res_addr_parcels.geometry.values)
|
|
)
|
|
print(f" total residential geoms (pre-union): {len(res_geoms):,}")
|
|
stamp("collected", t1)
|
|
|
|
t1 = stamp("union of residential geoms")
|
|
residential_union = unary_union(res_geoms)
|
|
stamp("done", t1)
|
|
|
|
with open(OUT / "residential_summary.txt", "w") as f:
|
|
f.write(f"Residential districts in scope : {sorted(res_district_classes)}\n")
|
|
f.write(f"Residential district polygons : {len(res_zones):,}\n")
|
|
f.write(f"Parcels w/ residential LANDUSE : {len(res_landuse_parcels):,}\n")
|
|
f.write(f"Parcels w/ a dwelling address : {len(res_addr_parcels):,}\n")
|
|
f.write(f"Wargame-excluded GISLINKs : {len(exclude_gislinks)}\n")
|
|
f.write(f"Residential union area (acres) : {residential_union.area/43560:.1f}\n")
|
|
|
|
# ---- For each setback compute STRICT and BUILDABLE eligibility ----
|
|
for ft in SETBACKS_FT:
|
|
t1 = stamp(f"buffering residential by {ft} ft")
|
|
excl = residential_union.buffer(ft)
|
|
excl_sindex = gpd.GeoSeries([excl], crs=parcels.crs).sindex
|
|
stamp("done", t1)
|
|
|
|
t1 = stamp(f"computing eligibility @ {ft} ft")
|
|
strict_flags = []
|
|
total_sqft = []
|
|
max_contig_sqft = []
|
|
max_siteable_sqft = []
|
|
inscribed_diam = []
|
|
patch_frontage_ft = []
|
|
buildable_geoms = []
|
|
siteable_geoms = []
|
|
for row in candidates.itertuples(index=False):
|
|
parcel_g = row.geometry
|
|
i2g = row.i2_geom
|
|
road_g = row.road_frontage_geom
|
|
|
|
# Strict: entire parcel boundary >= ft from residential.
|
|
hits = list(excl_sindex.query(parcel_g, predicate="intersects"))
|
|
if not hits:
|
|
strict_flags.append(True)
|
|
total_sqft.append(i2g.area)
|
|
max_contig_sqft.append(_max_polygon_area(i2g))
|
|
buildable_geoms.append(i2g)
|
|
eroded = fix(i2g.buffer(-DC_INTERNAL_SETBACK_FT))
|
|
max_siteable_sqft.append(_max_polygon_area(eroded))
|
|
siteable_geoms.append(eroded)
|
|
inscribed_diam.append(2 * _inscribed_radius(eroded))
|
|
# For strict (whole-parcel) the relevant frontage is the parent's.
|
|
patch_frontage_ft.append(row.parent_frontage_ft)
|
|
continue
|
|
strict_flags.append(False)
|
|
|
|
# Buildable remnant inside parcel AND inside I-2 AND >= ft from residential.
|
|
remnant = fix(i2g.difference(excl))
|
|
if remnant.is_empty:
|
|
total_sqft.append(0.0)
|
|
max_contig_sqft.append(0.0)
|
|
max_siteable_sqft.append(0.0)
|
|
buildable_geoms.append(remnant)
|
|
siteable_geoms.append(remnant)
|
|
inscribed_diam.append(0.0)
|
|
patch_frontage_ft.append(0.0)
|
|
else:
|
|
total_sqft.append(remnant.area)
|
|
max_contig_sqft.append(_max_polygon_area(remnant))
|
|
buildable_geoms.append(remnant)
|
|
eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT))
|
|
max_siteable_sqft.append(_max_polygon_area(eroded))
|
|
siteable_geoms.append(eroded)
|
|
inscribed_diam.append(2 * _inscribed_radius(eroded))
|
|
# Patch frontage = length of patch boundary lying along parent
|
|
# road frontage. Buffer the road geom slightly to handle small
|
|
# numerical mismatches from the difference operation.
|
|
if road_g is None or road_g.is_empty:
|
|
patch_frontage_ft.append(0.0)
|
|
else:
|
|
# 5ft snap tolerance for numerical alignment between
|
|
# parent boundary and patch boundary.
|
|
front = remnant.boundary.intersection(road_g.buffer(5))
|
|
patch_frontage_ft.append(round(front.length, 1) if not front.is_empty else 0.0)
|
|
stamp("done", t1)
|
|
|
|
candidates[f"strict_{ft}"] = strict_flags
|
|
candidates[f"buildable_sqft_{ft}"] = total_sqft
|
|
candidates[f"max_contig_sqft_{ft}"] = max_contig_sqft
|
|
candidates[f"max_siteable_sqft_{ft}"] = max_siteable_sqft
|
|
candidates[f"inscribed_diam_ft_{ft}"] = [round(d, 1) for d in inscribed_diam]
|
|
candidates[f"patch_frontage_ft_{ft}"] = patch_frontage_ft
|
|
candidates[f"buildable_acres_{ft}"] = [a / 43560.0 for a in total_sqft]
|
|
candidates[f"max_contig_acres_{ft}"] = [a / 43560.0 for a in max_contig_sqft]
|
|
candidates[f"max_siteable_acres_{ft}"] = [a / 43560.0 for a in max_siteable_sqft]
|
|
candidates[f"buildable_pct_of_i2_{ft}"] = (
|
|
candidates[f"buildable_sqft_{ft}"] / candidates["i2_sqft"] * 100
|
|
).round(1)
|
|
candidates[f"_buildable_geom_{ft}"] = buildable_geoms
|
|
candidates[f"_siteable_geom_{ft}"] = siteable_geoms
|
|
|
|
# Strict eligibility output. Filters: parcel passes clearance, parcel
|
|
# has meaningful I-2 acreage (no ROW slivers), siteable patch has
|
|
# inscribed-circle diameter >= min_width_ft, AND the PARENT parcel
|
|
# has >= min_frontage_ft of public road frontage so the parcel could
|
|
# legally subdivide if needed (JC Subdivision Reg §4-4.1).
|
|
strict_mask = candidates[f"strict_{ft}"] \
|
|
& (candidates["i2_acres"] >= strict_min_i2_acres) \
|
|
& (candidates[f"inscribed_diam_ft_{ft}"] >= min_width_ft) \
|
|
& (candidates["parent_frontage_ft"] >= min_frontage_ft)
|
|
strict = candidates[strict_mask].copy()
|
|
s_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
|
|
"parcel_acres", "i2_acres",
|
|
f"max_siteable_acres_{ft}", f"inscribed_diam_ft_{ft}",
|
|
"parent_frontage_ft", f"patch_frontage_ft_{ft}"]
|
|
out_strict = OUT / f"eligible_{ft}ft_strict{out_suffix}.csv"
|
|
strict[[c for c in s_cols if c in strict.columns]] \
|
|
.sort_values("i2_acres", ascending=False) \
|
|
.to_csv(out_strict, index=False)
|
|
|
|
# Buildable filter: largest contiguous patch >= min_sqft, inscribed
|
|
# building circle diameter >= min_width_ft (so a real building fits),
|
|
# PARENT parcel has >= min_frontage_ft of public road frontage. We
|
|
# filter on PARENT frontage (not patch frontage) because the 50ft
|
|
# subdivision rule applies to the sub-parcel boundary the developer
|
|
# draws -- and the developer can draw lot lines to include road-
|
|
# adjacent land outside the compliant patch, as long as the data
|
|
# center building itself sits inside the patch.
|
|
build_mask = (candidates[f"max_contig_sqft_{ft}"] >= min_sqft) \
|
|
& (candidates[f"inscribed_diam_ft_{ft}"] >= min_width_ft) \
|
|
& (candidates["parent_frontage_ft"] >= min_frontage_ft)
|
|
if min_building_sqft is not None:
|
|
build_mask &= candidates[f"max_siteable_sqft_{ft}"] >= min_building_sqft
|
|
buildable = candidates[build_mask].copy()
|
|
b_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
|
|
"parcel_acres", "i2_acres",
|
|
f"max_contig_acres_{ft}", f"max_siteable_acres_{ft}",
|
|
f"inscribed_diam_ft_{ft}",
|
|
"parent_frontage_ft", f"patch_frontage_ft_{ft}",
|
|
f"buildable_acres_{ft}", f"buildable_pct_of_i2_{ft}"]
|
|
out_build = OUT / f"eligible_{ft}ft_buildable{out_suffix}.csv"
|
|
buildable[[c for c in b_cols if c in buildable.columns]] \
|
|
.sort_values(f"max_siteable_acres_{ft}", ascending=False) \
|
|
.to_csv(out_build, index=False)
|
|
|
|
flag = "ORDINANCE" if ft == ORDINANCE_SETBACK_FT else "exploratory"
|
|
extra = f", siteable >= {int(min_building_sqft):,}" if min_building_sqft else ""
|
|
print(f" setback {ft}ft ({flag}): "
|
|
f"strict={len(strict):,} "
|
|
f"buildable(contig >= {int(min_sqft):,} sqft, "
|
|
f"width >= {int(min_width_ft)}ft, frontage >= {int(min_frontage_ft)}ft{extra})={len(buildable):,}")
|
|
|
|
if write_geojson:
|
|
attr_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
|
|
"parcel_acres", "i2_acres",
|
|
f"max_contig_acres_{ft}", f"max_siteable_acres_{ft}",
|
|
f"inscribed_diam_ft_{ft}",
|
|
"parent_frontage_ft", f"patch_frontage_ft_{ft}",
|
|
f"buildable_acres_{ft}", f"buildable_pct_of_i2_{ft}"]
|
|
attr_cols = [c for c in attr_cols if c in candidates.columns]
|
|
|
|
# Strict-eligible parcels (whole parcel polygons)
|
|
if len(strict):
|
|
strict_layer = gpd.GeoDataFrame(
|
|
strict[attr_cols], geometry=strict["geometry"], crs=candidates.crs,
|
|
)
|
|
_write_gjs(strict_layer, OUT / f"eligible_{ft}ft_strict{out_suffix}.geojson")
|
|
|
|
# Buildable patches: the actual sub-parcel polygons. One feature per
|
|
# eligible parcel; geometry is the patch (post-buffer-subtraction).
|
|
if len(buildable):
|
|
build_layer = gpd.GeoDataFrame(
|
|
buildable[attr_cols],
|
|
geometry=buildable[f"_buildable_geom_{ft}"].values,
|
|
crs=candidates.crs,
|
|
)
|
|
build_layer = build_layer[~build_layer.geometry.is_empty]
|
|
_write_gjs(build_layer, OUT / f"eligible_{ft}ft_buildable{out_suffix}.geojson")
|
|
|
|
# Site-able patches (buildable eroded by DC's 50ft internal yard)
|
|
site_layer = gpd.GeoDataFrame(
|
|
buildable[attr_cols],
|
|
geometry=buildable[f"_siteable_geom_{ft}"].values,
|
|
crs=candidates.crs,
|
|
)
|
|
site_layer = site_layer[~site_layer.geometry.is_empty]
|
|
_write_gjs(site_layer, OUT / f"eligible_{ft}ft_siteable{out_suffix}.geojson")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|