feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""RFC 8785 canonical JSON hashing shared by persisted records."""
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
import rfc8785
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
try:
|
||||
return rfc8785.dumps(value)
|
||||
except rfc8785.CanonicalizationError as exc:
|
||||
raise DomainError("SCHEMA_INVALID", "Content is not canonicalizable JSON") from exc
|
||||
|
||||
|
||||
def digest(value: object) -> str:
|
||||
return "sha256:" + sha256(canonical(value)).hexdigest()
|
||||
@@ -80,6 +80,8 @@ def _create_validated_placement(
|
||||
min_diameter: int,
|
||||
created_by: str,
|
||||
sensor_locations: list[str],
|
||||
sensor_type: str = "pressure",
|
||||
method: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
validate_sensor_placement_nodes(project_code, sensor_locations)
|
||||
return sensor_placement_repository.create_sensor_placement(
|
||||
@@ -88,6 +90,8 @@ def _create_validated_placement(
|
||||
min_diameter=min_diameter,
|
||||
created_by=created_by,
|
||||
sensor_locations=sensor_locations,
|
||||
sensor_type=sensor_type,
|
||||
method=method,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +101,7 @@ def optimize_sensor_placement_by_sensitivity(
|
||||
sensor_count: int,
|
||||
min_diameter: int,
|
||||
created_by: str,
|
||||
sensor_type: str = "pressure",
|
||||
) -> dict[str, Any]:
|
||||
"""Run sensitivity placement and persist the validated result."""
|
||||
|
||||
@@ -112,6 +117,8 @@ def optimize_sensor_placement_by_sensitivity(
|
||||
min_diameter=min_diameter,
|
||||
created_by=created_by,
|
||||
sensor_locations=sensor_locations,
|
||||
sensor_type=sensor_type,
|
||||
method="sensitivity",
|
||||
)
|
||||
|
||||
|
||||
@@ -121,6 +128,7 @@ def optimize_sensor_placement_by_kmeans(
|
||||
sensor_count: int,
|
||||
min_diameter: int,
|
||||
created_by: str,
|
||||
sensor_type: str = "pressure",
|
||||
) -> dict[str, Any]:
|
||||
"""Export the model, run K-means placement, and persist the result."""
|
||||
|
||||
@@ -136,6 +144,8 @@ def optimize_sensor_placement_by_kmeans(
|
||||
min_diameter=min_diameter,
|
||||
created_by=created_by,
|
||||
sensor_locations=sensor_locations,
|
||||
sensor_type=sensor_type,
|
||||
method="kmeans",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Monotonic, idempotent state machine for task-produced alerts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.domain.schemas.task_alert import TaskAlertEvent, TaskAlertState
|
||||
from app.domain.domain_errors import require
|
||||
from app.services.content_digest import digest
|
||||
|
||||
|
||||
class TaskAlertService:
|
||||
def __init__(
|
||||
self,
|
||||
repository,
|
||||
context,
|
||||
*,
|
||||
clock=None,
|
||||
internal_producer_id: str | None = None,
|
||||
):
|
||||
self.repo = repository
|
||||
self.context = context
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.internal_producer_id = internal_producer_id
|
||||
|
||||
async def push(self, command: TaskAlertEvent) -> TaskAlertState:
|
||||
if self.internal_producer_id is None:
|
||||
require(
|
||||
self.context is not None and self.context.project_role == "member",
|
||||
"PERMISSION_DENIED",
|
||||
"Alert events require project write permission",
|
||||
status=403,
|
||||
)
|
||||
require(
|
||||
command.producer_id == f"principal:{self.context.user_id}",
|
||||
"PERMISSION_DENIED",
|
||||
"Alert producer must match the authenticated project principal",
|
||||
status=403,
|
||||
)
|
||||
else:
|
||||
require(
|
||||
command.producer_id == self.internal_producer_id,
|
||||
"PERMISSION_DENIED",
|
||||
"Internal alert producer identity does not match",
|
||||
status=403,
|
||||
)
|
||||
event = command.model_dump(mode="json")
|
||||
async with self.repo.transaction():
|
||||
await self.repo.lock_alert(command.alert_id)
|
||||
prior_event = await self.repo.get_event(
|
||||
command.producer_id, str(command.event_id)
|
||||
)
|
||||
if prior_event is not None:
|
||||
prior_command = {
|
||||
key: value for key, value in prior_event.items()
|
||||
if key != "resulting_state"
|
||||
}
|
||||
require(
|
||||
digest(prior_command) == digest(event),
|
||||
"IDEMPOTENCY_CONFLICT",
|
||||
"event_id was used with a different alert event",
|
||||
status=409,
|
||||
)
|
||||
return TaskAlertState.model_validate(prior_event["resulting_state"])
|
||||
|
||||
current = await self.repo.get_alert(command.alert_id)
|
||||
state = self._transition(command, current)
|
||||
state_document = state.model_dump(mode="json")
|
||||
await self.repo.save_event(event, state_document)
|
||||
await self.repo.save_alert(state_document)
|
||||
return state
|
||||
|
||||
def _transition(
|
||||
self, command: TaskAlertEvent, current: tuple[int, dict] | None
|
||||
) -> TaskAlertState:
|
||||
now = self.clock()
|
||||
require(now.tzinfo is not None, "SCHEMA_INVALID", "Alert clock must be timezone-aware")
|
||||
if current is None:
|
||||
require(command.event_type == "raised", "INVALID_TRANSITION",
|
||||
"An alert must be raised before it can change state", status=409)
|
||||
revision = 1
|
||||
status = "active"
|
||||
else:
|
||||
revision, document = current
|
||||
require(document.get("producer_id") == command.producer_id,
|
||||
"PERMISSION_DENIED", "Only the originating producer can update an alert", status=403)
|
||||
require(document.get("source") == command.source.model_dump(mode="json"),
|
||||
"SCOPE_MISMATCH", "Alert source cannot change", status=409)
|
||||
require(
|
||||
all(
|
||||
document.get(field) == event_value
|
||||
for field, event_value in {
|
||||
"condition_id": command.condition_id,
|
||||
"title": command.title,
|
||||
"description": command.description,
|
||||
"occurred_at": command.model_dump(mode="json")["occurred_at"],
|
||||
"severity": command.severity,
|
||||
}.items()
|
||||
),
|
||||
"IDEMPOTENCY_CONFLICT",
|
||||
"Alert facts cannot change after the alert is raised",
|
||||
status=409,
|
||||
)
|
||||
allowed = {
|
||||
("active", "acknowledged"): "acknowledged",
|
||||
("active", "resolved"): "resolved",
|
||||
("acknowledged", "resolved"): "resolved",
|
||||
}
|
||||
status = allowed.get((document["status"], command.event_type))
|
||||
require(status is not None, "INVALID_TRANSITION",
|
||||
"Alert state transition is not permitted", status=409)
|
||||
revision += 1
|
||||
return TaskAlertState(
|
||||
alert_id=command.alert_id,
|
||||
revision=revision,
|
||||
producer_id=command.producer_id,
|
||||
condition_id=command.condition_id,
|
||||
source=command.source,
|
||||
title=command.title,
|
||||
description=command.description,
|
||||
occurred_at=command.occurred_at,
|
||||
severity=command.severity,
|
||||
status=status,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -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