Initial commit: 200feet pipeline, docker packaging, and static site

This commit is contained in:
2026-07-02 00:38:32 -04:00
commit df18210492
31 changed files with 7188 additions and 0 deletions
+683
View File
@@ -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())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Build the static payload, cross-build the Docker image for linux/amd64
# (the typical Portainer host arch), push to the registry.
# Usage: scripts/build_and_push.sh [tag]
# Default tag = current date + short git sha (if git repo) else 'latest'.
#
# Env vars:
# PLATFORMS - default "linux/amd64". Set to "linux/amd64,linux/arm64" for
# multi-arch (registry must support OCI manifest lists; most do).
# PY - default ".venv/bin/python"
# SKIP_GEN - "1" to skip regeneration of analysis outputs.
set -euo pipefail
cd "$(dirname "$0")/.."
REGISTRY="registry.davidadams.rocks"
IMAGE="${REGISTRY}/200feet"
PLATFORMS="${PLATFORMS:-linux/amd64}"
PY="${PY:-.venv/bin/python}"
BUILDER_NAME="200feet-builder"
if [[ $# -ge 1 ]]; then
TAG="$1"
else
DATE_TAG="$(date +%Y%m%d-%H%M)"
if git rev-parse --short HEAD >/dev/null 2>&1; then
SHA="$(git rev-parse --short HEAD)"
TAG="${DATE_TAG}-${SHA}"
else
TAG="${DATE_TAG}"
fi
fi
if [[ "${SKIP_GEN:-0}" != "1" ]]; then
echo "==> Regenerating analysis"
"$PY" scripts/analyze.py
"$PY" scripts/compare_setbacks.py
"$PY" scripts/build_web.py
fi
# Ensure a buildx builder exists with multi-arch / QEMU support. Reuse any
# existing docker-container builder rather than always creating ours -- many
# M1 setups already have one (e.g. 'multiarch' under Colima).
PICKED_BUILDER=""
if docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
PICKED_BUILDER="${BUILDER_NAME}"
else
# Try first existing docker-container builder, else create ours.
while IFS= read -r line; do
name=$(awk '{print $1}' <<<"$line" | sed 's/\*$//')
driver=$(awk '{print $2}' <<<"$line")
if [[ "$driver" == "docker-container" && -n "$name" && "$name" != "NAME/NODE" ]]; then
PICKED_BUILDER="$name"; break
fi
done < <(docker buildx ls 2>/dev/null | tail -n +2)
if [[ -z "${PICKED_BUILDER}" ]]; then
echo "==> Creating buildx builder '${BUILDER_NAME}'"
docker buildx create --name "${BUILDER_NAME}" --driver docker-container >/dev/null
docker buildx inspect "${BUILDER_NAME}" --bootstrap >/dev/null
PICKED_BUILDER="${BUILDER_NAME}"
fi
fi
docker buildx use "${PICKED_BUILDER}"
echo "==> Using buildx builder '${PICKED_BUILDER}'"
# Sanity-check the builder actually supports the requested platforms.
SUPPORTED=$(docker buildx inspect "${PICKED_BUILDER}" 2>/dev/null | awk -F: '/Platforms/ {print $2}' | tr -d ' ')
for plat in $(echo "${PLATFORMS}" | tr ',' ' '); do
if ! grep -q "${plat}" <<<"${SUPPORTED}"; then
echo "ERROR: builder '${PICKED_BUILDER}' does not support ${plat}." >&2
echo "Supported: ${SUPPORTED}" >&2
exit 1
fi
done
echo "==> Building & pushing ${IMAGE}:${TAG} for ${PLATFORMS}"
docker buildx build \
--builder "${PICKED_BUILDER}" \
--platform "${PLATFORMS}" \
--provenance=false \
--sbom=false \
--tag "${IMAGE}:${TAG}" \
--tag "${IMAGE}:latest" \
--push \
.
echo
echo "==> Verifying pushed manifest"
docker buildx imagetools inspect "${IMAGE}:latest"
echo
echo "Pushed:"
echo " ${IMAGE}:${TAG}"
echo " ${IMAGE}:latest"
echo "Platforms: ${PLATFORMS}"
echo
echo "In Portainer: Stacks -> 200feet -> Update -> 'Re-pull image and redeploy'."
+107
View File
@@ -0,0 +1,107 @@
"""Build the static-site data payload for web/.
Reads cache/addresses.parquet, slims to {addr, lat, lng}, writes web/data/
addresses.json. Copies the GeoJSONs produced by analyze.py into web/data/.
Generates web/data/manifest.json listing the available layers and scenarios.
"""
from __future__ import annotations
import json
import shutil
import sys
import time
from pathlib import Path
import geopandas as gpd
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
OUT = ROOT / "out"
WEB_DATA = ROOT / "web" / "data"
WEB_DATA.mkdir(parents=True, exist_ok=True)
def build_addresses() -> None:
t0 = time.time()
print("loading addresses...", flush=True)
addr = gpd.read_parquet(CACHE / "addresses.parquet").to_crs("EPSG:4326")
addr = addr[addr.geometry.notna() & ~addr.geometry.is_empty].copy()
addr["lng"] = addr.geometry.x.round(6)
addr["lat"] = addr.geometry.y.round(6)
addr["a"] = addr["FULLADDR"].fillna("").astype(str).str.strip()
addr = addr[addr["a"] != ""].drop_duplicates(subset=["a", "lat", "lng"])
# Slim shape: list of [addr, lat, lng] triples. Position-only for compact JSON.
rows = addr[["a", "lat", "lng"]].values.tolist()
out_path = WEB_DATA / "addresses.json"
with open(out_path, "w") as f:
json.dump(rows, f, separators=(",", ":"))
print(f" wrote {out_path.relative_to(ROOT)} "
f"({len(rows):,} addresses, {out_path.stat().st_size/1e6:.2f} MB, "
f"{time.time()-t0:.1f}s)")
def copy_geojsons() -> dict:
"""Copy out/*.geojson into web/data/. Return a manifest dict.
First sweep stale eligible_*.geojson and marginal_/homes_protected_
files from web/data/ that no longer exist in out/ -- otherwise a layer
that used to be non-empty can keep serving its old polygons after a
filter change made it empty.
"""
sources = {p.name for p in OUT.glob("*.geojson")}
stale_prefixes = ("eligible_", "marginal_parcels_", "homes_protected_")
for existing in WEB_DATA.glob("*.geojson"):
if existing.name.startswith(stale_prefixes) and existing.name not in sources:
existing.unlink()
print(f" removed stale {existing.name}")
layers = []
scenarios = {}
for gj in sorted(OUT.glob("*.geojson")):
dst = WEB_DATA / gj.name
shutil.copyfile(gj, dst)
size = dst.stat().st_size
layers.append({"file": gj.name, "size_kb": round(size / 1024, 1)})
# Detect scenario suffix: eligible_200ft_buildable_scenarioFOO.geojson
stem = gj.stem
if "_" in stem:
parts = stem.split("_")
if len(parts) >= 4 and parts[0] == "eligible":
ft = parts[1] # e.g. "200ft"
tier = parts[2] # strict / buildable / siteable
suffix = "_".join(parts[3:]) if len(parts) > 3 else "default"
scenarios.setdefault(suffix, {}).setdefault(ft, {})[tier] = gj.name
return {"layers": layers, "scenarios": scenarios}
def write_manifest(manifest: dict) -> None:
# Headline numbers parsed from CSVs so the page can show stats without
# the user having to open files.
headlines = {}
for ft in ("200ft", "500ft", "1000ft"):
for tier in ("strict", "buildable"):
f = OUT / f"eligible_{ft}_{tier}.csv"
if f.exists():
# rows = total lines - header
n = max(0, sum(1 for _ in open(f)) - 1)
headlines[f"{ft}_{tier}"] = n
manifest["headlines"] = headlines
manifest["generated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
out = WEB_DATA / "manifest.json"
with open(out, "w") as f:
json.dump(manifest, f, indent=2)
print(f" wrote {out.relative_to(ROOT)}")
def main() -> int:
build_addresses()
print("copying geojsons...")
manifest = copy_geojsons()
for layer in manifest["layers"]:
print(f" {layer['file']:48s} {layer['size_kb']:>8.1f} KB")
write_manifest(manifest)
return 0
if __name__ == "__main__":
sys.exit(main())
+225
View File
@@ -0,0 +1,225 @@
"""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())
+152
View File
@@ -0,0 +1,152 @@
"""Diagnose why a specific parcel was/wasn't included in the buildable list.
Usage: scripts/diagnose_parcel.py [GISLINK]
Default: 090063 00203 (BrightRidge / Innovation Dr 300)
"""
from __future__ import annotations
import sys
from pathlib import Path
import geopandas as gpd
import pandas as pd
from shapely import make_valid, unary_union
from shapely.ops import polylabel
from shapely.geometry import MultiPolygon, Polygon
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
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",
}
VACANT_PLACETYPES = {"VACANT LOT", "VACANT", "VACANT UNIT"}
DC_INTERNAL_SETBACK_FT = 50
def fix(g):
if g is None or g.is_empty: return g
return g if g.is_valid else make_valid(g)
def landuse_is_residential(x):
if x is None or (isinstance(x, float) and pd.isna(x)): return False
prefix = str(x).split(" - ", 1)[0]
return bool({c.strip() for c in prefix.split(":") if c.strip()} & RESIDENTIAL_LANDUSE_CODES)
def inscribed_radius(geom, tol=2.0):
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)
best = 0.0
for p in polys:
if p.is_empty or p.area < 1: continue
try:
pt = polylabel(p, tolerance=tol)
r = p.exterior.distance(pt)
if r > best: best = r
except Exception:
continue
return best
def main():
target_gid = sys.argv[1] if len(sys.argv) > 1 else "090063 00203"
print(f"Diagnosing parcel: '{target_gid}'\n")
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()
target = parcels[parcels["GISLINK"].astype(str) == target_gid]
if target.empty:
print(f"No parcel found with GISLINK '{target_gid}'")
return
tgt = target.iloc[0]
tg = tgt.geometry
print(f"Owner: {tgt.OWNER} Address: {tgt.ADDRESS}")
print(f"Zone: {tgt.ZONECLASS} LANDUSE: {tgt.LANDUSE}")
print(f"Parcel acres: {tg.area/43560:.2f}")
# Build residential exclusion (vacant-override rule)
addr_pt = addresses["PLACETYPE"].astype(str).str.upper()
res_addrs = addresses[addr_pt.isin(RESIDENTIAL_PLACETYPES)]
vac_addrs = addresses[addr_pt.isin(VACANT_PLACETYPES)]
sj_res = gpd.sjoin(res_addrs[["geometry"]], parcels[["GISLINK","geometry"]], how="inner", predicate="within")
res_gids = set(sj_res["GISLINK"].dropna().unique())
sj_vac = gpd.sjoin(vac_addrs[["geometry"]], parcels[["GISLINK","geometry"]], how="inner", predicate="within")
vac_gids = set(sj_vac["GISLINK"].dropna().unique())
override = vac_gids - res_gids
res_lu_mask = parcels["LANDUSE"].apply(landuse_is_residential) & ~parcels["GISLINK"].isin(override)
res_lu_parcels = parcels[res_lu_mask]
res_addr_parcels = parcels[parcels["GISLINK"].isin(res_gids)]
zc = zoning["ZONECLASS"].fillna("").astype(str)
res_zones = zoning[zc.isin(RESIDENTIAL_DISTRICT_ZONECLASSES)]
i2_union = unary_union(zoning[zoning["ZONECLASS"]=="I-2"].geometry.values)
# I-2 portion of this parcel
i2_part = fix(tg.intersection(i2_union))
print(f"\nI-2 portion of parcel: {i2_part.area/43560:.2f} acres ({i2_part.area:.0f} sqft)")
# Residential exclusion sources WITHIN 1500ft of the parcel
buf_test = tg.buffer(1500)
nearby_res_zones = res_zones[res_zones.intersects(buf_test)]
nearby_res_lu = res_lu_parcels[res_lu_parcels.intersects(buf_test)]
nearby_res_addr = res_addr_parcels[res_addr_parcels.intersects(buf_test)]
print(f"\nResidential sources within 1500ft of parcel:")
print(f" residential zone polygons: {len(nearby_res_zones)}")
print(f" parcels with residential LU: {len(nearby_res_lu)}")
print(f" parcels with dwelling addr: {len(nearby_res_addr)}")
# Compute the residential exclusion union limited to nearby (faster)
nearby_geoms = list(nearby_res_zones.geometry.values) + \
list(nearby_res_lu.geometry.values) + \
list(nearby_res_addr.geometry.values)
if nearby_geoms:
nearby_union = unary_union(nearby_geoms)
else:
nearby_union = None
for setback in (200, 500, 1000):
if nearby_union is None:
buf = None
remnant = i2_part
else:
buf = nearby_union.buffer(setback)
remnant = fix(i2_part.difference(buf))
eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT)) if not remnant.is_empty else remnant
ir_remnant = inscribed_radius(remnant)
ir_eroded = inscribed_radius(eroded)
# Compute frontage
ps_idx = parcels.sindex
nearby_ids = list(ps_idx.query(tg.buffer(10)))
nearby_parcels = [parcels.iloc[i].geometry for i in nearby_ids
if parcels.iloc[i].geometry is not None
and not parcels.iloc[i].geometry.equals(tg)]
if nearby_parcels:
nbr_union = unary_union([n.buffer(5) for n in nearby_parcels])
parent_road = tg.boundary.difference(nbr_union)
else:
parent_road = tg.boundary
patch_frontage = 0.0
if not remnant.is_empty and not parent_road.is_empty:
front = remnant.boundary.intersection(parent_road.buffer(5))
patch_frontage = front.length if not front.is_empty else 0.0
print(f"\n@ {setback} ft setback:")
print(f" compliant remnant: {remnant.area/43560:.3f} ac ({remnant.area:.0f} sqft)")
print(f" inscribed diam: {2*ir_remnant:.1f} ft")
print(f" eroded (siteable): {eroded.area/43560:.3f} ac ({eroded.area:.0f} sqft)")
print(f" inscribed diam: {2*ir_eroded:.1f} ft")
print(f" patch road frontage: {patch_frontage:.1f} ft (parent has {parent_road.length:.1f} ft total)")
passes_filters = (remnant.area >= 5000 and 2*ir_eroded >= 50 and patch_frontage >= 50)
print(f" PASSES filters? {passes_filters}")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""One-time prep: load GeoJSONs, reproject to TN State Plane US Feet, cache to parquet."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import geopandas as gpd
ROOT = Path(__file__).resolve().parent.parent
GIS = ROOT / "gis"
CACHE = ROOT / "cache"
CACHE.mkdir(exist_ok=True)
TN_FT = "EPSG:2274" # NAD83 / Tennessee State Plane, US Survey Feet
def prep(name: str, src: Path, keep_cols: list[str]) -> None:
out = CACHE / f"{name}.parquet"
if out.exists():
print(f"[skip] {name} already cached -> {out}")
return
t0 = time.time()
print(f"[read] {src.name} ...", flush=True)
gdf = gpd.read_file(src)
print(f" loaded {len(gdf):,} features in {time.time()-t0:.1f}s; crs={gdf.crs}")
keep_cols = [c for c in keep_cols if c in gdf.columns]
gdf = gdf[keep_cols + ["geometry"]]
t1 = time.time()
print(f"[reproject] -> {TN_FT}", flush=True)
gdf = gdf.to_crs(TN_FT)
# Drop Z dimension if present (force XY)
try:
gdf["geometry"] = gpd.GeoSeries(
[g if g is None or not g.has_z else _drop_z(g) for g in gdf.geometry],
crs=gdf.crs,
)
except Exception:
pass
print(f" reprojected in {time.time()-t1:.1f}s")
gdf.to_parquet(out)
print(f"[wrote] {out} ({out.stat().st_size/1e6:.1f} MB)")
def _drop_z(geom):
from shapely.ops import transform
return transform(lambda x, y, z=None: (x, y), geom)
def main() -> None:
prep(
"zoning",
GIS / "Zoning.geojson",
keep_cols=["OBJECTID", "ZONECLASS", "ZONEDESC", "ACRES_CALC"],
)
prep(
"parcels",
GIS / "Parcels.geojson",
keep_cols=[
"OBJECTID", "PARID", "GISLINK", "ADDRESS", "STREET",
"OWNER", "OWNER2", "ZONECLASS", "ZONEDESC",
"LANDUSE", "PROPTYPE", "CALC_ACRE",
],
)
prep(
"addresses",
GIS / "Addresses.geojson",
keep_cols=[
"OBJECTID", "GISLINK", "FULLADDR", "PLACETYPE",
"ADDRCLASS", "ZONING", "OWNER_CALC",
],
)
prep(
"roads",
GIS / "Road_Centerlines.geojson",
keep_cols=[
"OBJECTID", "CENTERLINEID", "FULLNAME", "ROADCLASS",
"ROADCLASS_PW", "MAINTBY", "PRIVDRIVETYPE",
],
)
if __name__ == "__main__":
sys.exit(main())
+154
View File
@@ -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())