#!/usr/bin/env python3 # Copyright (c) 2025-2026, RTE (https://www.rte-france.com) # SPDX-License-Identifier: MPL-2.0 """End-to-end profile of a Load Study backend round-trip. Mimics what `/api/config` + the 4 subsequent parallel XHRs do on a real UI click, without the HTTP stack. Used to track the cumulative wall-clock gains documented in `docs/performance/history/loading-parallel.md` (v6 → v18+ trace entries). Usage: BENCH_NETWORK_PATH=/path/to/grid_dir python benchmarks/bench_load_study.py """ from __future__ import annotations import time from types import SimpleNamespace from _bench_common import ACTION_FILE, NETWORK_PATH def main() -> None: print(f"Network: {NETWORK_PATH}") print(f"Action file: {ACTION_FILE}") from expert_backend.services.network_service import network_service from expert_backend.services.recommender_service import recommender_service settings = SimpleNamespace( network_path=NETWORK_PATH, action_file_path=ACTION_FILE, layout_path=f"{NETWORK_PATH}/grid_layout.json", min_line_reconnections=2.0, min_close_coupling=3.0, min_open_coupling=2.0, min_line_disconnections=3.0, n_prioritized_actions=10, monitoring_factor=0.95, pre_existing_overload_threshold=0.02, ignore_reconnections=False, pypowsybl_fast_mode=True, min_pst=1.5, min_load_shedding=2.5, min_renewable_curtailment_actions=1, lines_monitoring_path=None, do_visualization=True, ) # Step 1 — recommender_service.reset() (clears all caches; drains # any stale NAD prefetch from the previous run). t0 = time.perf_counter() recommender_service.reset() dt_reset = (time.perf_counter() - t0) * 1000 # Step 2 — pypowsybl network parse (~2 s on the PyPSA-EUR France # 118 MB xiidm, dominated by JNI serialisation). t0 = time.perf_counter() network_service.load_network(NETWORK_PATH) dt_load = (time.perf_counter() - t0) * 1000 # Step 3 — update_config: the big one. Spawns the base-NAD # prefetch worker early (see docs/performance/history/nad-prefetch-earlier-spawn.md), # runs enrich_actions_lazy (NetworkTopologyCache — now ~700 ms # since 0.2.0.post5+post6), sets up SimulationEnvironment. t0 = time.perf_counter() recommender_service.update_config(settings) dt_update = (time.perf_counter() - t0) * 1000 # Step 4 — the 4 post-config XHRs fired in parallel by the frontend. t0 = time.perf_counter() total_lines = len(network_service.get_disconnectable_elements()) monitored = len(network_service.get_monitored_elements()) vls = len(network_service.get_voltage_levels()) nominals = len(network_service.get_nominal_voltages()) dt_resp = (time.perf_counter() - t0) * 1000 total = dt_reset + dt_load + dt_update + dt_resp print(f"\n{'reset()':<32} {dt_reset:>8.1f} ms") print(f"{'load_network':<32} {dt_load:>8.1f} ms") print(f"{'update_config':<32} {dt_update:>8.1f} ms") print(f"{'response XHRs (4)':<32} {dt_resp:>8.1f} ms") print(f"{'TOTAL':<32} {total:>8.1f} ms ({total / 1000:.1f} s)") print( f"\nCounts: lines={total_lines} monitored={monitored} " f"vls={vls} nominals={nominals}" ) # Step 5 — NAD prefetch overflow: time spent AFTER the 4 XHRs # waiting for the background worker to finish. Should be ~0 ms # if the prefetch was spawned early enough in update_config. ev = getattr(recommender_service, "_prefetched_base_nad_event", None) if ev is not None: t0 = time.perf_counter() ev.wait(timeout=30) dt_wait = (time.perf_counter() - t0) * 1000 print(f"\nNAD worker overflow after endpoint: {dt_wait:.1f} ms") if __name__ == "__main__": main()