Co-Study4Grid / expert_backend /services /network_service.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
22.8 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import pypowsybl.network as pn
import logging
import os
import tempfile
import zipfile
logger = logging.getLogger(__name__)
class NetworkService:
def __init__(self):
self.network = None
# Cached equipment tables + derived column lookups. The Network is
# read-only after load, so fetching the generators / loads DataFrame
# once (one pypowsybl/Java boundary crossing each) and serving every
# subsequent metadata query from in-process dicts avoids re-fetching
# the entire table per generator/load. Action enrichment calls these
# accessors once per generator per prioritized action — on the French
# grid (~3k generators) the un-cached path dominated the enrichment
# phase. All cleared in ``load_network`` when the Network changes.
self._generators_df = None
self._gen_vl_map = None # gen_id -> voltage_level_id
self._gen_source_map = None # gen_id -> energy_source
self._gen_limits_map = None # gen_id -> (min_p, max_p)
self._loads_df = None
self._load_vl_map = None # load_id -> voltage_level_id
def _invalidate_equipment_caches(self) -> None:
"""Drop the cached generator / load tables (called when the Network changes)."""
self._generators_df = None
self._gen_vl_map = None
self._gen_source_map = None
self._gen_limits_map = None
self._loads_df = None
self._load_vl_map = None
def _get_generators_df(self):
"""Return the generators DataFrame, fetched once and memoized.
The default ``get_generators()`` column set already includes
``energy_source``, ``min_p``, ``max_p`` and ``voltage_level_id``, so a
single cached table backs every generator metadata accessor below.
"""
if not self.network:
raise ValueError("Network not loaded")
if self._generators_df is None:
self._generators_df = self.network.get_generators()
return self._generators_df
def _get_gen_vl_map(self) -> dict:
if self._gen_vl_map is None:
df = self._get_generators_df()
self._gen_vl_map = (
df['voltage_level_id'].to_dict()
if df is not None and 'voltage_level_id' in df.columns else {}
)
return self._gen_vl_map
def _get_gen_source_map(self) -> dict:
if self._gen_source_map is None:
df = self._get_generators_df()
self._gen_source_map = (
df['energy_source'].to_dict()
if df is not None and 'energy_source' in df.columns else {}
)
return self._gen_source_map
def _get_gen_limits_map(self) -> dict:
if self._gen_limits_map is None:
df = self._get_generators_df()
if df is not None and 'min_p' in df.columns and 'max_p' in df.columns:
self._gen_limits_map = {
gid: (mn, mx)
for gid, mn, mx in zip(df.index, df['min_p'], df['max_p'])
}
else:
self._gen_limits_map = {}
return self._gen_limits_map
def _get_loads_df(self):
"""Return the loads DataFrame, fetched once and memoized."""
if not self.network:
raise ValueError("Network not loaded")
if self._loads_df is None:
self._loads_df = self.network.get_loads()
return self._loads_df
def _get_load_vl_map(self) -> dict:
if self._load_vl_map is None:
df = self._get_loads_df()
self._load_vl_map = (
df['voltage_level_id'].to_dict()
if df is not None and 'voltage_level_id' in df.columns else {}
)
return self._load_vl_map
def _extract_network_zip(self, zip_path: str) -> str:
"""Extract the first .xiidm/.xml inside ``zip_path`` and return its
path. Extraction targets the zip's own directory so the result is
cached for subsequent loads; if that directory is read-only, fall
back to a temp dir.
"""
with zipfile.ZipFile(zip_path) as zf:
members = [n for n in zf.namelist()
if n.lower().endswith(('.xiidm', '.xml'))]
if not members:
raise FileNotFoundError(
f"No .xiidm or .xml file found inside {zip_path}")
member = members[0]
out_name = os.path.basename(member)
out_dir = os.path.dirname(os.path.abspath(zip_path))
out_path = os.path.join(out_dir, out_name)
if os.path.isfile(out_path):
return out_path # already decompressed — reuse
data = zf.read(member)
try:
with open(out_path, 'wb') as f:
f.write(data)
except OSError:
tmp_dir = tempfile.mkdtemp(prefix='cs4g_net_')
out_path = os.path.join(tmp_dir, out_name)
with open(out_path, 'wb') as f:
f.write(data)
logger.info("Decompressed %s -> %s", zip_path, out_path)
return out_path
def _resolve_network_file(self, network_path: str) -> str:
"""Resolve a network path to a loadable file, transparently
decompressing a zip when the path is (or only exists as) a ``.zip``.
Handles: an explicit ``*.zip`` path; a missing ``foo.xiidm`` whose
sibling ``foo.xiidm.zip`` exists; and a directory that holds only a
``.zip`` archive.
"""
if network_path.lower().endswith('.zip') and os.path.isfile(network_path):
return self._extract_network_zip(network_path)
if os.path.isfile(network_path):
return network_path
if os.path.isdir(network_path):
has_net = any(f.endswith(('.xiidm', '.xml'))
for f in os.listdir(network_path))
if not has_net:
zips = [f for f in os.listdir(network_path) if f.endswith('.zip')]
if zips:
return self._extract_network_zip(
os.path.join(network_path, zips[0]))
return network_path
# Missing path: try a sibling/companion .zip (e.g. the shipped
# ``network.xiidm.zip`` for a ``network.xiidm`` request).
for candidate in (network_path + '.zip',
os.path.splitext(network_path)[0] + '.zip'):
if os.path.isfile(candidate):
return self._extract_network_zip(candidate)
return network_path
def load_network(self, network_path: str) -> dict:
network_path = self._resolve_network_file(network_path)
if not os.path.exists(network_path):
raise FileNotFoundError(f"Network file/directory not found: {network_path}")
# Determine if it's a file or directory and load accordingly
# Assuming bare_env is a directory of xiidm files or a single xiidm file
# pypowsybl can load from file.
# If it's a directory, we might need to pick the xiidm file inside.
if os.path.isdir(network_path):
files = [f for f in os.listdir(network_path) if f.endswith('.xiidm') or f.endswith('.xml')]
if not files:
raise FileNotFoundError(f"No .xiidm or .xml file found in {network_path}")
file_path = os.path.join(network_path, files[0])
else:
file_path = network_path
# NOTE: pypowsybl exposes `allow_variant_multi_thread_access=True`
# on `pn.load()` which looks like a silver bullet for the
# `/api/config` contention between the NAD prefetch worker and
# grid2op env setup. It is NOT safe to enable here, see
# docs/performance/history/concurrent-variants.md: when ON, every thread that
# touches the Network must FIRST call `n.set_working_variant(...)`,
# otherwise pypowsybl raises "Variant index not set for current
# thread". FastAPI serves each request on a thread-pool worker
# whose identity is unstable — the read-only endpoints
# (`/api/branches`, `/api/voltage-levels`, `/api/nominal-voltages`)
# would need a per-endpoint variant-set guard, which we currently
# do NOT have. Keeping the default (False) preserves correctness;
# the contention (~2-3 s) is an accepted residual cost.
self.network = pn.load(file_path)
self._invalidate_equipment_caches()
return {"message": "Network loaded successfully", "id": self.network.id}
def get_disconnectable_elements(self) -> list:
if not self.network:
raise ValueError("Network not loaded")
# get lines and two winding transformers
lines = self.network.get_lines()
transformers = self.network.get_2_windings_transformers()
elements = []
if lines is not None and not lines.empty:
elements.extend(lines.index.tolist())
if transformers is not None and not transformers.empty:
elements.extend(transformers.index.tolist())
return sorted(elements)
def get_element_names(self) -> dict | None:
"""Return {element_id: display_name} for all lines and transformers.
The display name is the pypowsybl ``name`` field when it is set and
differs from the element ID; otherwise the ID itself.
For lines/transformers whose name is still a raw OSM identifier
(e.g. ``way/426020732-400``), a composite name is built from the
voltage-level names at each endpoint (e.g. ``CHARPENAY — ST-VULBAS-EST``).
"""
if not self.network:
raise ValueError("Network not loaded")
import re
_RAW_OSM_RE = re.compile(r'^(way|relation)[/_]')
# Pre-load VL display names for fallback construction
vl_names: dict[str, str] = {}
voltage_levels = self.network.get_voltage_levels()
if voltage_levels is not None and not voltage_levels.empty and 'name' in voltage_levels.columns:
for vl_id, row in voltage_levels.iterrows():
n = row.get('name')
if n and str(n) != 'nan':
# Strip trailing " 400kV" etc. for a cleaner composite name
clean = re.sub(r'\s+\d+\s*kV$', '', str(n))
vl_names[vl_id] = clean
def _display_name(eid: str, row, name_col_exists: bool, vl1_col: str, vl2_col: str) -> str | None:
"""Return a human-readable name, or None to skip."""
n = row.get('name') if name_col_exists else None
if n and str(n) != str(eid) and str(n) != 'nan' and not _RAW_OSM_RE.match(str(n)):
return str(n)
# Name is missing or is a raw OSM ID → build from VL endpoint names
vl1 = row.get(vl1_col) if vl1_col in row.index else None
vl2 = row.get(vl2_col) if vl2_col in row.index else None
name1 = vl_names.get(str(vl1), '') if vl1 else ''
name2 = vl_names.get(str(vl2), '') if vl2 else ''
if name1 and name2 and name1 != name2:
return f"{name1} \u2014 {name2}"
if name1:
return name1
if name2:
return name2
# Fallback: use the raw name if it exists and differs from ID
if n and str(n) != str(eid) and str(n) != 'nan':
return str(n)
return None
name_map: dict[str, str] = {}
lines = self.network.get_lines()
if lines is not None and not lines.empty:
has_name = 'name' in lines.columns
for eid, row in lines.iterrows():
display = _display_name(eid, row, has_name, 'voltage_level1_id', 'voltage_level2_id')
if display:
name_map[eid] = display
transformers = self.network.get_2_windings_transformers()
if transformers is not None and not transformers.empty:
has_name = 'name' in transformers.columns
for eid, row in transformers.iterrows():
display = _display_name(eid, row, has_name, 'voltage_level1_id', 'voltage_level2_id')
if display:
name_map[eid] = display
return name_map
def get_monitored_elements(self) -> list:
"""Return the list of element IDs that have at least one permanent operational limit."""
if not self.network:
raise ValueError("Network not loaded")
# Narrow query — only (element_id, type, acceptable_duration) are
# consumed below, and all three live in the pypowsybl MultiIndex.
# `value`, `element_type`, `name`, `group_name` are fetched by the
# default call but unused here. `attributes=[]` drops those
# columns and saves ~90 ms on the 55 k-row limit table of the
# PyPSA-EUR France grid (265 ms → 175 ms). A `6835 × 0`
# DataFrame is reported as `.empty` by pandas, so we check
# `len(index)` instead.
limits = self.network.get_operational_limits(attributes=[])
if limits is None or len(limits.index) == 0:
return []
limits = limits.reset_index()
# Filter for limits of type 'CURRENT' with acceptable_duration == -1 (permanent)
# Note: some networks might use 'THERMAL' or other types, but 'CURRENT' is standard for ampere limits.
# Expert Assist uses 'CURRENT' (see recommender_service.py:601)
permanent_limits = limits[(limits['type'] == 'CURRENT') & (limits['acceptable_duration'] == -1)]
if permanent_limits.empty:
return []
ids = sorted(permanent_limits['element_id'].unique().tolist())
return ids
def get_voltage_levels(self) -> list:
if not self.network:
raise ValueError("Network not loaded")
# Narrow query — only the index is consumed downstream. Requesting
# `attributes=[]` skips pypowsybl's Java→Python serialisation of
# `nominal_v`, `name`, `topology_kind`, etc. (~3-4 ms saved on the
# 6 835-VL PyPSA-EUR France grid). We check `len(index)` rather
# than `.empty`, because a DataFrame with rows but 0 columns is
# still reported as empty by pandas.
voltage_levels = self.network.get_voltage_levels(attributes=[])
if voltage_levels is not None and len(voltage_levels.index) > 0:
return sorted(voltage_levels.index.tolist())
return []
def get_voltage_level_substations(self) -> dict:
"""Return ``{vl_id: substation_id}`` for every voltage level.
Used by the frontend to anchor action-overview pins on the
overflow graph: the overflow graph nodes are pypowsybl
substation IDs, while action data references voltage-level IDs
— this map closes that gap. Returns an empty dict if the
``substation_id`` column is missing (pure-VL networks without
substations).
"""
if not self.network:
raise ValueError("Network not loaded")
# `substation_id` ships in the default attribute set so a narrow
# query is enough; we don't pull `name` / `nominal_v` here.
voltage_levels = self.network.get_voltage_levels(attributes=['substation_id'])
if voltage_levels is None or voltage_levels.empty:
return {}
if 'substation_id' not in voltage_levels.columns:
return {}
sub_ids = voltage_levels['substation_id'].tolist()
idx = voltage_levels.index.tolist()
return {
vl_id: str(sub_id)
for vl_id, sub_id in zip(idx, sub_ids)
if sub_id is not None and str(sub_id) != 'nan'
}
def get_voltage_level_names(self) -> dict:
"""Return {vl_id: display_name} for all voltage levels."""
if not self.network:
raise ValueError("Network not loaded")
name_map: dict[str, str] = {}
voltage_levels = self.network.get_voltage_levels()
if voltage_levels is not None and not voltage_levels.empty and 'name' in voltage_levels.columns:
for vl_id, row in voltage_levels.iterrows():
n = row.get('name')
if n and str(n) != str(vl_id) and str(n) != 'nan':
name_map[vl_id] = str(n)
return name_map
def get_nominal_voltages(self) -> dict:
"""Return {vl_id: nominal_v_kv} mapping for all voltage levels, snapped to detected grid values.
Optimised path — narrow pypowsybl query + vectorised final dict
build (no pandas `iterrows`). Measured on the 6 835-VL PyPSA-EUR
France grid: 144 ms → 6.6 ms (~22× speedup). Output strictly
identical.
"""
if not self.network:
raise ValueError("Network not loaded")
# Narrow query — only `nominal_v` is needed. `get_voltage_levels()`
# with `all_attributes=True` materialises `name`, `topology_kind`,
# `substation_id`, ... adding ~4 ms of Java→Python serialisation.
voltage_levels = self.network.get_voltage_levels(attributes=['nominal_v'])
if voltage_levels is None or voltage_levels.empty:
return {}
# Pull the column as a plain numpy array once — avoids repeated
# pandas column access in the final dict comprehension.
nom_v_arr = voltage_levels['nominal_v'].values
idx_list = voltage_levels.index.tolist()
# 1. Collect all unique nominal voltages
import numpy as np
raw_voltages = sorted(np.unique(nom_v_arr).tolist())
if not raw_voltages:
return {}
# 2. Cluster voltages within 2% of each other
clusters = []
current_cluster = [raw_voltages[0]]
for v in raw_voltages[1:]:
# If v is within 2% of the cluster average, add it
avg = sum(current_cluster) / len(current_cluster)
if abs(v - avg) / avg < 0.02:
current_cluster.append(v)
else:
clusters.append(current_cluster)
current_cluster = [v]
clusters.append(current_cluster)
# 3. Create representative cleaned values for each cluster
# Map each raw voltage to its clean representative
raw_to_clean = {}
for cluster in clusters:
avg = sum(cluster) / len(cluster)
# Bucketing: anything < 25kV goes into the 25kV bucket
if avg < 25:
clean_v = 25.0
else:
# Clean representative: round to int
clean_v = round(avg, 0)
for v in cluster:
raw_to_clean[v] = clean_v
# 4. Map each voltage level to its clean representative.
# Vectorised over the numpy array (avoids iterrows which was the
# dominant cost — ~130 ms for 6 835 rows).
nom_v_list = nom_v_arr.tolist()
return {
idx_list[i]: raw_to_clean[float(nom_v_list[i])]
for i in range(len(idx_list))
}
def get_element_voltage_levels(self, element_id: str) -> list:
"""Resolve an equipment ID (line, transformer, or VL) to its voltage level IDs."""
if not self.network:
raise ValueError("Network not loaded")
# Check if it's already a voltage level
voltage_levels = self.network.get_voltage_levels()
if voltage_levels is not None and element_id in voltage_levels.index:
return [element_id]
# Check lines (have voltage_level1_id and voltage_level2_id columns)
lines = self.network.get_lines()
if lines is not None and element_id in lines.index:
row = lines.loc[element_id]
vls = set()
if 'voltage_level1_id' in row.index:
vls.add(row['voltage_level1_id'])
if 'voltage_level2_id' in row.index:
vls.add(row['voltage_level2_id'])
return sorted(vls)
# Check 2-winding transformers
transformers = self.network.get_2_windings_transformers()
if transformers is not None and element_id in transformers.index:
row = transformers.loc[element_id]
vls = set()
if 'voltage_level1_id' in row.index:
vls.add(row['voltage_level1_id'])
if 'voltage_level2_id' in row.index:
vls.add(row['voltage_level2_id'])
return sorted(vls)
return []
def get_load_voltage_level(self, load_id: str) -> str | None:
"""Return the voltage level ID that a given load belongs to."""
return self._get_load_vl_map().get(load_id)
def get_load_voltage_levels_bulk(self, load_ids: list[str]) -> dict[str, str]:
"""Return {load_id: voltage_level_id} for a list of loads."""
vl_map = self._get_load_vl_map()
return {lid: vl_map[lid] for lid in load_ids if lid in vl_map}
def get_generator_voltage_level(self, gen_id: str) -> str | None:
"""Return the voltage level ID that a given generator belongs to."""
return self._get_gen_vl_map().get(gen_id)
def get_generator_active_power_limits(self, gen_id: str) -> tuple[float, float] | None:
"""Return ``(min_p, max_p)`` active-power limits (MW) of a generator.
Used to expose the maximum redispatch headroom on a remedial action
card (raise: ``max_p - current``; lower: ``current - min_p``)."""
limits = self._get_gen_limits_map().get(gen_id)
if limits is None:
return None
try:
return float(limits[0]), float(limits[1])
except (TypeError, ValueError):
return None
def get_generator_type(self, gen_id: str) -> str | None:
"""Return the energy source type of a given generator."""
return self._get_gen_source_map().get(gen_id)
def get_generator_types_bulk(self, gen_ids: list[str]) -> dict[str, str]:
"""Return {gen_id: energy_source} for a list of generators."""
source_map = self._get_gen_source_map()
return {gid: source_map[gid] for gid in gen_ids if gid in source_map}
network_service = NetworkService()