feat: add power plants layer with WRI Global Power Plant Database

Map ~35,000 power generation facilities from 164 countries using the
WRI Global Power Plant Database (CC BY 4.0). Follows the existing
datacenter layer pattern with clustered icon symbols, amber color
scheme, and click popups showing fuel type, capacity, and operator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adust09
2026-03-18 16:56:24 +09:00
co-authored by Claude Opus 4.6
parent 2812d43f49
commit b40f9d1fd0
12 changed files with 253 additions and 6 deletions
File diff suppressed because one or more lines are too long
+1
View File
@@ -306,6 +306,7 @@ async def live_data_slow(request: Request,
"firms_fires": _f(d.get("firms_fires", [])),
"datacenters": _f(d.get("datacenters", [])),
"military_bases": _f(d.get("military_bases", [])),
"power_plants": _f(d.get("power_plants", [])),
"freshness": dict(source_timestamps),
}
bbox_tag = f"{s},{w},{n},{e}" if has_bbox else "full"
+58
View File
@@ -0,0 +1,58 @@
"""Download WRI Global Power Plant Database CSV and convert to compact JSON.
Usage:
python backend/scripts/convert_power_plants.py
Output:
backend/data/power_plants.json
"""
import csv
import json
import io
import zipfile
import urllib.request
from pathlib import Path
# WRI Global Power Plant Database v1.3.0 (GitHub release)
CSV_URL = "https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv"
OUT_PATH = Path(__file__).parent.parent / "data" / "power_plants.json"
def main() -> None:
print(f"Downloading WRI Global Power Plant Database from GitHub...")
req = urllib.request.Request(CSV_URL, headers={"User-Agent": "ShadowBroker-OSINT/1.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode("utf-8")
reader = csv.DictReader(io.StringIO(raw))
plants: list[dict] = []
skipped = 0
for row in reader:
try:
lat = float(row["latitude"])
lng = float(row["longitude"])
except (ValueError, KeyError):
skipped += 1
continue
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
skipped += 1
continue
capacity_raw = row.get("capacity_mw", "")
capacity_mw = float(capacity_raw) if capacity_raw else None
plants.append({
"name": row.get("name", "Unknown"),
"country": row.get("country_long", ""),
"fuel_type": row.get("primary_fuel", "Unknown"),
"capacity_mw": capacity_mw,
"owner": row.get("owner", ""),
"lat": round(lat, 5),
"lng": round(lng, 5),
})
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(json.dumps(plants, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print(f"Wrote {len(plants)} power plants to {OUT_PATH} (skipped {skipped})")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -40,7 +40,8 @@ from services.fetchers.earth_observation import ( # noqa: F401
fetch_earthquakes, fetch_firms_fires, fetch_space_weather, fetch_weather,
)
from services.fetchers.infrastructure import ( # noqa: F401
fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_cctv, fetch_kiwisdr,
fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_power_plants,
fetch_cctv, fetch_kiwisdr,
)
from services.fetchers.geo import ( # noqa: F401
fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
@@ -86,6 +87,7 @@ def update_slow_data():
fetch_gdelt,
fetch_datacenters,
fetch_military_bases,
fetch_power_plants,
]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
futures = [executor.submit(func) for func in slow_funcs]
+2 -1
View File
@@ -31,7 +31,8 @@ latest_data = {
"internet_outages": [],
"firms_fires": [],
"datacenters": [],
"military_bases": []
"military_bases": [],
"power_plants": []
}
# Per-source freshness timestamps
@@ -180,6 +180,44 @@ def fetch_military_bases():
_mark_fresh("military_bases")
# ---------------------------------------------------------------------------
# Power Plants (WRI Global Power Plant Database)
# ---------------------------------------------------------------------------
_POWER_PLANTS_PATH = Path(__file__).parent.parent.parent / "data" / "power_plants.json"
def fetch_power_plants():
"""Load WRI Global Power Plant Database (~35K facilities)."""
plants = []
try:
if not _POWER_PLANTS_PATH.exists():
logger.warning(f"Power plants file not found: {_POWER_PLANTS_PATH}")
return
raw = json.loads(_POWER_PLANTS_PATH.read_text(encoding="utf-8"))
for entry in raw:
lat = entry.get("lat")
lng = entry.get("lng")
if lat is None or lng is None:
continue
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
continue
plants.append({
"name": entry.get("name", "Unknown"),
"country": entry.get("country", ""),
"fuel_type": entry.get("fuel_type", "Unknown"),
"capacity_mw": entry.get("capacity_mw"),
"owner": entry.get("owner", ""),
"lat": lat, "lng": lng,
})
logger.info(f"Power plants: {len(plants)} facilities loaded")
except Exception as e:
logger.error(f"Error loading power plants: {e}")
with _data_lock:
latest_data["power_plants"] = plants
if plants:
_mark_fresh("power_plants")
# ---------------------------------------------------------------------------
# CCTV Cameras
# ---------------------------------------------------------------------------