feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -1,47 +1,116 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.auth.project_dependencies import get_project_pg_connection
|
||||
from app.domain.schemas.gis_attributes import (
|
||||
ElementGisProperties,
|
||||
GisElementType,
|
||||
SourceGisFeatureProperties,
|
||||
)
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_database_connection(
|
||||
@router.get(
|
||||
"/source-gis/layers/{layer_key}/features/{source_fid}/properties",
|
||||
response_model=SourceGisFeatureProperties,
|
||||
summary="获取源 GIS 要素台账属性",
|
||||
)
|
||||
async def get_source_gis_feature_properties(
|
||||
layer_key: str,
|
||||
source_fid: int,
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""获取数据库连接"""
|
||||
yield conn
|
||||
normalized_layer_key = layer_key.strip()
|
||||
if (
|
||||
not normalized_layer_key
|
||||
or len(normalized_layer_key) > 80
|
||||
or source_fid < 0
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="无效的源 GIS 要素编号")
|
||||
try:
|
||||
group = await GisAttributeRepository.get_source_feature_group(
|
||||
conn,
|
||||
normalized_layer_key,
|
||||
source_fid,
|
||||
)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="源 GIS 要素不存在")
|
||||
return SourceGisFeatureProperties(
|
||||
layer_key=normalized_layer_key,
|
||||
source_fid=source_fid,
|
||||
group=group,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Source GIS feature properties are unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询源 GIS 要素属性失败") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/elements/{element_type}/{element_id}/gis-properties",
|
||||
response_model=ElementGisProperties,
|
||||
summary="获取元素 GIS 台账属性",
|
||||
)
|
||||
async def get_element_gis_properties(
|
||||
element_type: GisElementType,
|
||||
element_id: str,
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""Return source GIS records linked to one current-project map element."""
|
||||
normalized_id = element_id.strip()
|
||||
if not normalized_id or len(normalized_id) > 160:
|
||||
raise HTTPException(status_code=422, detail="无效的元素编号")
|
||||
try:
|
||||
groups = await GisAttributeRepository.list_element_groups(
|
||||
conn,
|
||||
element_type,
|
||||
normalized_id,
|
||||
)
|
||||
return ElementGisProperties(
|
||||
element_type=element_type,
|
||||
element_id=normalized_id,
|
||||
groups=groups,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Element GIS properties are unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询 GIS 台账属性失败") from exc
|
||||
|
||||
|
||||
@router.get("/analysis/runs", summary="获取分析运行列表")
|
||||
async def get_analysis_runs(
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""
|
||||
获取所有方案信息
|
||||
|
||||
返回项目中所有方案的详细信息
|
||||
"""
|
||||
"""分页返回当前项目的持久分析运行。"""
|
||||
try:
|
||||
runs = await AnalysisRepository.list_runs(conn)
|
||||
return {"success": True, "data": runs, "count": len(runs)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}")
|
||||
runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset)
|
||||
return {
|
||||
"success": True,
|
||||
"data": runs,
|
||||
"count": len(runs),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Analysis run page is unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
|
||||
|
||||
|
||||
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
|
||||
async def get_analysis_run(
|
||||
run_id: UUID,
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""
|
||||
获取所有爆管定位结果
|
||||
|
||||
返回项目中所有的爆管定位分析结果
|
||||
"""
|
||||
"""返回当前项目的一条持久分析运行。"""
|
||||
try:
|
||||
run = await AnalysisRepository.get_run(conn, run_id)
|
||||
if run is None:
|
||||
@@ -49,28 +118,31 @@ async def get_analysis_run(
|
||||
return run
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Analysis run is unavailable", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
|
||||
|
||||
|
||||
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
|
||||
async def get_analysis_results(
|
||||
run_id: UUID,
|
||||
result_type: str | None = Query(default=None, description="结果类型"),
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""
|
||||
根据爆管事件ID查询爆管定位结果
|
||||
|
||||
参数:
|
||||
burst_incident: 爆管事件的唯一标识符
|
||||
"""
|
||||
"""分页返回一条分析运行的持久结果。"""
|
||||
try:
|
||||
return await AnalysisRepository.list_results(conn, run_id, result_type)
|
||||
except Exception as e:
|
||||
return await AnalysisRepository.list_results(
|
||||
conn,
|
||||
run_id,
|
||||
result_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Analysis result page is unavailable", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"查询分析结果时发生错误: {str(e)}",
|
||||
)
|
||||
detail="查询分析结果失败",
|
||||
) from exc
|
||||
|
||||
@@ -146,6 +146,7 @@ async def optimize_sensor_placement_scheme(
|
||||
sensor_count=payload.sensor_count,
|
||||
min_diameter=payload.min_diameter,
|
||||
created_by=current_user.username,
|
||||
sensor_type=payload.sensor_type,
|
||||
)
|
||||
run = get_sensor_placement_run(network, created["run_id"])
|
||||
return {**run, "can_edit": True}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Read-only workbench runtime aggregation."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from psycopg import AsyncConnection
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.auth.project_dependencies import (
|
||||
ProjectContext,
|
||||
get_project_context,
|
||||
get_project_pg_connection,
|
||||
)
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchAnalysisHistoryPage,
|
||||
WorkbenchPersistedAlert,
|
||||
WorkbenchRuntimeLatestAnalysis,
|
||||
WorkbenchRuntimeSnapshot,
|
||||
WorkbenchRuntimeWorkOrder,
|
||||
)
|
||||
from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.infra.db.postgresql.workbench_operations import (
|
||||
WorkbenchOperationsRepository,
|
||||
)
|
||||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||
from app.services.task_alerts import TaskAlertService
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.services.workbench_runtime import (
|
||||
build_workbench_runtime_snapshot,
|
||||
project_analysis_history_item,
|
||||
project_latest_analysis,
|
||||
project_sensor_location_map_payload,
|
||||
sensor_location_ids_for_map_projection,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _projection_transaction(conn: AsyncConnection):
|
||||
"""Isolate an optional projection so one SQL failure cannot poison others."""
|
||||
transaction = getattr(conn, "transaction", None)
|
||||
if transaction is None: # Lightweight endpoint test doubles.
|
||||
yield
|
||||
return
|
||||
async with transaction():
|
||||
yield
|
||||
|
||||
|
||||
@router.get(
|
||||
"/workbench/analysis-runs",
|
||||
response_model=WorkbenchAnalysisHistoryPage,
|
||||
)
|
||||
async def workbench_analysis_runs(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""Return a bounded, sanitized analysis history page for the workbench."""
|
||||
rows = await AnalysisRepository.list_run_summaries(
|
||||
conn,
|
||||
limit=limit + 1,
|
||||
offset=offset,
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return {
|
||||
"items": [project_analysis_history_item(run) for run in items],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"next_offset": offset + limit if has_more else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/workbench/analysis-runs/{run_id}",
|
||||
response_model=WorkbenchRuntimeLatestAnalysis,
|
||||
)
|
||||
async def workbench_analysis_run(
|
||||
run_id: UUID,
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
"""Return one authorized project's analysis run as a typed workbench artifact."""
|
||||
run = await AnalysisRepository.get_run(conn, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Analysis run not found",
|
||||
)
|
||||
results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100)
|
||||
try:
|
||||
referenced_map_payload = await _sensor_location_map_payload(conn, results)
|
||||
except Exception:
|
||||
referenced_map_payload = None
|
||||
logger.warning(
|
||||
"Workbench analysis spatial references are unavailable",
|
||||
exc_info=True,
|
||||
)
|
||||
return project_latest_analysis(
|
||||
run,
|
||||
results,
|
||||
referenced_map_payload=referenced_map_payload,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/workbench/task-alert-events", response_model=TaskAlertState)
|
||||
async def push_task_alert_event(
|
||||
command: TaskAlertCommand,
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
event = TaskAlertEvent(
|
||||
**command.model_dump(),
|
||||
producer_id=f"principal:{ctx.user_id}",
|
||||
)
|
||||
return await TaskAlertService(
|
||||
TaskAlertRepository(conn, ctx.project_id), ctx
|
||||
).push(event)
|
||||
except DomainError as exc:
|
||||
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
|
||||
|
||||
|
||||
@router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot)
|
||||
async def workbench_runtime(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
analysis_runs: list[dict] | None = None
|
||||
latest_results: list[dict] | None = None
|
||||
latest_analysis_map_payload: dict | None = None
|
||||
analysis_availability = "not_configured"
|
||||
work_order_storage_configured = False
|
||||
work_orders: list[dict] | None = None
|
||||
alert_storage_configured = False
|
||||
alerts: list[dict] | None = None
|
||||
schedule_availability = "not_configured"
|
||||
work_order_availability = "not_configured"
|
||||
alert_availability = "not_configured"
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
|
||||
if analysis_runs:
|
||||
latest_results = await AnalysisRepository.list_recent_results(
|
||||
conn, analysis_runs[0]["run_id"], limit=100
|
||||
)
|
||||
analysis_availability = "ready" if analysis_runs else "not_configured"
|
||||
except Exception:
|
||||
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
|
||||
analysis_runs = None
|
||||
latest_results = None
|
||||
analysis_availability = "degraded"
|
||||
if analysis_runs and latest_results:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
latest_analysis_map_payload = await _sensor_location_map_payload(
|
||||
conn, latest_results
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Workbench analysis spatial references are unavailable",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
operations_availability = await WorkbenchOperationsRepository.availability(conn)
|
||||
work_order_storage_configured = operations_availability.work_orders
|
||||
alert_storage_configured = operations_availability.alerts
|
||||
except Exception:
|
||||
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
|
||||
schedule_availability = "degraded"
|
||||
work_order_availability = "degraded"
|
||||
alert_availability = "degraded"
|
||||
if work_order_storage_configured:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
work_order_documents = await WorkbenchOperationsRepository.list_work_orders(
|
||||
conn, str(ctx.project_id)
|
||||
)
|
||||
work_orders, invalid_work_orders = _validate_documents(
|
||||
work_order_documents, WorkbenchRuntimeWorkOrder, "work order"
|
||||
)
|
||||
work_order_availability = (
|
||||
"degraded" if invalid_work_orders else "ready"
|
||||
)
|
||||
except Exception:
|
||||
work_orders = None
|
||||
work_order_availability = "degraded"
|
||||
logger.warning("Workbench work-order projection is unavailable", exc_info=True)
|
||||
if alert_storage_configured:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
alert_documents = await WorkbenchOperationsRepository.list_alerts(
|
||||
conn, str(ctx.project_id)
|
||||
)
|
||||
alerts, invalid_alerts = _validate_documents(
|
||||
alert_documents, WorkbenchPersistedAlert, "alert"
|
||||
)
|
||||
alert_availability = "degraded" if invalid_alerts else "ready"
|
||||
except Exception:
|
||||
alerts = None
|
||||
alert_availability = "degraded"
|
||||
logger.warning("Workbench alert projection is unavailable", exc_info=True)
|
||||
return await build_workbench_runtime_snapshot(
|
||||
analysis_runs=analysis_runs,
|
||||
latest_analysis_results=latest_results,
|
||||
latest_analysis_map_payload=latest_analysis_map_payload,
|
||||
work_order_storage_configured=work_order_storage_configured,
|
||||
work_orders=work_orders,
|
||||
alert_storage_configured=alert_storage_configured,
|
||||
persisted_alerts=alerts,
|
||||
scenario_availability=analysis_availability,
|
||||
artifact_availability=analysis_availability,
|
||||
schedule_availability=schedule_availability,
|
||||
work_order_availability=work_order_availability,
|
||||
alert_availability=alert_availability,
|
||||
projection_degraded=False,
|
||||
)
|
||||
|
||||
|
||||
async def _sensor_location_map_payload(
|
||||
conn: AsyncConnection, results: list[dict]
|
||||
) -> dict | None:
|
||||
node_ids = sensor_location_ids_for_map_projection(results)
|
||||
if not node_ids:
|
||||
return None
|
||||
map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids)
|
||||
return project_sensor_location_map_payload(node_ids, map_points)
|
||||
|
||||
|
||||
def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]:
|
||||
valid: list[dict] = []
|
||||
rejected = 0
|
||||
for document in documents:
|
||||
try:
|
||||
valid.append(model.model_validate(document).model_dump())
|
||||
except ValidationError:
|
||||
rejected += 1
|
||||
logger.warning("Workbench %s document was rejected", label)
|
||||
return valid, rejected
|
||||
@@ -17,6 +17,7 @@ from app.api.v1.endpoints import (
|
||||
sensor_placement,
|
||||
simulation,
|
||||
web_search,
|
||||
workbench_runtime,
|
||||
)
|
||||
from app.api.v1.endpoints.components import (
|
||||
controls,
|
||||
@@ -85,6 +86,11 @@ burst_run_access = Depends(require_permission(BURST_RUN))
|
||||
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
|
||||
|
||||
# Core services
|
||||
api_router.include_router(
|
||||
workbench_runtime.router,
|
||||
tags=["Workbench Runtime"],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
api_router.include_router(access.router, tags=["Access Control"])
|
||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||
api_router.include_router(
|
||||
|
||||
@@ -9,7 +9,6 @@ class Settings(BaseSettings):
|
||||
ENVIRONMENT: str = "production"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
NETWORK_NAME: str = "default_network"
|
||||
|
||||
# 敏感配置加密密钥 (Fernet)
|
||||
DATABASE_ENCRYPTION_KEY: str = ""
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
"""A domain rejection with a stable API error payload."""
|
||||
|
||||
def __init__(self, code: str, message: str, *, refs: list | None = None,
|
||||
recovery: str = "none", status: int = 422):
|
||||
super().__init__(message)
|
||||
self.code, self.message, self.status = code, message, status
|
||||
self.refs, self.recovery = refs or [], recovery
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {"code": self.code, "message": self.message,
|
||||
"retryable": self.recovery == "retry_same_request",
|
||||
"related_refs": self.refs, "recovery_action": self.recovery,
|
||||
"retry_after_seconds": None}
|
||||
|
||||
|
||||
def require(condition: bool, code: str, message: str, **kwargs) -> None:
|
||||
if not condition:
|
||||
raise DomainError(code, message, **kwargs)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Schemas for project-scoped source GIS attributes."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
GisElementType = Literal[
|
||||
"pipes",
|
||||
"junctions",
|
||||
"valves",
|
||||
"reservoirs",
|
||||
"pumps",
|
||||
"tanks",
|
||||
"scada",
|
||||
]
|
||||
|
||||
|
||||
class GisAttributeGroup(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dataset_key: str
|
||||
dataset_title: str
|
||||
source_date: date
|
||||
layer_key: str
|
||||
layer_name: str
|
||||
source_fid: int
|
||||
match_method: Literal["node_pair", "geometry", "source"]
|
||||
match_distance_m: float | None = None
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ElementGisProperties(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
element_type: GisElementType
|
||||
element_id: str
|
||||
groups: list[GisAttributeGroup] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SourceGisFeatureProperties(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
layer_key: str
|
||||
source_fid: int
|
||||
group: GisAttributeGroup
|
||||
@@ -25,7 +25,7 @@ class SensorPlacementOptimizeRequest(BaseModel):
|
||||
pattern=r"^[^/\\\x00]+$",
|
||||
)
|
||||
run_name: str = Field(..., min_length=1, max_length=64)
|
||||
sensor_type: Literal["pressure"]
|
||||
sensor_type: Literal["pressure", "water_quality"]
|
||||
method: Literal["sensitivity", "kmeans"]
|
||||
sensor_count: int = Field(..., gt=0, le=200)
|
||||
min_diameter: int = Field(default=0, ge=0)
|
||||
@@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel):
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
status: str
|
||||
sensor_type: Literal["pressure", "water_quality"] = "pressure"
|
||||
method: Literal["sensitivity", "kmeans"] | None = None
|
||||
sensor_locations: list[str]
|
||||
sensor_points: list[SensorPointResponse]
|
||||
can_edit: bool = False
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Small strict value types shared by independent backend features."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, model_validator
|
||||
|
||||
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
StrictBool = Annotated[bool, Field(strict=True)]
|
||||
PositiveInt = Annotated[int, Field(strict=True, ge=1)]
|
||||
NonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
|
||||
|
||||
|
||||
def _datetime_wire_value(value: object) -> datetime | str:
|
||||
if not isinstance(value, (datetime, str)):
|
||||
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
|
||||
return value
|
||||
|
||||
|
||||
WireDateTime = Annotated[datetime, BeforeValidator(_datetime_wire_value)]
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
|
||||
ObjectType = Literal[
|
||||
"analysis_task",
|
||||
"goal",
|
||||
"state_snapshot",
|
||||
"action_contract",
|
||||
"validation",
|
||||
"run",
|
||||
"outcome",
|
||||
"evidence",
|
||||
"artifact",
|
||||
"operation",
|
||||
]
|
||||
|
||||
|
||||
class ObjectRef(StrictModel):
|
||||
object_type: ObjectType
|
||||
object_id: UUID
|
||||
revision: PositiveInt
|
||||
|
||||
|
||||
class TimeRange(StrictModel):
|
||||
start: WireDateTime
|
||||
end: WireDateTime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_range(self) -> "TimeRange":
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("timestamps must include a timezone")
|
||||
if self.start >= self.end:
|
||||
raise ValueError("start must be before end")
|
||||
return self
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Idempotent alert events emitted by scheduled and other backend tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
|
||||
|
||||
|
||||
class TaskAlertCommand(StrictModel):
|
||||
event_id: UUID
|
||||
alert_id: NonEmptyStr
|
||||
event_type: Literal["raised", "acknowledged", "resolved"]
|
||||
occurred_at: WorkbenchDateTime
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
severity: Literal["warning", "critical"]
|
||||
|
||||
|
||||
class TaskAlertEvent(TaskAlertCommand):
|
||||
producer_id: NonEmptyStr
|
||||
|
||||
|
||||
class TaskAlertState(StrictModel):
|
||||
alert_id: NonEmptyStr
|
||||
revision: PositiveInt
|
||||
producer_id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
severity: Literal["warning", "critical"]
|
||||
status: Literal["active", "acknowledged", "resolved"]
|
||||
updated_at: WorkbenchDateTime
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Typed read-only projections consumed by the supply workbench."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import AwareDatetime, BeforeValidator, JsonValue, model_validator
|
||||
|
||||
from app.domain.schemas.shared import (
|
||||
NonEmptyStr,
|
||||
NonNegativeInt,
|
||||
PositiveInt,
|
||||
StrictBool,
|
||||
StrictModel,
|
||||
)
|
||||
|
||||
|
||||
def _workbench_datetime_wire_value(value: object):
|
||||
from datetime import datetime
|
||||
|
||||
if not isinstance(value, (datetime, str)):
|
||||
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
|
||||
return value
|
||||
|
||||
|
||||
WorkbenchDateTime = Annotated[
|
||||
AwareDatetime, BeforeValidator(_workbench_datetime_wire_value)
|
||||
]
|
||||
ProjectionAvailability = Literal["ready", "not_configured", "degraded"]
|
||||
|
||||
|
||||
class WorkbenchRuntimeCondition(StrictModel):
|
||||
id: NonEmptyStr
|
||||
task_id: Literal["backend-schedule"]
|
||||
session_id: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal["pending", "running", "completed", "warning", "error"]
|
||||
risk_level: Literal["normal", "attention", "critical"]
|
||||
scheduled_at: WorkbenchDateTime
|
||||
updated_at: WorkbenchDateTime
|
||||
detail: NonEmptyStr
|
||||
recommendation: NonEmptyStr | None
|
||||
evidence: list[NonEmptyStr]
|
||||
model_name: Literal["TJWater scheduler"]
|
||||
|
||||
|
||||
class WorkbenchAlertSource(StrictModel):
|
||||
task_type: Literal["scheduled_task", "analysis_task", "other_task"]
|
||||
task_id: NonEmptyStr
|
||||
run_id: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeAlert(StrictModel):
|
||||
id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
severity: Literal["warning", "critical"]
|
||||
|
||||
|
||||
class WorkbenchPersistedAlert(StrictModel):
|
||||
alert_id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
severity: Literal["warning", "critical"]
|
||||
status: Literal["active", "acknowledged", "resolved"]
|
||||
producer_id: NonEmptyStr | None = None
|
||||
updated_at: WorkbenchDateTime | None = None
|
||||
|
||||
|
||||
class WorkbenchRuntimeScenario(StrictModel):
|
||||
id: NonEmptyStr
|
||||
run_id: NonEmptyStr
|
||||
name: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
status: Literal["draft", "active", "review"]
|
||||
run_type: NonEmptyStr
|
||||
run_type_label: NonEmptyStr
|
||||
run_status: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class WorkbenchRuntimeAnalysisFact(StrictModel):
|
||||
label: NonEmptyStr
|
||||
value: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeAnalysisResult(StrictModel):
|
||||
result_type: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
facts: list[WorkbenchRuntimeAnalysisFact]
|
||||
|
||||
|
||||
class WorkbenchRuntimeLatestAnalysis(StrictModel):
|
||||
run_id: NonEmptyStr
|
||||
name: NonEmptyStr
|
||||
run_type: NonEmptyStr
|
||||
run_type_label: NonEmptyStr
|
||||
status: NonEmptyStr
|
||||
status_label: NonEmptyStr
|
||||
created_by: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
started_at: WorkbenchDateTime
|
||||
results: list[WorkbenchRuntimeAnalysisResult]
|
||||
map_payload: dict[str, JsonValue] | None
|
||||
|
||||
|
||||
class WorkbenchAnalysisHistoryItem(StrictModel):
|
||||
run_id: NonEmptyStr
|
||||
name: NonEmptyStr
|
||||
run_type: NonEmptyStr
|
||||
run_type_label: NonEmptyStr
|
||||
status: NonEmptyStr
|
||||
status_label: NonEmptyStr
|
||||
created_by: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
started_at: WorkbenchDateTime
|
||||
has_results: StrictBool
|
||||
|
||||
|
||||
class WorkbenchAnalysisHistoryPage(StrictModel):
|
||||
items: list[WorkbenchAnalysisHistoryItem]
|
||||
limit: PositiveInt
|
||||
offset: NonNegativeInt
|
||||
next_offset: NonNegativeInt | None
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrderStage(StrictModel):
|
||||
id: NonEmptyStr
|
||||
sequence: PositiveInt
|
||||
title: NonEmptyStr
|
||||
status: Literal["done", "current", "pending", "blocked", "skipped"]
|
||||
owner: NonEmptyStr
|
||||
time_label: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrderSubtask(StrictModel):
|
||||
id: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal["pending", "running", "blocked", "completed", "cancelled"]
|
||||
scheduled_at: WorkbenchDateTime
|
||||
location: NonEmptyStr
|
||||
assignee: NonEmptyStr
|
||||
duration_minutes: PositiveInt
|
||||
reply_window_minutes: PositiveInt
|
||||
stages: list[WorkbenchRuntimeWorkOrderStage]
|
||||
reply_requirements: list[NonEmptyStr]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_stages(self):
|
||||
if not self.stages:
|
||||
raise ValueError("subtask stages must not be empty")
|
||||
if [stage.sequence for stage in self.stages] != list(
|
||||
range(1, len(self.stages) + 1)
|
||||
):
|
||||
raise ValueError("subtask stages must use contiguous sequence numbers")
|
||||
if len({stage.id for stage in self.stages}) != len(self.stages):
|
||||
raise ValueError("subtask stage ids must be unique")
|
||||
if sum(stage.status == "current" for stage in self.stages) > 1:
|
||||
raise ValueError("subtask stages may contain at most one current stage")
|
||||
return self
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrder(StrictModel):
|
||||
id: NonEmptyStr
|
||||
code: NonEmptyStr
|
||||
scheduled_at: WorkbenchDateTime
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal["pending", "running", "awaiting_reply", "completed"]
|
||||
risk_level: Literal["normal", "attention", "critical"]
|
||||
updated_at: WorkbenchDateTime
|
||||
detail: NonEmptyStr
|
||||
recommendation: NonEmptyStr | None
|
||||
duration_minutes: PositiveInt
|
||||
source: NonEmptyStr
|
||||
dispatcher: NonEmptyStr
|
||||
priority: Literal["normal", "urgent"]
|
||||
subtasks: list[WorkbenchRuntimeWorkOrderSubtask]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_subtasks(self):
|
||||
if not self.subtasks:
|
||||
raise ValueError("work order subtasks must not be empty")
|
||||
if len({subtask.id for subtask in self.subtasks}) != len(self.subtasks):
|
||||
raise ValueError("work order subtask ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class WorkbenchRuntimeSnapshot(StrictModel):
|
||||
availability: ProjectionAvailability
|
||||
source: Literal["none", "scheduled_tasks"]
|
||||
scenario_availability: ProjectionAvailability
|
||||
artifact_availability: ProjectionAvailability
|
||||
schedule_availability: ProjectionAvailability
|
||||
work_order_availability: ProjectionAvailability
|
||||
alert_availability: ProjectionAvailability
|
||||
generated_at: WorkbenchDateTime
|
||||
conditions: list[WorkbenchRuntimeCondition]
|
||||
alerts: list[WorkbenchRuntimeAlert]
|
||||
scenarios: list[WorkbenchRuntimeScenario]
|
||||
latest_analysis: WorkbenchRuntimeLatestAnalysis | None
|
||||
work_orders: list[WorkbenchRuntimeWorkOrder]
|
||||
@@ -101,7 +101,11 @@ class AnalysisRepository:
|
||||
return cur.fetchone()
|
||||
|
||||
@staticmethod
|
||||
async def list_runs(conn: AsyncConnection) -> list[dict]:
|
||||
async def list_runs(
|
||||
conn: AsyncConnection, *, limit: int = 100, offset: int = 0
|
||||
) -> list[dict]:
|
||||
if not 1 <= limit <= 100 or offset < 0:
|
||||
raise ValueError("analysis run page is out of range")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
@@ -109,7 +113,52 @@ class AnalysisRepository:
|
||||
started_at, status, parameters
|
||||
FROM analysis.runs
|
||||
ORDER BY created_at DESC, run_id
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def list_recent_runs(
|
||||
conn: AsyncConnection, *, limit: int = 100
|
||||
) -> list[dict]:
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("recent analysis run limit must be between 1 and 100")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT run_id, name, run_type, created_by, created_at,
|
||||
started_at, status, parameters
|
||||
FROM analysis.runs
|
||||
ORDER BY created_at DESC, run_id
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def list_run_summaries(
|
||||
conn: AsyncConnection, *, limit: int = 21, offset: int = 0
|
||||
) -> list[dict]:
|
||||
if not 1 <= limit <= 101 or offset < 0:
|
||||
raise ValueError("analysis run summary page is out of range")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT r.run_id, r.name, r.run_type, r.created_by,
|
||||
r.created_at, r.started_at, r.status,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM analysis.results AS result
|
||||
WHERE result.run_id = r.run_id
|
||||
) AS has_results
|
||||
FROM analysis.runs AS r
|
||||
ORDER BY r.created_at DESC, r.run_id
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@@ -129,20 +178,75 @@ class AnalysisRepository:
|
||||
|
||||
@staticmethod
|
||||
async def list_results(
|
||||
conn: AsyncConnection, run_id: UUID, result_type: str | None = None
|
||||
conn: AsyncConnection,
|
||||
run_id: UUID,
|
||||
result_type: str | None = None,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
if not 1 <= limit <= 500 or offset < 0:
|
||||
raise ValueError("analysis result page is out of range")
|
||||
query = """
|
||||
SELECT result_id, run_id, result_type, node_id, link_id,
|
||||
payload, created_at
|
||||
FROM analysis.results
|
||||
WHERE run_id = %s
|
||||
"""
|
||||
params: tuple[UUID] | tuple[UUID, str] = (run_id,)
|
||||
params: tuple = (run_id,)
|
||||
if result_type is not None:
|
||||
query += " AND result_type = %s"
|
||||
params = (run_id, result_type)
|
||||
query += " ORDER BY created_at, result_id"
|
||||
query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s"
|
||||
params += (limit, offset)
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def list_recent_results(
|
||||
conn: AsyncConnection, run_id: UUID, *, limit: int = 100
|
||||
) -> list[dict]:
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("recent analysis result limit must be between 1 and 100")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT result_id, run_id, result_type, node_id, link_id,
|
||||
payload, created_at
|
||||
FROM (
|
||||
SELECT result_id, run_id, result_type, node_id, link_id,
|
||||
payload, created_at
|
||||
FROM analysis.results
|
||||
WHERE run_id = %s
|
||||
ORDER BY created_at DESC, result_id DESC
|
||||
LIMIT %s
|
||||
) AS recent_results
|
||||
ORDER BY created_at, result_id
|
||||
""",
|
||||
(run_id, limit),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def list_junction_map_points(
|
||||
conn: AsyncConnection, node_ids: list[str]
|
||||
) -> list[dict]:
|
||||
if not node_ids:
|
||||
return []
|
||||
if len(node_ids) > 500:
|
||||
raise ValueError("analysis map projection supports at most 500 junctions")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT g.id AS node_id,
|
||||
ST_X(ST_Transform(g.geom, 4326)) AS longitude,
|
||||
ST_Y(ST_Transform(g.geom, 4326)) AS latitude
|
||||
FROM gis.junctions AS g
|
||||
WHERE g.id = ANY(%s)
|
||||
ORDER BY g.id
|
||||
""",
|
||||
(node_ids,),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Read-only access to imported source GIS attributes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
class GisAttributeRepository:
|
||||
@staticmethod
|
||||
async def list_element_groups(
|
||||
conn: AsyncConnection,
|
||||
element_type: str,
|
||||
element_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT to_regclass('source_gis.element_links') AS relation"
|
||||
)
|
||||
availability = await cur.fetchone()
|
||||
if not availability or availability["relation"] is None:
|
||||
return []
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
dataset.dataset_key,
|
||||
dataset.title AS dataset_title,
|
||||
dataset.source_date,
|
||||
layer.layer_key,
|
||||
layer.display_name AS layer_name,
|
||||
feature.source_fid,
|
||||
link.match_method,
|
||||
link.distance_m AS match_distance_m,
|
||||
feature.attributes
|
||||
FROM source_gis.element_links AS link
|
||||
JOIN source_gis.features AS feature
|
||||
ON feature.layer_key = link.layer_key
|
||||
AND feature.source_fid = link.source_fid
|
||||
JOIN source_gis.layers AS layer
|
||||
ON layer.layer_key = feature.layer_key
|
||||
JOIN source_gis.datasets AS dataset
|
||||
ON dataset.dataset_key = layer.dataset_key
|
||||
WHERE link.element_type = %s
|
||||
AND link.element_id = %s
|
||||
ORDER BY dataset.source_date DESC, layer.display_name, feature.source_fid
|
||||
""",
|
||||
(element_type, element_id),
|
||||
)
|
||||
return [dict(row) for row in await cur.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_source_feature_group(
|
||||
conn: AsyncConnection,
|
||||
layer_key: str,
|
||||
source_fid: int,
|
||||
) -> dict[str, Any] | None:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
dataset.dataset_key,
|
||||
dataset.title AS dataset_title,
|
||||
dataset.source_date,
|
||||
layer.layer_key,
|
||||
layer.display_name AS layer_name,
|
||||
feature.source_fid,
|
||||
'source' AS match_method,
|
||||
NULL::double precision AS match_distance_m,
|
||||
feature.attributes
|
||||
FROM source_gis.features AS feature
|
||||
JOIN source_gis.layers AS layer
|
||||
ON layer.layer_key = feature.layer_key
|
||||
JOIN source_gis.datasets AS dataset
|
||||
ON dataset.dataset_key = layer.dataset_key
|
||||
WHERE feature.layer_key = %s
|
||||
AND feature.source_fid = %s
|
||||
""",
|
||||
(layer_key, source_fid),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -24,6 +24,8 @@ def _placement_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"sensor_type": payload.get("sensor_type", "pressure"),
|
||||
"method": payload.get("method"),
|
||||
"sensor_locations": locations,
|
||||
}
|
||||
|
||||
@@ -57,11 +59,15 @@ def create_sensor_placement(
|
||||
min_diameter: int,
|
||||
created_by: str,
|
||||
sensor_locations: list[str],
|
||||
sensor_type: str = "pressure",
|
||||
method: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
run_id = uuid4()
|
||||
payload = {
|
||||
"sensor_number": len(sensor_locations),
|
||||
"minimum_diameter": min_diameter,
|
||||
"sensor_type": sensor_type,
|
||||
"method": method,
|
||||
"sensor_locations": sensor_locations,
|
||||
}
|
||||
with project_connection(name) as conn, conn.transaction():
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""PostgreSQL persistence for task-produced alert events and current state."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
|
||||
class TaskAlertRepository:
|
||||
def __init__(self, connection: AsyncConnection, project_id):
|
||||
self.connection = connection
|
||||
self.project_id = str(project_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
try:
|
||||
async with self.connection.transaction():
|
||||
yield self
|
||||
except UndefinedTable as exc:
|
||||
raise DomainError(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"Project alert storage is not provisioned",
|
||||
status=503,
|
||||
) from exc
|
||||
|
||||
async def lock_alert(self, alert_id: str) -> None:
|
||||
await self.connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
|
||||
(f"tjwater-alert:{self.project_id}:{alert_id}",),
|
||||
)
|
||||
|
||||
async def get_event(self, producer_id: str, event_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.alert_events
|
||||
WHERE project_id=%s AND producer_id=%s AND event_id=%s""",
|
||||
(self.project_id, producer_id, event_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def get_alert(self, alert_id: str) -> tuple[int, dict] | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT revision,document FROM workbench.alerts
|
||||
WHERE project_id=%s AND alert_id=%s""",
|
||||
(self.project_id, alert_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return (row["revision"], row["document"]) if row else None
|
||||
|
||||
async def save_event(self, event: dict, resulting_state: dict) -> None:
|
||||
document = event | {"resulting_state": resulting_state}
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.alert_events
|
||||
(project_id,producer_id,event_id,alert_id,event_type,occurred_at,document)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
self.project_id,
|
||||
event["producer_id"],
|
||||
event["event_id"],
|
||||
event["alert_id"],
|
||||
event["event_type"],
|
||||
event["occurred_at"],
|
||||
Jsonb(document),
|
||||
),
|
||||
)
|
||||
|
||||
async def save_alert(self, state: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.alerts
|
||||
(project_id,alert_id,revision,status,occurred_at,updated_at,document)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,alert_id) DO UPDATE SET
|
||||
revision=excluded.revision,status=excluded.status,
|
||||
updated_at=excluded.updated_at,document=excluded.document""",
|
||||
(
|
||||
self.project_id,
|
||||
state["alert_id"],
|
||||
state["revision"],
|
||||
state["status"],
|
||||
state["occurred_at"],
|
||||
state["updated_at"],
|
||||
Jsonb({key: value for key, value in state.items() if key != "revision"}),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Optional project-local schedule, work-order and alert projections."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkbenchOperationsAvailability:
|
||||
work_orders: bool
|
||||
alerts: bool
|
||||
schedules: bool = False
|
||||
|
||||
|
||||
class WorkbenchOperationsRepository:
|
||||
"""Read provisioned workbench records without creating or mutating storage."""
|
||||
|
||||
@staticmethod
|
||||
async def availability(
|
||||
conn: AsyncConnection,
|
||||
) -> WorkbenchOperationsAvailability:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
to_regclass('workbench.work_orders') IS NOT NULL AS work_orders,
|
||||
to_regclass('workbench.alerts') IS NOT NULL AS alerts
|
||||
"""
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return WorkbenchOperationsAvailability(
|
||||
work_orders=bool(row and row["work_orders"]),
|
||||
alerts=bool(row and row["alerts"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def list_work_orders(
|
||||
conn: AsyncConnection, project_id: str, *, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT document
|
||||
FROM workbench.work_orders
|
||||
WHERE project_id = %s
|
||||
ORDER BY updated_at DESC, work_order_id
|
||||
LIMIT %s
|
||||
""",
|
||||
(project_id, limit),
|
||||
)
|
||||
return [row["document"] for row in await cur.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def list_alerts(
|
||||
conn: AsyncConnection, project_id: str, *, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT document
|
||||
FROM workbench.alerts
|
||||
WHERE project_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY occurred_at DESC, alert_id
|
||||
LIMIT %s
|
||||
""",
|
||||
(project_id, limit),
|
||||
)
|
||||
return [row["document"] for row in await cur.fetchall()]
|
||||
@@ -94,6 +94,89 @@ class GeoServerAdminClient:
|
||||
)
|
||||
return True
|
||||
|
||||
def feature_type_exists(
|
||||
self,
|
||||
*,
|
||||
workspace: str,
|
||||
datastore: str,
|
||||
layer: str,
|
||||
) -> bool:
|
||||
response = self._client.get(
|
||||
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}.json"
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
if response.is_error:
|
||||
raise GeoServerProvisioningError(
|
||||
f"GeoServer feature type check failed with HTTP {response.status_code}"
|
||||
)
|
||||
return True
|
||||
|
||||
def publish_feature_type(
|
||||
self,
|
||||
*,
|
||||
workspace: str,
|
||||
datastore: str,
|
||||
layer: str,
|
||||
title: str,
|
||||
map_bbox: tuple[float, float, float, float],
|
||||
) -> None:
|
||||
minx, miny, maxx, maxy = map_bbox
|
||||
payload = {
|
||||
"featureType": {
|
||||
"name": layer,
|
||||
"nativeName": layer,
|
||||
"title": title,
|
||||
"srs": "EPSG:3857",
|
||||
"projectionPolicy": "FORCE_DECLARED",
|
||||
"enabled": True,
|
||||
"advertised": True,
|
||||
"nativeBoundingBox": {
|
||||
"minx": minx,
|
||||
"miny": miny,
|
||||
"maxx": maxx,
|
||||
"maxy": maxy,
|
||||
"crs": "EPSG:3857",
|
||||
},
|
||||
"latLonBoundingBox": {
|
||||
"minx": _longitude(minx),
|
||||
"miny": _latitude(miny),
|
||||
"maxx": _longitude(maxx),
|
||||
"maxy": _latitude(maxy),
|
||||
"crs": "EPSG:4326",
|
||||
},
|
||||
}
|
||||
}
|
||||
if self.feature_type_exists(
|
||||
workspace=workspace,
|
||||
datastore=datastore,
|
||||
layer=layer,
|
||||
):
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}",
|
||||
json=payload,
|
||||
)
|
||||
else:
|
||||
self._request(
|
||||
"POST",
|
||||
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes",
|
||||
json=payload,
|
||||
)
|
||||
self._set_client_cache(workspace, layer)
|
||||
|
||||
def truncate_layer_cache(self, workspace: str, layer: str) -> None:
|
||||
self._request(
|
||||
"POST",
|
||||
"/gwc/rest/masstruncate",
|
||||
content=(
|
||||
"<truncateLayer><layerName>"
|
||||
f"{workspace}:{layer}"
|
||||
"</layerName></truncateLayer>"
|
||||
),
|
||||
headers={"Content-Type": "text/xml"},
|
||||
)
|
||||
|
||||
def create_project_workspace(
|
||||
self,
|
||||
*,
|
||||
|
||||
+9
-8
@@ -28,13 +28,14 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("TJWater CloudService is starting...")
|
||||
logger.info("**********************************************************")
|
||||
|
||||
yield
|
||||
# 清理资源
|
||||
await project_connection_manager.close_all()
|
||||
close_all_timescale_pools()
|
||||
close_all_project_pools()
|
||||
await close_metadata_engine()
|
||||
logger.info("Database connections closed")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await project_connection_manager.close_all()
|
||||
close_all_timescale_pools()
|
||||
close_all_project_pools()
|
||||
await close_metadata_engine()
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
# 根据环境配置决定是否启用文档
|
||||
@@ -55,7 +56,7 @@ app = FastAPI(
|
||||
# Include Routers
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
install_problem_details_handlers(app)
|
||||
# Legcy Routers without version prefix
|
||||
# Legacy routers without version prefix
|
||||
# app.include_router(api_router)
|
||||
|
||||
# 配置中间件
|
||||
|
||||
@@ -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