feat: Enhance simulation engine integration and error handling
- Added support for new simulation engine parameters in SimulationInput schema. - Updated get_critical_equipment to return a success message when no results are found. - Introduced error notification functionality in temporal activities, including email and webhook support. - Implemented new activities for handling simulation engine selection and RBD reliability collection. - Enhanced SimulationWorkflow to manage different simulation engines (AEROS and RAMP) with appropriate activity calls. - Added unit tests for ramp engine functionalities, ensuring correct behavior of distribution overrides and calculation results.main
parent
d26326191c
commit
7ff017c99d
@ -0,0 +1,685 @@
|
|||||||
|
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_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 beta and beta > 0:
|
||||||
|
exp_eta = 1 / 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 _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 _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 _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 = _availability_percent(_first_present(metrics, "availability", "system_availability", "production_availability"))
|
||||||
|
if not availability and period_hours:
|
||||||
|
availability = total_uptime / period_hours * 100
|
||||||
|
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=availability,
|
||||||
|
efor=efor,
|
||||||
|
derating_hours=effective_loss,
|
||||||
|
beta=reliability.get("beta"),
|
||||||
|
eta=reliability.get("eta"),
|
||||||
|
mttr=reliability.get("mttr"),
|
||||||
|
parameters=parameters or reliability.get("parameters"),
|
||||||
|
contribution=_get_number(metrics, ["contribution"]),
|
||||||
|
criticality=_get_number(metrics, ["criticality"]),
|
||||||
|
contribution_factor=_get_number(metrics, ["contribution_factor"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.completed_at = datetime.now()
|
||||||
|
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}")
|
||||||
|
|
||||||
|
deadline = time.monotonic() + RAMP_POLL_TIMEOUT_SECONDS
|
||||||
|
last_status = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
status_response = await client.get(f"{_simulation_api_base()}/status/{task_id}")
|
||||||
|
status_response.raise_for_status()
|
||||||
|
status_payload = status_response.json()
|
||||||
|
last_status = status_payload.get("status")
|
||||||
|
|
||||||
|
if last_status == "completed":
|
||||||
|
result = status_payload.get("result")
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise RuntimeError(f"RAMP task {task_id} completed without a result payload.")
|
||||||
|
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)
|
||||||
|
|
||||||
|
raise TimeoutError(f"RAMP task timed out after {RAMP_POLL_TIMEOUT_SECONDS}s. Last status: {last_status}")
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from src.aeros_simulation.ramp_engine import (
|
||||||
|
_calc_result,
|
||||||
|
_duration_to_hours,
|
||||||
|
_filter_overrides_to_model,
|
||||||
|
build_ramp_distribution_overrides_from_rbd_reliability,
|
||||||
|
build_ramp_distribution_overrides,
|
||||||
|
selected_simulation_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_simulation_engine_defaults_to_aeros():
|
||||||
|
assert selected_simulation_engine({}) == "aeros"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duration_to_hours_supports_aeros_units():
|
||||||
|
assert _duration_to_hours(2, "UYear") == 17520
|
||||||
|
assert _duration_to_hours(3, "UDay") == 72
|
||||||
|
assert _duration_to_hours(5, "UHour") == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ramp_distribution_overrides_from_ahm_custom_input():
|
||||||
|
overrides = build_ramp_distribution_overrides(
|
||||||
|
{
|
||||||
|
"CustomInput": {
|
||||||
|
"EQ001": {
|
||||||
|
"mttr": 10,
|
||||||
|
"failure_rate": 0.5,
|
||||||
|
"distribution": "Weibull",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overrides["EQ001"]["repair_duration"] == 10
|
||||||
|
assert overrides["EQ001"]["model"] == {
|
||||||
|
"type": "NonRepairable",
|
||||||
|
"distribution": {"type": "Weibull", "shape": 1.0, "scale": 2000000.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ramp_distribution_overrides_from_aeros_distribution_fields():
|
||||||
|
overrides = build_ramp_distribution_overrides(
|
||||||
|
{
|
||||||
|
"DistributionOverrides": {
|
||||||
|
"PUMP-A": {
|
||||||
|
"relDisType": "Weibull2",
|
||||||
|
"relDisP1": 1.7,
|
||||||
|
"relDisP2": 9000,
|
||||||
|
"cmDisType": "Normal",
|
||||||
|
"cmDisP1": 8,
|
||||||
|
"cmDisP2": 1.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overrides["PUMP-A"]["model"]["distribution"] == {
|
||||||
|
"type": "Weibull",
|
||||||
|
"shape": 1.7,
|
||||||
|
"scale": 9000.0,
|
||||||
|
}
|
||||||
|
assert overrides["PUMP-A"]["repair_duration"] == {
|
||||||
|
"type": "Normal",
|
||||||
|
"mean": 8.0,
|
||||||
|
"std_dev": 1.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ramp_calc_result_uses_numeric_defaults_for_aeros_only_fields():
|
||||||
|
calc = _calc_result(
|
||||||
|
simulation_id=uuid4(),
|
||||||
|
node_id=uuid4(),
|
||||||
|
metrics={
|
||||||
|
"system_availability": 0.95,
|
||||||
|
"system_uptime": 95,
|
||||||
|
"system_downtime": 5,
|
||||||
|
"system_failures": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calc.stg_input == 0
|
||||||
|
assert calc.stg_output == 0
|
||||||
|
assert calc.average_level == 0
|
||||||
|
assert calc.availability == 95.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ramp_distribution_overrides_from_rbd_reliability():
|
||||||
|
overrides = build_ramp_distribution_overrides_from_rbd_reliability(
|
||||||
|
{
|
||||||
|
"PUMP-A": {
|
||||||
|
"distribution": "NHPPTTFF",
|
||||||
|
"beta": 1.4,
|
||||||
|
"eta": 8000,
|
||||||
|
"mttr": 12,
|
||||||
|
},
|
||||||
|
"PUMP-B": {
|
||||||
|
"distribution": "Weibull2",
|
||||||
|
"beta": 2,
|
||||||
|
"eta": 9000,
|
||||||
|
"mttr": 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overrides["PUMP-A"]["model"] == {
|
||||||
|
"type": "RepairableNHPP",
|
||||||
|
"beta": 1.4,
|
||||||
|
"eta": 8000.0,
|
||||||
|
}
|
||||||
|
assert overrides["PUMP-A"]["repair_duration"] == 12.0
|
||||||
|
assert overrides["PUMP-B"]["model"]["distribution"] == {
|
||||||
|
"type": "Weibull",
|
||||||
|
"shape": 2.0,
|
||||||
|
"scale": 9000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_overrides_keeps_only_assets_present_in_model():
|
||||||
|
overrides = {
|
||||||
|
"3AH-AU501A": {"repair_duration": 4},
|
||||||
|
"3bad-m531": {"repair_duration": 6},
|
||||||
|
"00CHA-CV804": {"repair_duration": 8},
|
||||||
|
}
|
||||||
|
kept, dropped = _filter_overrides_to_model(overrides, {"3AH-AU501A", "3BAD-M531"})
|
||||||
|
|
||||||
|
assert set(kept) == {"3AH-AU501A", "3bad-m531"}
|
||||||
|
assert dropped == ["00CHA-CV804"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_overrides_passes_through_when_model_assets_unknown():
|
||||||
|
overrides = {"3AH-AU501A": {"repair_duration": 4}}
|
||||||
|
kept, dropped = _filter_overrides_to_model(overrides, set())
|
||||||
|
|
||||||
|
assert kept == overrides
|
||||||
|
assert dropped == []
|
||||||
Loading…
Reference in New Issue