feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
"""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,
|
||||
WorkbenchRuntimeWorkOrder,
|
||||
)
|
||||
|
||||
_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,
|
||||
work_order_storage_configured: bool = False,
|
||||
work_orders: list[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,
|
||||
work_order_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
|
||||
)
|
||||
persisted_work_orders = [
|
||||
WorkbenchRuntimeWorkOrder.model_validate(item).model_dump()
|
||||
for item in (
|
||||
(work_orders or [])[:100]
|
||||
if work_order_storage_configured
|
||||
else []
|
||||
)
|
||||
]
|
||||
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 = work_order_availability or (
|
||||
"ready" if work_order_storage_configured else "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 work_order_storage_configured
|
||||
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": persisted_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()
|
||||
Reference in New Issue
Block a user