feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+107 -35
View File
@@ -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
+1
View File
@@ -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}
+244
View File
@@ -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
+6
View File
@@ -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(
-1
View File
@@ -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 = ""
+22
View File
@@ -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)
+47
View File
@@ -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
+3 -1
View File
@@ -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
+58
View File
@@ -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
+38
View File
@@ -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
+209
View File
@@ -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]
+108 -4
View File
@@ -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()
+80
View File
@@ -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():
+88
View File
@@ -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()]
+83
View File
@@ -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
View File
@@ -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)
# 配置中间件
+18
View File
@@ -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()
+10
View File
@@ -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",
)
+124
View File
@@ -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,
)
+390
View File
@@ -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()