|
|
|
|
@ -0,0 +1,814 @@
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
import time
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Any, Optional
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
import src.aeros_equipment.model # noqa: F401
|
|
|
|
|
from src.aeros_simulation.model import AerosSimulationCalcResult, AerosSimulationPlotResult
|
|
|
|
|
from src.aeros_simulation.service import get_all_aeros_node, get_simulation_by_id
|
|
|
|
|
from src.config import (
|
|
|
|
|
RAMP_API_URL,
|
|
|
|
|
RAMP_MODEL_ID,
|
|
|
|
|
RAMP_MODEL_NAME,
|
|
|
|
|
RAMP_POLL_INTERVAL_SECONDS,
|
|
|
|
|
RAMP_POLL_TIMEOUT_SECONDS,
|
|
|
|
|
RAMP_STALE_TIMEOUT_SECONDS,
|
|
|
|
|
RAMP_TARGET_SCHEMA,
|
|
|
|
|
SIMULATION_ENGINE,
|
|
|
|
|
)
|
|
|
|
|
from src.database.core import DbSession
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
PLANT_NODE_NAME = "- TJB - Unit 3 -"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def selected_simulation_engine(sim_data: dict | None = None) -> str:
|
|
|
|
|
sim_data = sim_data or {}
|
|
|
|
|
engine = (
|
|
|
|
|
sim_data.get("SimulationEngine")
|
|
|
|
|
or sim_data.get("simulation_engine")
|
|
|
|
|
or SIMULATION_ENGINE
|
|
|
|
|
or "aeros"
|
|
|
|
|
)
|
|
|
|
|
return str(engine).strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _simulation_api_base() -> str:
|
|
|
|
|
base_url = str(RAMP_API_URL).rstrip("/")
|
|
|
|
|
if base_url.endswith("/api/simulation"):
|
|
|
|
|
return base_url
|
|
|
|
|
if base_url.endswith("/api"):
|
|
|
|
|
return f"{base_url}/simulation"
|
|
|
|
|
return f"{base_url}/api/simulation"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _norm_key(value: Any) -> str:
|
|
|
|
|
return str(value or "").strip().upper()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_present(data: dict, *keys: str):
|
|
|
|
|
for key in keys:
|
|
|
|
|
if key in data and data[key] is not None:
|
|
|
|
|
return data[key]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _as_float(value, default: Optional[float] = None):
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
try:
|
|
|
|
|
return float(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clean_dict(data: dict) -> dict:
|
|
|
|
|
return {key: value for key, value in data.items() if value is not None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _distribution_type(value: Any) -> str:
|
|
|
|
|
raw = str(value or "Fixed").strip()
|
|
|
|
|
aliases = {
|
|
|
|
|
"WEIBULL2": "Weibull",
|
|
|
|
|
"WEIBULL": "Weibull",
|
|
|
|
|
"EXPONENTIAL2": "Exponential",
|
|
|
|
|
"EXPONENTIAL": "Exponential",
|
|
|
|
|
"LOGNORMAL": "LogNormal",
|
|
|
|
|
"LOG_NORMAL": "LogNormal",
|
|
|
|
|
"NORMAL": "Normal",
|
|
|
|
|
"FIXED": "Fixed",
|
|
|
|
|
"NOFAILURE": "NoFailure",
|
|
|
|
|
"NO_FAILURE": "NoFailure",
|
|
|
|
|
"NHPP": "NHPP",
|
|
|
|
|
"NHPPTTFF": "NHPPTTFF",
|
|
|
|
|
"REPAIRABLENHPP": "RepairableNHPP",
|
|
|
|
|
}
|
|
|
|
|
return aliases.get(raw.upper(), raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _distribution_from_fields(data: dict, prefix: str = "") -> dict | None:
|
|
|
|
|
type_keys = [f"{prefix}type", f"{prefix}Type"] if prefix else ["type", "distribution", "dist_type", "distType"]
|
|
|
|
|
dist_type = _first_present(data, *type_keys)
|
|
|
|
|
if dist_type is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
dist_type = _distribution_type(dist_type)
|
|
|
|
|
p1 = _as_float(_first_present(data, f"{prefix}p1", f"{prefix}P1", "p1", "shape", "beta", "eta", "mean", "mu", "interval"))
|
|
|
|
|
p2 = _as_float(_first_present(data, f"{prefix}p2", f"{prefix}P2", "p2", "scale", "std_dev", "stdDev", "sigma"))
|
|
|
|
|
|
|
|
|
|
if dist_type == "Weibull":
|
|
|
|
|
return _clean_dict({
|
|
|
|
|
"type": "Weibull",
|
|
|
|
|
"shape": _as_float(_first_present(data, "shape", "beta"), p1),
|
|
|
|
|
"scale": _as_float(_first_present(data, "scale", "eta"), p2),
|
|
|
|
|
})
|
|
|
|
|
if dist_type == "Exponential":
|
|
|
|
|
return _clean_dict({"type": "Exponential", "eta": _as_float(_first_present(data, "eta"), p1)})
|
|
|
|
|
if dist_type == "Normal":
|
|
|
|
|
return _clean_dict({
|
|
|
|
|
"type": "Normal",
|
|
|
|
|
"mean": _as_float(_first_present(data, "mean"), p1),
|
|
|
|
|
"std_dev": _as_float(_first_present(data, "std_dev", "stdDev"), p2),
|
|
|
|
|
})
|
|
|
|
|
if dist_type == "LogNormal":
|
|
|
|
|
return _clean_dict({
|
|
|
|
|
"type": "LogNormal",
|
|
|
|
|
"mu": _as_float(_first_present(data, "mu"), p1),
|
|
|
|
|
"sigma": _as_float(_first_present(data, "sigma"), p2),
|
|
|
|
|
})
|
|
|
|
|
if dist_type == "NoFailure":
|
|
|
|
|
return {"type": "NoFailure"}
|
|
|
|
|
return _clean_dict({"type": "Fixed", "interval": _as_float(_first_present(data, "interval"), p1)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _model_from_aeros_style(override: dict) -> dict | None:
|
|
|
|
|
level = override.get("level")
|
|
|
|
|
if level == "NoFailure":
|
|
|
|
|
return {"type": "NonRepairable", "distribution": {"type": "NoFailure"}}
|
|
|
|
|
if level == "Out of Service":
|
|
|
|
|
return {"type": "NonRepairable", "distribution": {"type": "Fixed", "interval": 0.0001}}
|
|
|
|
|
|
|
|
|
|
failure_rate = override.get("failure_rate")
|
|
|
|
|
if isinstance(failure_rate, list):
|
|
|
|
|
failure_rate = failure_rate[0] if failure_rate else None
|
|
|
|
|
|
|
|
|
|
if failure_rate is not None:
|
|
|
|
|
rate_val = _as_float(failure_rate)
|
|
|
|
|
mtbf = 1000000000.0 if not rate_val or rate_val <= 0 else 1e6 / rate_val
|
|
|
|
|
dist_type = _distribution_type(override.get("distribution", "Fixed"))
|
|
|
|
|
if dist_type == "Weibull":
|
|
|
|
|
return {"type": "NonRepairable", "distribution": {"type": "Weibull", "shape": 1.0, "scale": mtbf}}
|
|
|
|
|
if dist_type == "Exponential":
|
|
|
|
|
return {"type": "NonRepairable", "distribution": {"type": "Exponential", "eta": mtbf}}
|
|
|
|
|
return {"type": "NonRepairable", "distribution": {"type": "Fixed", "interval": mtbf}}
|
|
|
|
|
|
|
|
|
|
rel_type = _first_present(override, "rel_dis_type", "relDisType")
|
|
|
|
|
if rel_type is not None:
|
|
|
|
|
dist = _distribution_from_fields(
|
|
|
|
|
{
|
|
|
|
|
"type": rel_type,
|
|
|
|
|
"p1": _first_present(override, "rel_dis_p1", "relDisP1"),
|
|
|
|
|
"p2": _first_present(override, "rel_dis_p2", "relDisP2"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return {"type": "NonRepairable", "distribution": dist}
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repair_duration_from_aeros_style(override: dict):
|
|
|
|
|
if "mttr" in override and override["mttr"] is not None:
|
|
|
|
|
return _as_float(override["mttr"], 0)
|
|
|
|
|
|
|
|
|
|
cm_type = _first_present(override, "cm_dis_type", "cmDisType")
|
|
|
|
|
if cm_type is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return _distribution_from_fields(
|
|
|
|
|
{
|
|
|
|
|
"type": cm_type,
|
|
|
|
|
"p1": _first_present(override, "cm_dis_p1", "cmDisP1"),
|
|
|
|
|
"p2": _first_present(override, "cm_dis_p2", "cmDisP2"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_asset_override(override: dict) -> dict:
|
|
|
|
|
normalized = {}
|
|
|
|
|
allowed_root_fields = {
|
|
|
|
|
"initial_age",
|
|
|
|
|
"repair_restoration_factor",
|
|
|
|
|
"max_capacity",
|
|
|
|
|
"design_capacity",
|
|
|
|
|
"buffer_duration",
|
|
|
|
|
"transition_delay",
|
|
|
|
|
"repair_duration",
|
|
|
|
|
"pm",
|
|
|
|
|
"overhaul",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for key in allowed_root_fields:
|
|
|
|
|
if key in override:
|
|
|
|
|
normalized[key] = override[key]
|
|
|
|
|
|
|
|
|
|
if isinstance(override.get("model"), dict):
|
|
|
|
|
normalized["model"] = override["model"]
|
|
|
|
|
elif isinstance(override.get("distribution"), dict):
|
|
|
|
|
normalized["model"] = {"type": "NonRepairable", "distribution": override["distribution"]}
|
|
|
|
|
else:
|
|
|
|
|
model = _model_from_aeros_style(override)
|
|
|
|
|
if model:
|
|
|
|
|
normalized["model"] = model
|
|
|
|
|
|
|
|
|
|
if "model" not in normalized:
|
|
|
|
|
dist = _distribution_from_fields(override)
|
|
|
|
|
if dist:
|
|
|
|
|
normalized["model"] = {"type": "NonRepairable", "distribution": dist}
|
|
|
|
|
|
|
|
|
|
if "repair_duration" not in normalized:
|
|
|
|
|
repair_duration = _repair_duration_from_aeros_style(override)
|
|
|
|
|
if repair_duration is not None:
|
|
|
|
|
normalized["repair_duration"] = repair_duration
|
|
|
|
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_ramp_distribution_overrides(sim_data: dict) -> dict:
|
|
|
|
|
raw_overrides = {}
|
|
|
|
|
for source_key in ("CustomInput", "DistributionOverrides", "RampDistributionOverrides"):
|
|
|
|
|
source = sim_data.get(source_key)
|
|
|
|
|
if isinstance(source, dict):
|
|
|
|
|
raw_overrides.update(source)
|
|
|
|
|
|
|
|
|
|
normalized = {}
|
|
|
|
|
for asset_key, override in raw_overrides.items():
|
|
|
|
|
if not isinstance(override, dict):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
asset_override = _normalize_asset_override(override)
|
|
|
|
|
if asset_override:
|
|
|
|
|
normalized[asset_key] = asset_override
|
|
|
|
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rbd_reliability_to_ramp_override(reliability: dict) -> dict:
|
|
|
|
|
distribution = _distribution_type(reliability.get("distribution"))
|
|
|
|
|
beta = _as_float(reliability.get("beta"))
|
|
|
|
|
eta = _as_float(reliability.get("eta"))
|
|
|
|
|
mttr = _as_float(reliability.get("mttr"))
|
|
|
|
|
|
|
|
|
|
override = {}
|
|
|
|
|
if distribution in {"NHPPTTFF", "NHPP", "RepairableNHPP"}:
|
|
|
|
|
override["model"] = _clean_dict({
|
|
|
|
|
"type": "RepairableNHPP",
|
|
|
|
|
"beta": beta,
|
|
|
|
|
"eta": eta,
|
|
|
|
|
})
|
|
|
|
|
elif distribution == "Weibull":
|
|
|
|
|
override["model"] = {
|
|
|
|
|
"type": "NonRepairable",
|
|
|
|
|
"distribution": _clean_dict({"type": "Weibull", "shape": beta, "scale": eta}),
|
|
|
|
|
}
|
|
|
|
|
elif distribution == "Exponential":
|
|
|
|
|
exp_eta = eta if eta is not None else beta
|
|
|
|
|
override["model"] = {
|
|
|
|
|
"type": "NonRepairable",
|
|
|
|
|
"distribution": _clean_dict({"type": "Exponential", "eta": exp_eta}),
|
|
|
|
|
}
|
|
|
|
|
elif distribution == "Normal":
|
|
|
|
|
override["model"] = {
|
|
|
|
|
"type": "NonRepairable",
|
|
|
|
|
"distribution": _clean_dict({"type": "Normal", "mean": beta, "std_dev": eta}),
|
|
|
|
|
}
|
|
|
|
|
elif distribution == "Fixed":
|
|
|
|
|
override["model"] = {
|
|
|
|
|
"type": "NonRepairable",
|
|
|
|
|
"distribution": _clean_dict({"type": "Fixed", "interval": beta or eta}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if mttr is not None:
|
|
|
|
|
override["repair_duration"] = mttr
|
|
|
|
|
return override
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_ramp_distribution_overrides_from_rbd_reliability(eq_update: dict | None) -> dict:
|
|
|
|
|
overrides = {}
|
|
|
|
|
for asset_key, reliability in (eq_update or {}).items():
|
|
|
|
|
if not isinstance(reliability, dict):
|
|
|
|
|
continue
|
|
|
|
|
override = _rbd_reliability_to_ramp_override(reliability)
|
|
|
|
|
if override:
|
|
|
|
|
overrides[asset_key] = override
|
|
|
|
|
return overrides
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _duration_to_hours(value: Any, unit: str | None) -> int:
|
|
|
|
|
duration = _as_float(value, 0) or 0
|
|
|
|
|
unit_key = str(unit or "UHour").strip().upper()
|
|
|
|
|
multipliers = {
|
|
|
|
|
"UHOUR": 1,
|
|
|
|
|
"UHOURS": 1,
|
|
|
|
|
"HOUR": 1,
|
|
|
|
|
"HOURS": 1,
|
|
|
|
|
"UDAY": 24,
|
|
|
|
|
"UDAYS": 24,
|
|
|
|
|
"DAY": 24,
|
|
|
|
|
"DAYS": 24,
|
|
|
|
|
"UWEEK": 24 * 7,
|
|
|
|
|
"UWEEKS": 24 * 7,
|
|
|
|
|
"WEEK": 24 * 7,
|
|
|
|
|
"WEEKS": 24 * 7,
|
|
|
|
|
"UMONTH": 730,
|
|
|
|
|
"UMONTHS": 730,
|
|
|
|
|
"MONTH": 730,
|
|
|
|
|
"MONTHS": 730,
|
|
|
|
|
"UYEAR": 8760,
|
|
|
|
|
"UYEARS": 8760,
|
|
|
|
|
"YEAR": 8760,
|
|
|
|
|
"YEARS": 8760,
|
|
|
|
|
}
|
|
|
|
|
return max(1, int(duration * multipliers.get(unit_key, 1)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _availability_percent(value: Any) -> float:
|
|
|
|
|
availability = _as_float(value, 0) or 0
|
|
|
|
|
if 0 < abs(availability) <= 1:
|
|
|
|
|
availability *= 100
|
|
|
|
|
return availability
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _availability_fraction(value: Any) -> float:
|
|
|
|
|
availability = _as_float(value, 0) or 0
|
|
|
|
|
if abs(availability) > 1:
|
|
|
|
|
availability /= 100
|
|
|
|
|
return availability
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_number(data: dict, keys: list[str], default: float = 0) -> float:
|
|
|
|
|
for key in keys:
|
|
|
|
|
if key in data and data[key] is not None:
|
|
|
|
|
parsed = _as_float(data[key])
|
|
|
|
|
if parsed is not None:
|
|
|
|
|
return parsed
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _result_number(value: Any, default: float = 0) -> float:
|
|
|
|
|
parsed = _as_float(value)
|
|
|
|
|
return default if parsed is None else parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ramp_model_id_from_sim_data(sim_data: dict) -> str | None:
|
|
|
|
|
model_id = (
|
|
|
|
|
sim_data.get("RampModelId")
|
|
|
|
|
or sim_data.get("RAMPModelId")
|
|
|
|
|
or sim_data.get("ramp_model_id")
|
|
|
|
|
or RAMP_MODEL_ID
|
|
|
|
|
)
|
|
|
|
|
return str(model_id) if model_id not in (None, "") else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ramp_model_name_from_sim_data(sim_data: dict) -> str | None:
|
|
|
|
|
model_name = sim_data.get("RampModelName") or sim_data.get("ramp_model_name") or RAMP_MODEL_NAME
|
|
|
|
|
return str(model_name).strip() if model_name else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _resolve_ramp_model_id(client: httpx.AsyncClient, sim_data: dict) -> str:
|
|
|
|
|
model_id = _ramp_model_id_from_sim_data(sim_data)
|
|
|
|
|
if model_id:
|
|
|
|
|
return model_id
|
|
|
|
|
|
|
|
|
|
model_name = _ramp_model_name_from_sim_data(sim_data)
|
|
|
|
|
if not model_name:
|
|
|
|
|
raise RuntimeError("RAMP model is not configured. Set RAMP_MODEL_ID, RAMP_MODEL_NAME, or pass RampModelId.")
|
|
|
|
|
|
|
|
|
|
response = await client.get(f"{_simulation_api_base()}/model")
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
for model in response.json().get("models", []):
|
|
|
|
|
if _norm_key(model.get("name")) == _norm_key(model_name):
|
|
|
|
|
return str(model["id"])
|
|
|
|
|
|
|
|
|
|
raise RuntimeError(f"RAMP model named {model_name!r} was not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fetch_ramp_model_asset_names(client: httpx.AsyncClient, model_id: str) -> set[str]:
|
|
|
|
|
"""Asset names defined in a RAMP model, normalized for comparison."""
|
|
|
|
|
response = await client.get(f"{_simulation_api_base()}/model/{model_id}")
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
assets = (response.json().get("payload") or {}).get("assets") or []
|
|
|
|
|
return {_norm_key(asset.get("name")) for asset in assets if asset.get("name")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_ramp_task(tasks_payload: dict, task_id: str) -> dict | None:
|
|
|
|
|
for task in tasks_payload.get("tasks", []):
|
|
|
|
|
if str(task.get("id")) == str(task_id):
|
|
|
|
|
return task
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fetch_ramp_task_status(client: httpx.AsyncClient, task_id: str) -> dict:
|
|
|
|
|
"""Fetch task status, falling back to the lighter task-list endpoint.
|
|
|
|
|
|
|
|
|
|
RAMP's status endpoint includes the result payload for completed tasks. When
|
|
|
|
|
the engine is busy that endpoint can occasionally time out even for running
|
|
|
|
|
tasks, while /tasks still returns the persisted progress row quickly.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
status_response = await client.get(f"{_simulation_api_base()}/status/{task_id}")
|
|
|
|
|
status_response.raise_for_status()
|
|
|
|
|
return status_response.json()
|
|
|
|
|
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ConnectError) as status_error:
|
|
|
|
|
log.warning("RAMP status request failed for task %s: %s", task_id, status_error)
|
|
|
|
|
|
|
|
|
|
tasks_response = await client.get(f"{_simulation_api_base()}/tasks")
|
|
|
|
|
tasks_response.raise_for_status()
|
|
|
|
|
task = _find_ramp_task(tasks_response.json(), task_id)
|
|
|
|
|
if task:
|
|
|
|
|
return task
|
|
|
|
|
raise RuntimeError(f"RAMP task {task_id} was not found in /tasks fallback.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fetch_completed_ramp_result(client: httpx.AsyncClient, task_id: str) -> dict:
|
|
|
|
|
response = await client.get(f"{_simulation_api_base()}/status/{task_id}", timeout=120.0)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
result = response.json().get("result")
|
|
|
|
|
if not isinstance(result, dict):
|
|
|
|
|
raise RuntimeError(f"RAMP task {task_id} completed without a result payload.")
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _cancel_ramp_task(client: httpx.AsyncClient, task_id: str) -> None:
|
|
|
|
|
try:
|
|
|
|
|
await client.post(f"{_simulation_api_base()}/cancel/{task_id}")
|
|
|
|
|
except Exception as cancel_error:
|
|
|
|
|
log.warning("Failed to cancel stale RAMP task %s: %s", task_id, cancel_error)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_overrides_to_model(overrides: dict, asset_names: set[str]) -> tuple[dict, list[str]]:
|
|
|
|
|
"""Keep only overrides whose asset exists in the RAMP model.
|
|
|
|
|
|
|
|
|
|
The RBD schematic is a superset of any single RAMP model, so distributions for
|
|
|
|
|
assets the model does not contain are dropped rather than failing the run.
|
|
|
|
|
"""
|
|
|
|
|
if not asset_names:
|
|
|
|
|
return dict(overrides), []
|
|
|
|
|
|
|
|
|
|
kept = {}
|
|
|
|
|
dropped = []
|
|
|
|
|
for asset_key, override in (overrides or {}).items():
|
|
|
|
|
if _norm_key(asset_key) in asset_names:
|
|
|
|
|
kept[asset_key] = override
|
|
|
|
|
else:
|
|
|
|
|
dropped.append(str(asset_key))
|
|
|
|
|
return kept, sorted(dropped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_ramp_execute_payload(sim_data: dict, eq_update: dict | None = None) -> dict:
|
|
|
|
|
payload = {
|
|
|
|
|
"simulation_duration": _duration_to_hours(sim_data.get("SimDuration"), sim_data.get("DurationUnit")),
|
|
|
|
|
"simulation_replications": sim_data.get("SimNumRun"),
|
|
|
|
|
"seed": sim_data.get("SimSeed"),
|
|
|
|
|
"target_schema": sim_data.get("RampTargetSchema") or RAMP_TARGET_SCHEMA,
|
|
|
|
|
}
|
|
|
|
|
overrides = build_ramp_distribution_overrides_from_rbd_reliability(eq_update)
|
|
|
|
|
overrides.update(build_ramp_distribution_overrides(sim_data))
|
|
|
|
|
if overrides:
|
|
|
|
|
payload["distribution_overrides"] = overrides
|
|
|
|
|
return {key: value for key, value in payload.items() if value is not None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _event_time(event: dict) -> float:
|
|
|
|
|
return _get_number(event, ["cumulativeTime", "cumulative_time", "start", "start_time", "time"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_ramp_timestamp_outs(ramp_result: dict, duration_hours: float) -> list[dict]:
|
|
|
|
|
events = (
|
|
|
|
|
ramp_result.get("system_events")
|
|
|
|
|
or ramp_result.get("events")
|
|
|
|
|
or ramp_result.get("system_event_log")
|
|
|
|
|
or []
|
|
|
|
|
)
|
|
|
|
|
timestamps = [
|
|
|
|
|
{
|
|
|
|
|
"cumulativeTime": 0,
|
|
|
|
|
"flowRate": _get_number(ramp_result, ["total_demand", "demand"], 660),
|
|
|
|
|
"currentEQStatus": "Svc",
|
|
|
|
|
"currentOPStatus": "Svc",
|
|
|
|
|
"isTriggerNode": False,
|
|
|
|
|
"storageLevel": 0,
|
|
|
|
|
"currentEvent": "START",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for event in events:
|
|
|
|
|
if not isinstance(event, dict):
|
|
|
|
|
continue
|
|
|
|
|
start_time = _event_time(event)
|
|
|
|
|
end_time = _get_number(event, ["end", "end_time"], start_time)
|
|
|
|
|
cause = event.get("cause") or event.get("event_type") or event.get("state") or "RAMP_EVENT"
|
|
|
|
|
load_factor = _as_float(event.get("load_factor"), 0)
|
|
|
|
|
flow_rate = 660 * load_factor if load_factor and load_factor <= 1 else _get_number(event, ["flowRate", "flow_rate"], 0)
|
|
|
|
|
timestamps.append({
|
|
|
|
|
"cumulativeTime": start_time,
|
|
|
|
|
"flowRate": flow_rate,
|
|
|
|
|
"currentEQStatus": "OoS",
|
|
|
|
|
"currentOPStatus": "OoS",
|
|
|
|
|
"isTriggerNode": True,
|
|
|
|
|
"storageLevel": 0,
|
|
|
|
|
"currentEvent": cause,
|
|
|
|
|
})
|
|
|
|
|
if end_time and end_time > start_time:
|
|
|
|
|
timestamps.append({
|
|
|
|
|
"cumulativeTime": end_time,
|
|
|
|
|
"flowRate": _get_number(ramp_result, ["total_demand", "demand"], 660),
|
|
|
|
|
"currentEQStatus": "Svc",
|
|
|
|
|
"currentOPStatus": "Svc",
|
|
|
|
|
"isTriggerNode": False,
|
|
|
|
|
"storageLevel": 0,
|
|
|
|
|
"currentEvent": "RECOVERY",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if duration_hours:
|
|
|
|
|
timestamps.append({
|
|
|
|
|
"cumulativeTime": duration_hours,
|
|
|
|
|
"flowRate": _get_number(ramp_result, ["total_demand", "demand"], 660),
|
|
|
|
|
"currentEQStatus": "Svc",
|
|
|
|
|
"currentOPStatus": "Svc",
|
|
|
|
|
"isTriggerNode": False,
|
|
|
|
|
"storageLevel": 0,
|
|
|
|
|
"currentEvent": "END",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return sorted(timestamps, key=lambda item: item["cumulativeTime"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _calc_result(
|
|
|
|
|
*,
|
|
|
|
|
simulation_id: UUID,
|
|
|
|
|
node_id: UUID,
|
|
|
|
|
metrics: dict,
|
|
|
|
|
reliability: dict | None = None,
|
|
|
|
|
parameters: dict | None = None,
|
|
|
|
|
) -> AerosSimulationCalcResult:
|
|
|
|
|
total_uptime = _get_number(metrics, ["total_uptime", "system_uptime", "uptime"])
|
|
|
|
|
total_downtime = _get_number(metrics, ["total_downtime", "system_downtime", "downtime"])
|
|
|
|
|
period_hours = total_uptime + total_downtime
|
|
|
|
|
availability_source = _first_present(metrics, "availability", "system_availability", "production_availability")
|
|
|
|
|
availability = _availability_fraction(availability_source)
|
|
|
|
|
if not availability and period_hours:
|
|
|
|
|
availability = total_uptime / period_hours
|
|
|
|
|
eaf = _availability_percent(availability)
|
|
|
|
|
efor = total_downtime / period_hours * 100 if period_hours else 0
|
|
|
|
|
|
|
|
|
|
production = _get_number(metrics, ["production", "total_productions", "total_production", "actual_production"])
|
|
|
|
|
ideal_production = _get_number(metrics, ["ideal_production", "total_demand", "demand"])
|
|
|
|
|
effective_loss = max(ideal_production - production, 0) if ideal_production else 0
|
|
|
|
|
efficiency = production / ideal_production * 100 if ideal_production else availability
|
|
|
|
|
|
|
|
|
|
reliability = reliability or {}
|
|
|
|
|
return AerosSimulationCalcResult(
|
|
|
|
|
aeros_simulation_id=simulation_id,
|
|
|
|
|
aeros_node_id=node_id,
|
|
|
|
|
total_downtime=total_downtime,
|
|
|
|
|
total_uptime=total_uptime,
|
|
|
|
|
num_events=int(_get_number(metrics, ["num_events", "system_failures", "failures"])),
|
|
|
|
|
production=production,
|
|
|
|
|
production_std=_get_number(metrics, ["production_std"]),
|
|
|
|
|
ideal_production=ideal_production,
|
|
|
|
|
availability=availability,
|
|
|
|
|
efficiency=efficiency,
|
|
|
|
|
effective_loss=effective_loss,
|
|
|
|
|
num_cm=int(_get_number(metrics, ["num_cm", "failures"])),
|
|
|
|
|
cm_waiting_time=_get_number(metrics, ["cm_waiting_time"]),
|
|
|
|
|
total_cm_downtime=total_downtime,
|
|
|
|
|
num_pm=int(_get_number(metrics, ["num_pm"])),
|
|
|
|
|
total_pm_downtime=_get_number(metrics, ["total_pm_downtime"]),
|
|
|
|
|
num_ip=int(_get_number(metrics, ["num_ip"])),
|
|
|
|
|
total_ip_downtime=_get_number(metrics, ["total_ip_downtime"]),
|
|
|
|
|
num_oh=int(_get_number(metrics, ["num_oh"])),
|
|
|
|
|
total_oh_downtime=_get_number(metrics, ["total_oh_downtime"]),
|
|
|
|
|
t_wait_for_crew=_get_number(metrics, ["t_wait_for_crew"]),
|
|
|
|
|
t_wait_for_spare=_get_number(metrics, ["t_wait_for_spare"]),
|
|
|
|
|
duration_at_full=total_uptime,
|
|
|
|
|
duration_above_hh=0,
|
|
|
|
|
duration_above_h=0,
|
|
|
|
|
duration_below_l=0,
|
|
|
|
|
duration_below_ll=0,
|
|
|
|
|
duration_at_empty=total_downtime,
|
|
|
|
|
stg_input=0,
|
|
|
|
|
stg_output=0,
|
|
|
|
|
average_level=0,
|
|
|
|
|
potential_production=ideal_production,
|
|
|
|
|
eaf=eaf,
|
|
|
|
|
efor=efor,
|
|
|
|
|
derating_hours=effective_loss,
|
|
|
|
|
beta=_result_number(reliability.get("beta")),
|
|
|
|
|
eta=_result_number(reliability.get("eta")),
|
|
|
|
|
mttr=int(_result_number(reliability.get("mttr"))),
|
|
|
|
|
parameters=parameters or reliability.get("parameters") or {},
|
|
|
|
|
contribution=_get_number(metrics, ["contribution"]),
|
|
|
|
|
criticality=_get_number(metrics, ["criticality"]),
|
|
|
|
|
contribution_factor=_get_number(metrics, ["contribution_factor"]),
|
|
|
|
|
total_mo_downtime=_get_number(metrics, ["total_mo_downtime"]),
|
|
|
|
|
total_po_downtime=_get_number(metrics, ["total_po_downtime"]),
|
|
|
|
|
sof=_get_number(metrics, ["sof"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def save_ramp_simulation_result(
|
|
|
|
|
*,
|
|
|
|
|
db_session: DbSession,
|
|
|
|
|
simulation_id: UUID,
|
|
|
|
|
sim_data: dict,
|
|
|
|
|
ramp_result: dict,
|
|
|
|
|
eq_update: dict,
|
|
|
|
|
ramp_task_id: str,
|
|
|
|
|
):
|
|
|
|
|
simulation = await get_simulation_by_id(db_session=db_session, simulation_id=simulation_id)
|
|
|
|
|
simulation.status = "running"
|
|
|
|
|
simulation.input = {
|
|
|
|
|
**(simulation.input or {}),
|
|
|
|
|
"engine": "ramp",
|
|
|
|
|
"ramp_task_id": ramp_task_id,
|
|
|
|
|
"ramp_result_id": ramp_result.get("id"),
|
|
|
|
|
}
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
schematic_name = sim_data.get("SchematicName") or PLANT_NODE_NAME
|
|
|
|
|
available_nodes = await get_all_aeros_node(db_session=db_session, schematic_name=schematic_name)
|
|
|
|
|
nodes_by_key = {}
|
|
|
|
|
for node in available_nodes:
|
|
|
|
|
nodes_by_key[_norm_key(node.node_name)] = node
|
|
|
|
|
|
|
|
|
|
plant_node = nodes_by_key.get(_norm_key(schematic_name)) or nodes_by_key.get(_norm_key(PLANT_NODE_NAME))
|
|
|
|
|
if not plant_node:
|
|
|
|
|
raise RuntimeError(f"Plant node {schematic_name!r} was not found in RBD master nodes.")
|
|
|
|
|
|
|
|
|
|
root_metrics = {
|
|
|
|
|
"system_availability": ramp_result.get("system_availability"),
|
|
|
|
|
"production_availability": ramp_result.get("production_availability"),
|
|
|
|
|
"system_downtime": ramp_result.get("system_downtime"),
|
|
|
|
|
"system_failures": ramp_result.get("system_failures"),
|
|
|
|
|
"system_uptime": ramp_result.get("system_uptime"),
|
|
|
|
|
"total_demand": ramp_result.get("total_demand"),
|
|
|
|
|
"total_productions": ramp_result.get("total_productions") or ramp_result.get("total_production"),
|
|
|
|
|
}
|
|
|
|
|
db_session.add(_calc_result(simulation_id=simulation_id, node_id=plant_node.id, metrics=root_metrics))
|
|
|
|
|
duration_hours = _get_number(root_metrics, ["system_uptime"]) + _get_number(root_metrics, ["system_downtime"])
|
|
|
|
|
timestamp_outs = _build_ramp_timestamp_outs(ramp_result, duration_hours)
|
|
|
|
|
db_session.add(
|
|
|
|
|
AerosSimulationPlotResult(
|
|
|
|
|
aeros_simulation_id=simulation_id,
|
|
|
|
|
aeros_node_id=plant_node.id,
|
|
|
|
|
max_flow_rate=660,
|
|
|
|
|
storage_capacity=0,
|
|
|
|
|
point_availabilities=[],
|
|
|
|
|
point_flowrates=[],
|
|
|
|
|
timestamp_outs=timestamp_outs,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for asset in ramp_result.get("asset_results") or []:
|
|
|
|
|
if not isinstance(asset, dict):
|
|
|
|
|
continue
|
|
|
|
|
node = nodes_by_key.get(_norm_key(asset.get("name"))) or nodes_by_key.get(_norm_key(asset.get("id")))
|
|
|
|
|
if not node:
|
|
|
|
|
log.warning("Skipping RAMP asset result with no matching RBD node: %s", asset.get("name") or asset.get("id"))
|
|
|
|
|
continue
|
|
|
|
|
reliability = eq_update.get(asset.get("name")) or eq_update.get(asset.get("id")) or {}
|
|
|
|
|
db_session.add(
|
|
|
|
|
_calc_result(
|
|
|
|
|
simulation_id=simulation_id,
|
|
|
|
|
node_id=node.id,
|
|
|
|
|
metrics=asset,
|
|
|
|
|
reliability=reliability,
|
|
|
|
|
parameters={"engine": "ramp", "asset_id": asset.get("id")},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
simulation.status = "completed"
|
|
|
|
|
simulation.error = None
|
|
|
|
|
simulation.completed_at = datetime.now()
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def record_ramp_task_start(
|
|
|
|
|
*,
|
|
|
|
|
db_session: DbSession,
|
|
|
|
|
simulation_id: UUID,
|
|
|
|
|
model_id: str,
|
|
|
|
|
ramp_task_id: str,
|
|
|
|
|
payload: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
simulation = await get_simulation_by_id(db_session=db_session, simulation_id=simulation_id)
|
|
|
|
|
simulation.status = "running"
|
|
|
|
|
simulation.input = {
|
|
|
|
|
**(simulation.input or {}),
|
|
|
|
|
"engine": "ramp",
|
|
|
|
|
"ramp_model_id": model_id,
|
|
|
|
|
"ramp_task_id": ramp_task_id,
|
|
|
|
|
"ramp_payload": {
|
|
|
|
|
key: value
|
|
|
|
|
for key, value in payload.items()
|
|
|
|
|
if key != "distribution_overrides"
|
|
|
|
|
},
|
|
|
|
|
"ramp_distribution_override_count": len(payload.get("distribution_overrides") or {}),
|
|
|
|
|
}
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def execute_ramp_simulation(
|
|
|
|
|
*,
|
|
|
|
|
db_session: DbSession,
|
|
|
|
|
simulation_id: UUID,
|
|
|
|
|
sim_data: dict,
|
|
|
|
|
eq_update: dict | None = None,
|
|
|
|
|
) -> bool:
|
|
|
|
|
timeout = httpx.Timeout(30.0, connect=10.0)
|
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
|
|
|
model_id = await _resolve_ramp_model_id(client, sim_data)
|
|
|
|
|
payload = _build_ramp_execute_payload(sim_data, eq_update=eq_update)
|
|
|
|
|
|
|
|
|
|
overrides = payload.get("distribution_overrides")
|
|
|
|
|
if overrides:
|
|
|
|
|
asset_names = await _fetch_ramp_model_asset_names(client, model_id)
|
|
|
|
|
kept, dropped = _filter_overrides_to_model(overrides, asset_names)
|
|
|
|
|
if dropped:
|
|
|
|
|
log.warning(
|
|
|
|
|
"Dropping %s distribution override(s) absent from RAMP model %s: %s",
|
|
|
|
|
len(dropped), model_id, ", ".join(dropped[:25]),
|
|
|
|
|
)
|
|
|
|
|
if kept:
|
|
|
|
|
payload["distribution_overrides"] = kept
|
|
|
|
|
else:
|
|
|
|
|
payload.pop("distribution_overrides")
|
|
|
|
|
log.info(
|
|
|
|
|
"RAMP model %s matched %s/%s distribution overrides",
|
|
|
|
|
model_id, len(kept), len(overrides),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
log.info("Executing RAMP simulation model_id=%s simulation_id=%s", model_id, simulation_id)
|
|
|
|
|
|
|
|
|
|
response = await client.post(f"{_simulation_api_base()}/execute/{model_id}", json=payload)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
task_id = response.json().get("task_id")
|
|
|
|
|
if not task_id:
|
|
|
|
|
raise RuntimeError(f"RAMP execute response did not include task_id: {response.text}")
|
|
|
|
|
await record_ramp_task_start(
|
|
|
|
|
db_session=db_session,
|
|
|
|
|
simulation_id=simulation_id,
|
|
|
|
|
model_id=model_id,
|
|
|
|
|
ramp_task_id=task_id,
|
|
|
|
|
payload=payload,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
deadline = time.monotonic() + RAMP_POLL_TIMEOUT_SECONDS
|
|
|
|
|
last_status = None
|
|
|
|
|
last_progress = None
|
|
|
|
|
last_updated_at = None
|
|
|
|
|
last_change_at = time.monotonic()
|
|
|
|
|
last_successful_poll_at = time.monotonic()
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
try:
|
|
|
|
|
status_payload = await _fetch_ramp_task_status(client, task_id)
|
|
|
|
|
last_successful_poll_at = time.monotonic()
|
|
|
|
|
except httpx.HTTPError as poll_error:
|
|
|
|
|
if time.monotonic() - last_successful_poll_at > RAMP_STALE_TIMEOUT_SECONDS:
|
|
|
|
|
await _cancel_ramp_task(client, task_id)
|
|
|
|
|
raise TimeoutError(
|
|
|
|
|
f"RAMP task {task_id} status polling failed for "
|
|
|
|
|
f"{RAMP_STALE_TIMEOUT_SECONDS}s: {poll_error}"
|
|
|
|
|
) from poll_error
|
|
|
|
|
log.warning("Transient RAMP poll failure for task %s: %s", task_id, poll_error)
|
|
|
|
|
await asyncio.sleep(RAMP_POLL_INTERVAL_SECONDS)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
previous_marker = (last_status, last_progress, last_updated_at)
|
|
|
|
|
last_status = status_payload.get("status")
|
|
|
|
|
progress = status_payload.get("progress")
|
|
|
|
|
updated_at = status_payload.get("updated_at")
|
|
|
|
|
|
|
|
|
|
if (last_status, progress, updated_at) != previous_marker:
|
|
|
|
|
last_change_at = time.monotonic()
|
|
|
|
|
last_progress = progress
|
|
|
|
|
last_updated_at = updated_at
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
last_status == "running"
|
|
|
|
|
and RAMP_STALE_TIMEOUT_SECONDS > 0
|
|
|
|
|
and time.monotonic() - last_change_at > RAMP_STALE_TIMEOUT_SECONDS
|
|
|
|
|
):
|
|
|
|
|
await _cancel_ramp_task(client, task_id)
|
|
|
|
|
raise TimeoutError(
|
|
|
|
|
f"RAMP task {task_id} made no progress for "
|
|
|
|
|
f"{RAMP_STALE_TIMEOUT_SECONDS}s. Last progress: {progress}."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if last_status == "completed":
|
|
|
|
|
result = status_payload.get("result")
|
|
|
|
|
if not isinstance(result, dict):
|
|
|
|
|
result = await _fetch_completed_ramp_result(client, task_id)
|
|
|
|
|
await save_ramp_simulation_result(
|
|
|
|
|
db_session=db_session,
|
|
|
|
|
simulation_id=simulation_id,
|
|
|
|
|
sim_data=sim_data,
|
|
|
|
|
ramp_result=result,
|
|
|
|
|
eq_update=eq_update or build_ramp_distribution_overrides(sim_data),
|
|
|
|
|
ramp_task_id=task_id,
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if last_status in {"failed", "cancelled"}:
|
|
|
|
|
error = status_payload.get("error") or f"RAMP task ended with status {last_status}"
|
|
|
|
|
raise RuntimeError(error)
|
|
|
|
|
|
|
|
|
|
await asyncio.sleep(RAMP_POLL_INTERVAL_SECONDS)
|
|
|
|
|
|
|
|
|
|
await _cancel_ramp_task(client, task_id)
|
|
|
|
|
raise TimeoutError(f"RAMP task timed out after {RAMP_POLL_TIMEOUT_SECONDS}s. Last status: {last_status}")
|