feat(scada): add project monitoring replay
This commit is contained in:
@@ -49,6 +49,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
settings=getattr(project, "settings", {}) or {},
|
||||
status=project.status,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
@@ -276,6 +277,7 @@ async def create_admin_project(
|
||||
gs_workspace=payload.gs_workspace,
|
||||
map_extent=payload.map_extent,
|
||||
map_config=payload.map_config.model_dump(exclude_unset=True),
|
||||
settings=payload.settings.model_dump(exclude_unset=True),
|
||||
status=payload.status,
|
||||
creator_user_id=current_user.id,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ async def get_project_metadata(
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
settings=getattr(project, "settings", {}) or {},
|
||||
status=project.status,
|
||||
project_role=ctx.project_role,
|
||||
)
|
||||
|
||||
@@ -320,6 +320,7 @@ async def provision_project(
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
settings=getattr(project, "settings", {}) or {},
|
||||
status=project.status,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
|
||||
@@ -567,10 +567,24 @@ async def query_operational_task_data(
|
||||
if not await service.can_read_source(task_id, query.source):
|
||||
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
|
||||
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||
snapshot = await read_task_data(
|
||||
context=ctx, query=query, credential=authorization,
|
||||
delegated=source_permission not in _task_permissions(ctx),
|
||||
)
|
||||
from app.services.scada_replay_queries import replay_snapshot_rows
|
||||
replay_context = await service.replay_context(task_id)
|
||||
if replay_context:
|
||||
if query.source != "scada_readings":
|
||||
raise HTTPException(403, "回放调查数据接口仅开放本轮 SCADA 快照,请通过 MCP 查询设备目录")
|
||||
rows = await replay_snapshot_rows(conn, ctx.project_id, replay_context, query.device_ids,
|
||||
query.start, query.end, 1001)
|
||||
from app.services.content_digest import digest
|
||||
from datetime import datetime, timezone
|
||||
snapshot = TaskDataSnapshot(
|
||||
source="scada_readings", source_ref=f"scada_replay:{replay_context['replay_id']}:{digest(rows)}",
|
||||
queried_at=datetime.now(timezone.utc), origin="synthetic", data=rows[:1000], truncated=len(rows) > 1000,
|
||||
)
|
||||
else:
|
||||
snapshot = await read_task_data(
|
||||
context=ctx, query=query, credential=authorization,
|
||||
delegated=source_permission not in _task_permissions(ctx),
|
||||
)
|
||||
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
|
||||
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
|
||||
except TaskDataError as exc:
|
||||
|
||||
@@ -35,6 +35,7 @@ async def get_project_info_endpoint(
|
||||
gs_workspace=project_detail.gs_workspace,
|
||||
map_extent=project_detail.map_extent,
|
||||
map_config=getattr(project_detail, "map_config", {}) or {},
|
||||
settings=getattr(project_detail, "settings", {}) or {},
|
||||
status=project_detail.status,
|
||||
project_role="viewer", # Default role for public access
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""SCADA replay controls. Manual steps are processed by the command request."""
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
|
||||
from app.auth.metadata_dependencies import get_metadata_repository
|
||||
from app.auth.permissions import SCADA_VIEW, SCADA_CLEAN, require_permission, permissions_for_context
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection, get_project_timescale_connection
|
||||
from app.domain.schemas.scada_replay import (
|
||||
ScadaReplayCapabilities, ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot,
|
||||
ScadaReplayEventsPage, ScadaReplayReadings,
|
||||
)
|
||||
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||
from app.domain.schemas.project_settings import ProjectSettings
|
||||
from app.services.scada_replay import ScadaReplayService, snapshot
|
||||
|
||||
router = APIRouter(prefix="/scada-replays", tags=["SCADA Replay"])
|
||||
|
||||
|
||||
async def repository(
|
||||
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
|
||||
connection=Depends(get_project_pg_connection),
|
||||
):
|
||||
return ScadaReplayRepository(connection, ctx.project_id)
|
||||
|
||||
|
||||
async def project_settings(
|
||||
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
|
||||
metadata_repo=Depends(get_metadata_repository),
|
||||
) -> ProjectSettings:
|
||||
project = await metadata_repo.get_project_by_id(ctx.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "当前项目不存在")
|
||||
return ProjectSettings.model_validate(project.settings or {})
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=ScadaReplayCapabilities)
|
||||
async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_permission(SCADA_VIEW)),
|
||||
settings=Depends(project_settings)):
|
||||
available = await repo.available()
|
||||
current = await repo.current() if available else None
|
||||
monitoring = settings.scada_monitoring
|
||||
monitoring_ready = bool(
|
||||
monitoring
|
||||
and len(monitoring.pressure_device_ids) >= 5
|
||||
and monitoring.outlet_flow_device_ids
|
||||
)
|
||||
if monitoring_ready:
|
||||
monitoring_reason = None
|
||||
elif monitoring is None:
|
||||
monitoring_reason = "项目尚未配置压力与出厂边界流量监测角色"
|
||||
elif len(monitoring.pressure_device_ids) < 5:
|
||||
monitoring_reason = "爆管侦测至少需要 5 个压力监测设备"
|
||||
else:
|
||||
monitoring_reason = "项目尚未配置出厂边界流量设备"
|
||||
return ScadaReplayCapabilities(
|
||||
available=available, can_write=SCADA_CLEAN in permissions_for_context(ctx),
|
||||
reason=None if available else "需先部署 SCADA 回放存储结构",
|
||||
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
|
||||
burst_monitoring_ready=monitoring_ready,
|
||||
burst_monitoring_reason=monitoring_reason,
|
||||
current=snapshot(current) if current else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ScadaReplaySnapshot, status_code=201)
|
||||
async def create_replay(command: ScadaReplayCreate, repo=Depends(repository),
|
||||
ctx=Depends(require_permission(SCADA_CLEAN)), settings=Depends(project_settings)):
|
||||
return await ScadaReplayService(repo, ctx).create(
|
||||
command,
|
||||
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
|
||||
monitoring_config=settings.scada_monitoring,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{replay_id}", response_model=ScadaReplaySnapshot)
|
||||
async def get_replay(replay_id: UUID, repo=Depends(repository)):
|
||||
await ScadaReplayService(repo, None).ensure_storage()
|
||||
return snapshot(await repo.get(replay_id))
|
||||
|
||||
|
||||
@router.post("/{replay_id}/commands", response_model=ScadaReplaySnapshot)
|
||||
async def replay_command(replay_id: UUID, command: ScadaReplayCommand, repo=Depends(repository),
|
||||
ctx=Depends(require_permission(SCADA_CLEAN)),
|
||||
timescale=Depends(get_project_timescale_connection)):
|
||||
service = ScadaReplayService(repo, ctx, timescale)
|
||||
result = await service.command(replay_id, command)
|
||||
if command.action in {"step", "retry"} and result.step_pending:
|
||||
return await service.process_pending_step(replay_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{replay_id}/events", response_model=ScadaReplayEventsPage)
|
||||
async def replay_events(replay_id: UUID, offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200),
|
||||
repo=Depends(repository)):
|
||||
await ScadaReplayService(repo, None).ensure_storage()
|
||||
await repo.get(replay_id)
|
||||
items = await repo.events(replay_id, offset, limit + 1)
|
||||
return ScadaReplayEventsPage(items=items[:limit], next_offset=offset + limit if len(items) > limit else None)
|
||||
|
||||
|
||||
@router.get("/{replay_id}/observations", response_model=ScadaReplayReadings)
|
||||
async def replay_observations(replay_id: UUID, through: datetime, repo=Depends(repository)):
|
||||
await ScadaReplayService(repo, None).ensure_storage()
|
||||
document = await repo.get(replay_id)
|
||||
if (through.tzinfo is None or not document.get("delivered_until")
|
||||
or through > datetime.fromisoformat(document["delivered_until"])
|
||||
or through < datetime.fromisoformat(document["start_time"])):
|
||||
raise HTTPException(422, "回看仅允许本轮已读取的时间区间")
|
||||
return ScadaReplayReadings(
|
||||
items=await repo.latest_outputs(replay_id, through),
|
||||
through=through,
|
||||
)
|
||||
@@ -1,10 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from psycopg import AsyncConnection
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.domain.schemas.scada import ScadaLatestReadingsResponse
|
||||
from app.domain.schemas.scada import ScadaLatestReadingsResponse, ScadaRawPage
|
||||
from app.auth.project_dependencies import get_project_context, ProjectContext
|
||||
from app.services.scada_raw import read_raw_page, validate_window
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from .dependencies import get_postgres_connection, get_timescale_connection
|
||||
@@ -13,6 +16,46 @@ router = APIRouter()
|
||||
SCADA_BATCH_MAX_ITEMS = 10_000
|
||||
|
||||
|
||||
@router.get("/timeseries/scada-readings/raw", response_model=ScadaRawPage,
|
||||
summary="按时间顺序分页读取 SCADA 原始值")
|
||||
async def get_scada_raw(
|
||||
start_time: datetime, end_time: datetime, device_ids: str,
|
||||
limit: int = Query(500, ge=1, le=500),
|
||||
cursor: str | None = Query(None, max_length=2048),
|
||||
replay_id: UUID | None = None,
|
||||
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
) -> ScadaRawPage:
|
||||
try:
|
||||
validate_window(start_time, end_time)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
ids = list(dict.fromkeys(item.strip() for item in device_ids.split(",") if item.strip()))
|
||||
if not ids or len(ids) > 500:
|
||||
raise HTTPException(422, "请选择 1 至 500 个 SCADA 设备")
|
||||
existing = await ScadaInfoRepository.get_existing_device_ids(postgres_conn, ids)
|
||||
if set(ids) != existing:
|
||||
raise HTTPException(422, "查询包含当前项目未配置的 SCADA 设备")
|
||||
try:
|
||||
through = None
|
||||
if replay_id is not None:
|
||||
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||
from app.services.scada_replay import ScadaReplayService
|
||||
repo = ScadaReplayRepository(postgres_conn, ctx.project_id)
|
||||
await ScadaReplayService(repo, ctx).ensure_storage()
|
||||
replay = await repo.get(replay_id)
|
||||
if not set(ids) <= set(replay["config"]["device_ids"]):
|
||||
raise HTTPException(403, "设备超出回放范围")
|
||||
if start_time < datetime.fromisoformat(replay["start_time"]) or end_time > datetime.fromisoformat(replay["end_time"]):
|
||||
raise HTTPException(422, "查询超出选定回放区间")
|
||||
through = datetime.fromisoformat(replay["virtual_now"])
|
||||
return await read_raw_page(conn, project_id=str(ctx.project_id), device_ids=ids,
|
||||
start=start_time, end=end_time, limit=limit, cursor=cursor, through=through)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
|
||||
|
||||
class ScadaReadingBatchItem(BaseModel):
|
||||
time: datetime
|
||||
device_id: str = Field(min_length=1)
|
||||
|
||||
Reference in New Issue
Block a user