374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""Project persisted runtime state into the read-only supply workbench feed."""
|
|
|
|
from collections.abc import Callable
|
|
from datetime import datetime, timezone
|
|
from math import isfinite
|
|
from typing import Any
|
|
|
|
from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert
|
|
|
|
_ANALYSIS_TYPE_LABELS = {
|
|
"burst_analysis": "爆管分析",
|
|
"burst_detection": "爆管检测",
|
|
"burst_location": "爆管定位",
|
|
"contaminant_analysis": "污染物分析",
|
|
"contaminant_simulation": "污染物模拟",
|
|
"dma_leak_identification": "DMA 漏损识别",
|
|
"flushing_analysis": "冲洗分析",
|
|
"pressure_regulation": "压力调控",
|
|
"sensor_placement": "传感器布点",
|
|
"valve_close_analysis": "关阀分析",
|
|
}
|
|
_ANALYSIS_STATUS_LABELS = {
|
|
"queued": "排队中",
|
|
"pending": "等待中",
|
|
"running": "运行中",
|
|
"completed": "已完成",
|
|
"succeeded": "已成功",
|
|
"failed": "已失败",
|
|
"error": "异常",
|
|
"cancelled": "已取消",
|
|
}
|
|
|
|
|
|
async def build_workbench_runtime_snapshot(
|
|
*,
|
|
analysis_runs: list[dict[str, Any]] | None = None,
|
|
latest_analysis_results: list[dict[str, Any]] | None = None,
|
|
latest_analysis_map_payload: dict[str, Any] | None = None,
|
|
alert_storage_configured: bool = False,
|
|
persisted_alerts: list[dict[str, Any]] | None = None,
|
|
scenario_availability: str | None = None,
|
|
artifact_availability: str | None = None,
|
|
schedule_availability: str | None = None,
|
|
alert_availability: str | None = None,
|
|
projection_degraded: bool = False,
|
|
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
) -> dict[str, Any]:
|
|
persisted_analysis_runs = (analysis_runs or [])[:100]
|
|
scenarios = [project_analysis_scenario(run) for run in persisted_analysis_runs[:20]]
|
|
latest_analysis = (
|
|
project_latest_analysis(
|
|
persisted_analysis_runs[0],
|
|
latest_analysis_results or [],
|
|
referenced_map_payload=latest_analysis_map_payload,
|
|
)
|
|
if persisted_analysis_runs
|
|
else None
|
|
)
|
|
stored_alerts = [
|
|
WorkbenchPersistedAlert.model_validate(item)
|
|
for item in (
|
|
(persisted_alerts or [])[:100]
|
|
if alert_storage_configured
|
|
else []
|
|
)
|
|
]
|
|
active_stored_alerts = [
|
|
{
|
|
"id": f"backend-alert-{alert.alert_id}",
|
|
"condition_id": alert.condition_id,
|
|
"source": alert.source.model_dump(),
|
|
"title": alert.title,
|
|
"description": alert.description,
|
|
"occurred_at": alert.occurred_at,
|
|
"severity": alert.severity,
|
|
}
|
|
for alert in stored_alerts
|
|
if alert.status == "active"
|
|
]
|
|
projected_alerts = active_stored_alerts
|
|
projected_alerts.sort(
|
|
key=lambda alert: _timestamp(alert["occurred_at"]),
|
|
reverse=True,
|
|
)
|
|
source = "none"
|
|
resolved_scenario_availability = scenario_availability or (
|
|
"ready" if scenarios else "not_configured"
|
|
)
|
|
resolved_artifact_availability = artifact_availability or (
|
|
"ready" if latest_analysis else "not_configured"
|
|
)
|
|
resolved_schedule_availability = schedule_availability or "not_configured"
|
|
resolved_work_order_availability = "not_configured"
|
|
resolved_alert_availability = alert_availability or (
|
|
"ready" if alert_storage_configured else "not_configured"
|
|
)
|
|
section_availabilities = (
|
|
resolved_scenario_availability,
|
|
resolved_artifact_availability,
|
|
resolved_schedule_availability,
|
|
resolved_work_order_availability,
|
|
resolved_alert_availability,
|
|
)
|
|
return {
|
|
"availability": (
|
|
"degraded"
|
|
if projection_degraded or "degraded" in section_availabilities
|
|
else
|
|
"ready"
|
|
if (
|
|
source != "none"
|
|
or alert_storage_configured
|
|
)
|
|
else "not_configured"
|
|
),
|
|
"source": source,
|
|
"scenario_availability": resolved_scenario_availability,
|
|
"artifact_availability": resolved_artifact_availability,
|
|
"schedule_availability": resolved_schedule_availability,
|
|
"work_order_availability": resolved_work_order_availability,
|
|
"alert_availability": resolved_alert_availability,
|
|
"generated_at": clock(),
|
|
"conditions": [],
|
|
"alerts": projected_alerts,
|
|
"scenarios": scenarios,
|
|
"latest_analysis": latest_analysis,
|
|
"work_orders": [],
|
|
}
|
|
|
|
|
|
def project_analysis_scenario(run: dict[str, Any]) -> dict[str, Any]:
|
|
run_id = str(run["run_id"])
|
|
run_type = str(run.get("run_type") or "analysis")
|
|
run_status = str(run.get("status") or "unknown").lower()
|
|
type_label = _analysis_type_label(run_type)
|
|
return {
|
|
"id": f"analysis-run-{run_id}",
|
|
"run_id": run_id,
|
|
"name": str(run.get("name") or type_label),
|
|
"description": (
|
|
f"{type_label} · {_analysis_status_label(run_status)} · "
|
|
f"创建人 {run.get('created_by') or 'unknown'}"
|
|
),
|
|
"status": _analysis_scenario_state(run_status),
|
|
"run_type": run_type,
|
|
"run_type_label": type_label,
|
|
"run_status": run_status,
|
|
"created_at": run["created_at"],
|
|
}
|
|
|
|
|
|
def project_analysis_history_item(run: dict[str, Any]) -> dict[str, Any]:
|
|
run_type = str(run.get("run_type") or "analysis")
|
|
run_status = str(run.get("status") or "unknown").lower()
|
|
created_at = run["created_at"]
|
|
return {
|
|
"run_id": str(run["run_id"]),
|
|
"name": str(run.get("name") or _analysis_type_label(run_type)),
|
|
"run_type": run_type,
|
|
"run_type_label": _analysis_type_label(run_type),
|
|
"status": run_status,
|
|
"status_label": _analysis_status_label(run_status),
|
|
"created_by": str(run.get("created_by") or "unknown"),
|
|
"created_at": created_at,
|
|
"started_at": run.get("started_at") or created_at,
|
|
"has_results": bool(run.get("has_results")),
|
|
}
|
|
|
|
|
|
def project_latest_analysis(
|
|
run: dict[str, Any],
|
|
results: list[dict[str, Any]],
|
|
*,
|
|
referenced_map_payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
run_type = str(run.get("run_type") or "analysis")
|
|
run_status = str(run.get("status") or "unknown").lower()
|
|
created_at = run["created_at"]
|
|
return {
|
|
"run_id": str(run["run_id"]),
|
|
"name": str(run.get("name") or _analysis_type_label(run_type)),
|
|
"run_type": run_type,
|
|
"run_type_label": _analysis_type_label(run_type),
|
|
"status": run_status,
|
|
"status_label": _analysis_status_label(run_status),
|
|
"created_by": str(run.get("created_by") or "unknown"),
|
|
"created_at": created_at,
|
|
"started_at": run.get("started_at") or created_at,
|
|
"results": [
|
|
{
|
|
"result_type": str(result.get("result_type") or "analysis_result"),
|
|
"created_at": result.get("created_at") or created_at,
|
|
"facts": _analysis_result_facts(result),
|
|
}
|
|
for result in results
|
|
],
|
|
"map_payload": _analysis_map_payload(results) or referenced_map_payload,
|
|
}
|
|
|
|
|
|
def sensor_location_ids_for_map_projection(
|
|
results: list[dict[str, Any]],
|
|
) -> list[str]:
|
|
if _analysis_map_payload(results) is not None:
|
|
return []
|
|
for result in reversed(results):
|
|
if result.get("result_type") != "sensor_placement":
|
|
continue
|
|
payload = result.get("payload")
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
locations = _non_empty_text_list(payload.get("sensor_locations"))
|
|
if locations:
|
|
return list(dict.fromkeys(locations))[:500]
|
|
return []
|
|
|
|
|
|
def project_sensor_location_map_payload(
|
|
node_ids: list[str], map_points: list[dict[str, Any]]
|
|
) -> dict[str, Any] | None:
|
|
points_by_id = {
|
|
str(point.get("node_id")): point
|
|
for point in map_points
|
|
if point.get("node_id") is not None
|
|
}
|
|
features: list[dict[str, Any]] = []
|
|
for position, node_id in enumerate(node_ids, start=1):
|
|
point = points_by_id.get(node_id)
|
|
if point is None:
|
|
continue
|
|
longitude = point.get("longitude")
|
|
latitude = point.get("latitude")
|
|
if (
|
|
isinstance(longitude, bool)
|
|
or isinstance(latitude, bool)
|
|
or not isinstance(longitude, (int, float))
|
|
or not isinstance(latitude, (int, float))
|
|
or not isfinite(float(longitude))
|
|
or not isfinite(float(latitude))
|
|
):
|
|
continue
|
|
features.append({
|
|
"type": "Feature",
|
|
"id": node_id,
|
|
"properties": {
|
|
"label": f"监测点 {position} · {node_id}",
|
|
"node_id": node_id,
|
|
"result_type": "sensor_placement",
|
|
"position": position,
|
|
},
|
|
"geometry": {
|
|
"type": "Point",
|
|
"coordinates": [float(longitude), float(latitude)],
|
|
},
|
|
})
|
|
if not features:
|
|
return None
|
|
return {"type": "FeatureCollection", "features": features}
|
|
|
|
|
|
def _analysis_result_facts(result: dict[str, Any]) -> list[dict[str, str]]:
|
|
facts: list[dict[str, str]] = []
|
|
node_id = _non_empty_text(result.get("node_id"))
|
|
link_id = _non_empty_text(result.get("link_id"))
|
|
if node_id:
|
|
facts.append({"label": "关联节点", "value": node_id})
|
|
if link_id:
|
|
facts.append({"label": "关联管段", "value": link_id})
|
|
|
|
payload = result.get("payload")
|
|
if not isinstance(payload, dict):
|
|
return facts
|
|
result_type = str(result.get("result_type") or "")
|
|
if result_type == "sensor_placement":
|
|
locations = _non_empty_text_list(payload.get("sensor_locations"))
|
|
sensor_count = _finite_number_text(payload.get("sensor_number"))
|
|
if sensor_count is None and locations:
|
|
sensor_count = str(len(locations))
|
|
if sensor_count is not None:
|
|
facts.append({"label": "测点数量", "value": sensor_count})
|
|
minimum_diameter = _finite_number_text(payload.get("minimum_diameter"))
|
|
if minimum_diameter is not None:
|
|
facts.append({"label": "最小管径参数", "value": minimum_diameter})
|
|
if locations:
|
|
facts.append({
|
|
"label": "测点位置",
|
|
"value": _summarize_identifiers(locations),
|
|
})
|
|
elif result_type == "leakage_identification":
|
|
run_status = _non_empty_text(payload.get("run_status"))
|
|
if run_status:
|
|
facts.append({"label": "结果状态", "value": run_status})
|
|
sensor_nodes = _non_empty_text_list(payload.get("sensor_nodes"))
|
|
if sensor_nodes:
|
|
facts.append({"label": "传感器节点数", "value": str(len(sensor_nodes))})
|
|
rows = payload.get("rows")
|
|
if isinstance(rows, list):
|
|
facts.append({"label": "结果行数", "value": str(len(rows))})
|
|
areas = payload.get("areas")
|
|
if isinstance(areas, list):
|
|
facts.append({"label": "分区数量", "value": str(len(areas))})
|
|
return facts
|
|
|
|
|
|
def _non_empty_text(value: object) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
normalized = value.strip()
|
|
return normalized or None
|
|
|
|
|
|
def _non_empty_text_list(value: object) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [text for item in value if (text := _non_empty_text(item)) is not None]
|
|
|
|
|
|
def _finite_number_text(value: object) -> str | None:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
if isinstance(value, float) and not isfinite(value):
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
def _summarize_identifiers(values: list[str], limit: int = 12) -> str:
|
|
visible = values[:limit]
|
|
suffix = f" 等 {len(values)} 个" if len(values) > limit else ""
|
|
return "、".join(visible) + suffix
|
|
|
|
|
|
def _analysis_map_payload(
|
|
results: list[dict[str, Any]],
|
|
) -> dict[str, Any] | None:
|
|
for result in reversed(results):
|
|
payload = result.get("payload")
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
candidate = payload.get("drawing_payload")
|
|
if not isinstance(candidate, dict):
|
|
candidate = payload
|
|
if (
|
|
candidate.get("type") == "FeatureCollection"
|
|
and isinstance(candidate.get("features"), list)
|
|
and candidate["features"]
|
|
):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _analysis_type_label(run_type: str) -> str:
|
|
return _ANALYSIS_TYPE_LABELS.get(run_type, run_type)
|
|
|
|
|
|
def _analysis_status_label(status: str) -> str:
|
|
return _ANALYSIS_STATUS_LABELS.get(status, status)
|
|
|
|
|
|
def _analysis_scenario_state(status: str) -> str:
|
|
if status == "running":
|
|
return "active"
|
|
if status in {"completed", "succeeded", "failed", "error"}:
|
|
return "review"
|
|
return "draft"
|
|
|
|
|
|
def _timestamp(value: datetime | str) -> float:
|
|
parsed = (
|
|
value
|
|
if isinstance(value, datetime)
|
|
else datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
)
|
|
return parsed.timestamp()
|