""" fetch_osm_names.py ================== Batch-fetches real names (RTE codes, human-readable names) for all OSM objects referenced by a PyPSA-EUR network from the Overpass API. Produces: data/pypsa_eur_{country}{voltage}/osm_names.json Structure: { "substations": { "way/100087916": {"name": "BOUTRE", "ref_rte": "BOUTRE", "ref_rte_nom": "BOUTRE", "power": "substation"}, "relation/13260100": {"name": "HAVRE (LE) (CENTRALE)", ...}, ... }, "circuits": { "relation/6221844": {"name": "Avelin - Weppes 1", "ref_rte": "AVELIL71WEPPE", "power": "circuit"}, ... } } The script caches results in osm_names.json. Re-running it will skip already-fetched IDs unless --force is passed. Usage: python scripts/fetch_osm_names.py # default: FR, 400 kV python scripts/fetch_osm_names.py --voltages 225,400 # FR, 225 + 400 kV python scripts/fetch_osm_names.py --country ALL --voltages 380,400 # all of Europe python scripts/fetch_osm_names.py --voltages 225,400 --output-dir data/pypsa_eur_fr225_400 python scripts/fetch_osm_names.py --force # re-fetch all """ import argparse import os import re import sys import json import time import logging import urllib.request import urllib.parse import pandas as pd logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") log = logging.getLogger(__name__) # ─── Parse command-line arguments ──────────────────────────────────────────── parser = argparse.ArgumentParser(description="Fetch OSM names for PyPSA-EUR network") parser.add_argument( "--voltages", type=str, default="400", help="Target voltage levels (comma-separated, e.g., '225,400'). Default: 400", ) parser.add_argument( "--country", type=str, default="FR", help=( "Country filter (ISO-2 code, default: FR). " "Pass '' or 'ALL' to query all countries (European-wide network)." ), ) parser.add_argument( "--output-dir", type=str, default=None, help="Output directory (default: data/pypsa_eur_{country}{voltage})", ) parser.add_argument( "--force", action="store_true", help="Re-fetch all names, ignoring cache", ) parser.add_argument( "--cache-from", type=str, default=None, help="Seed cache from another osm_names.json (e.g., data/pypsa_eur_fr400/osm_names.json)", ) # Support legacy --force without argparse args, _unknown = parser.parse_known_args() if "--force" in _unknown: args.force = True # ─── Configuration ──────────────────────────────────────────────────────────── SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.join(SCRIPT_DIR, "..", "..") DATA_DIR = os.path.join(BASE_DIR, "data", "pypsa_eur_osm") TARGET_VOLTAGES = [float(v.strip()) for v in args.voltages.split(",")] TARGET_COUNTRY = ( args.country if args.country and args.country.strip() and args.country.strip().upper() != "ALL" else None ) COUNTRY_SLUG = TARGET_COUNTRY.lower() if TARGET_COUNTRY else "eur" if args.output_dir: OUT_DIR = args.output_dir if os.path.isabs(args.output_dir) else os.path.join(BASE_DIR, args.output_dir) else: v_str = "_".join(str(int(v)) for v in TARGET_VOLTAGES) OUT_DIR = os.path.join(BASE_DIR, "data", f"pypsa_eur_{COUNTRY_SLUG}{v_str}") os.makedirs(OUT_DIR, exist_ok=True) OUTPUT_FILE = os.path.join(OUT_DIR, "osm_names.json") OVERPASS_URL = "https://overpass-api.de/api/interpreter" log.info(f"Target country: {TARGET_COUNTRY if TARGET_COUNTRY else 'ALL countries'}") log.info(f"Target voltages: {TARGET_VOLTAGES} kV") log.info(f"Output directory: {OUT_DIR}") # Overpass API rate limiting BATCH_SIZE = 30 # IDs per query (conservative to avoid timeouts) DELAY_BETWEEN_REQUESTS = 5 # seconds USER_AGENT = "Co-Study4Grid/1.0 (fetch_osm_names.py; +https://github.com/)" def _overpass_query(query: str, retries: int = 4) -> dict: """Execute an Overpass API query with retries, using POST to avoid URL length issues.""" data_bytes = urllib.parse.urlencode({"data": query}).encode("utf-8") req_obj = urllib.request.Request( OVERPASS_URL, data=data_bytes, method="POST", headers={"User-Agent": USER_AGENT, "Accept": "application/json"}, ) for attempt in range(retries): try: resp = urllib.request.urlopen(req_obj, timeout=60) return json.loads(resp.read()) except Exception as e: if attempt < retries - 1: wait = DELAY_BETWEEN_REQUESTS * (2 ** attempt) log.warning(f" Overpass error: {e} — retrying in {wait}s …") time.sleep(wait) else: log.error(f" Overpass failed after {retries} attempts: {e}") return {"elements": []} return {"elements": []} def _extract_tags(element: dict) -> dict: """Extract relevant tags from an OSM element.""" tags = element.get("tags", {}) result = {} # FR-specific RTE codes (kept for backward compatibility with the FR cache) ref_rte_nom = tags.get("ref:FR:RTE_nom", "") ref_rte = tags.get("ref:FR:RTE", "") name = tags.get("name", "") if ref_rte_nom: result["ref_rte_nom"] = ref_rte_nom if ref_rte: result["ref_rte"] = ref_rte if name: result["name"] = name # Generic operator-specific code (e.g. ref:DE:50Hertz, ref:IT:Terna, ref:ES:REE). # Pick the first non-empty `ref:XX:*` tag we find as a country-agnostic fallback. generic_ref = "" for tag_key, tag_val in tags.items(): if ( tag_key.startswith("ref:") and tag_val and tag_key not in ("ref:FR:RTE", "ref:FR:RTE_nom") ): generic_ref = tag_val break if generic_ref: result["ref_generic"] = generic_ref # Best human-readable name. Order: # FR RTE codes (preserve historical FR cache) > OSM `name` > any operator ref result["display_name"] = ref_rte_nom or ref_rte or name or generic_ref or "" # Power type power = tags.get("power", "") if power: result["power"] = power # Voltage voltage = tags.get("voltage", "") if voltage: result["voltage"] = voltage # Operator operator = tags.get("operator:short", "") or tags.get("operator", "") if operator: result["operator"] = operator # Circuits (for lines) circuits = tags.get("circuits", "") if circuits: result["circuits"] = circuits return result def _batch_fetch(osm_type: str, osm_ids: list, existing: dict) -> dict: """Fetch tags for a list of OSM IDs of the same type, in batches.""" # Filter out already-fetched IDs to_fetch = [oid for oid in osm_ids if f"{osm_type}/{oid}" not in existing] if not to_fetch: log.info(f" All {len(osm_ids)} {osm_type}s already cached") return {} log.info(f" Fetching {len(to_fetch)} {osm_type}s ({len(osm_ids) - len(to_fetch)} already cached)") results = {} n_batches = (len(to_fetch) + BATCH_SIZE - 1) // BATCH_SIZE for i in range(0, len(to_fetch), BATCH_SIZE): batch = to_fetch[i:i + BATCH_SIZE] id_list = ",".join(batch) if osm_type == "relation": query = f"[out:json];relation(id:{id_list});out tags;" else: query = f"[out:json];way(id:{id_list});out tags;" batch_num = i // BATCH_SIZE + 1 log.info(f" Batch {batch_num}/{n_batches}: {len(batch)} {osm_type}s …") data = _overpass_query(query) for el in data.get("elements", []): key = f"{el['type']}/{el['id']}" results[key] = _extract_tags(el) # Rate limiting between batches if i + BATCH_SIZE < len(to_fetch): time.sleep(DELAY_BETWEEN_REQUESTS) return results def _parse_bus_osm_id(bus_index: str) -> tuple: """ Extract the base OSM type and numeric ID from a bus index string. Examples: 'relation/13260100-400' -> ('relation', '13260100') 'virtual_relation/19874522:0-400' -> ('relation', '19874522') 'way/100087916-400' -> ('way', '100087916') 'virtual_way/1346026649:1-400' -> ('way', '1346026649') """ base = re.sub(r"-\d+$", "", bus_index) # strip voltage suffix base = re.sub(r"^virtual_", "", base) # strip virtual_ prefix base = re.sub(r":[a-z0-9:]+$", "", base) # strip :0, :a:1, etc. m = re.match(r"(relation|way)/(\d+)", base) if m: return m.group(1), m.group(2) return None, None def _parse_line_osm_id(line_index: str) -> tuple: """ Extract the base OSM type and numeric ID from a line index string. Examples: 'merged_relation/6221844:a-400+1' -> ('relation', '6221844') 'merged_way/100497456-400+1' -> ('way', '100497456') """ base = re.sub(r"^merged_", "", line_index) base = re.sub(r"[+]\d+$", "", base) # strip circuit suffix base = re.sub(r"-\d+$", "", base) # strip voltage suffix base = re.sub(r":[a-z0-9:]+$", "", base) # strip segment suffix m = re.match(r"(relation|way)/(\d+)", base) if m: return m.group(1), m.group(2) return None, None def main(): force = args.force # Load existing cache existing = {} # Seed from another osm_names.json (e.g., reuse fr400 cache for fr225_400) if args.cache_from and os.path.exists(args.cache_from): with open(args.cache_from, encoding="utf-8") as f: seed = json.load(f) for section in ["substations", "circuits"]: for key, val in seed.get(section, {}).items(): existing[key] = val log.info(f"Seeded {len(existing)} entries from {args.cache_from}") if os.path.exists(OUTPUT_FILE) and not force: with open(OUTPUT_FILE, encoding="utf-8") as f: cache = json.load(f) # Flatten for lookup (overwrites seed entries if present) for section in ["substations", "circuits"]: for key, val in cache.get(section, {}).items(): existing[key] = val log.info(f"Loaded {len(existing)} cached entries from {OUTPUT_FILE}") # ── Load and filter buses ── log.info("Loading OSM CSVs …") buses = pd.read_csv(os.path.join(DATA_DIR, "buses.csv"), index_col=0) voltage_mask = ( buses["voltage"].isin(TARGET_VOLTAGES) & (buses["dc"] == "f") & (buses["under_construction"] == "f") ) if TARGET_COUNTRY: voltage_mask = voltage_mask & (buses["country"] == TARGET_COUNTRY) fr400 = buses[voltage_mask] lines = pd.read_csv(os.path.join(DATA_DIR, "lines.csv"), index_col=0, quotechar="'") fr_lines = lines[ lines["bus0"].isin(fr400.index) & lines["bus1"].isin(fr400.index) & (lines["under_construction"] == "f") ] scope_label = TARGET_COUNTRY if TARGET_COUNTRY else "ALL" log.info(f" {len(fr400)} {scope_label} buses, {len(fr_lines)} {scope_label} lines") # ── Collect OSM IDs to query ── # 1) Substation IDs: from bus index (the bus itself references a substation) bus_relations = set() bus_ways = set() bus_id_to_osm = {} # bus_index -> "type/id" for idx in fr400.index: osm_type, osm_id = _parse_bus_osm_id(idx) if osm_type and osm_id: bus_id_to_osm[idx] = f"{osm_type}/{osm_id}" if osm_type == "relation": bus_relations.add(osm_id) else: bus_ways.add(osm_id) log.info(f" Bus OSM IDs: {len(bus_relations)} relations + {len(bus_ways)} ways = {len(bus_relations) + len(bus_ways)}") # 2) Circuit/line IDs: from line index line_relations = set() line_ways = set() line_id_to_osm = {} # line_index -> "type/id" for idx in fr_lines.index: osm_type, osm_id = _parse_line_osm_id(idx) if osm_type and osm_id: line_id_to_osm[idx] = f"{osm_type}/{osm_id}" if osm_type == "relation": line_relations.add(osm_id) else: line_ways.add(osm_id) log.info(f" Line OSM IDs: {len(line_relations)} relations + {len(line_ways)} ways = {len(line_relations) + len(line_ways)}") # ── Fetch from Overpass ── all_results = dict(existing) def _save_intermediate(): """Save intermediate results so progress isn't lost on failure.""" with open(OUTPUT_FILE + ".partial", "w", encoding="utf-8") as fp: json.dump({"raw_results": all_results}, fp, indent=2, ensure_ascii=False) # Substations (from bus IDs) log.info("Fetching substation names …") if bus_relations: all_results.update(_batch_fetch("relation", sorted(bus_relations), all_results)) _save_intermediate() if bus_ways: all_results.update(_batch_fetch("way", sorted(bus_ways), all_results)) _save_intermediate() # Circuits (from line IDs) log.info("Fetching circuit/line names …") if line_relations: all_results.update(_batch_fetch("relation", sorted(line_relations), all_results)) _save_intermediate() if line_ways: all_results.update(_batch_fetch("way", sorted(line_ways), all_results)) _save_intermediate() # ── Organize into sections ── substations = {} circuits = {} for key, val in all_results.items(): power = val.get("power", "") if power == "substation": substations[key] = val elif power in ("circuit", "line", "cable", "minor_line"): circuits[key] = val else: # Default: bus-referenced IDs are substations, line-referenced are circuits osm_id = key.split("/")[-1] if "/" in key else key if osm_id in bus_relations or osm_id in bus_ways: substations[key] = val elif osm_id in line_relations or osm_id in line_ways: circuits[key] = val else: substations[key] = val # fallback # ── Save bus_id → OSM key mapping for the conversion script ── bus_mapping = {} for bus_idx, osm_key in bus_id_to_osm.items(): entry = substations.get(osm_key) or all_results.get(osm_key) or {} bus_mapping[bus_idx] = { "osm_key": osm_key, "display_name": entry.get("display_name", ""), } line_mapping = {} for line_idx, osm_key in line_id_to_osm.items(): entry = circuits.get(osm_key) or all_results.get(osm_key) or {} line_mapping[line_idx] = { "osm_key": osm_key, "display_name": entry.get("display_name", ""), "ref_rte": entry.get("ref_rte", ""), "name": entry.get("name", ""), } output = { "substations": substations, "circuits": circuits, "bus_to_name": bus_mapping, "line_to_name": line_mapping, } with open(OUTPUT_FILE, "w", encoding="utf-8") as f: json.dump(output, f, indent=2, ensure_ascii=False) log.info(f"\nWritten: {OUTPUT_FILE}") log.info(f" {len(substations)} substations, {len(circuits)} circuits") log.info(f" {len(bus_mapping)} bus→name mappings, {len(line_mapping)} line→name mappings") # Print sample log.info("\nSample substation names:") for key, val in sorted(substations.items())[:10]: log.info(f" {key}: {val.get('display_name', '?')}") log.info("\nSample circuit names:") for key, val in sorted(circuits.items())[:10]: log.info(f" {key}: {val.get('display_name', '?')}") if __name__ == "__main__": main()