github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
9.09 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
"""Legacy single-step analysis runner.
Wraps the original ``run_analysis`` with:
- AC load flow → DC load-flow fallback on the "Initial contingency
simulation failed" RuntimeError surfaced by the discovery engine
- a background worker thread so the caller can poll the output
folder for the overflow-graph PDF and emit a ``pdf`` NDJSON event
before the full result lands
- stdout redirection so library ``print`` diagnostics are captured
as a string, not leaked to the FastAPI process log
Yields NDJSON-ready dicts ``{"type": "pdf", "pdf_path": ...}`` and
a final ``{"analysis_result": ..., "analysis_message": ...,
"dc_fallback_used": ..., "output": ..., "latest_pdf": ...}``. The
orchestrator on ``AnalysisMixin`` turns that last dict into the
public ``result`` event after enriching actions and scores.
"""
from __future__ import annotations
import io
import logging
import os
import threading
import time
from contextlib import redirect_stdout
from typing import Any, Callable, Generator
from expert_op4grid_recommender import config
from expert_op4grid_recommender.main import Backend
from expert_op4grid_recommender.main import run_analysis as _default_run_analysis
from expert_backend.services.analysis.pdf_watcher import find_latest_pdf
logger = logging.getLogger(__name__)
# How often the caller polls for PDF availability / thread completion.
_POLL_INTERVAL_S = 0.5
# Hard deadline for the legacy single-step analysis stream (QW22). The
# background AnalysisWorker sets ``shared_state['done']`` when it finishes; if
# it hangs inside ``runner_fn`` and never does, this generator would loop
# forever — the streaming endpoint never closes and the D3 study-mutation gate
# stays claimed, wedging every subsequent study operation behind an HTTP 409.
# On the deadline we raise ``TimeoutError``; the endpoint turns it into a
# ``{type:'error'}`` event and releases the gate in its ``finally``. The
# orphaned worker is a daemon thread, so it can't block interpreter shutdown.
# Env-overridable; 10-minute default (well above the slowest real analysis).
_ANALYSIS_DEADLINE_S = float(os.environ.get("COSTUDY4GRID_ANALYSIS_TIMEOUT_S", "600"))
def _make_worker(
disconnected_elements,
shared_state: dict[str, Any],
runner_fn: Callable,
) -> Callable[[], None]:
"""Return a zero-arg worker that populates ``shared_state``.
``disconnected_elements`` may be a single string (legacy single-
element contingency) or an iterable of element IDs for N-K
contingencies — both are normalised to a list before forwarding to
the discovery engine's ``current_lines_defaut`` parameter.
Returned as a closure (not a bound method) so callers that mock
``threading.Thread`` and invoke ``target()`` with no args — the
pre-extraction shape in legacy tests — keep working.
"""
if isinstance(disconnected_elements, str):
elements_list = [disconnected_elements] if disconnected_elements else []
else:
elements_list = [e for e in (disconnected_elements or []) if e]
def worker() -> None:
try:
# Attempt 1: AC
config.USE_DC_LOAD_FLOW = False
f_stdout = io.StringIO()
with redirect_stdout(f_stdout):
res = runner_fn(
analysis_date=None,
current_timestep=0,
current_lines_defaut=list(elements_list),
backend=Backend.PYPOWSYBL,
)
shared_state["result"] = res
shared_state["output"] = f_stdout.getvalue()
except RuntimeError as e:
if "Initial contingency simulation failed" in str(e):
try:
config.USE_DC_LOAD_FLOW = True
shared_state["dc_fallback_used"] = True
shared_state["analysis_message"] = (
"Warning: AC Load Flow did not converge. "
"Fallback to DC Load Flow was used."
)
f_stdout = io.StringIO()
with redirect_stdout(f_stdout):
res = runner_fn(
analysis_date=None,
current_timestep=0,
current_lines_defaut=list(elements_list),
backend=Backend.PYPOWSYBL,
)
shared_state["result"] = res
shared_state["output"] = f_stdout.getvalue()
except Exception as inner_e:
shared_state["error"] = RuntimeError(
f"Analysis failed globally (AC and DC): {inner_e}"
)
else:
shared_state["error"] = e
except Exception as e:
shared_state["error"] = e
finally:
shared_state["done"] = True
return worker
def run_with_pdf_polling(
disconnected_elements,
save_folder: str,
runner_fn: Callable | None = None,
) -> Generator[dict, None, None]:
"""Run the legacy analysis, yielding a ``pdf`` event ASAP then the final payload.
``runner_fn`` is the discovery engine's ``run_analysis`` entry point.
It's injected so callers (the mixin, tests) can plug a mock without
monkey-patching this module directly. Defaults to
``expert_op4grid_recommender.main.run_analysis``.
Events yielded (first-to-last):
- ``{"type": "pdf", "pdf_path": "..."}`` — emitted at most once,
as soon as the overflow PDF lands in ``save_folder``.
- ``{"_final": True, ...}`` — internal sentinel carrying the
raw analysis result / output / fallback flag / latest_pdf.
The orchestrator consumes it and produces the public
``result`` NDJSON event after enrichment.
"""
if runner_fn is None:
runner_fn = _default_run_analysis
analysis_start_time = time.time()
shared_state: dict[str, Any] = {
"analysis_message": "Analysis completed successfully using AC Load Flow.",
"dc_fallback_used": False,
"result": None,
"output": "",
"error": None,
"done": False,
"latest_pdf": None,
}
worker = _make_worker(disconnected_elements, shared_state, runner_fn)
# daemon=True so a hung worker (see the deadline below) can't block
# interpreter shutdown after this generator abandons it.
thread = threading.Thread(target=worker, name="AnalysisWorker", daemon=True)
thread.start()
deadline = analysis_start_time + _ANALYSIS_DEADLINE_S
pdf_sent = False
while not shared_state["done"]:
if not pdf_sent:
latest = find_latest_pdf(save_folder, analysis_start_time)
if latest:
shared_state["latest_pdf"] = latest
yield {"type": "pdf", "pdf_path": str(latest)}
pdf_sent = True
if shared_state["error"]:
raise shared_state["error"]
if time.time() > deadline:
raise TimeoutError(
f"Legacy analysis exceeded its {_ANALYSIS_DEADLINE_S:.0f}s "
"deadline and was abandoned"
)
time.sleep(_POLL_INTERVAL_S)
if shared_state["error"]:
raise shared_state["error"]
# Final PDF check, if the worker finished before the poll loop
# noticed the file.
if not pdf_sent:
latest = find_latest_pdf(save_folder, analysis_start_time)
if latest:
shared_state["latest_pdf"] = latest
yield {"type": "pdf", "pdf_path": str(latest)}
yield {
"_final": True,
"result": shared_state["result"],
"output": shared_state["output"],
"analysis_message": shared_state["analysis_message"],
"dc_fallback_used": shared_state["dc_fallback_used"],
"latest_pdf": shared_state["latest_pdf"],
}
def derive_analysis_message(
analysis_message: str,
output: str,
result: Any,
) -> str:
"""Refine the message based on known discovery-output phrases.
Called by the orchestrator when ``result`` is ``None`` — the library
prints a diagnostic like "No topological solution without load
shedding" and the UI needs a user-friendly translation.
"""
if result is not None:
return analysis_message
if "No topological solution without load shedding" in output:
return (
"No topological solution found without load shedding. "
"The grid might be too constrained."
)
if "Overload breaks the grid apart" in output:
return "Grid instability detected: Overload breaks the grid apart."
return "Analysis finished but no recommendations were found."