{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "# Enhanced Data Center Cluster Map\n", "\n", "This notebook starts from the spatial clustering outputs created by `spatial_clustering_master_data_centers.ipynb` and adds contextual layers from the demographic/RUCA/energy analysis.\n", "\n", "Current features:\n", "- Loads point and cluster summary CSVs from `output/`.\n", "- Recreates the cluster-colored Folium map.\n", "- Enriches point popups with HUC8 watershed, RUCA, tract demographics, and state energy context where available.\n", "- Adds separate layers for clustered points, isolated/noise points, cluster centroids, HUC8 watersheds, and state IM3 projected demand.\n", "- Saves a standalone HTML map to `output/enhanced_master_data_center_spatial_clusters_map.html`.\n", "\n", "Notes from `output/data_center_demographic_ruca_energy_summary.md`:\n", "- HUC8 watershed join is a recommended next step for water-context analysis.\n", "- `im3_state_projected_moderate_50` is populated and used for state projected demand context.\n", "- `seds_state_msn_year` is checked through the state context export, but it currently has no rows, so SEDS fields are blank until that table is populated.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "1", "metadata": {}, "outputs": [], "source": [ "import os\n", "import json\n", "import subprocess\n", "from html import escape\n", "from pathlib import Path\n", "\n", "os.environ.setdefault('MPLCONFIGDIR', '/tmp/matplotlib')\n", "Path(os.environ['MPLCONFIGDIR']).mkdir(parents=True, exist_ok=True)\n", "\n", "import pandas as pd\n", "import folium\n", "import psycopg2\n", "from folium import plugins\n", "\n", "print('pandas:', pd.__version__)\n", "print('folium:', folium.__version__)\n", "print('psycopg2:', psycopg2.__version__)\n" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "## Paths And Display Settings" ] }, { "cell_type": "code", "execution_count": null, "id": "3", "metadata": {}, "outputs": [], "source": [ "OUTPUT_DIR = Path('output')\n", "POINTS_CSV = OUTPUT_DIR / 'master_data_center_spatial_cluster_points.csv'\n", "CLUSTERS_CSV = OUTPUT_DIR / 'master_data_center_spatial_cluster_summary.csv'\n", "POINT_CONTEXT_CSV = OUTPUT_DIR / 'master_data_center_map_context.csv'\n", "HUC8_GEOJSON = OUTPUT_DIR / 'master_data_center_huc8_watersheds.geojson'\n", "STATE_ENERGY_CSV = OUTPUT_DIR / 'master_data_center_state_energy_context.csv'\n", "MAP_HTML = OUTPUT_DIR / 'enhanced_master_data_center_spatial_clusters_map.html'\n", "\n", "MAP_CENTER = [39, -98]\n", "MAP_ZOOM = 4\n", "BASE_TILES = 'CartoDB positron'\n", "\n", "MAX_POINTS = None\n", "\n", "CLUSTERED_RADIUS = 5\n", "NOISE_RADIUS = 3\n", "CENTROID_RADIUS = 7\n", "SHOW_CENTROID_P90_CIRCLES = True\n", "SHOW_HUC8_LAYER = True\n", "SHOW_STATE_ENERGY_LAYER = True\n", "\n", "# Existing DB-backed overlays.\n", "ENABLE_DB_LAYER_LOAD = True\n", "SHOW_INTERNET_CABLES_LAYER = True\n", "SHOW_OPPOSITION_CASES_LAYER = True\n", "SHOW_DROUGHT_AND_SMOKE_CONTEXT = True\n", "\n", "# New requested overlays.\n", "SHOW_CLIMATE_LAYER = True\n", "SHOW_BROADBAND_LAYER = True\n", "SHOW_ELECTION_LAYER = True\n", "SHOW_ELECTION_2020_LAYER = True\n", "SHOW_ELECTION_2024_LAYER = False\n", "\n", "OUTPUT_DIR.mkdir(exist_ok=True)\n", "print('points:', POINTS_CSV)\n", "print('clusters:', CLUSTERS_CSV)\n", "print('point context:', POINT_CONTEXT_CSV)\n", "print('HUC8 GeoJSON:', HUC8_GEOJSON)\n", "print('state energy context:', STATE_ENERGY_CSV)\n", "print('html output:', MAP_HTML)\n" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "## Load Cluster Outputs" ] }, { "cell_type": "code", "execution_count": null, "id": "5", "metadata": {}, "outputs": [], "source": [ "required_files = [POINTS_CSV, CLUSTERS_CSV]\n", "missing = [str(p) for p in required_files if not p.exists()]\n", "if missing:\n", " raise FileNotFoundError('Missing required cluster output CSV(s): ' + ', '.join(missing))\n", "\n", "points = pd.read_csv(POINTS_CSV)\n", "clusters = pd.read_csv(CLUSTERS_CSV)\n", "point_context = pd.read_csv(POINT_CONTEXT_CSV) if POINT_CONTEXT_CSV.exists() else pd.DataFrame()\n", "state_energy = pd.read_csv(STATE_ENERGY_CSV) if STATE_ENERGY_CSV.exists() else pd.DataFrame()\n", "\n", "if MAX_POINTS is not None:\n", " points = points.head(MAX_POINTS).copy()\n", "\n", "points['cluster_id'] = pd.to_numeric(points['cluster_id'], errors='coerce').fillna(-1).astype(int)\n", "points['is_noise'] = points['cluster_id'].eq(-1)\n", "points['is_clustered'] = ~points['is_noise']\n", "points['name'] = points['name'].fillna('')\n", "points['operator'] = points['operator'].fillna('Unknown').replace('', 'Unknown')\n", "points['city'] = points['city'].fillna('Unknown').replace('', 'Unknown')\n", "points['state'] = points['state'].fillna('UNK').replace('', 'UNK')\n", "points['source'] = points['source'].fillna('unknown').replace('', 'unknown')\n", "\n", "if not point_context.empty:\n", " context_cols = [c for c in point_context.columns if c != 'master_id']\n", " points = points.merge(point_context[['master_id'] + context_cols], on='master_id', how='left')\n", "\n", "if not state_energy.empty:\n", " state_cols = [c for c in state_energy.columns if c != 'state_code']\n", " points = points.merge(state_energy[['state_code'] + state_cols], left_on='state', right_on='state_code', how='left')\n", "\n", "clusters['cluster_id'] = pd.to_numeric(clusters['cluster_id'], errors='coerce').astype(int)\n", "clusters = clusters.sort_values(['point_count', 'radius_km_p90'], ascending=[False, True]).reset_index(drop=True)\n", "clusters['cluster_rank'] = clusters.index + 1\n", "\n", "huc8_geojson = None\n", "if HUC8_GEOJSON.exists():\n", " huc8_geojson = json.loads(HUC8_GEOJSON.read_text())\n", "\n", "n_clusters = points.loc[points['cluster_id'].ne(-1), 'cluster_id'].nunique()\n", "print(f'Loaded {len(points):,} points and {n_clusters:,} clusters')\n", "print('point context columns:', 0 if point_context.empty else len(point_context.columns))\n", "print('HUC8 features:', 0 if huc8_geojson is None else len(huc8_geojson.get('features', [])))\n", "if not state_energy.empty:\n", " seds_available = state_energy['seds_series_count'].notna().sum() if 'seds_series_count' in state_energy.columns else 0\n", " print(f'state energy rows: {len(state_energy):,}; SEDS rows represented: {seds_available:,}')\n", "else:\n", " print('state energy context file not found')\n", "display(points.head())\n", "display(clusters.head(10))\n", "if not state_energy.empty:\n", " display(state_energy.head(10))\n" ] }, { "cell_type": "code", "execution_count": null, "id": "6", "metadata": {}, "outputs": [], "source": [ "DB_NAME = 'data_centers'\n", "DB_REQUIRED_ENV = ['PGWEB_HOST', 'PGWEB_PORT', 'PGWEB_USER', 'PGWEB_PASSWORD']\n", "\n", "internet_cables_geojson = None\n", "opposition_cases = pd.DataFrame()\n", "drought_context = pd.DataFrame()\n", "smoke_context = pd.DataFrame()\n", "climate_context = pd.DataFrame()\n", "broadband_context = pd.DataFrame()\n", "election_context = pd.DataFrame()\n", "\n", "\n", "def load_zsh_secrets() -> None:\n", " secrets = Path.home() / '.zsh_secrets'\n", " if not secrets.exists():\n", " return\n", " result = subprocess.run(\n", " ['zsh', '-lc', 'source ~/.zsh_secrets >/dev/null 2>&1; env'],\n", " check=True,\n", " capture_output=True,\n", " text=True,\n", " )\n", " for line in result.stdout.splitlines():\n", " if '=' not in line:\n", " continue\n", " key, value = line.split('=', 1)\n", " if key and key not in os.environ:\n", " os.environ[key] = value\n", "\n", "\n", "def db_ready() -> bool:\n", " return all(os.getenv(k) for k in DB_REQUIRED_ENV)\n", "\n", "\n", "def get_conn():\n", " return psycopg2.connect(\n", " host=os.environ['PGWEB_HOST'],\n", " port=os.environ['PGWEB_PORT'],\n", " user=os.environ['PGWEB_USER'],\n", " password=os.environ['PGWEB_PASSWORD'],\n", " dbname=DB_NAME,\n", " )\n", "\n", "\n", "def load_optional_db_layers() -> None:\n", " global internet_cables_geojson, opposition_cases, drought_context, smoke_context\n", " global climate_context, broadband_context, election_context, points\n", "\n", " if not ENABLE_DB_LAYER_LOAD:\n", " print('DB layer load disabled')\n", " return\n", "\n", " load_zsh_secrets()\n", " if not db_ready():\n", " print('Skipping DB-backed layers: missing PGWEB_* environment variables')\n", " return\n", "\n", " with get_conn() as conn:\n", " if SHOW_INTERNET_CABLES_LAYER:\n", " cable_sql = \"\"\"\n", " select json_build_object(\n", " 'type','FeatureCollection',\n", " 'features', coalesce(json_agg(\n", " json_build_object(\n", " 'type','Feature',\n", " 'geometry', ST_AsGeoJSON(geom)::json,\n", " 'properties', json_build_object(\n", " 'feature_id', feature_id,\n", " 'name', name,\n", " 'owners', owners,\n", " 'rfs_year', rfs_year,\n", " 'decommission_year', decommission_year,\n", " 'length_km', length_km,\n", " 'cable_type', cable_type\n", " )\n", " )\n", " ), '[]'::json)\n", " ) as fc\n", " from public.internet_cables\n", " where geom is not null\n", " \"\"\"\n", " internet_cables_geojson = pd.read_sql(cable_sql, conn).iloc[0]['fc']\n", " n_cables = len(internet_cables_geojson.get('features', [])) if internet_cables_geojson else 0\n", " print(f'internet_cables features: {n_cables:,}')\n", "\n", " if SHOW_OPPOSITION_CASES_LAYER:\n", " opposition_sql = \"\"\"\n", " select\n", " id, location, state, lat, lon, investment_billion, status,\n", " developer, commons_type, governance_response, outcome, opposition_type, data_source\n", " from public.opposition_cases_geocoded\n", " where lat is not null and lon is not null\n", " \"\"\"\n", " opposition_cases = pd.read_sql(opposition_sql, conn)\n", " print(f'opposition_cases rows: {len(opposition_cases):,}')\n", "\n", " if SHOW_DROUGHT_AND_SMOKE_CONTEXT:\n", " drought_sql = \"\"\"\n", " select\n", " master_id, usdm_status, worst_dm_category, mean_dm_category,\n", " pct_weeks_in_d2_or_worse, pct_weeks_in_d3_or_worse,\n", " longest_d2_streak_weeks, longest_d3_streak_weeks\n", " from public.data_center_usdm_drought_exposure\n", " \"\"\"\n", " smoke_sql = \"\"\"\n", " select\n", " master_id, hms_status, smoke_period_start, smoke_period_end,\n", " days_observed, days_with_any_smoke, days_with_heavy_smoke,\n", " pct_days_with_any_smoke, pct_days_with_heavy_smoke,\n", " worst_density, mean_density_rank\n", " from public.data_center_hms_smoke_exposure\n", " \"\"\"\n", " drought_context = pd.read_sql(drought_sql, conn)\n", " smoke_context = pd.read_sql(smoke_sql, conn)\n", " print(f'drought_context rows: {len(drought_context):,}')\n", " print(f'smoke_context rows: {len(smoke_context):,}')\n", "\n", " if not drought_context.empty:\n", " cols = [c for c in drought_context.columns if c != 'master_id']\n", " points = points.merge(drought_context[['master_id'] + cols], on='master_id', how='left')\n", "\n", " if not smoke_context.empty:\n", " cols = [c for c in smoke_context.columns if c != 'master_id']\n", " points = points.merge(smoke_context[['master_id'] + cols], on='master_id', how='left')\n", "\n", " if SHOW_CLIMATE_LAYER:\n", " climate_sql = \"\"\"\n", " select\n", " master_id, mean_annual_temperature_c, mean_summer_temperature_c,\n", " max_wet_bulb_temperature_c, extreme_heat_days,\n", " annual_cooling_degree_days_c_mean, annual_precipitation_mm_mean\n", " from public.data_center_historical_climate\n", " \"\"\"\n", " climate_context = pd.read_sql(climate_sql, conn)\n", " print(f'climate_context rows: {len(climate_context):,}')\n", " if not climate_context.empty:\n", " cols = [c for c in climate_context.columns if c != 'master_id']\n", " points = points.merge(climate_context[['master_id'] + cols], on='master_id', how='left')\n", "\n", " if SHOW_BROADBAND_LAYER:\n", " broadband_sql = \"\"\"\n", " select\n", " master_id, census_broadband_subscription_pct,\n", " fcc_bdc_status, fcc_bdc_as_of_date,\n", " fcc_provider_count, fcc_fiber_provider_count, fcc_cable_provider_count,\n", " fcc_fixed_wireless_provider_count,\n", " fcc_max_advertised_download_mbps, fcc_max_advertised_upload_mbps,\n", " fcc_100_20_provider_count\n", " from public.data_center_broadband_connection\n", " \"\"\"\n", " broadband_context = pd.read_sql(broadband_sql, conn)\n", " print(f'broadband_context rows: {len(broadband_context):,}')\n", " if not broadband_context.empty:\n", " cols = [c for c in broadband_context.columns if c != 'master_id']\n", " points = points.merge(broadband_context[['master_id'] + cols], on='master_id', how='left')\n", "\n", " if SHOW_ELECTION_LAYER:\n", " election_sql = \"\"\"\n", " with best_match as (\n", " select distinct on (m.master_id)\n", " m.master_id,\n", " m.state_code as election_state_code,\n", " m.join_method as election_join_method,\n", " m.match_distance_m as election_match_distance_m,\n", " f.feature_id, f.layer_id, f.properties,\n", " ST_Y(ST_PointOnSurface(f.geom)) as election_latitude,\n", " ST_X(ST_PointOnSurface(f.geom)) as election_longitude\n", " from public.data_center_rdh_precinct_vote_matches m\n", " join public.rdh_precinct_vote_features f\n", " on f.feature_id = m.feature_id and f.layer_id = m.layer_id\n", " where f.geom is not null\n", " order by m.master_id,\n", " case m.join_method when 'point_in_precinct' then 0 else 1 end,\n", " m.match_distance_m asc nulls last\n", " )\n", " select\n", " master_id, election_state_code, election_join_method, election_match_distance_m,\n", " feature_id, layer_id as election_layer_id, election_latitude, election_longitude, properties,\n", " coalesce((properties->>'LOCALITY'), '') as election_locality,\n", " coalesce((properties->>'PRECINCT'), '') as election_precinct,\n", " nullif(properties->>'G20PREDBID','')::double precision as election_2020_dem_votes,\n", " nullif(properties->>'G20PRERTRU','')::double precision as election_2020_rep_votes\n", " from best_match\n", " \"\"\"\n", " election_context = pd.read_sql(election_sql, conn)\n", " if not election_context.empty:\n", " election_context['election_2020_total_votes'] = (\n", " election_context['election_2020_dem_votes'].fillna(0) + election_context['election_2020_rep_votes'].fillna(0)\n", " )\n", " election_context.loc[election_context['election_2020_total_votes'].eq(0), 'election_2020_total_votes'] = pd.NA\n", "\n", "\n", " election_context['election_2020_dem_share_pct'] = 100.0 * election_context['election_2020_dem_votes'] / election_context['election_2020_total_votes']\n", " election_context['election_2020_rep_share_pct'] = 100.0 * election_context['election_2020_rep_votes'] / election_context['election_2020_total_votes']\n", " election_context['election_2020_rep_margin_pct'] = (\n", " election_context['election_2020_rep_share_pct'] - election_context['election_2020_dem_share_pct']\n", " )\n", "\n", "\n", " election_context['election_biden_votes'] = election_context['election_2020_dem_votes']\n", " election_context['election_trump_votes'] = election_context['election_2020_rep_votes']\n", " election_context['election_biden_share_pct'] = election_context['election_2020_dem_share_pct']\n", " election_context['election_trump_share_pct'] = election_context['election_2020_rep_share_pct']\n", " election_context['election_trump_margin_pct'] = election_context['election_2020_rep_margin_pct']\n", " election_context = election_context.drop(columns=['properties'])\n", "\n", " print(f'election_context rows: {len(election_context):,}')\n", " if not election_context.empty:\n", " cols = [c for c in election_context.columns if c != 'master_id']\n", " points = points.merge(election_context[['master_id'] + cols], on='master_id', how='left')\n", "\n", "\n", "load_optional_db_layers()\n" ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "## Optional DB-backed Layer Context\n", "\n", "This section pulls additional overlays directly from PostGIS:\n", "- `public.internet_cables` (line layer)\n", "- `public.opposition_cases_geocoded` (point layer)\n", "- `public.data_center_usdm_drought_exposure` (point popup enrichment)\n", "- `public.data_center_hms_smoke_exposure` (point popup enrichment)\n", "\n", "If DB credentials are unavailable, map generation still works with CSV/GeoJSON sources." ] }, { "cell_type": "markdown", "id": "8", "metadata": {}, "source": [ "## Map Helpers" ] }, { "cell_type": "code", "execution_count": null, "id": "9", "metadata": {}, "outputs": [], "source": [ "CLUSTER_COLORS = [\n", " '#2563eb', '#dc2626', '#16a34a', '#9333ea', '#ea580c', '#0891b2',\n", " '#be123c', '#4f46e5', '#65a30d', '#c026d3', '#0f766e', '#b45309',\n", "]\n", "NOISE_COLOR = '#9ca3af'\n", "CENTROID_COLOR = '#111827'\n", "STATE_ENERGY_COLOR = '#f59e0b'\n", "INTERNET_CABLE_COLOR = '#7c3aed'\n", "OPPOSITION_CASE_COLOR = '#b91c1c'\n", "\n", "cluster_info = clusters.set_index('cluster_id').to_dict('index')\n", "\n", "\n", "def clean_value(value):\n", " if pd.isna(value):\n", " return ''\n", " return escape(str(value))\n", "\n", "\n", "def fmt_number(value, decimals=0, prefix='', suffix=''):\n", " if pd.isna(value):\n", " return ''\n", " try:\n", " value = float(value)\n", " except (TypeError, ValueError):\n", " return clean_value(value)\n", " return f\"{prefix}{value:,.{decimals}f}{suffix}\"\n", "\n", "\n", "def cluster_color(cluster_id):\n", " if cluster_id == -1:\n", " return NOISE_COLOR\n", " info = cluster_info.get(cluster_id, {})\n", " rank = int(info.get('cluster_rank', cluster_id + 1))\n", " return CLUSTER_COLORS[(rank - 1) % len(CLUSTER_COLORS)]\n", "\n", "\n", "def cluster_label_and_size(cluster_id):\n", " if cluster_id == -1:\n", " return 'Noise / isolated', '1', ''\n", " info = cluster_info.get(cluster_id, {})\n", " rank = int(info.get('cluster_rank', cluster_id + 1))\n", " point_count = int(info.get('point_count', 0))\n", " return f'Cluster ID {cluster_id}', f'{point_count:,}', f'Rank {rank} of {n_clusters} by size'\n", "\n", "\n", "def climate_color(mean_summer_c):\n", " if pd.isna(mean_summer_c):\n", " return '#94a3b8'\n", " if mean_summer_c >= 32:\n", " return '#7f1d1d'\n", " if mean_summer_c >= 29:\n", " return '#b91c1c'\n", " if mean_summer_c >= 26:\n", " return '#ea580c'\n", " if mean_summer_c >= 23:\n", " return '#f59e0b'\n", " return '#0284c7'\n", "\n", "\n", "def broadband_color(provider_count):\n", " if pd.isna(provider_count):\n", " return '#94a3b8'\n", " p = float(provider_count)\n", " if p >= 20:\n", " return '#166534'\n", " if p >= 10:\n", " return '#16a34a'\n", " if p >= 5:\n", " return '#65a30d'\n", " if p >= 2:\n", " return '#ca8a04'\n", " return '#b45309'\n", "\n", "\n", "def election_color(margin_pct):\n", " if pd.isna(margin_pct):\n", " return '#94a3b8'\n", " m = float(margin_pct)\n", " if m >= 20:\n", " return '#7f1d1d'\n", " if m >= 5:\n", " return '#dc2626'\n", " if m <= -20:\n", " return '#1e3a8a'\n", " if m <= -5:\n", " return '#2563eb'\n", " return '#6b7280'\n", "\n", "\n", "def point_popup(row):\n", " cluster_label, cluster_size, cluster_rank = cluster_label_and_size(row.cluster_id)\n", " nearest = row.nearest_neighbor_km\n", " nearest_text = f'{nearest:.2f} km' if pd.notna(nearest) else ''\n", " title = clean_value(row.name) or clean_value(row.master_id)\n", "\n", " huc8_lines = ''\n", " if hasattr(row, 'huc8') and pd.notna(row.huc8):\n", " huc8_lines = f'''\n", "