Initial commit: 200feet pipeline, docker packaging, and static site
This commit is contained in:
@@ -0,0 +1,683 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user