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