diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py
index 872499c..3fc19b6 100644
--- a/app/api/v1/endpoints/admin_metadata.py
+++ b/app/api/v1/endpoints/admin_metadata.py
@@ -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,
)
diff --git a/app/api/v1/endpoints/meta.py b/app/api/v1/endpoints/meta.py
index 7b8be7d..96fcba6 100644
--- a/app/api/v1/endpoints/meta.py
+++ b/app/api/v1/endpoints/meta.py
@@ -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,
)
diff --git a/app/api/v1/endpoints/model_import.py b/app/api/v1/endpoints/model_import.py
index 329ba4a..f1cd2f9 100644
--- a/app/api/v1/endpoints/model_import.py
+++ b/app/api/v1/endpoints/model_import.py
@@ -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,
diff --git a/app/api/v1/endpoints/operational_tasks.py b/app/api/v1/endpoints/operational_tasks.py
index 5c31c8b..68a2540 100644
--- a/app/api/v1/endpoints/operational_tasks.py
+++ b/app/api/v1/endpoints/operational_tasks.py
@@ -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:
diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py
index de2d599..3f2f4a1 100644
--- a/app/api/v1/endpoints/project.py
+++ b/app/api/v1/endpoints/project.py
@@ -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
)
diff --git a/app/api/v1/endpoints/scada_replay.py b/app/api/v1/endpoints/scada_replay.py
new file mode 100644
index 0000000..4c39215
--- /dev/null
+++ b/app/api/v1/endpoints/scada_replay.py
@@ -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,
+ )
diff --git a/app/api/v1/endpoints/timeseries/scada.py b/app/api/v1/endpoints/timeseries/scada.py
index f02976a..e4951f3 100644
--- a/app/api/v1/endpoints/timeseries/scada.py
+++ b/app/api/v1/endpoints/timeseries/scada.py
@@ -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)
diff --git a/app/api/v1/router.py b/app/api/v1/router.py
index 7108288..3c5311f 100644
--- a/app/api/v1/router.py
+++ b/app/api/v1/router.py
@@ -61,8 +61,10 @@ from app.auth.permissions import (
require_method_permission,
require_permission,
)
+from app.api.v1.endpoints import scada_replay
api_router = APIRouter()
+api_router.include_router(scada_replay.router)
webgis_access = Depends(
require_method_permission(
diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py
index eef0e5b..8d87326 100644
--- a/app/domain/schemas/admin_metadata.py
+++ b/app/domain/schemas/admin_metadata.py
@@ -2,9 +2,10 @@ from datetime import datetime
from typing import Literal
from uuid import UUID
-from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
+from app.domain.schemas.project_settings import ProjectSettings
BusinessRole = Literal["admin", "service", "user"]
@@ -72,6 +73,7 @@ class ProjectMemberResponse(BaseModel):
class AdminProjectCreateRequest(BaseModel):
+ settings: ProjectSettings = Field(default_factory=ProjectSettings)
name: str = Field(..., min_length=1, max_length=100)
code: str = Field(..., min_length=1, max_length=50)
description: str | None = None
@@ -82,6 +84,10 @@ class AdminProjectCreateRequest(BaseModel):
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
+ settings: ProjectSettings | None = Field(
+ default=None,
+ description="按顶层键合并项目配置;省略的键保持原值,嵌套对象整体替换",
+ )
name: str | None = Field(default=None, min_length=1, max_length=100)
code: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
@@ -89,8 +95,16 @@ class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
map_extent: dict | None = None
status: ProjectStatus | None = None
+ @field_validator("settings")
+ @classmethod
+ def reject_null_settings(cls, value: ProjectSettings | None) -> ProjectSettings:
+ if value is None:
+ raise ValueError("settings must be an object")
+ return value
+
class AdminProjectResponse(BaseModel):
+ settings: ProjectSettings = Field(default_factory=ProjectSettings)
project_id: UUID
name: str
code: str
diff --git a/app/domain/schemas/metadata.py b/app/domain/schemas/metadata.py
index b358edd..7c72d3b 100644
--- a/app/domain/schemas/metadata.py
+++ b/app/domain/schemas/metadata.py
@@ -4,9 +4,11 @@ from uuid import UUID
from pydantic import BaseModel, Field
from app.domain.schemas.project_map_config import ProjectMapConfig
+from app.domain.schemas.project_settings import ProjectSettings
class ProjectMetaResponse(BaseModel):
+ settings: ProjectSettings = Field(default_factory=ProjectSettings)
project_id: UUID
name: str
code: str
diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py
index 9bdeaeb..c64231a 100644
--- a/app/domain/schemas/operational_task.py
+++ b/app/domain/schemas/operational_task.py
@@ -41,12 +41,19 @@ ExecutionStatus = Literal[
]
+class ScadaReplayTaskContext(StrictModel):
+ replay_id: UUID
+ knowledge_cutoff: WorkbenchDateTime
+ batch_sequence: PositiveInt
+
+
class TaskSource(StrictModel):
kind: TaskSourceKind
external_id: NonEmptyStr | None = None
external_revision: PositiveInt | None = None
event_kind: NonEmptyStr | None = None
deleted_at: WorkbenchDateTime | None = None
+ replay_context: ScadaReplayTaskContext | None = None
class TaskTrigger(StrictModel):
diff --git a/app/domain/schemas/project_settings.py b/app/domain/schemas/project_settings.py
new file mode 100644
index 0000000..a5d84e5
--- /dev/null
+++ b/app/domain/schemas/project_settings.py
@@ -0,0 +1,44 @@
+from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
+
+
+class ScadaMonitoringSettings(BaseModel):
+ """Project-owned device roles used by the manual SCADA monitoring demo."""
+
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
+
+ rule_version: str = Field(
+ default="burst-monitor-v1",
+ min_length=1,
+ max_length=80,
+ pattern=r"^[a-zA-Z0-9_.-]+$",
+ )
+ pressure_device_ids: list[str] = Field(default_factory=list, max_length=20)
+ outlet_flow_device_ids: list[str] = Field(default_factory=list, max_length=20)
+
+ @model_validator(mode="after")
+ def distinct_roles(self):
+ for values in (self.pressure_device_ids, self.outlet_flow_device_ids):
+ if len(set(values)) != len(values) or any(not value.strip() for value in values):
+ raise ValueError("SCADA 监测角色中的设备标识不能为空或重复")
+ if set(self.pressure_device_ids) & set(self.outlet_flow_device_ids):
+ raise ValueError("同一设备不能同时作为压力与出厂边界流量")
+ return self
+
+
+class ProjectSettings(BaseModel):
+ """Extensible, non-secret project parameters stored in MetaDB as JSON."""
+
+ model_config = ConfigDict(extra="allow", allow_inf_nan=False)
+
+ __pydantic_extra__: dict[str, JsonValue] = Field(init=False)
+ scada_monitoring_interval_seconds: int | None = Field(
+ default=None,
+ strict=True,
+ ge=60,
+ le=31_536_000,
+ description="SCADA 主动监测演示步长,单位秒;null 表示未配置",
+ )
+ scada_monitoring: ScadaMonitoringSettings | None = Field(
+ default=None,
+ description="主动监测使用的项目设备角色;不创建定时任务",
+ )
diff --git a/app/domain/schemas/scada.py b/app/domain/schemas/scada.py
index 1b0b379..4873146 100644
--- a/app/domain/schemas/scada.py
+++ b/app/domain/schemas/scada.py
@@ -1,8 +1,8 @@
from datetime import datetime
+from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
-
class ScadaDeviceResponse(BaseModel):
"""Project SCADA metadata keyed by the canonical device identifier."""
@@ -47,3 +47,20 @@ class ScadaLatestReadingsResponse(BaseModel):
offset: int = Field(ge=0)
observed_count: int = Field(ge=0)
latest_observed_at: datetime | None = None
+
+
+class ScadaRawReading(BaseModel):
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
+
+ time: datetime
+ device_id: str
+ monitored_value: float | None
+ quality: Literal["unvalidated", "missing", "invalid"] = "unvalidated"
+
+
+class ScadaRawPage(BaseModel):
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
+
+ items: list[ScadaRawReading]
+ next_cursor: str | None
+ scanned_through: datetime
diff --git a/app/domain/schemas/scada_replay.py b/app/domain/schemas/scada_replay.py
new file mode 100644
index 0000000..9fe2962
--- /dev/null
+++ b/app/domain/schemas/scada_replay.py
@@ -0,0 +1,142 @@
+"""Persisted replay controls and explicit, unit-aware demonstration rules."""
+from datetime import datetime, timedelta
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+
+class ReplayModel(BaseModel):
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
+
+
+class ScadaReplayRule(ReplayModel):
+ rule_id: str = Field(min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$")
+ device_ids: list[str] = Field(min_length=1, max_length=500)
+ kind: Literal["relative_change", "bounds"]
+ direction: Literal["both", "increase", "decrease"] = "both"
+ threshold: float | None = Field(None, gt=0)
+ lower: float | None = None
+ upper: float | None = None
+ unit: str = Field(min_length=1, max_length=40)
+ metric_name: str = Field(default="", max_length=80)
+ minimum_samples: int = Field(3, ge=1, le=96)
+ window_samples: int = Field(4, ge=1, le=96)
+ consecutive_samples: int = Field(1, ge=1, le=20)
+ recovery_samples: int = Field(2, ge=1, le=20)
+ minimum_devices: int = Field(1, ge=1, le=500)
+
+ @model_validator(mode="after")
+ def parameters(self):
+ if len(set(self.device_ids)) != len(self.device_ids):
+ raise ValueError("规则内设备不能重复")
+ if self.minimum_devices > len(self.device_ids):
+ raise ValueError("同时异常设备数超过规则设备数")
+ if self.kind == "relative_change" and (self.threshold is None or self.minimum_samples > self.window_samples):
+ raise ValueError("变化率规则需要阈值和足够的历史窗口")
+ if self.kind == "bounds" and self.lower is None and self.upper is None:
+ raise ValueError("上下限规则至少配置一个阈值")
+ if self.lower is not None and self.upper is not None and self.lower >= self.upper:
+ raise ValueError("规则下限必须小于上限")
+ return self
+
+
+class ScadaQualityRange(ReplayModel):
+ lower: float | None = None
+ upper: float | None = None
+
+ @model_validator(mode="after")
+ def bounds(self):
+ if self.lower is not None and self.upper is not None and self.lower >= self.upper:
+ raise ValueError("仪表量程下限必须小于上限")
+ return self
+
+
+class ScadaReplayCreate(ReplayModel):
+ request_id: UUID
+ start_time: datetime
+ end_time: datetime
+ device_ids: list[str] = Field(min_length=1, max_length=500)
+ rules: list[ScadaReplayRule] = Field(min_length=1, max_length=30)
+ quality_ranges: dict[str, ScadaQualityRange] = Field(default_factory=dict)
+
+ @model_validator(mode="after")
+ def configuration(self):
+ if (self.start_time.tzinfo is None or self.end_time.tzinfo is None
+ or not timedelta(0) < self.end_time - self.start_time <= timedelta(days=31)):
+ raise ValueError("请选择带时区、最长 31 天的有效时间区间")
+ ids = set(self.device_ids)
+ if len(ids) != len(self.device_ids) or any(not value.strip() for value in ids):
+ raise ValueError("设备标识不能为空或重复")
+ if len({rule.rule_id for rule in self.rules}) != len(self.rules):
+ raise ValueError("规则标识不能重复")
+ if any(not set(rule.device_ids) <= ids for rule in self.rules) or not set(self.quality_ranges) <= ids:
+ raise ValueError("规则及量程设备必须属于本次回放")
+ return self
+
+
+class ScadaReplayCommand(ReplayModel):
+ request_id: UUID
+ expected_revision: int = Field(ge=1)
+ action: Literal["step", "stop", "retry"]
+
+
+class ScadaMonitoringResult(ReplayModel):
+ monitoring_id: UUID
+ period_start: datetime
+ period_end: datetime
+ knowledge_cutoff: datetime
+ batch_sequence: int = Field(ge=1)
+ rule_version: str = Field(min_length=1, max_length=80)
+ status: Literal["normal", "attention", "insufficient_data", "suspected_burst"]
+ detail: str = Field(min_length=1, max_length=1000)
+ task_id: UUID | None = None
+ pressure: dict
+ outlet_flow: dict
+ burst_detection: dict
+ role_gaps: list[str] = Field(default_factory=list)
+
+
+class ScadaReplaySnapshot(ReplayModel):
+ replay_id: UUID
+ project_id: UUID
+ revision: int
+ status: Literal["ready", "paused", "completed", "stopped", "failed"]
+ start_time: datetime
+ end_time: datetime
+ monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000)
+ step_pending: bool = False
+ monitoring_count: int = 0
+ last_monitoring: ScadaMonitoringResult | None = None
+ virtual_now: datetime
+ delivered_until: datetime | None = None
+ processed_until: datetime | None = None
+ processed_count: int = 0
+ anomaly_count: int = 0
+ devices: list[dict]
+ rules: list[ScadaReplayRule]
+ latest: list[dict]
+ active_events: list[dict]
+ error: str | None = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class ScadaReplayCapabilities(ReplayModel):
+ available: bool
+ can_write: bool
+ reason: str | None = None
+ monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000)
+ burst_monitoring_ready: bool = False
+ burst_monitoring_reason: str | None = None
+ current: ScadaReplaySnapshot | None = None
+
+
+class ScadaReplayEventsPage(ReplayModel):
+ items: list[dict]
+ next_offset: int | None
+
+
+class ScadaReplayReadings(ReplayModel):
+ items: list[dict]
+ through: datetime
diff --git a/app/domain/schemas/task_alert.py b/app/domain/schemas/task_alert.py
index 3b8a5b4..b5ab710 100644
--- a/app/domain/schemas/task_alert.py
+++ b/app/domain/schemas/task_alert.py
@@ -5,7 +5,7 @@ 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
+from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchScadaReplaySource, WorkbenchDateTime
class TaskAlertCommand(StrictModel):
@@ -14,7 +14,7 @@ class TaskAlertCommand(StrictModel):
event_type: Literal["raised", "acknowledged", "resolved"]
occurred_at: WorkbenchDateTime
condition_id: NonEmptyStr | None
- source: WorkbenchAlertSource
+ source: WorkbenchAlertSource | WorkbenchScadaReplaySource
title: NonEmptyStr
description: NonEmptyStr
severity: Literal["warning", "critical"]
@@ -29,7 +29,7 @@ class TaskAlertState(StrictModel):
revision: PositiveInt
producer_id: NonEmptyStr
condition_id: NonEmptyStr | None
- source: WorkbenchAlertSource
+ source: WorkbenchAlertSource | WorkbenchScadaReplaySource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
diff --git a/app/domain/schemas/workbench_runtime.py b/app/domain/schemas/workbench_runtime.py
index a25080b..edc07b3 100644
--- a/app/domain/schemas/workbench_runtime.py
+++ b/app/domain/schemas/workbench_runtime.py
@@ -49,10 +49,17 @@ class WorkbenchAlertSource(StrictModel):
run_id: NonEmptyStr
+class WorkbenchScadaReplaySource(StrictModel):
+ kind: Literal["scada_replay"]
+ replay_id: NonEmptyStr
+ rule_id: NonEmptyStr
+ batch_sequence: int = Field(ge=1)
+
+
class WorkbenchRuntimeAlert(StrictModel):
id: NonEmptyStr
condition_id: NonEmptyStr | None
- source: WorkbenchAlertSource
+ source: WorkbenchAlertSource | WorkbenchScadaReplaySource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
@@ -62,7 +69,7 @@ class WorkbenchRuntimeAlert(StrictModel):
class WorkbenchPersistedAlert(StrictModel):
alert_id: NonEmptyStr
condition_id: NonEmptyStr | None
- source: WorkbenchAlertSource
+ source: WorkbenchAlertSource | WorkbenchScadaReplaySource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
diff --git a/app/infra/db/metadb/models.py b/app/infra/db/metadb/models.py
index c510e6d..72df5b4 100644
--- a/app/infra/db/metadb/models.py
+++ b/app/infra/db/metadb/models.py
@@ -44,6 +44,7 @@ class Project(Base):
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
+ settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow
diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py
index 41b1f07..945a6a7 100644
--- a/app/infra/db/metadb/repositories/metadata_repository.py
+++ b/app/infra/db/metadb/repositories/metadata_repository.py
@@ -67,6 +67,7 @@ class ProjectDetail:
map_extent: Optional[dict]
status: str
map_config: dict = field(default_factory=dict)
+ settings: dict = field(default_factory=dict)
@dataclass(frozen=True)
@@ -223,6 +224,7 @@ class MetadataRepository:
status: str,
creator_user_id: UUID | None = None,
map_config: dict | None = None,
+ settings: dict | None = None,
) -> models.Project:
project = models.Project(
id=uuid4(),
@@ -233,6 +235,7 @@ class MetadataRepository:
map_extent=map_extent,
status=status,
map_config=map_config or {},
+ settings=settings or {},
created_at=_utcnow(),
updated_at=_utcnow(),
)
@@ -263,6 +266,7 @@ class MetadataRepository:
timescale_dsn: str,
pool_min_size: int = 1,
pool_max_size: int = 4,
+ settings: dict | None = None,
) -> models.Project:
"""Atomically expose a fully provisioned project and both DB routes."""
business_secret = _encrypt_database_secret(business_dsn)
@@ -276,6 +280,7 @@ class MetadataRepository:
map_extent=map_extent,
status="active",
map_config={},
+ settings=settings or {},
created_at=_utcnow(),
updated_at=_utcnow(),
)
@@ -333,6 +338,8 @@ class MetadataRepository:
setattr(project, field, updates[field])
if "map_config" in updates:
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
+ if "settings" in updates:
+ project.settings = {**(project.settings or {}), **updates["settings"]}
project.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(project)
@@ -350,6 +357,7 @@ class MetadataRepository:
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=project.map_config or {},
+ settings=project.settings or {},
status=project.status,
)
diff --git a/app/infra/db/postgresql/scada_replays.py b/app/infra/db/postgresql/scada_replays.py
new file mode 100644
index 0000000..21724c3
--- /dev/null
+++ b/app/infra/db/postgresql/scada_replays.py
@@ -0,0 +1,188 @@
+"""Short-transaction storage for replay control and resumable batches."""
+from contextlib import asynccontextmanager
+from datetime import datetime, timezone
+
+from fastapi import HTTPException
+from psycopg.types.json import Jsonb
+
+
+class ScadaReplayRepository:
+ def __init__(self, connection, project_id):
+ self.connection, self.project_id = connection, str(project_id)
+ self.lock_key = f"scada-replay:{self.project_id}"
+
+ async def available(self) -> bool:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT to_regclass('peripheral_sample.scada_replays') IS NOT NULL "
+ "AND to_regclass('peripheral_sample.scada_replay_batches') IS NOT NULL AS ready"
+ )
+ return (await cur.fetchone())["ready"]
+
+ @asynccontextmanager
+ async def control(self):
+ async with self.connection.transaction():
+ await self.connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (self.lock_key,))
+ yield
+
+ @asynccontextmanager
+ async def processing(self):
+ # Session lock outlives short transactions, never an open SQL transaction
+ # across Timescale IO. A dead connection automatically releases ownership.
+ async with self.connection.transaction():
+ cur = await self.connection.execute("SELECT pg_try_advisory_lock(hashtextextended(%s,0)) AS owned", (self.lock_key,))
+ owned = (await cur.fetchone())["owned"]
+ try:
+ yield owned
+ finally:
+ if owned:
+ async with self.connection.transaction():
+ await self.connection.execute("SELECT pg_advisory_unlock(hashtextextended(%s,0))", (self.lock_key,))
+
+ async def get(self, replay_id) -> dict:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id=%s",
+ (self.project_id, str(replay_id)),
+ )
+ row = await cur.fetchone()
+ if not row:
+ raise HTTPException(404, "当前项目不存在此 SCADA 回放")
+ return row["document"]
+
+ async def current(self) -> dict | None:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s"
+ " ORDER BY created_at DESC LIMIT 1", (self.project_id,),
+ )
+ row = await cur.fetchone()
+ return row["document"] if row else None
+
+ async def by_request(self, request_id) -> dict | None:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND request_id=%s",
+ (self.project_id, str(request_id)),
+ )
+ row = await cur.fetchone()
+ return row["document"] if row else None
+
+ async def active_other(self, replay_id) -> bool:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT 1 FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id<>%s "
+ "AND status IN ('ready','paused') LIMIT 1",
+ (self.project_id, str(replay_id)),
+ )
+ return await cur.fetchone() is not None
+
+ async def insert(self, document: dict):
+ await self.connection.execute(
+ "INSERT INTO peripheral_sample.scada_replays(project_id,replay_id,request_id,status,document) "
+ "VALUES(%s,%s,%s,%s,%s)",
+ (self.project_id, document["replay_id"], document["config"]["request_id"], document["status"], Jsonb(document)),
+ )
+
+ async def save(self, document: dict):
+ document["updated_at"] = datetime.now(timezone.utc).isoformat()
+ await self.connection.execute(
+ "UPDATE peripheral_sample.scada_replays SET status=%s,document=%s,updated_at=now() "
+ "WHERE project_id=%s AND replay_id=%s",
+ (document["status"], Jsonb(document), self.project_id, document["replay_id"]),
+ )
+
+ async def pending_batch(self, replay_id) -> dict | None:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT document FROM peripheral_sample.scada_replay_batches "
+ "WHERE project_id=%s AND replay_id=%s AND stage<>'detected' ORDER BY sequence LIMIT 1",
+ (self.project_id, str(replay_id)),
+ )
+ row = await cur.fetchone()
+ return row["document"] if row else None
+
+ async def save_batch(self, replay_id, batch: dict):
+ await self.connection.execute(
+ "INSERT INTO peripheral_sample.scada_replay_batches(project_id,replay_id,sequence,stage,document) "
+ "VALUES(%s,%s,%s,%s,%s) ON CONFLICT(project_id,replay_id,sequence) "
+ "DO UPDATE SET stage=excluded.stage,document=excluded.document",
+ (self.project_id, str(replay_id), batch["sequence"], batch["stage"], Jsonb(batch)),
+ )
+
+ async def events(self, replay_id, offset=0, limit=100) -> list[dict]:
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT e.value AS event FROM peripheral_sample.scada_replay_batches b "
+ "CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'events','[]'::jsonb)) e "
+ "WHERE b.project_id=%s AND b.replay_id=%s ORDER BY b.sequence DESC LIMIT %s OFFSET %s",
+ (self.project_id, str(replay_id), limit, offset),
+ )
+ return [r["event"] for r in await cur.fetchall()]
+
+ async def monitoring_outputs(
+ self,
+ replay_id,
+ device_ids: list[str],
+ start: datetime,
+ end: datetime,
+ before_sequence: int,
+ ) -> list[dict]:
+ """Read only committed replay outputs available before the current batch."""
+ if not device_ids:
+ return []
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT r.value AS reading FROM peripheral_sample.scada_replay_batches b "
+ "CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
+ "WHERE b.project_id=%s AND b.replay_id=%s AND b.stage='detected' AND b.sequence<%s "
+ "AND r.value->>'device_id'=ANY(%s) "
+ "AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
+ "ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'",
+ (self.project_id, str(replay_id), before_sequence, device_ids, start, end),
+ )
+ return [row["reading"] for row in await cur.fetchall()]
+
+ async def detected_outputs(
+ self,
+ replay_id,
+ device_ids: list[str],
+ start: datetime,
+ end: datetime,
+ before_sequence: int,
+ *,
+ limit: int | None = None,
+ offset: int = 0,
+ ) -> list[dict]:
+ """Read immutable detector outputs available to a bounded replay investigation."""
+ statement = (
+ "SELECT r.value AS reading FROM peripheral_sample.scada_replay_batches b "
+ "CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
+ "WHERE b.project_id=%s AND b.replay_id=%s AND b.sequence<=%s AND b.stage='detected' "
+ "AND r.value->>'device_id'=ANY(%s) "
+ "AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
+ "ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'"
+ )
+ params: tuple = (
+ self.project_id, str(replay_id), before_sequence, device_ids, start, end,
+ )
+ if limit is not None:
+ statement += " LIMIT %s OFFSET %s"
+ params += (limit, offset)
+ async with self.connection.transaction():
+ cur = await self.connection.execute(statement, params)
+ return [row["reading"] for row in await cur.fetchall()]
+
+ async def latest_outputs(self, replay_id, through: datetime, limit: int = 500) -> list[dict]:
+ """Return the latest committed cleaned output for each replay device."""
+ async with self.connection.transaction():
+ cur = await self.connection.execute(
+ "SELECT DISTINCT ON(r.value->>'device_id') r.value AS reading "
+ "FROM peripheral_sample.scada_replay_batches b "
+ "CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
+ "WHERE b.project_id=%s AND b.replay_id=%s AND b.stage IN ('cleaned','detected') "
+ "AND (r.value->>'time')::timestamptz<=%s "
+ "ORDER BY r.value->>'device_id',(r.value->>'time')::timestamptz DESC LIMIT %s",
+ (self.project_id, str(replay_id), through, limit),
+ )
+ return [row["reading"] for row in await cur.fetchall()]
diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py
index 89c047d..d5f0ae5 100644
--- a/app/infra/db/timescaledb/repositories/scada.py
+++ b/app/infra/db/timescaledb/repositories/scada.py
@@ -9,6 +9,86 @@ class ScadaRepository:
VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"})
RESULT_COLUMNS = "time, device_id, monitored_value, cleaned_value"
+ @staticmethod
+ async def get_warmup_values(
+ conn: AsyncConnection,
+ device_ids: list[str],
+ before: datetime,
+ per_device_limit: int,
+ ) -> list[dict]:
+ """Return the latest raw values before a replay starts for detector warmup."""
+ async with conn.cursor() as cur:
+ await cur.execute(
+ "SELECT d.device_id,h.monitored_value FROM unnest(%s::text[]) d(device_id) "
+ "CROSS JOIN LATERAL (SELECT monitored_value,time FROM scada.measurements "
+ "WHERE device_id=d.device_id AND time<%s ORDER BY time DESC LIMIT %s) h "
+ "ORDER BY d.device_id,h.time",
+ (device_ids, before, per_device_limit),
+ )
+ return [dict(row) for row in await cur.fetchall()]
+
+ @staticmethod
+ async def get_raw_page(
+ conn: AsyncConnection, device_ids: list[str], start: datetime,
+ end: datetime, through: datetime, limit: int,
+ after: tuple[datetime, str] | None = None, *, frame: bool = False,
+ ) -> list[dict]:
+ """Read raw only, with a stable composite key and a bounded frame/page."""
+ predicate = "device_id=ANY(%s) AND time>=%s AND time<%s AND time<=%s"
+ params: tuple = (device_ids, start, end, through)
+ if after is not None:
+ predicate += " AND (time,device_id)>(%s,%s)"
+ params += after
+ if frame:
+ statement = (
+ "WITH next_time AS (SELECT min(time) AS time FROM scada.measurements WHERE "
+ + predicate + ") SELECT m.time,m.device_id,m.monitored_value,"
+ "md5(COALESCE(m.monitored_value::text,'NULL')) AS source_hash "
+ "FROM scada.measurements m WHERE m.device_id=ANY(%s) "
+ "AND m.time=(SELECT time FROM next_time) ORDER BY m.time,m.device_id LIMIT %s"
+ )
+ params += (device_ids, limit)
+ else:
+ statement = (
+ "SELECT time,device_id,monitored_value,"
+ "md5(COALESCE(monitored_value::text,'NULL')) AS source_hash "
+ "FROM scada.measurements WHERE " + predicate
+ + " ORDER BY time,device_id LIMIT %s"
+ )
+ params += (limit,)
+ async with conn.cursor() as cur:
+ await cur.execute(statement, params)
+ return [dict(row) for row in await cur.fetchall()]
+
+ @staticmethod
+ async def apply_replay_cleaning(conn: AsyncConnection, rows: list[dict]) -> None:
+ """Guard every raw version; update corrected values without inserting raw."""
+ if not rows:
+ return
+ async with conn.transaction():
+ async with conn.cursor() as cur:
+ await cur.execute(
+ "SELECT m.time,m.device_id,md5(COALESCE(m.monitored_value::text,'NULL')) AS source_hash "
+ "FROM scada.measurements m JOIN unnest(%s::timestamptz[],%s::text[]) "
+ "b(time,device_id) USING(time,device_id) FOR UPDATE OF m",
+ ([r["time"] for r in rows], [r["device_id"] for r in rows]),
+ )
+ existing = {(r["time"].isoformat(), r["device_id"]): r["source_hash"]
+ for r in await cur.fetchall()}
+ if len(existing) != len(rows) or any(
+ existing.get((datetime.fromisoformat(r["time"]).isoformat(), r["device_id"]))
+ != r["source_hash"] for r in rows
+ ):
+ raise ValueError("SOURCE_CHANGED: SCADA 原始值在处理期间发生变化")
+ await cur.execute(
+ "UPDATE scada.measurements m SET cleaned_value=b.value "
+ "FROM unnest(%s::timestamptz[],%s::text[],%s::double precision[]) "
+ "b(time,device_id,value) WHERE m.time=b.time AND m.device_id=b.device_id "
+ "AND m.cleaned_value IS DISTINCT FROM b.value",
+ ([r["time"] for r in rows], [r["device_id"] for r in rows],
+ [r["cleaned_value"] for r in rows]),
+ )
+
@staticmethod
async def insert_scada_batch(conn: AsyncConnection, data: List[dict]):
if not data:
diff --git a/app/mcp/analysis_service.py b/app/mcp/analysis_service.py
index b346b14..4fed2d5 100644
--- a/app/mcp/analysis_service.py
+++ b/app/mcp/analysis_service.py
@@ -98,3 +98,19 @@ async def bounded_calculation(function, *args, **kwargs):
finally:
_slots.release()
return await asyncio.to_thread(run)
+
+
+async def calculate_replay_burst_location(
+ parameters: dict[str, Any], *, network: str, username: str,
+) -> dict[str, Any]:
+ """Run location with Server-built frozen replay series and no live SCADA query."""
+ from app.services.burst_location import run_burst_location_by_network
+
+ return await bounded_calculation(
+ run_burst_location_by_network,
+ network=network,
+ username=username,
+ scheme_name=None,
+ final_candidates_csv_path=None,
+ **parameters,
+ )
diff --git a/app/mcp/contexts.py b/app/mcp/contexts.py
index 8f95a41..0ab3141 100644
--- a/app/mcp/contexts.py
+++ b/app/mcp/contexts.py
@@ -30,6 +30,7 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
task = await repo.get_task(str(binding.task_id))
if not task:
raise HTTPException(404, "Task is not in this project")
+ binding.replay_context = task.get("source", {}).get("replay_context")
if binding.purpose == "task_collaboration":
return
if OPERATIONAL_TASK_EXECUTE not in permissions:
diff --git a/app/mcp/models.py b/app/mcp/models.py
index 109d73e..2b6ea23 100644
--- a/app/mcp/models.py
+++ b/app/mcp/models.py
@@ -291,5 +291,6 @@ class BoundContext(BaseModel):
task_id: UUID | None = None
run_id: UUID | None = None
execution_request_id: UUID | None = None
+ replay_context: dict | None = None
expires_at: datetime
revoked_at: datetime | None = None
diff --git a/app/mcp/query_service.py b/app/mcp/query_service.py
index cc5676a..23dcde7 100644
--- a/app/mcp/query_service.py
+++ b/app/mcp/query_service.py
@@ -66,8 +66,23 @@ def page_result(rows, args):
async def authorize_query(
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
) -> None:
+ replay_context = getattr(binding, "replay_context", None)
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
+ if replay_context:
+ from app.services.scada_replay_queries import check_replay_window
+ allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
+ "query_scada_readings", "get_network_schema", "run_burst_location",
+ "get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
+ if tool.name not in allowed:
+ raise HTTPException(403, "回放调查当前仅开放冻结 SCADA 证据与模型目录查询;请使用 query_scada_readings")
+ if tool.name == "query_scada_readings":
+ check_replay_window(replay_context, args.start, args.end)
+ if tool.name == "run_burst_location":
+ if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
+ raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
+ check_replay_window(replay_context, args.scada_burst_start, args.scada_burst_end)
+ check_replay_window(replay_context, args.scada_normal_start, args.scada_normal_end)
permissions = permissions_for_context(scope.project)
if tool.mutating:
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
@@ -149,7 +164,37 @@ async def execute_query(
gaps = []
source_ref = "mcp:" + str(uuid4())
evidence_id = None
- if tool.computation:
+ replay_context = getattr(binding, "replay_context", None)
+ if name == "query_scada_readings" and replay_context:
+ from app.services.scada_replay_queries import replay_snapshot_rows
+ rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
+ args.device_ids, args.start, args.end, args.limit + 1, args.offset)
+ data, page, truncated = page_result(rows, args)
+ origin = "synthetic"
+ gaps.append("历史数据回放证据,仅覆盖该轮调查已提交的批次,不表示现场实时观测。")
+ elif name == "run_burst_location" and replay_context:
+ from app.mcp.analysis_service import calculate_replay_burst_location
+ from app.services.scada_replay_queries import replay_burst_location_inputs
+
+ parameters = await replay_burst_location_inputs(
+ conn, scope.project.project_id, replay_context, args,
+ )
+ await conn.commit()
+ async with scope.simulation_routing():
+ result = await calculate_replay_burst_location(
+ parameters,
+ network=scope.project.project_code,
+ username=scope.user.username,
+ )
+ data = {
+ "parameters": args.model_dump(mode="json", exclude={"context_handle"}),
+ "result": result,
+ }
+ origin = "synthetic"
+ gaps = [
+ "定位只使用任务知识截止前已固化的回放清洗输出;候选排序依赖漏量假设、模型和测点覆盖,仍需现场核实。"
+ ]
+ elif tool.computation:
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
# Context/receipt reads may have started an implicit transaction. Release
diff --git a/app/mcp/work_order_skills.py b/app/mcp/work_order_skills.py
index 12ad30b..237c1ad 100644
--- a/app/mcp/work_order_skills.py
+++ b/app/mcp/work_order_skills.py
@@ -9,6 +9,7 @@ import yaml
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
WORKFLOW_SKILLS = {
+ "scada_anomaly": "scada-anomaly",
"burst_report": "burst-report",
"water_quality_complaint": "water-quality-complaint",
"general": "appointment-follow-up",
diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py
index ec80da7..48faf8c 100644
--- a/app/services/operational_tasks.py
+++ b/app/services/operational_tasks.py
@@ -161,6 +161,14 @@ class OperationalTaskService:
)
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
+ if command.source.replay_context:
+ from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
+ replay_context = command.source.replay_context
+ replay_doc = await ScadaReplayRepository(self.repo.connection, self.context.project_id).get(replay_context.replay_id)
+ require(replay_doc.get("delivered_until") is not None
+ and replay_context.knowledge_cutoff <= datetime.fromisoformat(replay_doc["delivered_until"])
+ and replay_context.batch_sequence <= replay_doc["batch_sequence"],
+ "REPLAY_SCOPE_INVALID", "回放调查不能使用尚未读取的批次", status=422)
body = _json(command)
request_id = str(command.request_id)
scope = "task:create"
@@ -219,6 +227,27 @@ class OperationalTaskService:
await self._task_event(task_document, now)
return await self.detail(task_id)
+ async def advance_replay_investigation(self, task_id: UUID, replay_context: dict, request_id: UUID) -> None:
+ """A recovery is new evidence, coalesced through the existing run lifecycle."""
+ scope = f"task:{task_id}:replay-evidence"
+ async with self.repo.transaction():
+ if await self._request_replay(scope, request_id, replay_context):
+ return
+ await self.repo.lock(f"task:{task_id}")
+ task = await self._task(task_id)
+ prior = task["source"].get("replay_context")
+ require(prior and prior["replay_id"] == replay_context["replay_id"], "REPLAY_SCOPE_INVALID",
+ "Replay evidence belongs to another investigation", status=409)
+ require(datetime.fromisoformat(prior["knowledge_cutoff"]) <= datetime.fromisoformat(replay_context["knowledge_cutoff"]),
+ "REPLAY_SCOPE_INVALID", "Replay evidence cannot move backwards", status=409)
+ task["source"]["replay_context"] = replay_context
+ task["source"]["external_revision"] = task["source"].get("external_revision", 1) + 1
+ now = self._now()
+ await self._requeue_task(task, now)
+ await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
+ await self._task_event(task, now)
+ await self._save_request(scope, request_id, replay_context, {"task_id": str(task_id)})
+
async def list(
self, *, task_type: str | None, status: str | None, limit: int, offset: int,
peripheral_work_order_id: UUID | None = None,
@@ -336,6 +365,9 @@ class OperationalTaskService:
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
)
+ async def replay_context(self, task_id: UUID) -> dict | None:
+ return (await self._task(task_id)).get("source", {}).get("replay_context")
+
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
task = await self._task(task_id)
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
@@ -1037,6 +1069,8 @@ class OperationalTaskService:
await self.repo.lock(f"run:{command.run_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
+ require(not task.get("source", {}).get("replay_context"), "REPLAY_ACTION_DISABLED",
+ "SCADA 回放调查仅生成分析与报告,不执行真实工单或设备动作", status=409)
run = await self._active_run(task)
require(run is not None and str(run["run_id"]) == str(command.run_id)
and run["generation"] == task["execution_generation"],
diff --git a/app/services/scada_raw.py b/app/services/scada_raw.py
new file mode 100644
index 0000000..01f3ff8
--- /dev/null
+++ b/app/services/scada_raw.py
@@ -0,0 +1,66 @@
+"""Read-only chronological SCADA paging shared by REST and replay processing."""
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import math
+from datetime import datetime
+
+from app.domain.schemas.scada import ScadaRawPage, ScadaRawReading
+from app.infra.db.timescaledb.repositories.scada import ScadaRepository
+
+
+def validate_window(start: datetime, end: datetime) -> None:
+ if start.tzinfo is None or end.tzinfo is None or start >= end:
+ raise ValueError("需要带时区、结束晚于开始的 SCADA 时间区间")
+
+
+def raw_reading(row: dict) -> ScadaRawReading:
+ value = row["monitored_value"]
+ invalid = value is not None and not math.isfinite(float(value))
+ return ScadaRawReading(
+ time=row["time"], device_id=row["device_id"],
+ monitored_value=None if invalid else value,
+ quality="invalid" if invalid else "missing" if value is None else "unvalidated",
+ )
+
+
+async def read_raw_page(
+ conn, *, project_id: str, device_ids: list[str], start: datetime,
+ end: datetime, limit: int = 500, cursor: str | None = None,
+ through: datetime | None = None,
+) -> ScadaRawPage:
+ validate_window(start, end)
+ ids = sorted(set(device_ids))
+ if not ids or len(ids) > 500 or not 1 <= limit <= 500:
+ raise ValueError("请选择 1 至 500 个设备,每页最多 500 条")
+ cap = min(through or end, end)
+ if cap.tzinfo is None or cap < start:
+ raise ValueError("模拟时间不在可读取区间内")
+ signature = hashlib.sha256(json.dumps(
+ [project_id, ids, start.isoformat(), end.isoformat()], separators=(",", ":")
+ ).encode()).hexdigest()
+ after = None
+ if cursor:
+ try:
+ data = json.loads(base64.urlsafe_b64decode(cursor.encode()))
+ frozen = datetime.fromisoformat(data["through"])
+ last = datetime.fromisoformat(data["time"])
+ if (data["query"] != signature or frozen.tzinfo is None or last.tzinfo is None
+ or frozen > cap or not start <= last <= frozen or data["device"] not in ids):
+ raise ValueError()
+ cap, after = frozen, (last, data["device"])
+ except (ValueError, KeyError, TypeError, UnicodeError) as exc:
+ raise ValueError("SCADA 分页游标无效或查询范围已改变") from exc
+ rows = await ScadaRepository.get_raw_page(conn, ids, start, end, cap, limit + 1, after)
+ selected = rows[:limit]
+ next_cursor = None
+ if len(rows) > limit:
+ last = selected[-1]
+ next_cursor = base64.urlsafe_b64encode(json.dumps({
+ "query": signature, "through": cap.isoformat(),
+ "time": last["time"].isoformat(), "device": last["device_id"],
+ }, separators=(",", ":")).encode()).decode()
+ return ScadaRawPage(items=[raw_reading(row) for row in selected],
+ next_cursor=next_cursor, scanned_through=cap)
diff --git a/app/services/scada_replay.py b/app/services/scada_replay.py
new file mode 100644
index 0000000..13c8c23
--- /dev/null
+++ b/app/services/scada_replay.py
@@ -0,0 +1,397 @@
+"""Durable, project-scoped SCADA playback; raw is never inserted or rewritten."""
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from uuid import NAMESPACE_URL, uuid4, uuid5
+
+from fastapi import HTTPException
+from app.auth.permissions import permissions_for_context
+
+from app.domain.schemas.project_settings import ScadaMonitoringSettings
+from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot
+from app.infra.db.postgresql.scada import ScadaInfoRepository
+from app.infra.db.timescaledb.repositories.scada import ScadaRepository
+from app.services.content_digest import digest
+from app.services.scada_replay_processing import prepare_cleaning, detect_frame
+from app.services.scada_replay_monitoring import evaluate_monitoring_period
+
+
+def parse_time(value: str) -> datetime:
+ return datetime.fromisoformat(value)
+
+
+def snapshot(document: dict) -> ScadaReplaySnapshot:
+ return ScadaReplaySnapshot(
+ **{key: document[key] for key in (
+ "replay_id", "project_id", "revision", "status", "start_time", "end_time",
+ "virtual_now", "created_at", "updated_at",
+ )},
+ delivered_until=document.get("delivered_until"), processed_until=document.get("processed_until"),
+ processed_count=document.get("processed_count", 0), anomaly_count=document.get("anomaly_count", 0),
+ devices=document["devices"], rules=document["config"]["rules"],
+ latest=list(document.get("latest", {}).values()),
+ active_events=[state["event"] for state in document.get("rule_states", {}).values() if state.get("event")],
+ error=document.get("error"),
+ monitoring_interval_seconds=document.get("monitoring_interval_seconds"),
+ step_pending=document.get("step_pending", False),
+ monitoring_count=document.get("monitoring_count", 0),
+ last_monitoring=document.get("last_monitoring"),
+ )
+
+
+class ScadaReplayService:
+ def __init__(self, repository, context, timescale=None, *, clock=None):
+ self.repo, self.context, self.timescale = repository, context, timescale
+ self.clock = clock or (lambda: datetime.now(timezone.utc))
+
+ async def ensure_storage(self):
+ if not await self.repo.available():
+ raise HTTPException(503, "SCADA 回放存储未配置,需要应用 031_scada_replays.sql")
+
+ async def create(
+ self,
+ command: ScadaReplayCreate,
+ *,
+ monitoring_interval_seconds: int | None,
+ monitoring_config: ScadaMonitoringSettings | None = None,
+ ):
+ await self.ensure_storage()
+ async with self.repo.control():
+ previous = await self.repo.by_request(command.request_id)
+ config = command.model_dump(mode="json")
+ if previous:
+ if previous["config"] != config:
+ raise HTTPException(409, "创建请求号已用于不同回放配置")
+ return snapshot(previous)
+ if monitoring_interval_seconds is None:
+ raise HTTPException(422, "请先配置项目 settings.scada_monitoring_interval_seconds,再创建演示")
+ replay_id = str(uuid4())
+ if await self.repo.active_other(replay_id):
+ raise HTTPException(409, "当前项目已有回放,请先停止原回放再新建")
+ metadata = {row["device_id"]: row for row in await ScadaInfoRepository.get_scadas(self.repo.connection)}
+ if not set(command.device_ids) <= metadata.keys():
+ raise HTTPException(422, "设备不属于当前项目")
+ for rule in command.rules:
+ if any(metadata[device]["measurement_unit"] != rule.unit for device in rule.device_ids):
+ raise HTTPException(422, "规则单位与设备单位不一致,请分组配置")
+ if any(metadata[device]["device_type"] == "quality" for device in rule.device_ids) and not rule.metric_name.strip():
+ raise HTTPException(422, "水质规则必须明确指标名称,不能仅凭单位解释读数")
+ if monitoring_config:
+ role_ids = set(monitoring_config.pressure_device_ids) | set(monitoring_config.outlet_flow_device_ids)
+ if not role_ids <= metadata.keys():
+ raise HTTPException(422, "项目 SCADA 监测角色包含不存在的设备")
+ if any(metadata[device]["device_type"] != "pressure" for device in monitoring_config.pressure_device_ids):
+ raise HTTPException(422, "压力监测角色必须引用 pressure 设备")
+ if any(metadata[device]["device_type"] not in {"pipe_flow", "flow"}
+ for device in monitoring_config.outlet_flow_device_ids):
+ raise HTTPException(422, "出厂边界流量角色必须引用流量设备")
+ now = self.clock().isoformat()
+ document = {
+ "replay_id": replay_id, "project_id": str(self.context.project_id),
+ "created_by": str(self.context.user_id), "config": config,
+ "revision": 1, "status": "ready", "start_time": config["start_time"],
+ "end_time": config["end_time"], "virtual_now": config["start_time"],
+ "created_at": now, "updated_at": now,
+ "monitoring_interval_seconds": monitoring_interval_seconds,
+ "monitoring_config": monitoring_config.model_dump(mode="json") if monitoring_config else None,
+ "devices": [metadata[device] for device in command.device_ids],
+ "batch_sequence": 0, "processed_count": 0, "anomaly_count": 0,
+ "monitoring_count": 0, "latest": {}, "histories": {}, "rule_states": {}, "receipts": {},
+ }
+ await self.repo.insert(document)
+ return snapshot(document)
+
+ async def command(self, replay_id, command: ScadaReplayCommand):
+ await self.ensure_storage()
+ async with self.repo.control():
+ document = await self.repo.get(replay_id)
+ key, body = str(command.request_id), command.model_dump(mode="json")
+ receipt = document.setdefault("receipts", {}).get(key)
+ if receipt:
+ if receipt != body:
+ raise HTTPException(409, "命令请求号已用于不同内容")
+ return snapshot(document)
+ if command.expected_revision != document["revision"]:
+ raise HTTPException(409, "回放控制状态已更新,请刷新后重试")
+ action, status = command.action, document["status"]
+ if document.get("step_pending") and action == "step":
+ raise HTTPException(409, "当前时间步正在处理,请完成后继续")
+ if action in {"step", "retry"} and await self.repo.active_other(replay_id):
+ raise HTTPException(409, "当前项目已有另一条活动回放")
+ if action == "step" and status in {"ready", "paused"}:
+ interval = document.get("monitoring_interval_seconds")
+ if interval is None:
+ raise HTTPException(409, "旧回放未固定监测周期,请停止后新建演示")
+ start = parse_time(document["start_time"])
+ elapsed = (parse_time(document["virtual_now"]) - start).total_seconds()
+ target = start + timedelta(seconds=(int(elapsed // interval) + 1) * interval)
+ document["step_start"] = document["virtual_now"]
+ document["step_target"] = min(parse_time(document["end_time"]), target).isoformat()
+ document["status"], document["step_pending"] = "paused", True
+ elif action == "stop" and status not in {"completed", "stopped"}:
+ document["status"], document["step_pending"] = "stopped", False
+ document.pop("step_target", None)
+ document.pop("step_start", None)
+ elif action == "retry" and status == "failed":
+ if "SOURCE_CHANGED" in (document.get("error") or ""):
+ raise HTTPException(409, "原始数据发生变化,需要停止并新建回放")
+ document["status"], document["error"] = "paused", None
+ document["step_pending"] = bool(document.get("step_target"))
+ else:
+ raise HTTPException(409, "当前回放状态不支持此操作")
+ document["revision"] += 1
+ document["receipts"][key] = body
+ if len(document["receipts"]) > 256:
+ del document["receipts"][next(iter(document["receipts"]))]
+ await self.repo.save(document)
+ return snapshot(document)
+
+ async def process_pending_step(self, replay_id):
+ await self.ensure_storage()
+ async with self.repo.processing() as owned:
+ if not owned:
+ raise HTTPException(409, "当前时间步正在由另一请求处理")
+ while True:
+ document = await self.repo.get(replay_id)
+ if not document.get("step_pending"):
+ return snapshot(document)
+ try:
+ await self._advance(document)
+ except Exception as exc:
+ # Failure never discards the prepared batch; retry resumes it.
+ document = await self.repo.get(replay_id)
+ document["status"], document["step_pending"] = "failed", False
+ document["error"] = (
+ "SOURCE_CHANGED: 原始观测已改变,需要新建回放"
+ if "SOURCE_CHANGED" in str(exc)
+ else "SCADA 处理失败,已保留批次与进度;请检查服务日志后重试"
+ )
+ async with self.repo.connection.transaction():
+ await self.repo.save(document)
+ raise
+
+ async def _advance(self, document):
+ config = ScadaReplayCreate.model_validate(document["config"])
+ replay_id = document["replay_id"]
+ if not document.get("warmed"):
+ import math
+ window = max(rule.window_samples for rule in config.rules)
+ async with self.timescale.transaction():
+ warmup = await ScadaRepository.get_warmup_values(
+ self.timescale, config.device_ids, config.start_time, window,
+ )
+ for row in warmup:
+ value = row["monitored_value"]
+ bounds = config.quality_ranges.get(row["device_id"])
+ if value is not None and math.isfinite(value) and not (bounds and (
+ bounds.lower is not None and value < bounds.lower
+ or bounds.upper is not None and value > bounds.upper
+ )):
+ document["histories"].setdefault(row["device_id"], []).append(value)
+ document["warmed"] = True
+ async with self.repo.connection.transaction():
+ await self.repo.save(document)
+ batch = await self.repo.pending_batch(replay_id)
+ if batch is None:
+ step = document.get("step_pending", False)
+ virtual = parse_time(document["virtual_now"])
+ through = parse_time(document["step_target"]) if step else virtual
+ after = ((parse_time(document["cursor"][0]), document["cursor"][1])
+ if document.get("cursor") else None)
+ async with self.timescale.transaction():
+ rows = await ScadaRepository.get_raw_page(
+ self.timescale, config.device_ids, config.start_time, config.end_time,
+ through, 501, after, frame=True,
+ )
+ if len(rows) > 500:
+ raise ValueError("SCADA frame exceeds selected device bound")
+ at = rows[0]["time"] if rows else through
+ if step:
+ virtual = through
+ document["virtual_now"] = virtual.isoformat()
+ if not rows and document.get("processed_until") == at.isoformat() and not step:
+ async with self.repo.connection.transaction():
+ await self.repo.save(document)
+ return
+ sequence = document["batch_sequence"] + 1
+ refs = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
+ "source_hash": row["source_hash"]} for row in rows]
+ batch = {"sequence": sequence, "stage": "selected", "at": at.isoformat(),
+ "refs": refs, "input_digest": digest(refs), "events": []}
+ async with self.repo.connection.transaction():
+ document["batch_sequence"] = sequence
+ document["delivered_until"] = at.isoformat()
+ await self.repo.save_batch(replay_id, batch)
+ await self.repo.save(document)
+ else:
+ rows = None
+ if batch["stage"] == "selected":
+ if rows is None:
+ # Only crash recovery reloads the selected raw frame.
+ at = parse_time(batch["at"])
+ async with self.timescale.transaction():
+ rows = await ScadaRepository.get_raw_page(
+ self.timescale, config.device_ids, at, config.end_time, at, 501,
+ ) if batch["refs"] else []
+ actual = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
+ "source_hash": row["source_hash"]} for row in rows]
+ if digest(actual) != batch["input_digest"]:
+ raise ValueError("SOURCE_CHANGED: selected raw frame differs")
+ batch["outputs"] = prepare_cleaning(rows, config)
+ batch["stage"] = "clean_prepared"
+ async with self.repo.connection.transaction():
+ await self.repo.save_batch(replay_id, batch)
+ if batch["stage"] == "clean_prepared":
+ await ScadaRepository.apply_replay_cleaning(self.timescale, batch["outputs"])
+ batch["stage"] = "cleaned"
+ async with self.repo.connection.transaction():
+ await self.repo.save_batch(replay_id, batch)
+ if batch["stage"] == "cleaned":
+ result, changes = detect_frame(document, batch["outputs"], config, parse_time(batch["at"]))
+ result["processed_until"] = batch["at"]
+ target = result.get("step_target")
+ was_step = bool(target)
+ result["step_pending"] = bool(target and parse_time(batch["at"]) < parse_time(target))
+ monitoring = None
+ if was_step and not result["step_pending"]:
+ period_start = parse_time(result.get("step_start", result["start_time"]))
+ period_end = parse_time(target)
+ role = result.get("monitoring_config") or {}
+ role_ids = list(dict.fromkeys(
+ role.get("pressure_device_ids", []) + role.get("outlet_flow_device_ids", [])
+ ))
+ previous = await self.repo.monitoring_outputs(
+ replay_id, role_ids, max(config.start_time, period_end - timedelta(days=15)),
+ period_end, batch["sequence"],
+ ) if role_ids else []
+ from app.mcp.analysis_service import bounded_calculation
+ monitoring = await bounded_calculation(
+ evaluate_monitoring_period,
+ result,
+ previous + batch["outputs"],
+ period_start=period_start,
+ period_end=period_end,
+ batch_sequence=batch["sequence"],
+ )
+ result["last_monitoring"] = monitoring
+ result["monitoring_count"] = result.get("monitoring_count", 0) + 1
+ if not result["step_pending"]:
+ result.pop("step_target", None)
+ result.pop("step_start", None)
+ if batch["refs"]:
+ last = batch["refs"][-1]
+ result["cursor"] = [last["time"], last["device_id"]]
+ if parse_time(batch["at"]) >= config.end_time:
+ result["status"] = "completed"
+ batch["stage"], batch["events"] = "detected", changes
+ if monitoring is not None:
+ batch["monitoring"] = monitoring
+ async with self.repo.connection.transaction():
+ await self._create_investigations(result, changes, batch, create_tasks=not was_step)
+ if monitoring is not None:
+ await self._create_monitoring_task(result, monitoring, batch)
+ batch["monitoring"] = monitoring
+ result["last_monitoring"] = monitoring
+ await self.repo.save_batch(replay_id, batch)
+ await self.repo.save(result)
+
+ async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
+ from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
+ from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
+ from app.services.operational_tasks import OperationalTaskService
+ from app.domain.schemas.task_alert import TaskAlertEvent
+ from app.infra.db.postgresql.task_alerts import TaskAlertRepository
+ from app.services.task_alerts import TaskAlertService
+ from uuid import UUID
+ producer = "scada-replay:" + document["replay_id"]
+ alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
+ self.context, internal_producer_id=producer)
+ tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
+ self.context, permissions_for_context(self.context))
+ for event in changes:
+ if event["change"] == "raised":
+ event["batch_sequence"] = batch["sequence"]
+ document["rule_states"][event["rule_id"]]["event"]["batch_sequence"] = batch["sequence"]
+ await alerts.push(TaskAlertEvent(
+ event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
+ alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
+ occurred_at=event["occurred_at"], condition_id=None,
+ source={"kind": "scada_replay", "replay_id": document["replay_id"],
+ "rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
+ title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning",
+ ))
+ if event["change"] != "raised":
+ if event.get("task_id"):
+ await tasks.advance_replay_investigation(UUID(event["task_id"]), {
+ "replay_id": document["replay_id"], "knowledge_cutoff": batch["at"],
+ "batch_sequence": batch["sequence"],
+ }, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
+ continue
+ if event.get("category") == "data_quality":
+ continue
+ if not create_tasks:
+ continue
+ external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
+ command = OperationalTaskCreate(
+ request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
+ workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}",
+ summary=event["detail"], source=TaskSource(
+ kind="external_system", external_id=external, external_revision=1,
+ event_kind="scada_anomaly",
+ replay_context={"replay_id": document["replay_id"],
+ "knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
+ ),
+ trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"),
+ )
+ task = await tasks.create(command)
+ event["task_id"] = str(task.task_id)
+ document["rule_states"][event["rule_id"]]["event"]["task_id"] = event["task_id"]
+
+ async def _create_monitoring_task(self, document, monitoring, batch):
+ if monitoring["status"] != "suspected_burst":
+ return
+ from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
+ from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
+ from app.services.operational_tasks import OperationalTaskService
+ tasks = OperationalTaskService(
+ OperationalTaskRepository(self.repo.connection, self.context.project_id),
+ self.context,
+ permissions_for_context(self.context),
+ )
+ external = (
+ f"scada_replay:{document['replay_id']}:monitor:"
+ f"{monitoring['period_end']}:{monitoring['rule_version']}"
+ )
+ pressure = monitoring["pressure"]
+ flow = monitoring["outlet_flow"]
+ detector = monitoring["burst_detection"]
+ summary = (
+ f"步长监测疑似爆管:爆管侦测得分 {detector['target_score']:.4f};"
+ f"压力下降测点 {len(pressure['hit_device_ids'])} 个;"
+ f"出厂边界流量增加测点 {len(flow['hit_device_ids'])} 个。"
+ )
+ command = OperationalTaskCreate(
+ request_id=uuid5(NAMESPACE_URL, external),
+ task_type="event_investigation",
+ workflow="burst_report",
+ title=f"SCADA 回放疑似爆管 · {monitoring['period_end']}",
+ summary=summary,
+ source=TaskSource(
+ kind="external_system",
+ external_id=external,
+ external_revision=1,
+ event_kind="scada_burst_suspected",
+ replay_context={
+ "replay_id": document["replay_id"],
+ "knowledge_cutoff": monitoring["knowledge_cutoff"],
+ "batch_sequence": batch["sequence"],
+ },
+ ),
+ trigger=TaskTrigger(
+ kind="event",
+ occurred_at=monitoring["period_end"],
+ detail="主动步长监测的多项指标相互支持,进入爆管调查;尚未现场确诊",
+ ),
+ )
+ task = await tasks.create(command)
+ monitoring["task_id"] = str(task.task_id)
diff --git a/app/services/scada_replay_monitoring.py b/app/services/scada_replay_monitoring.py
new file mode 100644
index 0000000..eebbd5a
--- /dev/null
+++ b/app/services/scada_replay_monitoring.py
@@ -0,0 +1,210 @@
+"""Deterministic monitoring summaries built only from frozen replay outputs."""
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from statistics import median
+from typing import Any
+from uuid import NAMESPACE_URL, uuid5
+
+import pandas as pd
+
+from app.domain.schemas.scada_replay import ScadaReplayCreate
+from app.services.burst_detection import (
+ TARGET_MU,
+ TARGET_N_ESTIMATORS,
+ TARGET_RANDOM_STATE,
+ TARGET_SCORE_THRESHOLD,
+ run_burst_detection,
+)
+
+
+def _time(value: str | datetime) -> datetime:
+ return value if isinstance(value, datetime) else datetime.fromisoformat(value)
+
+
+def _valid_rows(rows: list[dict], device_id: str) -> list[dict]:
+ return sorted(
+ (
+ row for row in rows
+ if row.get("device_id") == device_id
+ and row.get("quality") in {"valid", "corrected"}
+ and row.get("cleaned_value") is not None
+ ),
+ key=lambda row: _time(row["time"]),
+ )
+
+
+def _relative_indicator(
+ rows: list[dict],
+ config: ScadaReplayCreate,
+ device_ids: list[str],
+ period_start: datetime,
+ period_end: datetime,
+ *,
+ direction: str,
+) -> dict[str, Any]:
+ details: list[dict[str, Any]] = []
+ gaps: list[dict[str, str]] = []
+ for device_id in device_ids:
+ rule = next((
+ item for item in config.rules
+ if item.kind == "relative_change" and device_id in item.device_ids
+ and item.threshold is not None
+ ), None)
+ if rule is None:
+ gaps.append({"device_id": device_id, "reason": "relative_change_rule_missing"})
+ continue
+ valid = _valid_rows(rows, device_id)
+ baseline = [row for row in valid if _time(row["time"]) < period_start][-rule.window_samples:]
+ current = [row for row in valid if period_start <= _time(row["time"]) <= period_end]
+ if len(baseline) < rule.minimum_samples:
+ gaps.append({"device_id": device_id, "reason": "baseline_not_ready"})
+ continue
+ if not current:
+ gaps.append({"device_id": device_id, "reason": "period_reading_missing"})
+ continue
+ reference = median(float(row["cleaned_value"]) for row in baseline)
+ if reference == 0:
+ gaps.append({"device_id": device_id, "reason": "zero_baseline"})
+ continue
+ latest = current[-1]
+ value = float(latest["cleaned_value"])
+ change = (value - reference) / abs(reference) * 100
+ hit = change <= -rule.threshold if direction == "decrease" else change >= rule.threshold
+ details.append({
+ "device_id": device_id,
+ "observed_at": _time(latest["time"]).isoformat(),
+ "baseline": reference,
+ "value": value,
+ "change_percent": change,
+ "threshold_percent": rule.threshold,
+ "hit": hit,
+ })
+ return {
+ "status": "ready" if not gaps else "insufficient_data",
+ "hit": any(item["hit"] for item in details),
+ "hit_device_ids": [item["device_id"] for item in details if item["hit"]],
+ "devices": details,
+ "gaps": gaps,
+ }
+
+
+def _burst_detection(
+ rows: list[dict],
+ pressure_device_ids: list[str],
+ interval_seconds: int,
+ period_end: datetime,
+) -> dict[str, Any]:
+ if len(pressure_device_ids) < 5:
+ return {"status": "sensor_coverage_insufficient", "hit": False,
+ "required_sensors": 5, "available_sensors": len(pressure_device_ids)}
+ if interval_seconds % 60 or 86400 % interval_seconds:
+ return {"status": "unsupported_sampling_interval", "hit": False,
+ "interval_seconds": interval_seconds}
+ points_per_day = 86400 // interval_seconds
+ count = 15 * points_per_day
+ series_by_device: dict[str, dict[datetime, float]] = {}
+ common_times: set[datetime] | None = None
+ for device_id in pressure_device_ids:
+ values = {_time(row["time"]): float(row["cleaned_value"])
+ for row in _valid_rows(rows, device_id) if _time(row["time"]) <= period_end}
+ series_by_device[device_id] = values
+ common_times = set(values) if common_times is None else common_times & set(values)
+ if not common_times:
+ return {"status": "baseline_not_ready", "hit": False, "required_samples": count}
+ target = max(common_times)
+ expected = [target - timedelta(seconds=interval_seconds * offset)
+ for offset in range(count - 1, -1, -1)]
+ incomplete = [device_id for device_id, values in series_by_device.items()
+ if any(at not in values for at in expected)]
+ if incomplete:
+ return {"status": "baseline_not_ready", "hit": False, "required_samples": count,
+ "incomplete_device_ids": incomplete}
+ frame = pd.DataFrame({device_id: [series_by_device[device_id][at] for at in expected]
+ for device_id in pressure_device_ids}, index=expected)
+ try:
+ result = run_burst_detection(
+ network="scada-replay",
+ username="scada-replay",
+ observed_pressure_data=frame,
+ points_per_day=points_per_day,
+ mu=TARGET_MU,
+ iforest_params={
+ "n_estimators": TARGET_N_ESTIMATORS,
+ "random_state": TARGET_RANDOM_STATE,
+ "contamination": "auto",
+ },
+ sensor_nodes=pressure_device_ids,
+ )
+ except (TypeError, ValueError) as exc:
+ return {"status": "analysis_failed", "hit": False, "reason": str(exc)[:300]}
+ latest = result["rows"][-1]
+ score = float(latest["Score"])
+ return {
+ "status": "ready",
+ "hit": score <= TARGET_SCORE_THRESHOLD,
+ "target_time": target.isoformat(),
+ "target_score": score,
+ "score_threshold": TARGET_SCORE_THRESHOLD,
+ "sensor_count": len(pressure_device_ids),
+ "reference_day_count": 14,
+ "latest_sensor_rankings": result["summary"].get("latest_sensor_rankings", []),
+ }
+
+
+def evaluate_monitoring_period(
+ document: dict,
+ rows: list[dict],
+ *,
+ period_start: datetime,
+ period_end: datetime,
+ batch_sequence: int,
+) -> dict[str, Any]:
+ config = ScadaReplayCreate.model_validate(document["config"])
+ monitoring = document.get("monitoring_config")
+ rule_version = (monitoring or {}).get("rule_version", "unconfigured")
+ monitoring_id = str(uuid5(
+ NAMESPACE_URL,
+ f"{document['replay_id']}:{period_end.isoformat()}:{rule_version}",
+ ))
+ base = {
+ "monitoring_id": monitoring_id,
+ "period_start": period_start.isoformat(),
+ "period_end": period_end.isoformat(),
+ "knowledge_cutoff": period_end.isoformat(),
+ "batch_sequence": batch_sequence,
+ "rule_version": rule_version,
+ "task_id": None,
+ }
+ if not monitoring:
+ return {**base, "status": "insufficient_data", "detail": "项目未配置 SCADA 监测设备角色",
+ "pressure": {"status": "unconfigured", "hit": False},
+ "outlet_flow": {"status": "unconfigured", "hit": False},
+ "burst_detection": {"status": "not_run", "hit": False}}
+ pressure_ids = monitoring.get("pressure_device_ids", [])
+ flow_ids = monitoring.get("outlet_flow_device_ids", [])
+ selected = set(config.device_ids)
+ role_gaps = sorted((set(pressure_ids) | set(flow_ids)) - selected)
+ if not pressure_ids or not flow_ids or role_gaps:
+ return {**base, "status": "insufficient_data", "detail": "监测角色为空或不属于本轮回放设备",
+ "role_gaps": role_gaps,
+ "pressure": {"status": "unconfigured", "hit": False},
+ "outlet_flow": {"status": "unconfigured", "hit": False},
+ "burst_detection": {"status": "not_run", "hit": False}}
+ pressure = _relative_indicator(rows, config, pressure_ids, period_start, period_end, direction="decrease")
+ outlet_flow = _relative_indicator(rows, config, flow_ids, period_start, period_end, direction="increase")
+ detector = _burst_detection(
+ rows, pressure_ids, int(document["monitoring_interval_seconds"]), period_end,
+ )
+ complete = pressure["status"] == outlet_flow["status"] == detector["status"] == "ready"
+ hits = (bool(detector["hit"]), bool(pressure["hit"]), bool(outlet_flow["hit"]))
+ if not complete:
+ status, detail = "insufficient_data", "监测数据、设备角色或爆管侦测基线不足"
+ elif all(hits):
+ status, detail = "suspected_burst", "爆管侦测、压力下降与出厂边界流量增加相互支持"
+ elif any(hits):
+ status, detail = "attention", "仅部分爆管相关指标命中,需要继续观察或人工核查"
+ else:
+ status, detail = "normal", "本周期爆管相关指标均未命中"
+ return {**base, "status": status, "detail": detail, "pressure": pressure,
+ "outlet_flow": outlet_flow, "burst_detection": detector}
diff --git a/app/services/scada_replay_processing.py b/app/services/scada_replay_processing.py
new file mode 100644
index 0000000..6d9fe99
--- /dev/null
+++ b/app/services/scada_replay_processing.py
@@ -0,0 +1,139 @@
+"""Causal batch quality checks and grouped anomaly episodes, no database IO."""
+from copy import deepcopy
+from datetime import datetime
+from math import isfinite
+from statistics import median
+from uuid import NAMESPACE_URL, uuid5
+import re
+from app.domain.schemas.scada_replay import ScadaReplayCreate
+
+
+def prepare_cleaning(rows: list[dict], config: ScadaReplayCreate) -> list[dict]:
+ result = []
+ for row in rows:
+ value = row["monitored_value"]
+ quality, reason = "valid", "原始值通过数值与已配置量程校验"
+ if value is None:
+ quality, reason = "missing", "原始读数缺失"
+ elif not isfinite(float(value)):
+ quality, reason = "invalid", "原始读数不是有限数值"
+ else:
+ bounds = config.quality_ranges.get(row["device_id"])
+ if bounds and ((bounds.lower is not None and value < bounds.lower)
+ or (bounds.upper is not None and value > bounds.upper)):
+ quality, reason = "invalid", "超出本次明确配置的仪表量程"
+ result.append({
+ "time": row["time"].isoformat() if isinstance(row["time"], datetime) else row["time"],
+ "device_id": row["device_id"], "source_hash": row["source_hash"],
+ "monitored_value": value if value is not None and isfinite(float(value)) else None,
+ "cleaned_value": value if quality == "valid" else None,
+ "quality": quality, "reason": reason,
+ })
+ return result
+
+
+def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at: datetime) -> tuple[dict, list[dict]]:
+ """Consume a complete timestamp; never treat missing data as recovery."""
+ result = deepcopy(document)
+ histories = result.setdefault("histories", {})
+ states = result.setdefault("rule_states", {})
+ latest = result.setdefault("latest", {})
+ for row in rows:
+ latest[row["device_id"]] = row
+ by_id = {row["device_id"]: row for row in rows}
+ changes = []
+ devices = {device["device_id"]: device for device in result.get("devices", [])}
+ for device_id in config.device_ids:
+ row = by_id.get(device_id)
+ invalid = row is not None and row["quality"] != "valid"
+ previous = latest.get(device_id)
+ frequency = str(devices.get(device_id, {}).get("transmission_frequency", ""))
+ match = re.fullmatch(r"(\d+):(\d+):(\d+)", frequency)
+ if match:
+ seconds = int(match[1]) * 3600 + int(match[2]) * 60 + int(match[3])
+ else:
+ minutes = re.fullmatch(r"(\d+)\s*(?:min|分钟)", frequency)
+ seconds = int(minutes[1]) * 60 if minutes else 0
+ prior_time = datetime.fromisoformat(previous["time"]) if previous else config.start_time
+ missing = not row and seconds > 0 and (at - prior_time).total_seconds() >= seconds * 2
+ key = "quality-" + device_id
+ state = states.setdefault(key, {"event": None, "recoveries": 0})
+ if invalid or missing:
+ state["recoveries"] = 0
+ if state["event"] is None:
+ event = {"event_id": str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{key}:{at.isoformat()}")),
+ "rule_id": key, "device_ids": [device_id], "occurred_at": at.isoformat(),
+ "last_observed_at": at.isoformat(), "status": "active", "category": "data_quality",
+ "detail": row["reason"] if invalid else "连续两个设备周期未读取到观测",
+ "task_id": None}
+ state["event"] = event
+ changes.append({**event, "change": "raised"})
+ result["anomaly_count"] = result.get("anomaly_count", 0) + 1
+ elif row and row["quality"] == "valid" and state["event"]:
+ state["recoveries"] += 1
+ if state["recoveries"] >= 2:
+ changes.append({**state["event"], "status": "resolved", "resolved_at": at.isoformat(), "change": "resolved"})
+ state["event"] = None
+ for rule in config.rules:
+ state = states.setdefault(rule.rule_id, {"hits": 0, "recoveries": 0, "event": None})
+ hits, usable, reasons = [], [], []
+ for device_id in rule.device_ids:
+ row = by_id.get(device_id)
+ if not row or row["quality"] != "valid":
+ continue
+ value = row["cleaned_value"]
+ if rule.kind == "bounds":
+ triggered = ((rule.lower is not None and value < rule.lower)
+ or (rule.upper is not None and value > rule.upper))
+ reason = f"{device_id}={value} {rule.unit},超出配置范围"
+ else:
+ previous = histories.get(device_id, [])[-rule.window_samples:]
+ if len(previous) < rule.minimum_samples:
+ continue
+ baseline = median(previous)
+ if baseline == 0:
+ continue
+ change = (value - baseline) / abs(baseline) * 100
+ triggered = (abs(change) >= rule.threshold if rule.direction == "both"
+ else change >= rule.threshold if rule.direction == "increase"
+ else change <= -rule.threshold)
+ reason = f"{device_id} 较历史中位值变化 {change:.1f}%,阈值 {rule.threshold}%"
+ usable.append(device_id)
+ if triggered:
+ hits.append(device_id)
+ reasons.append(reason)
+ if len(hits) >= rule.minimum_devices:
+ state["hits"] += 1
+ state["recoveries"] = 0
+ if state["event"] is None and state["hits"] >= rule.consecutive_samples:
+ event_id = str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{rule.rule_id}:{at.isoformat()}"))
+ event = {"event_id": event_id, "rule_id": rule.rule_id, "device_ids": hits,
+ "occurred_at": at.isoformat(), "last_observed_at": at.isoformat(),
+ "status": "active", "detail": ";".join(reasons)[:2000], "task_id": None}
+ state["event"] = event
+ changes.append({**event, "change": "raised"})
+ result["anomaly_count"] = result.get("anomaly_count", 0) + 1
+ elif state["event"]:
+ state["event"]["last_observed_at"] = at.isoformat()
+ elif len(usable) == len(rule.device_ids):
+ state["hits"] = 0
+ if state["event"]:
+ state["recoveries"] += 1
+ if state["recoveries"] >= rule.recovery_samples:
+ event = {**state["event"], "status": "resolved", "resolved_at": at.isoformat(),
+ "change": "resolved"}
+ changes.append(event)
+ state["event"] = None
+ state["recoveries"] = 0
+ # Incomplete frames cannot count as a consecutive hit/recovery.
+ elif not usable and any(device in by_id for device in rule.device_ids):
+ state["hits"] = state["recoveries"] = 0
+ frozen = {device for rule in config.rules if states[rule.rule_id]["event"]
+ for device in rule.device_ids}
+ for row in rows:
+ if row["quality"] == "valid" and row["device_id"] not in frozen:
+ history = histories.setdefault(row["device_id"], [])
+ history.append(row["cleaned_value"])
+ histories[row["device_id"]] = history[-96:]
+ result["processed_count"] = result.get("processed_count", 0) + len(rows)
+ return result, changes
diff --git a/app/services/scada_replay_queries.py b/app/services/scada_replay_queries.py
new file mode 100644
index 0000000..d088527
--- /dev/null
+++ b/app/services/scada_replay_queries.py
@@ -0,0 +1,95 @@
+"""Server-enforced replay knowledge bounds and immutable corrected snapshots."""
+from datetime import datetime
+
+from fastapi import HTTPException
+
+from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
+
+
+def check_replay_window(context, start, end):
+ cap = datetime.fromisoformat(context["knowledge_cutoff"])
+ if start is None or end is None or start.tzinfo is None or end.tzinfo is None or start > end or end > cap:
+ raise HTTPException(422, f"回放查询需要明确时间范围,结束不得晚于 {cap.isoformat()}")
+
+
+async def replay_snapshot_rows(connection, project_id, context, device_ids, start, end, limit, offset=0):
+ check_replay_window(context, start, end)
+ repository = ScadaReplayRepository(connection, project_id)
+ run = await repository.get(context["replay_id"])
+ if not set(device_ids) <= set(run["config"]["device_ids"]):
+ raise HTTPException(403, "设备或回放不属于当前调查")
+ if start < datetime.fromisoformat(run["start_time"]):
+ raise HTTPException(422, "当前调查证据接口仅返回本轮已处理观测,回放前基线已用于后台初始化")
+ rows = await repository.detected_outputs(
+ context["replay_id"], device_ids, start, end, context["batch_sequence"],
+ limit=limit, offset=offset,
+ )
+ return [{key: row[key] for key in (
+ "time", "device_id", "monitored_value", "cleaned_value", "quality", "reason"
+ )} for row in rows]
+
+
+async def replay_burst_location_inputs(connection, project_id, context, args):
+ """Build location series from immutable replay outputs, never live TSDB rows."""
+ if args.data_source != "monitoring":
+ raise HTTPException(422, "回放定位只支持 monitoring 数据源")
+ if args.scada_normal_start is None or args.scada_normal_end is None:
+ raise HTTPException(422, "回放定位必须显式提供已处理范围内的正常对照窗口")
+ check_replay_window(context, args.scada_burst_start, args.scada_burst_end)
+ check_replay_window(context, args.scada_normal_start, args.scada_normal_end)
+ repository = ScadaReplayRepository(connection, project_id)
+ run = await repository.get(context["replay_id"])
+ if run:
+ replay_start = datetime.fromisoformat(run["start_time"])
+ if args.scada_burst_start < replay_start or args.scada_normal_start < replay_start:
+ raise HTTPException(422, "定位窗口必须位于本轮已处理回放范围内")
+ devices = {item["device_id"]: item for item in run.get("devices", [])}
+ monitoring = run.get("monitoring_config") or {}
+ pressure_devices = monitoring.get("pressure_device_ids", [])
+ pressure_map = {str(devices[item]["node_id"]): item for item in pressure_devices
+ if item in devices and devices[item].get("node_id")}
+ requested_pressure = args.pressure_scada_ids or list(pressure_map)
+ if not requested_pressure or not set(requested_pressure) <= set(pressure_map):
+ raise HTTPException(422, "压力节点必须来自本轮已配置的回放监测角色")
+ flow_devices = monitoring.get("outlet_flow_device_ids", [])
+ flow_map = {str(devices[item]["link_id"]): item for item in flow_devices
+ if item in devices and devices[item].get("link_id")}
+ requested_flow = args.flow_scada_ids or []
+ if not set(requested_flow) <= set(flow_map):
+ raise HTTPException(422, "流量要素必须来自本轮已配置的出厂边界流量角色")
+ selected = [pressure_map[item] for item in requested_pressure] + [flow_map[item] for item in requested_flow]
+ rows = await repository.detected_outputs(
+ context["replay_id"], selected,
+ min(args.scada_burst_start, args.scada_normal_start),
+ max(args.scada_burst_end, args.scada_normal_end),
+ context["batch_sequence"],
+ )
+
+ def averages(mapping, start, end):
+ result = {}
+ for element_id, device_id in mapping.items():
+ values = [float(row["cleaned_value"]) for row in rows
+ if row.get("device_id") == device_id
+ and row.get("quality") in {"valid", "corrected"}
+ and row.get("cleaned_value") is not None
+ and start <= datetime.fromisoformat(row["time"]) <= end]
+ if not values:
+ raise HTTPException(422, f"回放窗口缺少 {device_id} 的有效清洗观测")
+ result[element_id] = sum(values) / len(values)
+ return result
+
+ pressure_mapping = {item: pressure_map[item] for item in requested_pressure}
+ flow_mapping = {item: flow_map[item] for item in requested_flow}
+ return {
+ "data_source": "monitoring",
+ "burst_leakage": args.burst_leakage,
+ "pressure_scada_ids": requested_pressure,
+ "burst_pressure": averages(pressure_mapping, args.scada_burst_start, args.scada_burst_end),
+ "normal_pressure": averages(pressure_mapping, args.scada_normal_start, args.scada_normal_end),
+ **({
+ "flow_scada_ids": requested_flow,
+ "burst_flow": averages(flow_mapping, args.scada_burst_start, args.scada_burst_end),
+ "normal_flow": averages(flow_mapping, args.scada_normal_start, args.scada_normal_end),
+ "use_scada_flow": True,
+ } if requested_flow else {}),
+ }
diff --git a/contracts/manifest.json b/contracts/manifest.json
index feaef88..00c349d 100644
--- a/contracts/manifest.json
+++ b/contracts/manifest.json
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
- "sha256": "2c1d757ccf06ae72b10c592e1e9b3d32b4f72cda8489e224cf2c5f2690fc6972"
+ "sha256": "b039042be13243a88691309cb74eee105a3b71765c342edb9222374e41c0458e"
}
}
}
diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json
index 0c157ae..dc5c813 100644
--- a/contracts/server-v1.openapi.json
+++ b/contracts/server-v1.openapi.json
@@ -304,6 +304,9 @@
"title": "Name",
"type": "string"
},
+ "settings": {
+ "$ref": "#/components/schemas/ProjectSettings"
+ },
"status": {
"default": "active",
"enum": [
@@ -373,6 +376,9 @@
"title": "Project Id",
"type": "string"
},
+ "settings": {
+ "$ref": "#/components/schemas/ProjectSettings"
+ },
"status": {
"title": "Status",
"type": "string"
@@ -469,6 +475,17 @@
],
"title": "Name"
},
+ "settings": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ProjectSettings"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "按顶层键合并项目配置;省略的键保持原值,嵌套对象整体替换"
+ },
"status": {
"anyOf": [
{
@@ -6670,6 +6687,9 @@
"title": "Project Role",
"type": "string"
},
+ "settings": {
+ "$ref": "#/components/schemas/ProjectSettings"
+ },
"status": {
"title": "Status",
"type": "string"
@@ -6726,6 +6746,41 @@
"title": "ProjectProvisionResponse",
"type": "object"
},
+ "ProjectSettings": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/JsonValue"
+ },
+ "description": "Extensible, non-secret project parameters stored in MetaDB as JSON.",
+ "properties": {
+ "scada_monitoring": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ScadaMonitoringSettings"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "主动监测使用的项目设备角色;不创建定时任务"
+ },
+ "scada_monitoring_interval_seconds": {
+ "anyOf": [
+ {
+ "maximum": 31536000.0,
+ "minimum": 60.0,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "SCADA 主动监测演示步长,单位秒;null 表示未配置",
+ "title": "Scada Monitoring Interval Seconds"
+ }
+ },
+ "title": "ProjectSettings",
+ "type": "object"
+ },
"ProjectSummaryResponse": {
"properties": {
"code": {
@@ -7327,6 +7382,245 @@
"title": "ScadaLatestReadingsResponse",
"type": "object"
},
+ "ScadaMonitoringResult": {
+ "additionalProperties": false,
+ "properties": {
+ "batch_sequence": {
+ "minimum": 1.0,
+ "title": "Batch Sequence",
+ "type": "integer"
+ },
+ "burst_detection": {
+ "additionalProperties": true,
+ "title": "Burst Detection",
+ "type": "object"
+ },
+ "detail": {
+ "maxLength": 1000,
+ "minLength": 1,
+ "title": "Detail",
+ "type": "string"
+ },
+ "knowledge_cutoff": {
+ "format": "date-time",
+ "title": "Knowledge Cutoff",
+ "type": "string"
+ },
+ "monitoring_id": {
+ "format": "uuid",
+ "title": "Monitoring Id",
+ "type": "string"
+ },
+ "outlet_flow": {
+ "additionalProperties": true,
+ "title": "Outlet Flow",
+ "type": "object"
+ },
+ "period_end": {
+ "format": "date-time",
+ "title": "Period End",
+ "type": "string"
+ },
+ "period_start": {
+ "format": "date-time",
+ "title": "Period Start",
+ "type": "string"
+ },
+ "pressure": {
+ "additionalProperties": true,
+ "title": "Pressure",
+ "type": "object"
+ },
+ "role_gaps": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Role Gaps",
+ "type": "array"
+ },
+ "rule_version": {
+ "maxLength": 80,
+ "minLength": 1,
+ "title": "Rule Version",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "normal",
+ "attention",
+ "insufficient_data",
+ "suspected_burst"
+ ],
+ "title": "Status",
+ "type": "string"
+ },
+ "task_id": {
+ "anyOf": [
+ {
+ "format": "uuid",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Task Id"
+ }
+ },
+ "required": [
+ "monitoring_id",
+ "period_start",
+ "period_end",
+ "knowledge_cutoff",
+ "batch_sequence",
+ "rule_version",
+ "status",
+ "detail",
+ "pressure",
+ "outlet_flow",
+ "burst_detection"
+ ],
+ "title": "ScadaMonitoringResult",
+ "type": "object"
+ },
+ "ScadaMonitoringSettings": {
+ "additionalProperties": false,
+ "description": "Project-owned device roles used by the manual SCADA monitoring demo.",
+ "properties": {
+ "outlet_flow_device_ids": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 20,
+ "title": "Outlet Flow Device Ids",
+ "type": "array"
+ },
+ "pressure_device_ids": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 20,
+ "title": "Pressure Device Ids",
+ "type": "array"
+ },
+ "rule_version": {
+ "default": "burst-monitor-v1",
+ "maxLength": 80,
+ "minLength": 1,
+ "pattern": "^[a-zA-Z0-9_.-]+$",
+ "title": "Rule Version",
+ "type": "string"
+ }
+ },
+ "title": "ScadaMonitoringSettings",
+ "type": "object"
+ },
+ "ScadaQualityRange": {
+ "additionalProperties": false,
+ "properties": {
+ "lower": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lower"
+ },
+ "upper": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Upper"
+ }
+ },
+ "title": "ScadaQualityRange",
+ "type": "object"
+ },
+ "ScadaRawPage": {
+ "additionalProperties": false,
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/ScadaRawReading"
+ },
+ "title": "Items",
+ "type": "array"
+ },
+ "next_cursor": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Next Cursor"
+ },
+ "scanned_through": {
+ "format": "date-time",
+ "title": "Scanned Through",
+ "type": "string"
+ }
+ },
+ "required": [
+ "items",
+ "next_cursor",
+ "scanned_through"
+ ],
+ "title": "ScadaRawPage",
+ "type": "object"
+ },
+ "ScadaRawReading": {
+ "additionalProperties": false,
+ "properties": {
+ "device_id": {
+ "title": "Device Id",
+ "type": "string"
+ },
+ "monitored_value": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Monitored Value"
+ },
+ "quality": {
+ "default": "unvalidated",
+ "enum": [
+ "unvalidated",
+ "missing",
+ "invalid"
+ ],
+ "title": "Quality",
+ "type": "string"
+ },
+ "time": {
+ "format": "date-time",
+ "title": "Time",
+ "type": "string"
+ }
+ },
+ "required": [
+ "time",
+ "device_id",
+ "monitored_value"
+ ],
+ "title": "ScadaRawReading",
+ "type": "object"
+ },
"ScadaReadingBatchItem": {
"properties": {
"cleaned_value": {
@@ -7369,6 +7663,550 @@
"title": "ScadaReadingBatchItem",
"type": "object"
},
+ "ScadaReplayCapabilities": {
+ "additionalProperties": false,
+ "properties": {
+ "available": {
+ "title": "Available",
+ "type": "boolean"
+ },
+ "burst_monitoring_ready": {
+ "default": false,
+ "title": "Burst Monitoring Ready",
+ "type": "boolean"
+ },
+ "burst_monitoring_reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Burst Monitoring Reason"
+ },
+ "can_write": {
+ "title": "Can Write",
+ "type": "boolean"
+ },
+ "current": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ScadaReplaySnapshot"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "monitoring_interval_seconds": {
+ "anyOf": [
+ {
+ "maximum": 31536000.0,
+ "minimum": 60.0,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Monitoring Interval Seconds"
+ },
+ "reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Reason"
+ }
+ },
+ "required": [
+ "available",
+ "can_write"
+ ],
+ "title": "ScadaReplayCapabilities",
+ "type": "object"
+ },
+ "ScadaReplayCommand": {
+ "additionalProperties": false,
+ "properties": {
+ "action": {
+ "enum": [
+ "step",
+ "stop",
+ "retry"
+ ],
+ "title": "Action",
+ "type": "string"
+ },
+ "expected_revision": {
+ "minimum": 1.0,
+ "title": "Expected Revision",
+ "type": "integer"
+ },
+ "request_id": {
+ "format": "uuid",
+ "title": "Request Id",
+ "type": "string"
+ }
+ },
+ "required": [
+ "request_id",
+ "expected_revision",
+ "action"
+ ],
+ "title": "ScadaReplayCommand",
+ "type": "object"
+ },
+ "ScadaReplayCreate": {
+ "additionalProperties": false,
+ "properties": {
+ "device_ids": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 500,
+ "minItems": 1,
+ "title": "Device Ids",
+ "type": "array"
+ },
+ "end_time": {
+ "format": "date-time",
+ "title": "End Time",
+ "type": "string"
+ },
+ "quality_ranges": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/ScadaQualityRange"
+ },
+ "title": "Quality Ranges",
+ "type": "object"
+ },
+ "request_id": {
+ "format": "uuid",
+ "title": "Request Id",
+ "type": "string"
+ },
+ "rules": {
+ "items": {
+ "$ref": "#/components/schemas/ScadaReplayRule"
+ },
+ "maxItems": 30,
+ "minItems": 1,
+ "title": "Rules",
+ "type": "array"
+ },
+ "start_time": {
+ "format": "date-time",
+ "title": "Start Time",
+ "type": "string"
+ }
+ },
+ "required": [
+ "request_id",
+ "start_time",
+ "end_time",
+ "device_ids",
+ "rules"
+ ],
+ "title": "ScadaReplayCreate",
+ "type": "object"
+ },
+ "ScadaReplayEventsPage": {
+ "additionalProperties": false,
+ "properties": {
+ "items": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "title": "Items",
+ "type": "array"
+ },
+ "next_offset": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Next Offset"
+ }
+ },
+ "required": [
+ "items",
+ "next_offset"
+ ],
+ "title": "ScadaReplayEventsPage",
+ "type": "object"
+ },
+ "ScadaReplayReadings": {
+ "additionalProperties": false,
+ "properties": {
+ "items": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "title": "Items",
+ "type": "array"
+ },
+ "through": {
+ "format": "date-time",
+ "title": "Through",
+ "type": "string"
+ }
+ },
+ "required": [
+ "items",
+ "through"
+ ],
+ "title": "ScadaReplayReadings",
+ "type": "object"
+ },
+ "ScadaReplayRule": {
+ "additionalProperties": false,
+ "properties": {
+ "consecutive_samples": {
+ "default": 1,
+ "maximum": 20.0,
+ "minimum": 1.0,
+ "title": "Consecutive Samples",
+ "type": "integer"
+ },
+ "device_ids": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 500,
+ "minItems": 1,
+ "title": "Device Ids",
+ "type": "array"
+ },
+ "direction": {
+ "default": "both",
+ "enum": [
+ "both",
+ "increase",
+ "decrease"
+ ],
+ "title": "Direction",
+ "type": "string"
+ },
+ "kind": {
+ "enum": [
+ "relative_change",
+ "bounds"
+ ],
+ "title": "Kind",
+ "type": "string"
+ },
+ "lower": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lower"
+ },
+ "metric_name": {
+ "default": "",
+ "maxLength": 80,
+ "title": "Metric Name",
+ "type": "string"
+ },
+ "minimum_devices": {
+ "default": 1,
+ "maximum": 500.0,
+ "minimum": 1.0,
+ "title": "Minimum Devices",
+ "type": "integer"
+ },
+ "minimum_samples": {
+ "default": 3,
+ "maximum": 96.0,
+ "minimum": 1.0,
+ "title": "Minimum Samples",
+ "type": "integer"
+ },
+ "recovery_samples": {
+ "default": 2,
+ "maximum": 20.0,
+ "minimum": 1.0,
+ "title": "Recovery Samples",
+ "type": "integer"
+ },
+ "rule_id": {
+ "maxLength": 80,
+ "minLength": 1,
+ "pattern": "^[a-zA-Z0-9_-]+$",
+ "title": "Rule Id",
+ "type": "string"
+ },
+ "threshold": {
+ "anyOf": [
+ {
+ "exclusiveMinimum": 0.0,
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Threshold"
+ },
+ "unit": {
+ "maxLength": 40,
+ "minLength": 1,
+ "title": "Unit",
+ "type": "string"
+ },
+ "upper": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Upper"
+ },
+ "window_samples": {
+ "default": 4,
+ "maximum": 96.0,
+ "minimum": 1.0,
+ "title": "Window Samples",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "rule_id",
+ "device_ids",
+ "kind",
+ "unit"
+ ],
+ "title": "ScadaReplayRule",
+ "type": "object"
+ },
+ "ScadaReplaySnapshot": {
+ "additionalProperties": false,
+ "properties": {
+ "active_events": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "title": "Active Events",
+ "type": "array"
+ },
+ "anomaly_count": {
+ "default": 0,
+ "title": "Anomaly Count",
+ "type": "integer"
+ },
+ "created_at": {
+ "format": "date-time",
+ "title": "Created At",
+ "type": "string"
+ },
+ "delivered_until": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Delivered Until"
+ },
+ "devices": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "title": "Devices",
+ "type": "array"
+ },
+ "end_time": {
+ "format": "date-time",
+ "title": "End Time",
+ "type": "string"
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ },
+ "last_monitoring": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ScadaMonitoringResult"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "latest": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "title": "Latest",
+ "type": "array"
+ },
+ "monitoring_count": {
+ "default": 0,
+ "title": "Monitoring Count",
+ "type": "integer"
+ },
+ "monitoring_interval_seconds": {
+ "anyOf": [
+ {
+ "maximum": 31536000.0,
+ "minimum": 60.0,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Monitoring Interval Seconds"
+ },
+ "processed_count": {
+ "default": 0,
+ "title": "Processed Count",
+ "type": "integer"
+ },
+ "processed_until": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Processed Until"
+ },
+ "project_id": {
+ "format": "uuid",
+ "title": "Project Id",
+ "type": "string"
+ },
+ "replay_id": {
+ "format": "uuid",
+ "title": "Replay Id",
+ "type": "string"
+ },
+ "revision": {
+ "title": "Revision",
+ "type": "integer"
+ },
+ "rules": {
+ "items": {
+ "$ref": "#/components/schemas/ScadaReplayRule"
+ },
+ "title": "Rules",
+ "type": "array"
+ },
+ "start_time": {
+ "format": "date-time",
+ "title": "Start Time",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "ready",
+ "paused",
+ "completed",
+ "stopped",
+ "failed"
+ ],
+ "title": "Status",
+ "type": "string"
+ },
+ "step_pending": {
+ "default": false,
+ "title": "Step Pending",
+ "type": "boolean"
+ },
+ "updated_at": {
+ "format": "date-time",
+ "title": "Updated At",
+ "type": "string"
+ },
+ "virtual_now": {
+ "format": "date-time",
+ "title": "Virtual Now",
+ "type": "string"
+ }
+ },
+ "required": [
+ "replay_id",
+ "project_id",
+ "revision",
+ "status",
+ "start_time",
+ "end_time",
+ "virtual_now",
+ "devices",
+ "rules",
+ "latest",
+ "active_events",
+ "created_at",
+ "updated_at"
+ ],
+ "title": "ScadaReplaySnapshot",
+ "type": "object"
+ },
+ "ScadaReplayTaskContext": {
+ "additionalProperties": false,
+ "properties": {
+ "batch_sequence": {
+ "minimum": 1.0,
+ "title": "Batch Sequence",
+ "type": "integer"
+ },
+ "knowledge_cutoff": {
+ "format": "date-time",
+ "title": "Knowledge Cutoff",
+ "type": "string"
+ },
+ "replay_id": {
+ "format": "uuid",
+ "title": "Replay Id",
+ "type": "string"
+ }
+ },
+ "required": [
+ "replay_id",
+ "knowledge_cutoff",
+ "batch_sequence"
+ ],
+ "title": "ScadaReplayTaskContext",
+ "type": "object"
+ },
"SensorPlacementExportRequest": {
"properties": {
"adjustment_status": {
@@ -7960,7 +8798,15 @@
"type": "string"
},
"source": {
- "$ref": "#/components/schemas/WorkbenchAlertSource"
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WorkbenchAlertSource"
+ },
+ {
+ "$ref": "#/components/schemas/WorkbenchScadaReplaySource"
+ }
+ ],
+ "title": "Source"
},
"title": {
"minLength": 1,
@@ -8031,7 +8877,15 @@
"type": "string"
},
"source": {
- "$ref": "#/components/schemas/WorkbenchAlertSource"
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WorkbenchAlertSource"
+ },
+ {
+ "$ref": "#/components/schemas/WorkbenchScadaReplaySource"
+ }
+ ],
+ "title": "Source"
},
"status": {
"enum": [
@@ -9373,6 +10227,16 @@
],
"title": "Kind",
"type": "string"
+ },
+ "replay_context": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ScadaReplayTaskContext"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
@@ -9777,7 +10641,15 @@
"type": "string"
},
"source": {
- "$ref": "#/components/schemas/WorkbenchAlertSource"
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WorkbenchAlertSource"
+ },
+ {
+ "$ref": "#/components/schemas/WorkbenchScadaReplaySource"
+ }
+ ],
+ "title": "Source"
},
"title": {
"minLength": 1,
@@ -10385,6 +11257,39 @@
"title": "WorkbenchRuntimeWorkOrder",
"type": "object"
},
+ "WorkbenchScadaReplaySource": {
+ "additionalProperties": false,
+ "properties": {
+ "batch_sequence": {
+ "minimum": 1.0,
+ "title": "Batch Sequence",
+ "type": "integer"
+ },
+ "kind": {
+ "const": "scada_replay",
+ "title": "Kind",
+ "type": "string"
+ },
+ "replay_id": {
+ "minLength": 1,
+ "title": "Replay Id",
+ "type": "string"
+ },
+ "rule_id": {
+ "minLength": 1,
+ "title": "Rule Id",
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "replay_id",
+ "rule_id",
+ "batch_sequence"
+ ],
+ "title": "WorkbenchScadaReplaySource",
+ "type": "object"
+ },
"WorkbenchSyncSnapshot": {
"additionalProperties": false,
"properties": {
@@ -41258,6 +42163,681 @@
]
}
},
+ "/api/v1/scada-replays": {
+ "post": {
+ "operationId": "post_scada_replays",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplayCreate"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplaySnapshot"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "Create Replay",
+ "tags": [
+ "SCADA Replay"
+ ]
+ }
+ },
+ "/api/v1/scada-replays/capabilities": {
+ "get": {
+ "operationId": "get_scada_replays_capabilities",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplayCapabilities"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "Replay Capabilities",
+ "tags": [
+ "SCADA Replay"
+ ]
+ }
+ },
+ "/api/v1/scada-replays/{replay_id}": {
+ "get": {
+ "operationId": "get_scada_replays_replay_id",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "replay_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Replay Id",
+ "type": "string"
+ }
+ },
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplaySnapshot"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "Get Replay",
+ "tags": [
+ "SCADA Replay"
+ ]
+ }
+ },
+ "/api/v1/scada-replays/{replay_id}/commands": {
+ "post": {
+ "operationId": "post_scada_replays_replay_id_commands",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "replay_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Replay Id",
+ "type": "string"
+ }
+ },
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplayCommand"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplaySnapshot"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "Replay Command",
+ "tags": [
+ "SCADA Replay"
+ ]
+ }
+ },
+ "/api/v1/scada-replays/{replay_id}/events": {
+ "get": {
+ "operationId": "get_scada_replays_replay_id_events",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "replay_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Replay Id",
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "required": false,
+ "schema": {
+ "default": 0,
+ "minimum": 0,
+ "title": "Offset",
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 100,
+ "maximum": 200,
+ "minimum": 1,
+ "title": "Limit",
+ "type": "integer"
+ }
+ },
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplayEventsPage"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "Replay Events",
+ "tags": [
+ "SCADA Replay"
+ ]
+ }
+ },
+ "/api/v1/scada-replays/{replay_id}/observations": {
+ "get": {
+ "operationId": "get_scada_replays_replay_id_observations",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "replay_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Replay Id",
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "through",
+ "required": true,
+ "schema": {
+ "format": "date-time",
+ "title": "Through",
+ "type": "string"
+ }
+ },
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaReplayReadings"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "Replay Observations",
+ "tags": [
+ "SCADA Replay"
+ ]
+ }
+ },
"/api/v1/sensor-placement-candidates/{node_id}": {
"get": {
"operationId": "get_sensor_placement_candidates_node_id",
@@ -49361,6 +50941,178 @@
]
}
},
+ "/api/v1/timeseries/scada-readings/raw": {
+ "get": {
+ "operationId": "get_timeseries_scada_readings_raw",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "start_time",
+ "required": true,
+ "schema": {
+ "format": "date-time",
+ "title": "Start Time",
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "end_time",
+ "required": true,
+ "schema": {
+ "format": "date-time",
+ "title": "End Time",
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "device_ids",
+ "required": true,
+ "schema": {
+ "title": "Device Ids",
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 500,
+ "maximum": 500,
+ "minimum": 1,
+ "title": "Limit",
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "maxLength": 2048,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Cursor"
+ }
+ },
+ {
+ "in": "query",
+ "name": "replay_id",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "format": "uuid",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Replay Id"
+ }
+ },
+ {
+ "in": "header",
+ "name": "X-Project-Id",
+ "required": true,
+ "schema": {
+ "title": "X-Project-Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScadaRawPage"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Authentication required"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Insufficient permission"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Resource conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ },
+ "description": "Dependency unavailable"
+ }
+ },
+ "security": [
+ {
+ "OAuth2PasswordBearer": []
+ }
+ ],
+ "summary": "按时间顺序分页读取 SCADA 原始值",
+ "tags": [
+ "TimescaleDB - SCADA"
+ ]
+ }
+ },
"/api/v1/timeseries/scada-readings/{device_id}/field": {
"patch": {
"description": "更新指定设备的字段值\n\n更新SCADA设备在特定时间的某个字段监测数据。\n\nArgs:\n device_id: 设备ID\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误",
diff --git a/docs/assets/scada-fm-timeline.svg b/docs/assets/scada-fm-timeline.svg
new file mode 100644
index 0000000..dd3840b
--- /dev/null
+++ b/docs/assets/scada-fm-timeline.svg
@@ -0,0 +1,65 @@
+
diff --git a/docs/peripheral-sample.md b/docs/peripheral-sample.md
index c918603..b832e73 100644
--- a/docs/peripheral-sample.md
+++ b/docs/peripheral-sample.md
@@ -39,6 +39,8 @@ Forward Hostname 后追加 `/api/sample/v1`。本机开发和预览模式也将
- `/api/sample/v1/monitoring/points`、`observations`、`alerts`
- `/api/sample/v1/demo-data`
+SCADA 按月数据集、5 分钟时间定位、主动周期监测、质量校验、爆管调查与定位边界见 [SCADA 外围系统时段回放、主动监测与爆管调查设计](scada-peripheral-replay-design.md)。部署回放存储需在项目 BizDB 执行 `031_scada_replays.sql`;项目通用配置需在 MetaDB 执行 `033_metadata_project_settings.sql`。监测频率保存于 `projects.settings.scada_monitoring_interval_seconds`,压力与出厂边界流量角色保存在类型化 `projects.settings.scada_monitoring` 中;本阶段不注册定时任务,也不部署 SCADA 回放 worker。代码和结构迁移完成不等于月度数据或 Agent 联调已经验收。
+
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
## 工单事件调查
diff --git a/docs/scada-peripheral-replay-design.md b/docs/scada-peripheral-replay-design.md
new file mode 100644
index 0000000..ecb4ad9
--- /dev/null
+++ b/docs/scada-peripheral-replay-design.md
@@ -0,0 +1,476 @@
+# SCADA 外围系统时段回放、主动监测与爆管调查设计
+
+状态:时段回放、主动周期监测、条件式 `burst_report` 调查及回放安全的爆管定位适配已实现。单步处理由用户操作触发并在当前 API 请求内完成,不部署 SCADA 回放 worker。`tjwater_v2` 与 `tjwater2_v2` 尚未确认出厂边界流量角色;仍待完整月度数据和真实 Agent 联调。更新日期:2026-11-03。下面明确区分已实现范围、后续目标与验收要求。
+
+本次将外围 SCADA 定义为一个模拟外部数据源:本地 TSDB 已保存原始观测,通过只读 API 按选定时间区间和后台时钟分批读取 `scada.measurements.monitored_value`,将这一批直接交给清洗流水线,保存 `cleaned_value` 后执行异常检测及后续 Agent 调查。原始值、修正值、真实工况异常和处理结论分别保留。浏览器负责控制和展示,关闭页面不停止后台回放。
+
+演示主路径为“TSDB 已存 raw → 用户点击下一步并监测 → 顺序读取一个步长内的观测 → 清洗并更新修正值 → 在步长边界执行多指标监测 → 满足条件时进入爆管调查与定位”。真实外部系统新增观测时,仍先通过入库接口保存 raw,再进入相同的清洗检测流程。演示读取已有数据不再次调用原始值插入接口,也不把同一批数据从一个内部服务读出后再写回原表。
+
+演示操作以用户选定的时间区间为中心:一个月是可选择的数据范围,单次演示可以只覆盖其中几小时或几天。时间轴采用 FM 调频盘样式,玻璃刻度窗内有固定红色指示线,时间刻度带随拨动横向移动。时间轴使用 5 分钟时间戳定位;项目监测频率只决定“下一步并监测”覆盖的业务周期,两者互不替代。
+
+## 首版实现范围与限制
+
+已实现只读 raw 分页、持久回放控制、按项目监测频率推进、逐时刻清洗检测、质量告警与运行异常事件、周期监测回执、组合判定及调查任务。主动单步只有组合结果为 `suspected_burst` 时才创建 `burst_report`。前端改为后端状态投影,主界面只保留选定区间、5 分钟 FM 时间定位、主动周期控制和本周期监测结论,不重复展示 SCADA 设备目录与逐测点读数表。主要代码为 [回放服务](../app/services/scada_replay.py)、[组合监测](../app/services/scada_replay_monitoring.py)、[质量与检测](../app/services/scada_replay_processing.py)、[持久状态](../app/infra/db/postgresql/scada_replays.py) 和 [前端组件](../../next-tjwater-frontend/src/features/workbench/peripheral-sample/scada-replay-section.tsx)。
+
+本版采用每项目最多 500 个设备、一个完整时间戳一批、至多一个未完成批次的串行处理,不实现 20 批并发队列或大于 500 个设备的分片接收。处理变慢时逐批追赶且不跳过中间观测;后台停机期间最多补算两秒现实时间,不一次性追赶全部停机时长。数据质量以有限数值和显式仪表量程为依据,不自动插值或平滑;缺测判断使用设备档案的采样周期。分组规则的恢复需要该组设备在同一帧都有可信观测,不同采样周期或采样相位应拆成规则。
+
+本版冻结设备、区间、规则及批次输出,按批次原始值摘要校验源变更。完整月度数据清单、全月源版本摘要、独立事件真值和检测效果评估仍是后续数据准备工作;能力接口仅判断回放存储是否存在与当前操作权限,不声明月度数据已经就绪。现有 14 天数据不能当作一个月验收。
+
+Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 约束。开始前历史只供后台基线初始化,本版调查查询不返回该部分;爆管定位已支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。新 [SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明这些证据限制。运行异常自动建立调查,数据质量异常仅建立告警,不触发模型;恢复时推进调查代次。真实模型推理与报告效果尚未联调。
+
+控制进度和事件状态使用有界轮询,沿用告警流发布业务告警;本版未新增 scada.replay.changed SSE 主题。演示面板只显示最近一个周期的组合结论,逐测点输出和完整事件历史由分页 API 保留给诊断与调查入口,不在主面板重复铺开。
+
+## 已确定的设计边界
+
+- 数据集覆盖一个月,包含多次独立异常及各次事件发生、持续、恢复的过程。
+- 项目配置一个 SCADA 监测频率,只用于一次主动监测所覆盖的周期;时间轴独立使用 5 分钟时间戳,它不关联设备或设备类型,本阶段不据此创建定时任务。
+- 设备采样周期仍属于设备档案。回放实际传入时间以原始观测时间戳为准,不使用项目监测任务间隔生成、重采样或判断设备缺测。
+- 复用 `scada.measurements.monitored_value` 与 `cleaned_value`;不另建一套正式 SCADA 原始值和修正值表。
+- 回放读取 API 只返回原始观测,修正值由本地清洗流水线计算和保存;GET 不更新游标、不清洗、不创建检测任务。
+- 真实压力、流量、水质异常保留在历史序列中。事件恢复或调查结束不删除、不抹平历史异常。
+- 数据处理顺序为原始观测持久化、数据质量校验、必要的修正、业务异常检测、事件与调查。
+- 步长内的数据批次按原始时间驱动清洗和基础规则检测;到达步长边界后,用户触发的本周期监测汇总多项指标。正常周期不启动 Agent。
+- 时间范围、设备、数据集版本和检测规则在一次回放创建后固定;改变这些条件需要新建回放。
+- 页面首先选择演示起止时间,再建立回放。月度数据集、设备配置和规则作为来源与设置,不占据主播放区。
+- 本次演示对已存 raw 只读,清洗阶段仅更新同一条观测的 cleaned_value;不导出整月副本、不重复插入原始值。每次回放的质量结论、检测结果和调查证据按回放身份隔离。
+
+### 项目通用配置与监测频率
+
+MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`,用于保存可扩展的业务参数,不存放密钥。新增参数可直接增加 JSON 键,无需为每项配置新增数据库列;后端为已知参数提供类型校验,并保留其他 JSON 键。
+
+其中 `settings.scada_monitoring_interval_seconds` 表示项目的 SCADA 监测频率,单位为秒。本阶段只用于主动处理边界和监测周期标识,不控制时间轴刻度,不注册 cron、后台 schedule 或周期性 Agent 任务。它与设备目录、设备类型、设备采样周期和页面刷新周期均无关联。省略或设为 `null` 表示未配置;有效范围为 60 秒至 365 天。
+
+管理员可通过 `PATCH /api/v1/admin/projects/{project_id}` 配置:
+
+```json
+{
+ "settings": {
+ "scada_monitoring_interval_seconds": 900
+ }
+}
+```
+
+更新时按 `settings` 顶层键合并:未传入的键保留,传入的嵌套对象整体替换,`settings: {}` 不清空已有配置,`settings: null` 会被拒绝。读取项目详情和管理员项目列表均返回 `settings`。
+
+组合监测仍复用该 JSON 对象,不新增固定数据库列。后端类型化嵌套对象显式声明项目实际具备的监测角色:
+
+```json
+{
+ "settings": {
+ "scada_monitoring_interval_seconds": 900,
+ "scada_monitoring": {
+ "rule_version": "burst-monitor-v1",
+ "pressure_device_ids": ["明确的压力设备 ID"],
+ "outlet_flow_device_ids": ["明确的出厂边界流量设备 ID"]
+ }
+ }
+}
+```
+
+当前代码已类型校验并在创建回放时冻结 `scada_monitoring`。项目没有出厂流量或不足 5 个完整压力点时保持缺项,能力接口说明原因,监测回执返回 `insufficient_data`;不能为了让演示通过而把其他流量点冒充出厂流量或虚构设备。`tjwater_v2` 现有目录只有通用 `pipe_flow` 类型及模型管段映射,没有足以证明“出厂边界”的角色字段,因此本轮未写入该嵌套配置。
+
+部署前需在 MetaDB 执行 `resources/sql/033_metadata_project_settings.sql`。迁移会将实验版本可能存在的独立监测间隔列搬入 `settings`,核对冲突后移除旧列;重复执行不丢失已有配置。当前 `tjwater_v2` 与 `tjwater2_v2` 均配置 900 秒,即 15 分钟的主动监测周期。该参数不改变 5 分钟时间轴定位,本阶段明确不实现通用定时任务调度。
+
+## 实施前基础与原设计差距
+
+下表记录实施前基础,用于说明改动缘由;本次实际实现以首版范围及后文接口表为准。设备目录采样周期为本轮讨论中 2026-11-02 的只读核查快照,与项目监测任务间隔是两个概念。
+
+| 位置 | 当前能力 | 本次需要补齐 |
+| --- | --- | --- |
+| [外围 API](../samples/peripheral/api.py)、[存储路由](../samples/peripheral/routing.py) | 挂载在 `/peripheral`,使用 Bearer 与 `X-Project-Id`,按已授权项目解析 BizDB 和 TimescaleDB | 回放控制、源批次、回放状态与能力声明 |
+| [历史观测接口](../samples/peripheral/store.py) | 单点、最多 7 天、最多返回 1000 条并声明截断;当前使用 `api_query_id` 查历史 | 回放读取使用规范 `device_id`,按时间和设备联合游标分页;不能直接拼接现有单点接口来装载一个月 |
+| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
+| [清洗服务](../app/services/timeseries_analysis.py) | 从原始值计算并更新 `cleaned_value`;目前支持压力和流量 | 在线质量标记、因果修正及选择值依据;水质不能直接套用现有清洗 |
+| [压力清洗](../app/algorithms/scada_cleaning/pressure_series.py)、[流量清洗](../app/algorithms/scada_cleaning/flow_series.py) | 包含居中窗口、后向补值、Kalman 平滑;流量路径还会补为 1 分钟序列 | 在线路径只使用已到达的数据,保留设备频率,不整月预清洗后再模拟检测 |
+| [外围工单事件同步](peripheral-work-order-events.md) | 持久事件、通知唤醒、后台同步和定期对账 | 复用可靠处理方式,SCADA 的批次有自己的持久状态 |
+| [调查 worker](../scripts/run_operational_worker.py) | 可靠投递 `event_investigation`,恢复执行租约,管理 Agent 执行代次 | 接收由 SCADA 异常事件建立的调查;不承担每条观测的检测 |
+| [任务告警](../app/domain/schemas/task_alert.py) | 支持 raised、acknowledged、resolved;当前来源必须携带任务和执行标识 | 增加真实的回放检测来源,不能为调查前告警伪造 `task_id`、`run_id` |
+| [MCP 上下文](../app/mcp/contexts.py)、[SCADA 查询](../app/mcp/query_service.py) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
+| [业务 Skill 映射](../app/mcp/work_order_skills.py) | 目前映射爆管上报、水质投诉、预约跟进 | 增加 SCADA 回放调查流程,不将检测信号直接等同于已确认爆管或污染 |
+| [前端 SCADA 区域](../../next-tjwater-frontend/src/features/workbench/peripheral-sample/scada-system-section.tsx) | 旧版同时展示设备表、逐点读数、重复概览和手工合成读数 | 改为服务端状态投影;主界面只承担时间定位、主动单步和周期结论,详细数据留在诊断与调查接口 |
+
+实施前只读核查中,`tjwater_v2` 有压力、流量和水质监测类型,其他项目的类型集合与历史周期并不相同。项目通用配置保存一个监测任务周期,不与设备类型绑定,也不把 5/15 分钟写成枚举;设备原有采样信息仍用于缺测判断。
+
+现有 [演示记录](../../present.md) 描述的是 14 天合成观测,不能作为一个月数据已经准备好的证明。一个月数据准备与本设计实现分别验收。
+
+## 运行链路与职责
+
+```text
+本地 TSDB 已存月度原始观测;项目 BizDB 保存设备目录
+ |
+ v
+只读 raw API / 同一读取服务 <--- 持久回放状态 <--- 页面“下一步并监测”
+ |
+ | 返回一批原始观测,不再写入 raw
+ v
+后台固定本批记录引用、摘要和处理身份
+ |
+ v
+后台质量校验与清洗
+ |
+ v
+本地 TSDB:仅更新 cleaned_value
+ |
+ v
+用同批清洗结果执行基础规则检测
+ |
+ v
+到达步长边界,汇总爆管侦测、压力下降、出厂流量变化
+ |
+ v
+疑似爆管且定位输入具备 ---> burst_report 任务与 MCP Skill
+ |
+ v
+工作台告警、调查进度与结果
+```
+
+SCADA 单步命令在当前 API 请求内按原始时间顺序完成 raw 读取、清洗、基础规则检测和周期组合监测。每个阶段仍写入持久批次,网络中断后的同一幂等请求可以继续未完成步骤,但页面关闭后不会自动推进时间,也不需要单独的 SCADA 回放进程。
+
+现有调查 worker 只处理已经创建的调查执行,计算 worker 继续处理长计算。当前阶段不增加消息中间件,不扩展通用 schedule API,也不注册 SCADA 定时任务。页面的明确单步命令写入持久 `step_target`,API 只完成这次已授权的周期处理;不能用“每隔几分钟创建一条 Agent 任务”替代。
+
+最小可交付版本必须包含主动周期处理、批次可靠保存、至少一种明确配置的检测规则和结果展示;只移动页面时间轴不能代表数据已经处理。
+
+## 一个自然月的数据集
+
+数据集用带时区的半开区间定义。例如北京时间 `[2026-10-01 00:00, 2026-11-01 00:00)` 是 31 天,不能固定按 30 天计算。内部统一保存带时区时间,界面按 Asia/Shanghai 展示。
+
+准备数据时冻结清单:`dataset_id`、`dataset_revision`、源项目、观测区间、可用历史基线区间、设备规范 ID、类型、单位、设备采样周期、采样相位、指标含义、raw 分片摘要和规则配置版本。由服务端读取项目路由,页面不传物理库名、SQL 或连接信息。摘要只覆盖原始观测与必要元数据,不覆盖会被本轮清洗更新的 cleaned_value。月度摘要由数据集准备流程预先生成,创建回放的 HTTP 请求只校验清单,不同步扫描和加载整个月。
+
+首版演示直接读取已准备在本地 TSDB 的 raw,不另导出一套完整原始数据源。月份数据如果尚未齐备,由单独授权的数据准备过程补齐并生成版本清单;创建或播放回放本身不生成、删除、搬移或重复插入 SCADA 原始观测。演示批次是对已有观测的读取与处理身份,不能在统计中称为本轮新插入条数。
+
+设备档案采样周期与实际观测间隔不一致时,记录配置差异和缺测情况,仍按实际时间戳读取。不会将 15 分钟数据插值成 5 分钟观测,也不会把没有记录的时刻当作正常值。项目监测任务间隔不参与这项判断。
+
+多次异常应同时覆盖:真实压力异常、真实流量变化、真实水质变化、仪表或通信故障、正常调度引起的变化,以及恢复后的再次异常。数据集真值清单独立保存,用于事后计算命中、误报、漏报、重复事件数和发现延迟。真值不得出现在检测输入、Agent 上下文、预告型时间轴标记或普通业务报告中。
+
+水质必须声明具体指标。当前演示中的化学示踪物浓度不能直接解释为余氯、浊度,也不能只凭 `mg/L` 选择检测阈值。流量保留方向,压力确认 m、kPa、MPa 等单位后再应用规则。
+
+数据集准备负责人需要在运行前提交版本化设备清单、规则参数和独立真值。这是新数据集的输入要求,不表示本次已选定新的 5 分钟设备分组或生成了观测。
+
+## 时间轴和读取语义
+
+### 时间与进度
+
+| 名称 | 含义 |
+| --- | --- |
+| `observed_at` | 原始观测时间,不随回放改写 |
+| `virtual_now` | 已完成主动监测的周期边界时间 |
+| `delivered_until` | 本轮源区间已读完,且所有分片的记录引用已登记为待处理的时间上限;表示模拟传入进度,不是原始值新插入时间 |
+| `processed_until` | 质量校验和检测已完整提交的时间上限 |
+| `read_at`、`cleaned_at`、`processed_at` | 实际批次读取、清洗提交和检测完成时间,用于延迟、故障与审计 |
+
+始终满足 `processed_until <= delivered_until <= virtual_now <= end_at`。还未处理任何批次时,读取和处理进度为空,不能伪装成起点已经处理。GET 返回数据不自动推进任何进度;后台确认本批处理身份后才更新读取进度。表格可显示本轮已读取但仍在清洗的 raw 并标明状态,检测只能消费已完成清洗阶段的批次。主界面的次级进度使用“已读取”和“已检测”。
+
+后台仅在播放状态累计模拟时间。暂停期间不推进;恢复时重新锚定现实时间。进程重启从持久进度重新锚定,不把停机时长一次性折算成需要跳过的数据。
+
+### 顺序读取
+
+1. 整次回放选取 `[start_at, end_at)`;首批包含 `start_at` 的观测。
+2. 时间推进到 T 时,候选记录必须不晚于 T、早于 `end_at`,且在已成功接收的联合游标 `(observed_at, device_id)` 之后。
+3. 所有选中设备合并后按 `observed_at, device_id` 排序;优先按同一观测时刻成批,不按设备分别跑独立时钟。
+4. 每个传输片段最多 500 条、序列化正文最多 2 MiB;超限继续分片。记录片段序号与该时刻是否完整,不在半个时刻接收成功时提前推进时间上限;同一时刻的设备分组检测等待该时刻所有分片收齐。
+5. 后台将本批记录引用和原始摘要登记成功后,才推进读取游标。重试同一批使用相同编号和摘要;清洗或检测失败从该阶段恢复,不重新选择一批、不重新插入 raw。正常路径复用本次读取的 raw 对象计算清洗结果,清洗提交后直接将同批结果用于检测,不再重新查询该批 raw 或 corrected。
+6. 没有观测的时间段按设备档案采样周期与采样相位识别应到时刻。后台提交记录数为零的区间检查批次,推进覆盖进度并触发缺测判断;它不生成伪造观测。不能仅依赖“下一条数据到来”唤醒,否则连续断流永远不会被发现,终点也无法排空完成。
+
+例如设备档案中的压力采样周期为 5 分钟、流量采样周期为 15 分钟。时间轴到 09:10 时,压力设备可传入 09:00、09:05、09:10,流量设备只有 09:00;到 09:15 时,再传入两类设备对应的 09:15 记录。
+
+首版至多保留一个未完成批次,清洗检测提交后才选择下一批。单次请求不跳过周期内的中间观测、质量检查、异常开始和恢复,也不提供播放倍率。
+
+### 页面操作
+
+| 操作 | 约定 |
+| --- | --- |
+| 创建 | 固定数据集、范围、设备和规则,进入 ready |
+| 播放 | ready 或 paused 进入 running,由后台推进 |
+| 暂停 | 停止释放新批次;已持久接收的批次继续处理。返回实际生效进度,在途接收与暂停命令通过同一回放版本串行裁决 |
+| 下一步并监测 | 在 ready 或 paused 状态推进到下一个项目监测周期边界;周期内的所有观测按原始时间顺序处理,到边界后提交一次本周期监测回执并保持暂停,尾步可短于完整周期 |
+| 调速 | 原子更新速度和时间锚点,不改变观测顺序 |
+| 拖动时间轴 | 只查看已释放的数据与已产生的事件;未来区间不预览读数,不触发事件 |
+| 从指定时刻重播 | 新建 `replay_id`,独立检测与调查;新起点之前仅作为有声明的历史基线,跳过段不声称已经检测 |
+| 到达终点 | 停止新批次,进入 draining;待已接收批次完成后 completed。Agent 调查是否完成另行展示 |
+| 停止 | 终止继续传入,保留已接收批次、事件和证据;不自动撤销已经建立的调查 |
+| 重试故障 | 修复依赖后校验固定配置,failed 进入 paused,重试原未完成批次;源版本或批次内容冲突需要新建回放,不能靠重试忽略 |
+
+生命周期为 ready、running、paused、draining、completed、stopped、failed。队列等待、基线不足和依赖暂不可用是处理状态或原因字段,不再增加一套并行生命周期。
+
+首次运行遇到可重试投递错误,按现实时间退避重试同一批次;权限错误、数据集版本变化、同批编号内容冲突和结构缺失进入 failed,明确原因。用户恢复前必须解决原因;原批次事实继续保留。
+
+### 选定演示区间
+
+用户先在月度数据可用范围内选择起止时间。创建回放后,将该区间固定为当前播放范围;进度比例与剩余时长都相对该区间计算。例如选择 10 月 8 日 08:00 至 14:00,时间轴只覆盖这 6 小时,不能将月内 8 日的位置作为本轮进度。
+
+主要界面常驻显示选定区间和当前监测时间。月份、数据版本、设备范围及规则收进来源与设置区域;需要校验的配置差异仍在开始前明确显示。不存在上一次选择时不自动处理整个月,等待用户确定区间。
+
+演示建立后改变时间区间需要停止并创建新回放;保留旧回放的事件和证据,不把旧回放时间游标直接重设。已完成区间需要再次演示时,新建独立回放;不自动循环处理和重复触发调查。
+
+选定区间之前的可用历史仅用于已声明的基线初始化,不占用主时间轴、不作为新到达批次、不预触发此前事件。基线不足时在当前区间显示具体可用性,不能悄悄扩大演示范围或读取区间之后的数据。
+
+处理到选定终点后停在终点,显示数据处理是否完成及剩余调查数量;不会自动继续处理该月剩余数据。空区间明确显示没有观测,不能通过控件动画假装已经收到 SCADA 数据。
+
+### 5 分钟时间定位与项目监测周期
+
+时间轴以 5 分钟时间戳组织刻度、标签和选择值,只负责定位和回看,不读取 `projects.settings.scada_monitoring_interval_seconds`。方向键按 5 分钟移动;鼠标和触摸的拖动轨迹保持连续调频手感,选定值仍按最接近的 5 分钟时间戳归档。标签密度可以随面板宽度合并,不会改变拖动手感或时间选择精度。
+
+能力接口另行返回当前项目监测周期;创建回放时由服务端固定到回放文档及快照的 `monitoring_interval_seconds`,页面不能提交不同周期。项目配置后续调整只影响新回放,当前演示继续使用固定值;未配置时提示设置。
+
+| 交互 | 行为 |
+| --- | --- |
+| 方向键 | 前后移动一个 5 分钟时间戳 |
+| PageUp / PageDown | 移动一个当前可见的主刻度,主刻度始终为 5 分钟的整数倍 |
+| 拖动回看 | 拖动轨迹连续无缝,选择值按 5 分钟量化;只读定位不推进后台 |
+| 主刻度 | 宽屏可每 5 分钟标注,窄屏按 10、15 分钟等密度显示,细刻线保留 5 分钟定位 |
+| 下一步并监测 | 不读取时间轴位置;如后台已监测至 08:07,15 分钟周期处理到 08:22 |
+
+“下一步并监测”通过持久化 `step_target` 与 `step_pending` 管理。单步开始后,API 按原观测时刻读取、清洗和检测到目标边界,完成前禁用重复单步。若周期内有 5 分钟观测,15 分钟监测周期仍处理其中每个时刻;没有观测则提交空区间检查,不补造数据。尾步到达 end_time 时,仍遵守原始区间 `[start_time, end_time)`。
+
+时间轴以 5 分钟时间戳定位,不能控制或触发周期处理;“下一步并监测”按项目监测频率推进后台周期,不将原始观测重采样。旧回放没有固定周期时,页面要求停止后重新创建,不能把后来修改的项目周期追补为历史事实。
+
+### 步长边界主动监测与爆管流程
+
+一次主动监测的身份为 `(replay_id, period_end, monitor_rule_version)`。同一步骤重试复用原回执,不能重复创建告警、调查或计算。监测回执保存在该步最后一个回放批次的 `monitoring` 中,并将最近一次结果投影到回放快照;内容包含周期起止、知识截止、输入批次上限、数据完整性、各指标状态、组合结论和关联任务。它不是 `scheduled_flow`,也不需要通用定时器。
+
+步长边界按以下顺序执行:
+
+1. 确认本周期内所有已到达观测均已完成质量校验和清洗;缺测、无效值和单位冲突进入数据完整性结果,不能按正常值处理。
+2. 汇总压力下降幅度。只使用映射明确、单位一致、质量合格的压力测点,并同时保留异常测点数、覆盖测点数、基线窗口和最大或中位下降幅度。
+3. 汇总出厂流量变化。设备必须在项目配置或设备档案中明确标注为“出厂边界流量”,不能把任意 `pipe_flow` 测点当作出厂流量;保留方向、单位、基线和增量。
+4. 条件满足时运行爆管侦测。现有侦测要求至少 5 个完整压力点和目标时刻之前 15 天同周期数据;不满足时返回 `baseline_not_ready` 或 `sensor_coverage_insufficient`,不能将“未运行”解释为“无异常”。
+5. 组合上述证据形成监测结论;只有疑似爆管才创建 `event_investigation`,工作流使用现有 `burst_report`,并绑定本轮 `replay_context`。正常周期只保存回执,不启动 Agent;数据不足周期保存缺口并在界面展示。
+6. `burst_report` Skill 核对证据后决定是否调用爆管定位。定位必须有 m³/s 的有依据漏量估计或明确情景假设,以及模型节点映射、异常窗和正常对照窗;输入不具备时保持“疑似爆管、定位待补充”,不能填默认漏量。
+
+组合判定采用可版本化策略,首版演示建议如下:
+
+| 监测结果 | 判定条件 | 后续动作 |
+| --- | --- | --- |
+| `insufficient_data` | 必需点位缺测、质量无效、单位或角色未配置,或爆管侦测基线不满足 | 保存缺口,不声明正常,不启动定位 |
+| `normal` | 数据完整,爆管侦测未命中,压力与出厂流量规则均未命中 | 保存周期回执,不创建 Agent 任务 |
+| `attention` | 只有单项指标命中,或指标间方向不一致 | 保存观察结果;下一步继续监测,必要时由人发起调查 |
+| `suspected_burst` | 爆管侦测命中,且压力下降与出厂流量增加在同一业务时间窗内相互支持 | 创建一次 `burst_report` 调查;定位输入具备后进入计算 |
+
+爆管侦测和压力下降存在相关性,因此回执不能把两者描述成完全独立的两票。出厂流量也会受泵站调度、储水设施和正常用水变化影响。组合结论只是调查触发条件,不是现场确诊。
+
+回放中的 MCP 定位已走 Server 持有的安全适配层:按 `replay_id`、`knowledge_cutoff` 和 `batch_sequence` 从已固化清洗输出组装压力与可选流量序列,再调用现有计算队列。回放只允许 monitoring 模式,并要求显式异常窗、正常对照窗和有依据的 `burst_leakage`;不能直接查询 TSDB 中尚未回放的未来记录。步长爆管侦测同样只读取最近 15 天已固化输出。计算结果保存输入、算法与证据引用,任务恢复或重试仍使用相同知识上限。
+
+### FM 调频盘样式
+
+用户明确选择电台调频盘的刻度、红色指示线和玻璃质感。这里的 FM 表示调谐外观,不增加电台、音频、频段切换或拟物旋钮功能。
+
+视觉方向:工作台内一块冷白半透明的玻璃刻度窗,红色细线作为唯一醒目的时间定位标记;文字、控件与下方数据沿用现有工作台主题。内容顺序为选定区间、当前时间、刻度窗、控制栏、传入与检测进度。交互特征为固定指示线与横向移动的刻度带,手动拨动直接跟手。
+
+
+
+图中时间和状态仅为静态样式示意,不是运行结果。玻璃下的底色用于说明透光与边缘高光,运行时仍需验证实际工作台背景下的对比度。
+
+| 元素 | 设计约定 |
+| --- | --- |
+| 演示区间 | 常驻显示完整起止日期和时间;更换区间是次要入口,月度来源收在设置中 |
+| 当前时间 | 刻度窗上方突出显示红线对应的时间,数字等宽;跨日时同时显示日期 |
+| 时间刻度 | 主刻度有时间标签,次刻度细密且高低有别;实际密度按区间与容器宽度计算,窄屏优先减少标签而非缩小文字 |
+| 红色指示线 | 固定在刻度窗水平中央,1 至 2px 宽,带小型顶端指示;这是时间定位色,不代表告警严重度 |
+| 玻璃刻度窗 | 局部半透明、背景柔化、细边缘高光和轻微内阴影,玻璃层不影响数字、刻度和红线的清晰度 |
+| 控制栏 | 播放或暂停是唯一主操作,下一批与倍速为辅助;回看时才出现回到当前 |
+| 次级进度 | 单行显示已传入与已检测时间,积压时显示等待处理;不增加三块同等醒目的仪表 |
+
+玻璃效果是本次按用户要求对 SCADA 调频刻度窗的局部材质例外,外围面板外壳、表格、表单、菜单和 Agent 面板仍沿用现有 shadcn/ui 实体设计。新增时间指示颜色使用独立语义变量,不能让正常播放状态套用危险告警颜色。关闭透明效果或不支持背景模糊时,回退到清晰的实体刻度窗。
+
+固定红线表示当前定位时间,刻度带按 5 分钟时间戳移动;只在选定区间内标注时间,两端之外留白,不带出整个月的刻度。该窗是选定区间的只读定位视窗,完整起止范围始终由上方文字明确表达;拨动不改变传入或检测进度。
+
+手动拖动和触屏横拨改变查看位置,不立即改写后台时间。拨动到已释放时间时显示历史快照并标明回看;拨动到尚未释放时间时只显示待传入时间,不展示未来读数,若需从该位置开始则明确新建回放。回到当前只恢复跟随,不补发旧数据、告警或调查。
+
+时间拨动按 slider 语义提供中文名称、时间范围、当前时间文本和键盘操作:方向键按 5 分钟刻度调整,PageUp/PageDown 调整一个主刻度,Home/End 到选定范围两端。键盘操作即时生效;鼠标和触摸拖动轨迹连续无缝、不带惯性,选定时间按 5 分钟量化。减少动态效果时关闭装饰性过渡,保留真实时间定位。状态不用颜色单独表达。
+
+## 数据质量与业务异常
+
+真实外部系统新增观测时,先通过现有入库边界将 raw 保存到 TSDB。本次演示使用已经入库的原始值,读取 API 校验身份、项目、设备与时间范围后返回 raw,直接进入质量校验和清洗;不为了模拟传入而重复执行原始值 INSERT。清洗只写回同一条观测的 cleaned_value,monitored_value 始终保留。
+
+清洗完成后才启动业务异常检测。valid 可将 cleaned_value 设为原始值,corrected 保存有依据的修正值,无法可靠修正的 invalid 或 missing 保持空值并保存质量结论。suspect 保留 raw 与不确定性,不能自动替换为正常值。是否已经清洗以阶段回执和质量结论为准,不能仅用 cleaned_value 是否为空判断。
+
+| 质量结论 | 选择值与后续处理 |
+| --- | --- |
+| valid | 使用 raw,包括可靠采集的突降、突升、低压和浓度变化 |
+| corrected | 使用有依据的修正值,同时保留 raw、修正值、规则版本、原因和可用时间 |
+| suspect | 保留 raw 并声明不确定性,结合其他测点和设备状态复核;不自动改成正常值 |
+| invalid 或 missing | 保留原始记录或缺测事实,退出要求可信观测的规则输入,形成数据质量事件 |
+
+质量校验依据结构、有限数值、单位、设备身份、观测顺序、重复记录、量程与设备状态;仅偏离日常水平不能直接判 invalid。负流量、零浓度、低压均需按设备和业务含义解释。缺少仪表状态时明确证据不足,不能凭一次尖峰确认仪表损坏。
+
+当前正式表中的 `cleaned_value` 可以保留,但不等于本轮已经完成清洗。读取 raw 时不清空已有 corrected,也不将旧 corrected 当作清洗输入。清洗阶段使用本批 raw 和本轮已知历史重新判断,提交本轮清洗版本并保存处理结果;检测只能使用该版本已提交的结果,不能直接取一个来源和时间不明的旧 corrected 当作已校验输入。
+
+在线修正只使用当前已到达数据。当前居中窗口、后向补值、整段 Kalman 平滑和整段统计阈值保留为离线分析能力;不能一次性清洗整个月后将结果送入时间轴。在线清洗更新目标 TSDB 的修正值投影,原始值不变;原修正版本、当前修正版本和依据保留在处理证据中,旧回放仍读取自己的固定版本。
+
+每条参与判断的数据记录其 `value_source`、质量结论、选用值、原始记录引用、规则版本和计算时间。回放过程不为未知错误强制生成一个修正数值。
+
+这一区分对应 [EPA 在线水质监测指南第 6.1、6.2 节](https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=P100UJYF.txt) 的“先校验、再异常检测,并保留原始值”原则;历史修订留痕可参考 [OPC UA Historical Access 第 4.4 节](https://reference.opcfoundation.org/specs/OPC-10000-11/4)。本项目的具体质量判定仍由指标和设备证据决定。
+
+## 检测规则和多次事件
+
+首版实时检测使用有版本的类型化规则:压力变化与上下限、流量变化与方向、水质具体指标的上下限或变化,以及数据质量规则。规则输入包含质量结论,输出只表示异常信号及依据,不确认爆管位置或污染原因。
+
+每项启用的规则必须声明:`rule_id`、指标、方向、单位、阈值、使用的历史窗口、最少有效样本、连续命中点数、恢复阈值、连续恢复点数,以及事件组和最少同时命中的设备数。参数在创建回放前校验并冻结,至少需要一种已配置规则才可创建检测回放。缺失参数时该规则为 not_configured,不能借用统一 30% 或把零到非零统一解释为 100% 污染风险。
+
+同一个事件组由明确的设备集合组成,不使用预设异常真值自动分组。同组、同规则、同一持续异常归并为一个事件;不同事件组可以同时产生不同事件。恢复需要可信的新观测满足恢复条件,缺测不构成恢复。恢复后的再次命中建立新事件。
+
+事件保存首次异常、检测触发、最后异常、恢复时刻、证据引用和调查结论。传感器恢复正常只表示观测恢复,不代表现场维修、污染处置或业务调查已经完成。
+
+事件身份由 `replay_id + rule_id + event_group_id + first_trigger_at` 确定;同一事件更新不创建多条调查。批次重试不重复创建事件,新一次回放的事件与旧回放独立。
+
+现有 [爆管侦测服务](../app/services/burst_detection.py) 可作为满足条件后的进一步分析工具,不能直接等同于逐批在线检测器。当前 target 模式需要 15 天数据及至少 5 个完整压力测点。回放前没有足够历史时,展示 baseline_not_ready;支持短历史的规则仍可运行,不能用未来几天补足参考窗口。
+
+正常基线只使用当时已知、质量合格且适用于该规则的历史。已确认异常可以从正常基线中排除,原始记录仍保留。事后确认或重新标注产生新版本,不改写此前检测“当时知道了什么”。
+
+## 持久化和事务
+
+观测数据以项目目标 TSDB 的 `scada.measurements` 为权威存储,沿用 time、device_id、monitored_value 和 cleaned_value。业务库不替代 TSDB 保存正式观测。
+
+建议在 BizDB 增加以下处理对象:
+
+| 对象 | 权威信息 |
+| --- | --- |
+| `peripheral_sample.scada_replays` | 回放身份、项目、创建者、冻结配置、生命周期、版本、模拟时间锚点、读取与处理进度、工作租约和恢复检查点 |
+| `peripheral_sample.scada_replay_batches` | 本批 TSDB 记录键、raw 摘要、固定规则版本、清洗输出、质量结论、检测结果、处理阶段和重试信息 |
+
+两个对象分别具有控制生命周期与批次处理生命周期。现有 test_data 是手工单条演练 CRUD,缺少顺序、批次和消费语义;任务表表示业务调查,也不能存正常观测批次。这里保存记录引用和处理结果,不复制一整套 raw;需要固化异常证据或修正依据时,只保存必要的原始片段。由于本次不重新摄入 raw,不新增 TSDB 原始值收件表。表和列同时写清数据库注释。
+
+事件及当前告警复用 `workbench.alert_events`、`workbench.alerts`,调查及其证据复用现有 operational 表。告警来源扩展为判别联合:保留现有任务来源,新增 `kind=scada_replay`、`replay_id`、`dataset_revision`、规则组与触发批次,避免伪造调查身份。质量故障也用同一事件存储,按类别区分。
+
+### 批次处理与提交
+
+不使用一个长期跨库事务。批次按以下阶段单调推进,重试保持原批次身份:
+
+1. `selected`:后台读取 raw 后,BizDB 固定记录键、输入摘要、规则与知识截止时间,并在同一事务登记处理身份和读取检查点。GET 本身不做此写入。
+2. `clean_prepared`:直接对已读入的这一批 raw 和已加载的历史状态计算质量与修正结果,先将确定的输出及输入摘要保存在 BizDB。正常路径不调用会重新查询同批 raw 的整段清洗入口。
+3. `cleaned`:TSDB 用一个有界事务按记录键更新 cleaned_value,并校验 raw 仍与本批输入一致;不更新 monitored_value。只有 TSDB 提交成功且 BizDB 记录已提交阶段后,才允许检测。单行结果相同时不必重写字段。
+4. 若 TSDB 已提交但 BizDB 阶段尚未更新,恢复时核验预存的输入与输出;已经匹配则补记 cleaned,未写入则幂等应用预存结果,raw 已变则停止该批次。只在这种恢复或校验冲突路径增加读取,不为每批正常处理增加一次重复查询。
+5. `detected`:直接使用本批已提交的清洗结果做检测,BizDB 在同一事务保存检测结果、事件状态、调查意图、检查点和 processed_until,然后发布业务事件。检测失败只重试本阶段,不重新清洗或重写 raw。
+
+以上阶段保存在批次中。页面继续突出模拟时间、已读取进度和已检测进度,清洗中或失败作为当前批次状态展示。正常路径对新批次读取一次 raw、必要时批量更新一次修正值,再复用这批处理结果完成检测;界面后续查看与故障恢复属于独立的必要读取。
+
+BizDB 批次状态是持久待处理队列,通知只负责唤醒。后台启动、通知中断与定期对账均扫描未结束批次,按已提交阶段恢复;同库状态与事件在同一事务保存,不能假定两个数据库天然原子。[PostgreSQL NOTIFY](https://www.postgresql.org/docs/15/sql-notify.html) 和 [SELECT 锁定语义](https://www.postgresql.org/docs/15/sql-select.html) 提供通知及领取基础。
+
+调用慢算法和外部服务前结束数据库事务,不持有事务锁等待 HTTP。清洗结果未提交,不执行异常检测;中断后从已有检查点恢复,不丢弃批次身份重新处理一遍。
+
+### 重复读取与重复回放
+
+读取 API 的同一次游标请求可以安全重试,但相同数据不能被当作新的处理批次。后台以项目、回放、批次序号和分片序号登记幂等身份,记录级使用规范设备 ID 与原始时间;同键同摘要复用现有处理状态,不同摘要返回冲突。
+
+新一轮演示按新的 replay_id 建立独立处理状态,可以重新读取和分析选定区间,但仍不插入 raw。界面统计已读取条数和已处理条数,不能称为新写入 SCADA 条数。
+
+首版同一项目只允许一条活动的读取清洗流水线,避免不同回放同时改写同一 cleaned_value。历史结果仍可同时查看。修正字段表示最近一次已发布清洗结果,旧版结果和依据保留在批次输出及必要的原始证据中;旧 Agent 执行和历史回看不直接读取被后续处理替换的修正投影。
+
+工作台事件流只发布回放控制状态变化、故障与业务告警;不为每个测点值发布 SSE,也不把全部原始数据放入事件日志。未来若新增 `scada.replay.changed` 主题,只用于控制状态,并需同步主题模型、约束和消费契约;当前控制进度由轮询读取。
+
+未读取数据所在 raw 分片摘要变化时停止该回放,要求新数据集版本;本轮 cleaned_value 更新不计作源版本漂移。空检查批次也需要保存处理阶段,记录数为零不代表可以绕过缺测判断。
+
+每个回放同一时刻只有一个有效处理租约,采用数据库会话锁阻止两个请求重复推进同一周期。每个批次提交后再读取下一原始时间戳,不持久保存进程计时器值。
+
+源数据和相关回放证据在本次演示中不自动过期;一个月是数据覆盖范围,不是保存期限。事件结束、暂停或重播不清理已有证据。生产保留策略单独制定。
+
+## Agent 调查与时间隔离
+
+主动单步在边界形成组合监测回执:一般关注和数据不足只保存回执,只有 `suspected_burst` 进入 `burst_report`。SCADA 演示请求只负责建立持久调查任务,不要求先生成一条合成 test_data,也不在请求内执行 Agent。
+
+告警事实与“待建立调查”意图在事件事务中保存。创建调查失败时后台对账重试;组合监测调查的来源稳定标识使用 `scada_replay::monitor::`,一般事件继续使用 `scada_replay::event:`,均沿用现有外部来源幂等约束。事件不因 Agent 不可用而丢失。
+
+已新增 `scada_anomaly` 工作流与对应 Skill,要求先核验数据质量、点位和单位,再读取事件窗口及正常参考。疑似爆管复用现有 `burst_report` Skill;它会区分侦测、定位候选和现场确诊,并要求漏量依据。`run_burst_location` 在回放任务中通过冻结输出适配执行,其他未适配计算继续拒绝;不能仅修改 Skill 文本或 MCP allowlist 绕过知识截止。重计算沿用 [后台计算队列](calculation-queue.md),保留外层 HTTP 限制。
+
+新增 Server 持有的回放查询上下文:`replay_id`、数据集版本、知识截止时刻 `knowledge_cutoff`、已接收批次上限、处理版本和来源事件。它与任务及执行代次绑定,不能由模型或客户端自行扩大。
+
+每代调查开始时冻结其可用批次与时间上限;同一执行中后续回放推进不会偷偷改变证据。确有新的重要证据时,按现有 [调查轮次规则](operational-tasks.md) 创建或合并后续调查轮次,而不是每批次追加一次模型执行。
+
+时间隔离必须覆盖 MCP 的 SCADA 查询、要素历史、任务数据快照、外围观测,以及检测算法内部读取。raw 源读取 API 只允许读取不晚于模拟时钟的观测;检测只使用已完成本轮清洗的批次结果,原始表格可以显示本轮已读取且尚在清洗的 raw。历史回看和旧代调查读取对应固定版本或原始记录引用,异常证据使用已经固化的必要片段。即使 TSDB 存有整个月记录,也必须按本轮读取检查点、设备集合和知识截止时间过滤,不能只在提示词中要求遵守。
+
+所有回放调查证据保留真实来源:合成数据仍为 synthetic,历史回放有明确标识,不能因为读取的是正式表就改称现场实时观测。普通历史数据管理仍可按原权限查看数据集,但不能作为回放会话绕过截止时间的途径。
+
+回放调查只生成演示分析、计划和报告;真实外围工单和现场控制不属于自动回放动作。Server 动作执行边界必须识别回放上下文,不能仅靠 Agent 提示阻止将演示事件发成真实业务工单。
+
+## 接口与界面
+
+以下为已经加入 Server OpenAPI 的路径,统一使用 Bearer 与 X-Project-Id。保留原有 `/peripheral/api/sample/v1` 工单与手工演练接口,回放不增加一层外围代理。
+
+| 接口 | 语义 |
+| --- | --- |
+| `GET /api/v1/scada-replays/capabilities` | 回放表可用性、写权限、监测频率、爆管组合监测角色是否就绪及最近一轮状态;不代表完整月度数据就绪 |
+| `POST /api/v1/scada-replays` | 固定区间、设备、规则和质量量程,返回 ready;携带稳定 request_id |
+| `GET /api/v1/scada-replays/{replay_id}` | 权威控制、时间、计数、设备与固定规则、当前输出及故障 |
+| `POST /api/v1/scada-replays/{replay_id}/commands` | play、pause、step、set_speed、stop、retry;request_id + expected_revision |
+| `GET /api/v1/scada-replays/{replay_id}/observations?through=...` | 仅本轮已读取时间范围内的历史快照,每设备最近一条、最多 500 个 |
+| `GET /api/v1/timeseries/scada-readings/raw` | 当前项目 TSDB 原始值联合游标分页;不插入、不隐式清洗 |
+| `GET /api/v1/scada-replays/{replay_id}/events?offset=0&limit=100` | 检测变化与调查关联,返回 items、next_offset |
+
+raw API 允许参数为 start_time、end_time、device_ids、cursor、limit;回放请求携带经服务端验证的 replay_id,任务请求由已有绑定解析回放身份。有效范围为所选区间与模拟时间上限的交集,不能因省略 replay_id 绕过已绑定的任务限制。返回 items(time、device_id、monitored_value、quality)、next_cursor 和 scanned_through;本版不返回尚未准备的 dataset_revision,不返回 corrected 作为源输入。
+
+next_cursor 绑定项目、设备、查询区间并固定本轮分页时间上限;本版不为整个查询提供 MVCC 源版本快照,按 time、device_id 排序,每页最多 500 条。读取窗口未结束但当前页为空时,scanned_through 表示已经确认没有观测的区间上限,供后台登记空检查批次。分页、刷新和网络重试均是无副作用读取。
+
+查询和检查点更新使用类型化服务方法及已验证项目上下文。清洗函数接受已经读入的批次,不再调用当前会重新查询 raw 的整段清洗入口;检测函数接受这批已提交清洗结果。页面的单步 POST 命令同步完成一个监测周期,GET 预览和回看不启动任务。
+
+回放读取要求 scada.view,创建及控制要求 scada.clean;普通 raw API 沿用 timeseries SCADA 路由的读权限。API 不自动创建表,不回退默认项目,也不隐式枚举全部活跃项目。
+
+前端 SCADA 页签按以下顺序组织:
+
+1. 选定演示区间:开始时间、结束时间和项目监测步长;设备范围、规则及基线收纳在折叠的检测参数区域。
+2. FM 调频式时间轴:局部玻璃刻度窗、固定红色指示线、两侧渐暗及中央放大;按 5 分钟时间戳定位,不读取逐测点列表,也不触发监测。
+3. 主动监测操作:以“开始并监测”或“下一步并监测”为唯一主操作,按项目频率同步处理一个周期。
+4. 本周期结论:显示正常、关注、数据不足或疑似爆管,以及是否已经进入爆管调查与定位流程。
+
+设备目录、raw 与修正值明细、完整事件列表和手工单点异常录入不在演示主面板展示。底层数据与 API 继续保留,供诊断、追溯和调查任务按权限读取。
+
+沿用现有 workbench-shadcn-surface、日期控件和实体表面,调频刻度窗采用上文明确的局部玻璃例外;时间轴支持键盘操作和中文可访问名称。拖动回看不触发后台写操作。
+
+页面可见时每 5 秒读取一次权威控制快照;单步请求执行期间由操作本身返回最终快照。页面隐藏时停止界面采样。演示面板不轮询完整事件列表,调查入口需要时再按分页 API 查询。
+
+## 实施范围与验证
+
+这是跨 Server、供水前端和 Agent 契约的变更,不新增后台进程。数据、存储、身份及网络复用现有部署。
+
+| 仓库 | 主要落点 |
+| --- | --- |
+| Server | `app/api/v1/endpoints/scada_replay.py` 的回放控制与能力;`app/services/` 和 `app/infra/db/` 的批次、质量和事件;现有 task_alert、operational_task、MCP 上下文、查询和 Skill |
+| Server 契约与存储 | `resources/sql/` 的 BizDB 两张回放表迁移;`contracts/` 的 raw 读取、Server OpenAPI 和清单;外围协议不变;TSDB 沿用已有观测表 |
+| 前端 | `src/features/workbench/peripheral-sample/` 的回放控制与状态 hook;SCADA 客户端与生成契约;告警来源和事件解析 |
+| Agent | `src/product/` 的生成契约及任务上下文适配,保留已有原生 MCP 与执行权限边界 |
+
+验收用例必须覆盖:
+
+- 5/15 分钟设备混合、乱序源记录、同一时间戳跨页、空片段、结束边界和多次异常。
+- 重复批次、相同编号不同内容、响应丢失、请求中断和恢复重试;不漏读、不重复事件。
+- GET 重试、分页重复请求、清洗输出预存后中断、TSDB 更新提交而 BizDB 阶段未更新、检测失败;逐阶段恢复,不提前检测。
+- 正常处理每批只读一次 raw,清洗后检测复用结果;回放不执行 raw INSERT,不在读取时清空旧 corrected,空数据不伪造观测。
+- 周期内多批次单步、重复单步拒绝、并发请求互斥和尾步完成状态。
+- 5 分钟时间定位与 15 分钟监测周期相互独立、非整点起点、周期配置变更不影响当前回放、尾步不足完整周期和未配置时禁止开始。
+- 每个步长边界只产生一份幂等监测回执;正常、数据不足、单指标关注和疑似爆管分别处理,不为正常周期创建 Agent。
+- 爆管侦测基线不足、压力下降、出厂流量变化、设备角色错误、指标方向冲突和多指标同时命中;只有疑似爆管建立 `burst_report`。
+- 回放 MCP 侦测与定位只能读取固定批次和知识截止前数据;定位缺少漏量依据时停止在待补充,不调用算法。
+- 选定小时区间、跨日区间和再次演示;刻度与进度只对应选定区间,基线初始化不释放旧事件,到终点不自动播放整月。
+- FM 红线固定、刻度带移动、拖动回看不重复投递、未来位置不泄露读数;桌面与 375px、320px 窄屏下的长日期、键盘、减少动态效果及玻璃回退。
+- 真实突降不被当作采集误差修掉;负流量、零水质值、空值、错误单位和缺测恢复分别处理。
+- 首个异常、持续异常、可信恢复、再次异常及两个事件组并发;不会逐点创建调查。
+- 缺少基线、缺少水质指标配置、历史 corrected 无可用时间,以及 source_revision 变化。
+- MCP、任务数据接口及算法内部的未来查询均被拒绝;下一代调查才能使用新增证据。
+- 合成来源、历史回放来源、事件真值隔离;回放不发布真实现场动作。
+- 合成测试链路、真实项目 API、常驻后台、真实 Agent 执行和浏览器分别记录验证结果。
+
+后续实现的验证命令从各仓库执行:Server 运行新增回放、批次、质量、事件与查询边界的聚焦 pytest,并保留现有 SCADA/告警/调查回归;前端运行 `pnpm lint`、`pnpm typecheck`、`pnpm test` 和聚焦 `pnpm test:browser`;Agent 修改后运行 `bun run check` 及对应聚焦测试。具体已执行结果见下方,不把待部署链路计为通过。
+
+数据库应用遵守 [工作台数据库变更门禁](workbench-database-rollout.md):先核对目标项目,备份和回滚演练,再按明确范围应用及验收;不得默认向全部项目写入。本地 MetaDB 已对明确的演示项目应用并复验 `033_metadata_project_settings.sql`,其他项目保持原值;`tjwater2_v2` BizDB 已应用 `031_scada_replays.sql`,不据此推断其他项目已迁移。本次没有生成一个月观测或启动常驻进程。
+
+回退应用前暂停新批次读取,核对 clean_prepared、cleaned 与 detected 阶段,保留原始观测、已提交修正值、回放表、告警与调查证据;升级恢复后从原阶段继续。BizDB 流程记录和 TSDB 修正值不在同一事务,回滚应用不能假定另一库结果也被撤回。不能通过删除事件、重置时间游标或清空 SCADA 原始表来伪装成从未处理。若需要撤回数据库变更,另按备份与新增业务写入情况制定恢复步骤,不在仍有写入时覆盖数据库。
+
+本方案依赖的关键前提是源历史能提供稳定设备身份、可信时间戳和固定数据版本。缺少完整月度观测时,能力接口只能声明实际可用范围;缺少修正来源时采用 raw;缺少检测参数时该类检测不启用。任何这些缺口都不能由时间轴、插值或预设事件答案掩盖。
+
+## 部署准备与验证记录
+
+迁移脚本为 [031_scada_replays.sql](../resources/sql/031_scada_replays.sql),显式限制物理数据库 `tjwater_v2` 与 `tjwater2_v2`,依赖既有 peripheral_sample schema、告警及调查结构。仅新增两张 BizDB 表,不新增 TSDB 观测表。`tjwater2_v2` 已应用该迁移并配置 `settings.scada_monitoring_interval_seconds=900`。
+
+真实数据库验证使用 `SCADA_REPLAY_STORAGE_TEST=1 conda run -n server python -m pytest tests/integration/test_scada_replay_storage.py -q`。该测试在项目路由选出的 BizDB 与 TSDB 双回滚事务中临时建表、写测试观测,覆盖真实 SQL 的 cleaned 更新、事件持久化和主动周期推进,退出全部回滚;它不证明身份认证 HTTP 和真实 Agent 推理已经验收。浏览器测试以 mock Server 验证交互与窄屏布局,不能作为现场检测效果证明。
+
+本轮验证结果(2026-11-03,当前工作区):
+
+| 层级 | 结果与边界 |
+| --- | --- |
+| Server | 主动监测、条件式爆管调查、冻结定位适配、MCP、项目配置、raw、处理及 OpenAPI 聚焦回归共 120 项通过 |
+| 数据库与契约 | 本地 MetaDB 的 033 迁移已单项目应用并重复执行验证;OpenAPI 279 条路径与清单摘要一致;本轮未运行 031 的真实双库集成测试 |
+| 前端 | lint、typecheck、全量 148 文件共 884 项 Vitest 通过;设备列表、手工单点演练及对应触发适配已移除 |
+| 浏览器 | 1 项 mock Server 场景通过,包含 5 分钟刻度、选择值与键盘步进、连续拖动轨迹、15 分钟主动监测、中央放大、两侧渐暗、固定红针及 1280/375/320px 布局;未作为真实检测效果证明 |
+| Agent | 同步 Server OpenAPI 并生成 server-api.d.ts,bun run check 通过;无真实模型推理 |
+| 仓库 | Server、供水前端、Agent 的 git diff --check 通过;未提交、推送或部署 |
+
+生产联调仍需核验真实 HTTP 接口、调查执行链路和 Agent 报告,并单独准备完整月度数据及事件真值。不能将以上局部测试解释为这些环节已经完成。
diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql
index 0751487..54218c1 100644
--- a/resources/sql/005_metadata_project_configuration.sql
+++ b/resources/sql/005_metadata_project_configuration.sql
@@ -19,13 +19,18 @@ CREATE TABLE IF NOT EXISTS projects (
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
map_extent JSONB,
map_config JSONB NOT NULL DEFAULT '{}'::jsonb,
+ settings JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(20) DEFAULT 'active' NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
- CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived'))
+ CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived')),
+ CONSTRAINT projects_settings_object_check CHECK (jsonb_typeof(settings) = 'object')
);
ALTER TABLE projects ADD COLUMN IF NOT EXISTS map_config JSONB NOT NULL DEFAULT '{}'::jsonb;
+ALTER TABLE projects ADD COLUMN IF NOT EXISTS settings JSONB NOT NULL DEFAULT '{}'::jsonb;
+COMMENT ON COLUMN projects.settings IS
+ '项目级通用配置(JSON 对象,不存放密钥);scada_monitoring_interval_seconds 为监测任务间隔秒数。';
COMMENT ON COLUMN projects.map_config IS
'项目级地图配置(JSON);place_search 保存地名搜索启用状态、服务商、行政区编码及区域名称;保留既有图例与坐标参考配置。';
diff --git a/resources/sql/031_scada_replays.sql b/resources/sql/031_scada_replays.sql
new file mode 100644
index 0000000..e5883f4
--- /dev/null
+++ b/resources/sql/031_scada_replays.sql
@@ -0,0 +1,46 @@
+BEGIN;
+DO $$ BEGIN
+ IF current_database() NOT IN ('tjwater_v2', 'tjwater2_v2') THEN
+ RAISE EXCEPTION '031_scada_replays requires an explicitly verified SCADA demo project';
+ END IF;
+END $$;
+
+CREATE TABLE IF NOT EXISTS peripheral_sample.scada_replays (
+ project_id uuid NOT NULL,
+ replay_id uuid NOT NULL,
+ request_id uuid NOT NULL,
+ status text NOT NULL CHECK (status IN ('ready','paused','completed','stopped','failed')),
+ document jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (project_id,replay_id),
+ UNIQUE (project_id,request_id)
+);
+CREATE INDEX IF NOT EXISTS scada_replays_status_idx
+ ON peripheral_sample.scada_replays(project_id,status,created_at DESC);
+CREATE TABLE IF NOT EXISTS peripheral_sample.scada_replay_batches (
+ project_id uuid NOT NULL,
+ replay_id uuid NOT NULL,
+ sequence bigint NOT NULL,
+ stage text NOT NULL CHECK(stage IN ('selected','clean_prepared','cleaned','detected')),
+ document jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY(project_id,replay_id,sequence),
+ FOREIGN KEY(project_id,replay_id) REFERENCES peripheral_sample.scada_replays(project_id,replay_id)
+);
+COMMENT ON TABLE peripheral_sample.scada_replays IS 'SCADA 演示回放的权威控制状态与处理检查点,不保存正式原始观测';
+COMMENT ON COLUMN peripheral_sample.scada_replays.project_id IS '经认证与路由校验的所属项目';
+COMMENT ON COLUMN peripheral_sample.scada_replays.replay_id IS '独立回放身份,不等同 Agent 执行编号';
+COMMENT ON COLUMN peripheral_sample.scada_replays.request_id IS '创建请求的稳定幂等编号';
+COMMENT ON COLUMN peripheral_sample.scada_replays.status IS '供领取和约束使用的 document.status 查询投影';
+COMMENT ON COLUMN peripheral_sample.scada_replays.document IS '冻结配置、设备快照、时钟、检查点、规则状态和命令回执';
+COMMENT ON COLUMN peripheral_sample.scada_replays.created_at IS '实际创建时间';
+COMMENT ON COLUMN peripheral_sample.scada_replays.updated_at IS '实际控制或进度更新时间';
+COMMENT ON TABLE peripheral_sample.scada_replay_batches IS 'SCADA raw 引用、清洗输出及检测阶段的恢复凭证,禁止重写 raw';
+COMMENT ON COLUMN peripheral_sample.scada_replay_batches.project_id IS '所属项目';
+COMMENT ON COLUMN peripheral_sample.scada_replay_batches.replay_id IS '所属回放';
+COMMENT ON COLUMN peripheral_sample.scada_replay_batches.sequence IS '回放内单调批次序号';
+COMMENT ON COLUMN peripheral_sample.scada_replay_batches.stage IS '已提交处理阶段';
+COMMENT ON COLUMN peripheral_sample.scada_replay_batches.document IS '观测键、raw 摘要、确定的清洗结果与检测事件';
+COMMENT ON COLUMN peripheral_sample.scada_replay_batches.created_at IS '实际登记批次时间';
+COMMIT;
diff --git a/resources/sql/033_metadata_project_settings.sql b/resources/sql/033_metadata_project_settings.sql
new file mode 100644
index 0000000..8de4fca
--- /dev/null
+++ b/resources/sql/033_metadata_project_settings.sql
@@ -0,0 +1,54 @@
+BEGIN;
+
+DO $$
+BEGIN
+ IF to_regclass('public.project_databases') IS NULL
+ OR to_regclass('public.user_project_membership') IS NULL THEN
+ RAISE EXCEPTION '033_metadata_project_settings must run in the project metadata database';
+ END IF;
+END $$;
+
+ALTER TABLE public.projects
+ ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'::jsonb;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid = 'public.projects'::regclass
+ AND conname = 'projects_settings_object_check'
+ ) THEN
+ ALTER TABLE public.projects
+ ADD CONSTRAINT projects_settings_object_check
+ CHECK (jsonb_typeof(settings) = 'object');
+ END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'projects'
+ AND column_name = 'scada_monitoring_interval_seconds'
+ ) THEN
+ IF EXISTS (
+ SELECT 1 FROM public.projects
+ WHERE scada_monitoring_interval_seconds IS NOT NULL
+ AND settings ? 'scada_monitoring_interval_seconds'
+ AND settings->'scada_monitoring_interval_seconds'
+ IS DISTINCT FROM to_jsonb(scada_monitoring_interval_seconds)
+ ) THEN
+ RAISE EXCEPTION 'Conflicting SCADA monitoring intervals; resolve before migration';
+ END IF;
+
+ UPDATE public.projects
+ SET settings = settings || jsonb_build_object(
+ 'scada_monitoring_interval_seconds', scada_monitoring_interval_seconds
+ )
+ WHERE scada_monitoring_interval_seconds IS NOT NULL;
+
+ ALTER TABLE public.projects DROP COLUMN scada_monitoring_interval_seconds;
+ END IF;
+END $$;
+
+COMMENT ON COLUMN public.projects.settings IS
+ '项目级通用配置(JSON 对象,不存放密钥);scada_monitoring_interval_seconds 为监测任务间隔秒数。';
+
+COMMIT;
diff --git a/resources/work-order-skills/burst-report/SKILL.md b/resources/work-order-skills/burst-report/SKILL.md
index 207085f..6947dec 100644
--- a/resources/work-order-skills/burst-report/SKILL.md
+++ b/resources/work-order-skills/burst-report/SKILL.md
@@ -2,7 +2,7 @@
name: burst-report
description: 调查供水爆管上报,形成有依据的候选位置、隔离阀门、影响范围和后续处置建议。
metadata:
- version: "2.0.6"
+ version: "2.0.8"
---
# 爆管上报处置
@@ -13,6 +13,8 @@ metadata:
读取当前任务、事件时刻、地点、来源、已有报告、证据、动作和反馈。新一轮优先检查新增事实和上轮未决项,按时间、来源、对象及输入比较新旧结果,不按 ID 推断先后,也不因原工单未更新就断言没有新证据。保留本流程名称和版本。
+若任务来源带有 `replay_context`,这是 SCADA 历史回放中的疑似爆管演示。先读取任务摘要中的步长监测结论,再用 `query_scada_readings` 核对冻结批次;不得查询知识截止之后的数据。回放中的 `run_burst_location` 只接受 monitoring 模式,并要求显式提供本轮已处理范围内的异常窗和正常对照窗;Server 会从冻结清洗输出构造计算输入。缺少有依据的 `burst_leakage` 时保留“定位待补充”,不得用出厂流量增量、压力下降或默认值替代。回放结果保持 synthetic,不发布真实工单或设备动作。
+
## 定位与影响分析
根据现有线索自主选择必要步骤:
@@ -26,9 +28,19 @@ metadata:
查询错误、权限拒绝或接口故障不是缺数证据。必要分析无法执行时,说明失败步骤和已经完成的部分,不借有限摘要宣布调查完成。
+## 事件前后影响说明
+
+每轮按实际证据说明以下阶段的影响,给出结果或具体限制;尚未发生的处置列为预测和待验证项:
+
+1. **事件前与爆管后、隔离前**:核对正常基线和异常窗口,说明压力、流量变化及已覆盖的异常点位、资产和区域。候选管段及破口面积有依据时,可用 `run_burst_simulation` 补充情景水力影响,标明假设。漏量估计须说明边界流量、正常用水及储量变化等依据;有可靠漏量序列和时间覆盖时可累计漏水量,保留单位、方法和不确定性。定位工具的 `burst_leakage` 是输入,不能当成反演所得漏量。
+2. **关阀隔离方案**:说明止漏目标、直接隔离节点和关阀后新增失去水源连接的节点,分别保留 `affected_nodes` 与 `supply_affected_nodes` 及其计数。结合关阀水力结果说明预计停水、低压或供水不足的范围和覆盖限制;有用户关联资料时说明受影响用户、重要设施及保障需求。拓扑失源、模型预测和现场实际停水分别标注,不把节点数当户数。
+3. **实际隔离与恢复供水后**:后续轮次按实际关阀、抢修和恢复时间读取前后监测与现场反馈,对照预测核查止漏效果、实际影响范围和恢复情况,说明持续低压、异常流量、再次异常及尚待验证的影响。核对正常基线、观察窗口和判定依据,工单状态或单个正常读数不足以证明影响消除。
+
+比较须核对对象、单位、时间对齐、模型版本、需水与水源边界、泵阀状态及计算设置;区分实测变化与模型方案差异。关阀工具不会自动继承爆管情景,条件不同的独立计算不能直接作为同一事故前后效果。可比时给出变化量和方案差异,缺少可比基准时说明当前结果及缺口,不补造改善比例。必要的紧急核验、通知和供水保障不等待可选的完整模拟。
+
## 处置与报告
-报告分别给出位置候选及依据、隔离阀门及可用性、替代方案、拓扑与水力影响、用户影响边界,以及处置和闭环缺口。只有初步结果时明确范围,不宣称已经定位、确诊或闭环。位置、候选、监测、影响和阀门方案按实际调查步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。覆盖面保留分组、缺失几何和示意边界,不能当作实际停水、水力影响或用户服务边界。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
+报告分别给出位置候选及依据、隔离阀门及可用性、替代方案、事件前后分阶段的拓扑与水力影响、用户影响边界,以及处置和闭环缺口。影响说明保留时间窗、对象、证据、实测或预测属性及限制。只有初步结果时明确范围,不宣称已经定位、确诊或闭环。位置、候选、监测、影响和阀门方案按实际调查步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。覆盖面保留分组、缺失几何和示意边界,不能当作实际停水、水力影响或用户服务边界。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
形成具体建议后,先保存阶段结论,再提议必要动作。人工核验、通知、定位隔离、抢修、适用的清洗复测和恢复回访使用关联的 `work_order_type=general` 草稿;原爆管事件保持独立。草稿写明位置、任务、依赖、执行前核验和回执要求。没有通知适配器时明确由人工实施。设备命令仅用于真实可控目标,审批和执行由 Server 控制。
diff --git a/resources/work-order-skills/scada-anomaly/SKILL.md b/resources/work-order-skills/scada-anomaly/SKILL.md
new file mode 100644
index 0000000..91fa192
--- /dev/null
+++ b/resources/work-order-skills/scada-anomaly/SKILL.md
@@ -0,0 +1,18 @@
+---
+name: scada-anomaly
+description: 调查 SCADA 回放中的异常信号,区分读数质量与运行异常,形成可追溯的演示报告。
+metadata:
+ version: "1.0.1"
+---
+
+# SCADA 回放异常调查
+
+先读取任务上下文和来源中的 replay_context,核对规则、设备、单位、异常时刻及知识截止时间。数据来自已存 SCADA 的演示回放,不代表新采集的现场故障。
+
+通过 query_scada_readings 读取事件前后本轮已处理的观测;查询结束时间不得晚于 knowledge_cutoff。结合 monitored_value 与 cleaned_value 判断数值变化,不将被可靠采集的低压或水质变化当作数据错误。缺测、无效值和未完成清洗应明确记录。
+
+报告说明事件前后影响:在已开放且已处理的观测范围内,核对异常前参考、异常期间及已有恢复观测,列出压力、流量或水质变化、持续时间、涉及测点和已核实的资产关联。只有对象、单位、时间与运行条件可比时才给出变化量;说明参考窗口、证据和覆盖限制。尚无恢复观测时列为待验证,读数恢复不能证明现场修复或用户影响消除。停水范围、关阀影响及泄漏量需要相应拓扑、水力、流量平衡或现场依据;当前证据无法支持时明确待分析,不由单点低压推定停水区、受影响户数或漏量。
+
+当前回放调查仅开放冻结 SCADA 证据、设备与模型目录;不支持原生计算或绕过回放上限的其他数据入口。回放前历史用于后台基线初始化,当前证据接口不返回该部分。需要更多历史或进一步计算时,报告具体缺口,不能读取未来或虚构分析结果。
+
+报告列出观测事实、规则信号、质量问题、尚未确认的原因和建议核查。压力异常不等于已确认爆管,浓度变化不等于已确认污染。回放只生成演示分析与报告,不创建真实现场工单、不下发设备命令。
diff --git a/tests/api/test_admin_metadata_endpoints.py b/tests/api/test_admin_metadata_endpoints.py
index 6f23dcf..a0febbf 100644
--- a/tests/api/test_admin_metadata_endpoints.py
+++ b/tests/api/test_admin_metadata_endpoints.py
@@ -616,3 +616,52 @@ def test_project_map_config_accepts_search_and_preserves_extra_options():
AdminProjectUpdateRequest(map_config=None)
with pytest.raises(ValidationError):
AdminProjectUpdateRequest(map_config={'place_search': {'enabled': True, 'admin_code': 'wrong', 'region': '凤阳县'}})
+
+
+def test_project_scada_monitoring_interval_is_a_strict_schedule_parameter():
+ from pydantic import ValidationError
+ from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest
+
+ config = {
+ "scada_monitoring_interval_seconds": 900,
+ "future_option": {"enabled": True, "values": [1, "demo", None]},
+ }
+ request = AdminProjectUpdateRequest(settings=config)
+ assert request.model_dump(exclude_unset=True) == {"settings": config}
+ for value in (0, 59, True, 900.0, "900", 31_536_001):
+ with pytest.raises(ValidationError):
+ AdminProjectUpdateRequest(settings={"scada_monitoring_interval_seconds": value})
+ for value in (None, [], "settings", {"future_option": object()}):
+ with pytest.raises(ValidationError):
+ AdminProjectUpdateRequest(settings=value)
+ assert AdminProjectUpdateRequest().model_dump(exclude_unset=True) == {}
+ assert AdminProjectUpdateRequest(settings={}).model_dump(exclude_unset=True) == {"settings": {}}
+ assert AdminProjectUpdateRequest(
+ settings={"scada_monitoring_interval_seconds": None}
+ ).model_dump(exclude_unset=True) == {"settings": {"scada_monitoring_interval_seconds": None}}
+
+
+@pytest.mark.anyio
+async def test_update_project_passes_only_requested_settings_and_returns_extra_keys(monkeypatch):
+ from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest
+
+ now = datetime.now(timezone.utc)
+ project = _project(
+ code="demo", description=None, gs_workspace="demo", map_extent=None,
+ settings={"scada_monitoring_interval_seconds": 900, "future_option": {"enabled": True}},
+ status="active", created_at=now, updated_at=now,
+ )
+ repo = SimpleNamespace(session=object(), update_project=AsyncMock(return_value=project))
+ monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
+
+ response = await admin_metadata.update_admin_project(
+ AdminProjectUpdateRequest(settings={"future_option": {"enabled": True}}),
+ project_id=project.id,
+ current_user=_user(role="admin", is_superuser=True),
+ metadata_repo=repo,
+ )
+
+ repo.update_project.assert_awaited_once_with(
+ project.id, updates={"settings": {"future_option": {"enabled": True}}}
+ )
+ assert response.settings.model_dump() == {**project.settings, "scada_monitoring": None}
diff --git a/tests/api/test_operational_task_dynamic_flow.py b/tests/api/test_operational_task_dynamic_flow.py
index eb4d071..6803441 100644
--- a/tests/api/test_operational_task_dynamic_flow.py
+++ b/tests/api/test_operational_task_dynamic_flow.py
@@ -86,6 +86,7 @@ async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch
source_ref=snapshot.source_ref, observed_at=now, recorded_at=now,
recorded_by="service", has_snapshot=True)
service = SimpleNamespace(can_read_source=AsyncMock(return_value=True),
+ replay_context=AsyncMock(return_value=None),
record_data_snapshot=AsyncMock(return_value=(evidence, snapshot)))
source = AsyncMock(return_value=snapshot)
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
diff --git a/tests/api/test_scada_raw_api.py b/tests/api/test_scada_raw_api.py
new file mode 100644
index 0000000..5b7e18e
--- /dev/null
+++ b/tests/api/test_scada_raw_api.py
@@ -0,0 +1,60 @@
+"""HTTP boundary checks without project database access."""
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.api.v1.endpoints.timeseries import scada
+from app.domain.schemas.scada import ScadaRawPage
+
+
+@pytest.fixture
+def raw_api(monkeypatch):
+ app = FastAPI()
+ app.include_router(scada.router)
+ project = uuid4()
+ app.dependency_overrides[scada.get_timescale_connection] = lambda: object()
+ app.dependency_overrides[scada.get_postgres_connection] = lambda: object()
+ app.dependency_overrides[scada.get_project_context] = lambda: SimpleNamespace(project_id=project)
+ devices = AsyncMock(return_value={"P1"})
+ monkeypatch.setattr(scada.ScadaInfoRepository, "get_existing_device_ids", devices)
+ read = AsyncMock(return_value=ScadaRawPage(items=[], next_cursor=None, scanned_through=datetime(2026, 10, 2, tzinfo=timezone.utc)))
+ monkeypatch.setattr(scada, "read_raw_page", read)
+ with TestClient(app) as client:
+ yield client, devices, read, project
+
+
+def test_raw_http_returns_only_read_projection_and_binds_project(raw_api):
+ client, devices, read, project = raw_api
+ response = client.get("/timeseries/scada-readings/raw", params={
+ "start_time": "2026-10-01T00:00:00Z", "end_time": "2026-10-02T00:00:00Z", "device_ids": "P1,P1",
+ })
+ assert response.status_code == 200
+ assert response.json()["items"] == []
+ assert read.await_args.kwargs["project_id"] == str(project)
+ assert read.await_args.kwargs["device_ids"] == ["P1"]
+ devices.assert_awaited_once()
+
+
+def test_raw_http_rejects_naive_time_before_replay_lookup(raw_api):
+ client, devices, read, _ = raw_api
+ response = client.get("/timeseries/scada-readings/raw", params={
+ "start_time": "2026-10-01T00:00:00", "end_time": "2026-10-02T00:00:00Z",
+ "device_ids": "P1", "replay_id": str(uuid4()),
+ })
+ assert response.status_code == 422
+ devices.assert_not_awaited()
+ read.assert_not_awaited()
+
+
+def test_raw_http_rejects_cross_project_device_before_tsdb(raw_api):
+ client, _, read, _ = raw_api
+ response = client.get("/timeseries/scada-readings/raw", params={
+ "start_time": "2026-10-01T00:00:00Z", "end_time": "2026-10-02T00:00:00Z", "device_ids": "other",
+ })
+ assert response.status_code == 422
+ read.assert_not_awaited()
diff --git a/tests/api/test_scada_replay_api.py b/tests/api/test_scada_replay_api.py
new file mode 100644
index 0000000..8e6f4e5
--- /dev/null
+++ b/tests/api/test_scada_replay_api.py
@@ -0,0 +1,113 @@
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.api.v1.endpoints import scada_replay
+from app.auth.project_dependencies import ProjectContext, get_project_context
+
+
+@pytest.fixture
+def replay_api(monkeypatch):
+ context = ProjectContext(uuid4(), "demo", uuid4(), "member")
+ project = SimpleNamespace(settings={"scada_monitoring_interval_seconds": 900, "other": True})
+ metadata = SimpleNamespace(get_project_by_id=AsyncMock(return_value=project))
+ repo = SimpleNamespace(available=AsyncMock(return_value=True), current=AsyncMock(return_value=None))
+ create = AsyncMock(side_effect=RuntimeError("creation mock must be configured"))
+ monkeypatch.setattr(scada_replay.ScadaReplayService, "create", create)
+ app = FastAPI()
+ app.include_router(scada_replay.router)
+ app.dependency_overrides[get_project_context] = lambda: context
+ app.dependency_overrides[scada_replay.repository] = lambda: repo
+ app.dependency_overrides[scada_replay.get_metadata_repository] = lambda: metadata
+ with TestClient(app) as client:
+ yield client, project, metadata, context, create
+
+
+def test_capabilities_reads_frequency_from_current_project_settings(replay_api):
+ client, project, metadata, context, _ = replay_api
+ response = client.get("/scada-replays/capabilities")
+ assert response.status_code == 200
+ assert response.json()["monitoring_interval_seconds"] == 900
+ assert response.json()["burst_monitoring_ready"] is False
+ assert "尚未配置" in response.json()["burst_monitoring_reason"]
+ metadata.get_project_by_id.assert_awaited_once_with(context.project_id)
+ project.settings = {}
+ assert client.get("/scada-replays/capabilities").json()["monitoring_interval_seconds"] is None
+
+
+def test_capabilities_reports_ready_only_with_five_pressures_and_outlet_flow(replay_api):
+ client, project, *_ = replay_api
+ project.settings = {
+ "scada_monitoring_interval_seconds": 900,
+ "scada_monitoring": {
+ "pressure_device_ids": ["P1", "P2", "P3", "P4", "P5"],
+ "outlet_flow_device_ids": ["F1"],
+ },
+ }
+
+ response = client.get("/scada-replays/capabilities")
+
+ assert response.status_code == 200
+ assert response.json()["burst_monitoring_ready"] is True
+ assert response.json()["burst_monitoring_reason"] is None
+
+
+def test_capabilities_reports_only_missing_storage_not_worker():
+ repo = SimpleNamespace(available=AsyncMock(return_value=False))
+ context = ProjectContext(uuid4(), "demo", uuid4(), "member")
+
+ result = asyncio.run(scada_replay.replay_capabilities(
+ repo=repo,
+ ctx=context,
+ settings=scada_replay.ProjectSettings(scada_monitoring_interval_seconds=900),
+ ))
+
+ assert result.available is False
+ assert result.reason == "需先部署 SCADA 回放存储结构"
+
+
+def test_step_command_processes_pending_period_in_same_request(monkeypatch):
+ calls = []
+
+ class Service:
+ def __init__(self, repo, context, timescale):
+ calls.append((repo, context, timescale))
+
+ async def command(self, replay_id, command):
+ calls.append((replay_id, command.action))
+ return SimpleNamespace(step_pending=True)
+
+ async def process_pending_step(self, replay_id):
+ calls.append((replay_id, "processed"))
+ return "completed-period"
+
+ monkeypatch.setattr(scada_replay, "ScadaReplayService", Service)
+ replay_id = uuid4()
+ context = ProjectContext(uuid4(), "demo", uuid4(), "member")
+ command = scada_replay.ScadaReplayCommand(
+ request_id=uuid4(), expected_revision=1, action="step"
+ )
+
+ result = asyncio.run(scada_replay.replay_command(
+ replay_id, command, repo="repo", ctx=context, timescale="timescale"
+ ))
+
+ assert result == "completed-period"
+ assert calls[-2:] == [(replay_id, "step"), (replay_id, "processed")]
+
+
+def test_client_cannot_override_project_frequency_on_creation(replay_api):
+ client, _, _, _, create = replay_api
+ response = client.post("/scada-replays", json={
+ "request_id": str(uuid4()), "start_time": "2026-10-08T00:00:00Z",
+ "end_time": "2026-10-08T01:00:00Z", "device_ids": ["P1"],
+ "rules": [{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10, "unit": "m"}],
+ "monitoring_interval_seconds": 300,
+ })
+ assert response.status_code == 422
+ create.assert_not_awaited()
diff --git a/tests/integration/test_scada_replay_storage.py b/tests/integration/test_scada_replay_storage.py
new file mode 100644
index 0000000..39803e6
--- /dev/null
+++ b/tests/integration/test_scada_replay_storage.py
@@ -0,0 +1,82 @@
+"""Real project SQL, with all schema, observations, events and tasks rolled back."""
+import os
+from contextlib import asynccontextmanager
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from unittest.mock import AsyncMock
+from uuid import UUID, uuid4
+
+import psycopg
+from psycopg.rows import dict_row
+import pytest
+
+from app.auth.project_dependencies import ProjectContext
+from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand
+from app.infra.db.metadb.database import SessionLocal, engine
+from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
+from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
+from app.infra.db.postgresql.scada import ScadaInfoRepository
+from app.services.scada_replay import ScadaReplayService
+
+pytestmark = pytest.mark.skipif(os.getenv("SCADA_REPLAY_STORAGE_TEST") != "1", reason="explicit rollback-only SQL test required")
+
+
+@pytest.fixture
+def anyio_backend(): return "asyncio"
+
+
+@pytest.mark.anyio
+async def test_real_manual_steps_clean_raw_and_persist_events_roll_back(monkeypatch):
+ actual_project = UUID("871d7538-589c-47de-a6d5-3185ccb9f0e1")
+ async with SessionLocal() as session:
+ metadata = MetadataRepository(session)
+ business = await metadata.get_project_db_routing(actual_project, "biz_data")
+ timeseries = await metadata.get_project_db_routing(actual_project, "iot_data")
+ await engine.dispose()
+ pg = await psycopg.AsyncConnection.connect(business.dsn, row_factory=dict_row, connect_timeout=5)
+ ts = await psycopg.AsyncConnection.connect(timeseries.dsn, row_factory=dict_row, connect_timeout=5)
+ try:
+ async with pg.transaction(force_rollback=True), ts.transaction(force_rollback=True):
+ actual = await (await pg.execute("SELECT current_database() AS db")).fetchone()
+ assert actual["db"] == "tjwater2_v2"
+ migration = (Path(__file__).parents[2] / "resources/sql/031_scada_replays.sql").read_text()
+ migration = migration.replace("BEGIN;\n", "", 1).removesuffix("COMMIT;\n")
+ await pg.execute(migration)
+ device = "SCADA_TEST_" + uuid4().hex[:12]
+ start = datetime(2026, 10, 8, tzinfo=timezone.utc)
+ for minute, value in ((0, 10), (15, 20), (30, 20), (45, 10), (60, 10)):
+ await ts.execute("INSERT INTO scada.measurements(time,device_id,monitored_value) VALUES(%s,%s,%s)",
+ (start + timedelta(minutes=minute), device, value))
+ monkeypatch.setattr(ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[{
+ "device_id": device, "device_type": "pressure", "measurement_unit": "m",
+ "transmission_frequency": "0:15:00", "transmission_mode": "non_realtime"
+ }]))
+ context = ProjectContext(
+ project_id=uuid4(),
+ project_code="tjwater2_v2",
+ user_id=uuid4(),
+ project_role="member",
+ )
+ repo = ScadaReplayRepository(pg, context.project_id)
+ wall = [start]
+ service = ScadaReplayService(repo, context, ts, clock=lambda: wall[0])
+ config = ScadaReplayCreate(request_id=uuid4(), start_time=start, end_time=start + timedelta(minutes=75),
+ device_ids=[device],
+ rules=[{"rule_id": "pressure", "device_ids": [device], "kind": "bounds", "upper": 15, "unit": "m"}])
+ result = await service.create(config, monitoring_interval_seconds=900)
+ while result.status != "completed":
+ await service.command(result.replay_id, ScadaReplayCommand(
+ request_id=uuid4(), expected_revision=result.revision, action="step"
+ ))
+ result = await service.process_pending_step(result.replay_id)
+ assert result.status == "completed"
+ assert result.processed_count == 5
+ assert result.anomaly_count == 1
+ events = await repo.events(result.replay_id)
+ assert {event["change"] for event in events} == {"raised", "resolved"}
+ assert all(event["task_id"] is None for event in events)
+ raw = await (await ts.execute("SELECT monitored_value,cleaned_value FROM scada.measurements WHERE device_id=%s ORDER BY time", (device,))).fetchall()
+ assert [row["monitored_value"] for row in raw] == [10, 20, 20, 10, 10]
+ assert [row["cleaned_value"] for row in raw] == [10, 20, 20, 10, 10]
+ finally:
+ await pg.close(); await ts.close()
diff --git a/tests/unit/test_mcp_query.py b/tests/unit/test_mcp_query.py
index 9463d8a..129c3ba 100644
--- a/tests/unit/test_mcp_query.py
+++ b/tests/unit/test_mcp_query.py
@@ -300,6 +300,27 @@ def binding(task=False):
return value
+@pytest.mark.parametrize("tool_name", ["run_burst_detection", "query_element_history"])
+def test_replay_binding_denies_unscoped_computations_and_history(tool_name):
+ bound = binding(task=True)
+ bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00+00:00", "batch_sequence": 1}
+ with pytest.raises(HTTPException) as error:
+ asyncio.run(query_service.authorize_query(SimpleNamespace(), bound, TOOLS[tool_name], SimpleNamespace()))
+ assert error.value.status_code == 403
+
+
+def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
+ bound = binding(task=True)
+ bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00+00:00", "batch_sequence": 1}
+ args = SimpleNamespace(start=datetime(2026, 10, 8, tzinfo=timezone.utc),
+ end=datetime(2026, 10, 8, 2, tzinfo=timezone.utc))
+ with pytest.raises(HTTPException) as error:
+ asyncio.run(query_service.authorize_query(SimpleNamespace(), bound, TOOLS["query_scada_readings"], args))
+ assert error.value.status_code == 422
+ monkeypatch.setattr(query_service, "permissions_for_context", lambda _: PERMISSIONS)
+ asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
+
+
@pytest.mark.parametrize("seconds", [30, 120, 240])
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
app, client, _, _ = runtime
diff --git a/tests/unit/test_metadata_repository_projects.py b/tests/unit/test_metadata_repository_projects.py
index c07d82f..8d8db03 100644
--- a/tests/unit/test_metadata_repository_projects.py
+++ b/tests/unit/test_metadata_repository_projects.py
@@ -7,6 +7,7 @@ from sqlalchemy.dialects import postgresql
from app.infra.db.metadb.repositories import metadata_repository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
+from app.domain.schemas.project_settings import ProjectSettings
@pytest.fixture
@@ -99,3 +100,60 @@ async def test_update_map_config_preserves_legacy_settings_and_other_projects():
repository.get_project_by_id.assert_awaited_once_with(project_id)
assert project.map_config == {'legend': 'original', 'coordinate_reference': {'source_srid': 990001}, 'place_search': search}
session.commit.assert_awaited_once()
+
+
+@pytest.mark.anyio
+async def test_update_project_settings_preserves_unmentioned_parameters():
+ original = {"scada_monitoring_interval_seconds": 300, "future_option": {"enabled": True}}
+ project = SimpleNamespace(settings=original)
+ session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
+ repository = MetadataRepository(session)
+ repository.get_project_by_id = AsyncMock(return_value=project)
+ project_id = uuid4()
+
+ await repository.update_project(
+ project_id,
+ updates={"settings": {"scada_monitoring_interval_seconds": 900}},
+ )
+
+ assert project.settings == {"scada_monitoring_interval_seconds": 900, "future_option": {"enabled": True}}
+ assert original["scada_monitoring_interval_seconds"] == 300
+ session.commit.assert_awaited_once()
+
+
+@pytest.mark.anyio
+async def test_empty_settings_update_preserves_values_and_explicit_null_clears_interval():
+ project = SimpleNamespace(settings={"scada_monitoring_interval_seconds": 900, "other": True})
+ session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
+ repository = MetadataRepository(session)
+ repository.get_project_by_id = AsyncMock(return_value=project)
+ project_id = uuid4()
+
+ await repository.update_project(project_id, updates={"settings": {}})
+ assert project.settings == {"scada_monitoring_interval_seconds": 900, "other": True}
+ await repository.update_project(
+ project_id, updates={"settings": {"scada_monitoring_interval_seconds": None}}
+ )
+ assert project.settings == {"scada_monitoring_interval_seconds": None, "other": True}
+
+
+def test_scada_monitoring_roles_are_typed_inside_extensible_project_settings():
+ settings = ProjectSettings.model_validate({
+ "scada_monitoring_interval_seconds": 900,
+ "scada_monitoring": {
+ "rule_version": "burst-monitor-v1",
+ "pressure_device_ids": ["P1", "P2"],
+ "outlet_flow_device_ids": ["F1"],
+ },
+ "future_option": True,
+ })
+
+ assert settings.scada_monitoring.pressure_device_ids == ["P1", "P2"]
+ assert settings.model_dump()["future_option"] is True
+ with pytest.raises(ValueError):
+ ProjectSettings.model_validate({
+ "scada_monitoring": {
+ "pressure_device_ids": ["D1"],
+ "outlet_flow_device_ids": ["D1"],
+ }
+ })
diff --git a/tests/unit/test_scada_raw.py b/tests/unit/test_scada_raw.py
new file mode 100644
index 0000000..fdcd280
--- /dev/null
+++ b/tests/unit/test_scada_raw.py
@@ -0,0 +1,65 @@
+import asyncio
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.infra.db.timescaledb.repositories.scada import ScadaRepository
+from app.services.scada_raw import read_raw_page
+
+START = datetime(2026, 10, 8, tzinfo=timezone.utc)
+
+
+def reading(minutes, device="P1", value=1):
+ return {"time": START + timedelta(minutes=minutes), "device_id": device,
+ "monitored_value": value, "source_hash": "hash"}
+
+
+def test_pagination_keeps_same_timestamp_devices_and_freezes_clock(monkeypatch):
+ fetch = AsyncMock(side_effect=[[reading(0), reading(0, "P2")], [reading(0, "P2")]])
+ monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
+ async def run():
+ args = dict(project_id="project", device_ids=["P1", "P2"], start=START,
+ end=START + timedelta(hours=1), limit=1)
+ first = await read_raw_page(None, **args, through=START + timedelta(minutes=15))
+ second = await read_raw_page(None, **args, cursor=first.next_cursor, through=START + timedelta(minutes=30))
+ assert first.items[0].device_id == "P1"
+ assert second.items[0].device_id == "P2"
+ assert second.scanned_through == START + timedelta(minutes=15)
+ assert fetch.await_args.args[-1] == (START, "P1")
+ asyncio.run(run())
+
+
+def test_cursor_cannot_expand_query_or_move_into_future(monkeypatch):
+ fetch = AsyncMock(return_value=[reading(0), reading(5)])
+ monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
+ async def run():
+ args = dict(project_id="project", device_ids=["P1"], start=START,
+ end=START + timedelta(hours=1), limit=1)
+ first = await read_raw_page(None, **args, through=START + timedelta(minutes=15))
+ for change in ({"project_id": "other"}, {"device_ids": ["P2"]}, {"through": START}):
+ with pytest.raises(ValueError, match="游标"):
+ await read_raw_page(None, **(args | change), cursor=first.next_cursor)
+ asyncio.run(run())
+ assert fetch.await_count == 1
+
+
+def test_raw_response_does_not_expose_corrected_values_and_marks_bad_numbers(monkeypatch):
+ monkeypatch.setattr(ScadaRepository, "get_raw_page", AsyncMock(return_value=[
+ reading(0, value=float("nan")), reading(5, value=None), reading(10, value=0),
+ ]))
+ page = asyncio.run(read_raw_page(None, project_id="project", device_ids=["P1"], start=START,
+ end=START + timedelta(hours=1)))
+ assert [item.quality for item in page.items] == ["invalid", "missing", "unvalidated"]
+ assert page.items[2].monitored_value == 0
+ assert "cleaned_value" not in page.model_dump_json()
+
+
+@pytest.mark.parametrize("cursor", ["not-base64", "e30=", "W10="])
+def test_malformed_cursor_is_rejected_without_query(monkeypatch, cursor):
+ fetch = AsyncMock()
+ monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
+ with pytest.raises(ValueError):
+ asyncio.run(read_raw_page(None, project_id="project", device_ids=["P1"], start=START,
+ end=START + timedelta(hours=1), cursor=cursor))
+ fetch.assert_not_awaited()
diff --git a/tests/unit/test_scada_replay_monitoring.py b/tests/unit/test_scada_replay_monitoring.py
new file mode 100644
index 0000000..49ea09e
--- /dev/null
+++ b/tests/unit/test_scada_replay_monitoring.py
@@ -0,0 +1,108 @@
+from datetime import datetime, timedelta, timezone
+from uuid import uuid4
+
+from app.domain.schemas.scada_replay import ScadaReplayCreate
+from app.services import scada_replay_monitoring
+
+
+TARGET = datetime(2026, 10, 16, tzinfo=timezone.utc)
+PRESSURE_IDS = [f"P{index}" for index in range(1, 6)]
+FLOW_IDS = ["F1"]
+
+
+def _document():
+ config = ScadaReplayCreate.model_validate({
+ "request_id": str(uuid4()),
+ "start_time": TARGET - timedelta(days=15),
+ "end_time": TARGET + timedelta(hours=1),
+ "device_ids": PRESSURE_IDS + FLOW_IDS,
+ "rules": [
+ {"rule_id": "pressure_drop", "device_ids": PRESSURE_IDS,
+ "kind": "relative_change", "direction": "decrease", "threshold": 20, "unit": "m"},
+ {"rule_id": "outlet_flow", "device_ids": FLOW_IDS,
+ "kind": "relative_change", "direction": "increase", "threshold": 20, "unit": "m3/h"},
+ ],
+ })
+ return {
+ "replay_id": str(uuid4()),
+ "config": config.model_dump(mode="json"),
+ "monitoring_interval_seconds": 900,
+ "monitoring_config": {
+ "rule_version": "burst-monitor-v1",
+ "pressure_device_ids": PRESSURE_IDS,
+ "outlet_flow_device_ids": FLOW_IDS,
+ },
+ }
+
+
+def _rows():
+ rows = []
+ count = 15 * 96
+ start = TARGET - timedelta(minutes=15 * (count - 1))
+ for index in range(count):
+ at = start + timedelta(minutes=15 * index)
+ for device_id in PRESSURE_IDS:
+ rows.append({"time": at.isoformat(), "device_id": device_id,
+ "cleaned_value": 70 if at == TARGET else 100,
+ "quality": "valid"})
+ rows.append({"time": at.isoformat(), "device_id": "F1",
+ "cleaned_value": 150 if at == TARGET else 100,
+ "quality": "valid"})
+ return rows
+
+
+def test_multiple_indicators_create_suspected_burst_result(monkeypatch):
+ monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
+ "rows": [{"Score": -0.05}],
+ "summary": {"latest_sensor_rankings": []},
+ })
+
+ result = scada_replay_monitoring.evaluate_monitoring_period(
+ _document(), _rows(),
+ period_start=TARGET - timedelta(minutes=15),
+ period_end=TARGET,
+ batch_sequence=1440,
+ )
+
+ assert result["status"] == "suspected_burst"
+ assert result["pressure"]["hit_device_ids"] == PRESSURE_IDS
+ assert result["outlet_flow"]["hit_device_ids"] == FLOW_IDS
+ assert result["burst_detection"]["hit"] is True
+
+
+def test_single_indicator_stays_attention(monkeypatch):
+ monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
+ "rows": [{"Score": 0.1}],
+ "summary": {"latest_sensor_rankings": []},
+ })
+ rows = _rows()
+ for row in rows:
+ if row["device_id"] == "F1" and row["time"] == TARGET.isoformat():
+ row["cleaned_value"] = 100
+
+ result = scada_replay_monitoring.evaluate_monitoring_period(
+ _document(), rows,
+ period_start=TARGET - timedelta(minutes=15),
+ period_end=TARGET,
+ batch_sequence=1440,
+ )
+
+ assert result["status"] == "attention"
+ assert result["pressure"]["hit"] is True
+ assert result["outlet_flow"]["hit"] is False
+ assert result["burst_detection"]["hit"] is False
+
+
+def test_missing_project_roles_is_data_gap_not_normal():
+ document = _document()
+ document["monitoring_config"] = None
+
+ result = scada_replay_monitoring.evaluate_monitoring_period(
+ document, [],
+ period_start=TARGET - timedelta(minutes=15),
+ period_end=TARGET,
+ batch_sequence=1,
+ )
+
+ assert result["status"] == "insufficient_data"
+ assert result["burst_detection"]["status"] == "not_run"
diff --git a/tests/unit/test_scada_replay_processing.py b/tests/unit/test_scada_replay_processing.py
new file mode 100644
index 0000000..085dd02
--- /dev/null
+++ b/tests/unit/test_scada_replay_processing.py
@@ -0,0 +1,66 @@
+from datetime import datetime, timedelta, timezone
+from uuid import uuid4
+
+from app.domain.schemas.scada_replay import ScadaReplayCreate
+from app.services.scada_replay_processing import prepare_cleaning, detect_frame
+
+START = datetime(2026, 10, 8, tzinfo=timezone.utc)
+
+
+def config(**changes):
+ return ScadaReplayCreate.model_validate({
+ "request_id": str(uuid4()), "start_time": START, "end_time": START + timedelta(hours=2),
+ "device_ids": ["P1"], "rules": [{"rule_id": "pressure", "device_ids": ["P1"],
+ "kind": "relative_change", "direction": "decrease", "threshold": 30, "unit": "m"}],
+ **changes,
+ })
+
+
+def rows(value, minute=0):
+ return [{"time": START + timedelta(minutes=minute), "device_id": "P1", "monitored_value": value, "source_hash": "h"}]
+
+
+def test_quality_does_not_repair_a_real_pressure_drop_or_reverse_flow():
+ for value in (0, -5, 1):
+ result = prepare_cleaning(rows(value), config())
+ assert result[0]["quality"] == "valid"
+ assert result[0]["cleaned_value"] == value
+ invalid = prepare_cleaning(rows(-5), config(quality_ranges={"P1": {"lower": 0}}))
+ assert invalid[0]["quality"] == "invalid"
+ assert invalid[0]["monitored_value"] == -5
+ assert invalid[0]["cleaned_value"] is None
+
+
+def test_persistent_drop_is_one_episode_and_recovery_then_drop_is_new_episode():
+ cfg = config()
+ doc = {"replay_id": "run", "histories": {"P1": [10, 10, 10, 10]}}
+ all_events = []
+ for minute, value in enumerate((1, 1, None, 10, 10, 1)):
+ doc, events = detect_frame(doc, prepare_cleaning(rows(value, minute), cfg), cfg, START + timedelta(minutes=minute))
+ all_events.extend(events)
+ business = [event for event in all_events if event.get("category") != "data_quality"]
+ quality = [event for event in all_events if event.get("category") == "data_quality"]
+ assert [event["change"] for event in business] == ["raised", "resolved", "raised"]
+ assert [event["change"] for event in quality] == ["raised", "resolved"]
+ assert business[0]["event_id"] != business[2]["event_id"]
+ assert doc["anomaly_count"] == 3
+ assert doc["processed_count"] == 6
+
+
+def test_water_quality_zero_baseline_uses_explicit_bounds():
+ cfg = config(rules=[{"rule_id": "tracer", "device_ids": ["P1"], "kind": "bounds", "upper": 0.1, "unit": "m"}])
+ doc = {"replay_id": "run"}
+ doc, events = detect_frame(doc, prepare_cleaning(rows(0), cfg), cfg, START)
+ assert events == []
+ _, events = detect_frame(doc, prepare_cleaning(rows(0.4, 15), cfg), cfg, START + timedelta(minutes=15))
+ assert events[0]["change"] == "raised"
+
+
+def test_empty_clock_ticks_do_not_break_consecutive_observations():
+ cfg = config(rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10,
+ "unit": "m", "consecutive_samples": 2}])
+ doc, events = detect_frame({"replay_id": "run"}, prepare_cleaning(rows(20), cfg), cfg, START)
+ assert events == []
+ doc, _ = detect_frame(doc, [], cfg, START + timedelta(minutes=1))
+ _, events = detect_frame(doc, prepare_cleaning(rows(20, 15), cfg), cfg, START + timedelta(minutes=15))
+ assert events[0]["change"] == "raised"
diff --git a/tests/unit/test_scada_replay_queries.py b/tests/unit/test_scada_replay_queries.py
new file mode 100644
index 0000000..781b3b7
--- /dev/null
+++ b/tests/unit/test_scada_replay_queries.py
@@ -0,0 +1,145 @@
+import asyncio
+from contextlib import asynccontextmanager
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+
+from app.mcp.analysis_models import BurstLocationInput
+from app.mcp.catalog import TOOLS
+from app.mcp.models import BoundContext
+from app.mcp import query_service
+from app.services.scada_replay_queries import replay_burst_location_inputs
+
+
+START = datetime(2026, 10, 8, tzinfo=timezone.utc)
+
+
+class Cursor:
+ def __init__(self, rows): self.rows = rows
+ async def fetchone(self): return self.rows[0] if self.rows else None
+ async def fetchall(self): return self.rows
+
+
+class Connection:
+ def __init__(self, results):
+ self.results = list(results)
+ self.statements = []
+
+ @asynccontextmanager
+ async def transaction(self): yield
+
+ async def execute(self, statement, params):
+ self.statements.append((statement, params))
+ return Cursor(self.results.pop(0))
+
+ async def commit(self): pass
+
+
+def _args(**changes):
+ return BurstLocationInput.model_validate({
+ "context_handle": "a" * 43,
+ "burst_leakage": 0.02,
+ "pressure_scada_ids": ["J1"],
+ "flow_scada_ids": ["L1"],
+ "scada_burst_start": START + timedelta(minutes=15),
+ "scada_burst_end": START + timedelta(minutes=30),
+ "scada_normal_start": START,
+ "scada_normal_end": START + timedelta(minutes=14),
+ **changes,
+ })
+
+
+def test_replay_location_builds_series_only_from_frozen_outputs():
+ replay_id = str(uuid4())
+ run = {
+ "start_time": START.isoformat(),
+ "devices": [
+ {"device_id": "P1", "node_id": "J1", "link_id": None},
+ {"device_id": "F1", "node_id": None, "link_id": "L1"},
+ ],
+ "monitoring_config": {
+ "pressure_device_ids": ["P1"],
+ "outlet_flow_device_ids": ["F1"],
+ },
+ }
+ readings = [
+ {"reading": {"device_id": "P1", "time": START.isoformat(), "cleaned_value": 100, "quality": "valid"}},
+ {"reading": {"device_id": "F1", "time": START.isoformat(), "cleaned_value": 50, "quality": "valid"}},
+ {"reading": {"device_id": "P1", "time": (START + timedelta(minutes=15)).isoformat(), "cleaned_value": 70, "quality": "valid"}},
+ {"reading": {"device_id": "F1", "time": (START + timedelta(minutes=15)).isoformat(), "cleaned_value": 80, "quality": "valid"}},
+ ]
+ connection = Connection([[{"document": run}], readings])
+ context = {"replay_id": replay_id, "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
+ "batch_sequence": 9}
+
+ result = asyncio.run(replay_burst_location_inputs(connection, uuid4(), context, _args()))
+
+ assert result["burst_pressure"] == {"J1": 70}
+ assert result["normal_pressure"] == {"J1": 100}
+ assert result["burst_flow"] == {"L1": 80}
+ assert result["normal_flow"] == {"L1": 50}
+ assert connection.statements[1][1][2] == 9
+
+
+def test_replay_location_authorization_requires_explicit_bounded_normal_window(monkeypatch):
+ binding = BoundContext(
+ context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
+ tool_cap=["run_burst_location"], permission_cap=["burst.run", "scada.view"],
+ native_session_id="s", purpose="task_execution", task_id=uuid4(),
+ expires_at=START + timedelta(days=1),
+ replay_context={"replay_id": str(uuid4()),
+ "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
+ "batch_sequence": 9},
+ )
+ monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"burst.run", "scada.view"})
+ scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
+
+ asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_burst_location"], _args()))
+
+ missing_normal = _args().model_copy(update={"scada_normal_start": None, "scada_normal_end": None})
+ with pytest.raises(HTTPException) as caught:
+ asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_burst_location"], missing_normal))
+ assert caught.value.status_code == 422
+
+
+def test_replay_location_execution_uses_frozen_adapter_instead_of_live_scada(monkeypatch):
+ from app.mcp import analysis_service
+ from app.services import scada_replay_queries
+
+ binding = BoundContext(
+ context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
+ tool_cap=["run_burst_location"], permission_cap=["burst.run", "scada.view"],
+ native_session_id="s", purpose="task_execution", task_id=uuid4(),
+ expires_at=START + timedelta(days=1),
+ replay_context={"replay_id": str(uuid4()),
+ "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
+ "batch_sequence": 9},
+ )
+ monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"burst.run", "scada.view"})
+ frozen = {"burst_leakage": 0.02, "burst_pressure": {"J1": 70}, "normal_pressure": {"J1": 100}}
+ build = AsyncMock(return_value=frozen)
+ calculate = AsyncMock(return_value={"candidates": [{"id": "PIPE-1"}]})
+ monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", build)
+ monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", calculate)
+
+ @asynccontextmanager
+ async def routing(): yield
+
+ scope = SimpleNamespace(
+ connection=Connection([]),
+ project=SimpleNamespace(project_id=binding.project_id, project_code="demo", project_role="member"),
+ user=SimpleNamespace(username="tester"),
+ simulation_routing=routing,
+ )
+ result = asyncio.run(query_service.execute_query(
+ scope, binding, TOOLS["run_burst_location"], _args(),
+ ))
+
+ build.assert_awaited_once()
+ calculate.assert_awaited_once_with(frozen, network="demo", username="tester")
+ assert result.origin == "synthetic"
+ assert result.data["result"]["candidates"][0]["id"] == "PIPE-1"
diff --git a/tests/unit/test_scada_replay_service.py b/tests/unit/test_scada_replay_service.py
new file mode 100644
index 0000000..c9bb435
--- /dev/null
+++ b/tests/unit/test_scada_replay_service.py
@@ -0,0 +1,242 @@
+import asyncio
+from contextlib import asynccontextmanager
+from copy import deepcopy
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+
+from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand
+from app.infra.db.postgresql.scada import ScadaInfoRepository
+from app.infra.db.timescaledb.repositories.scada import ScadaRepository
+from app.services.scada_replay import ScadaReplayService
+
+START = datetime(2026, 10, 8, tzinfo=timezone.utc)
+
+
+class Connection:
+ @asynccontextmanager
+ async def transaction(self):
+ yield
+
+
+class Repository:
+ def __init__(self):
+ self.project_id = str(uuid4())
+ self.connection = Connection()
+ self.document = None
+ self.batches = {}
+
+ async def available(self): return True
+ @asynccontextmanager
+ async def control(self): yield
+ @asynccontextmanager
+ async def processing(self): yield True
+ async def by_request(self, key):
+ return deepcopy(self.document) if self.document and self.document["config"]["request_id"] == str(key) else None
+ async def active_other(self, key): return False
+ async def insert(self, document): self.document = deepcopy(document)
+ async def save(self, document): self.document = deepcopy(document)
+ async def get(self, _): return deepcopy(self.document)
+ async def current(self): return deepcopy(self.document)
+ async def pending_batch(self, _):
+ return next((deepcopy(batch) for batch in self.batches.values() if batch["stage"] != "detected"), None)
+ async def save_batch(self, _, batch): self.batches[batch["sequence"]] = deepcopy(batch)
+ async def monitoring_outputs(self, *_args): return []
+
+
+def setup(monkeypatch):
+ repo = Repository()
+ context = SimpleNamespace(
+ project_id=repo.project_id,
+ user_id=uuid4(),
+ )
+ service = ScadaReplayService(repo, context, Connection(), clock=lambda: START + timedelta(seconds=2))
+ monkeypatch.setattr(ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[{
+ "device_id": "P1", "device_type": "pressure", "measurement_unit": "m", "transmission_frequency": "0:15:00"
+ }]))
+ async def raw_page(_connection, _devices, _start, _end, _through, _limit, after=None, **_):
+ return ([{"time": START, "device_id": "P1", "monitored_value": 20., "source_hash": "h"}]
+ if after is None else [])
+ query = AsyncMock(side_effect=raw_page)
+ cleaning = AsyncMock()
+ monkeypatch.setattr(ScadaRepository, "get_raw_page", query)
+ monkeypatch.setattr(ScadaRepository, "apply_replay_cleaning", cleaning)
+ monkeypatch.setattr(service, "_create_investigations", AsyncMock())
+ create = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1),
+ device_ids=["P1"], rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10, "unit": "m"}])
+ return repo, service, query, cleaning, create
+
+
+def test_step_command_is_revisioned_idempotent_and_does_not_process_until_requested(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+ async def run():
+ first = await service.create(create, monitoring_interval_seconds=900)
+ restored = await service.create(create, monitoring_interval_seconds=300)
+ assert restored.replay_id == first.replay_id
+ assert restored.monitoring_interval_seconds == 900
+ command = ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step")
+ played = await service.command(first.replay_id, command)
+ assert played.status == "paused" and played.step_pending and played.revision == 2
+ assert (await service.command(first.replay_id, command)).revision == 2
+ with pytest.raises(HTTPException) as conflict:
+ await service.command(first.replay_id, command.model_copy(update={"request_id": uuid4()}))
+ assert conflict.value.status_code == 409
+ asyncio.run(run())
+ query.assert_not_awaited(); cleaning.assert_not_awaited()
+
+
+def test_one_raw_read_then_correction_then_detection_without_reread(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+ async def run():
+ first = await service.create(create, monitoring_interval_seconds=900)
+ repo.document["warmed"] = True
+ await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
+ result = await service.process_pending_step(first.replay_id)
+ assert result.processed_count == 1
+ assert result.anomaly_count == 1
+ assert repo.batches[1]["stage"] == "detected"
+ asyncio.run(run())
+ assert query.await_count == 2
+ assert cleaning.await_count == 2
+ assert cleaning.await_args_list[0].args[1][0]["cleaned_value"] == 20
+
+
+def test_detection_failure_resumes_cleaned_batch_without_duplicate_reads_or_writes(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+ publish = AsyncMock(side_effect=[RuntimeError("Agent storage unavailable"), None, None])
+ monkeypatch.setattr(service, "_create_investigations", publish)
+ async def run():
+ first = await service.create(create, monitoring_interval_seconds=900)
+ repo.document["warmed"] = True
+ await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
+ with pytest.raises(RuntimeError): await service.process_pending_step(first.replay_id)
+ assert repo.document["status"] == "failed"
+ assert repo.batches[1]["stage"] == "cleaned"
+ assert repo.document["processed_count"] == 0
+ await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=2, action="retry"))
+ result = await service.process_pending_step(first.replay_id)
+ assert result.processed_count == 1 and result.anomaly_count == 1
+ asyncio.run(run())
+ assert query.await_count == 2
+ assert cleaning.await_count == 2
+
+
+def test_cleaning_failure_never_runs_detection_and_preserves_prepared_result(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+ cleaning.side_effect = RuntimeError("Timescale unavailable")
+ async def run():
+ first = await service.create(create, monitoring_interval_seconds=900)
+ repo.document["warmed"] = True
+ await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
+ with pytest.raises(RuntimeError): await service.process_pending_step(first.replay_id)
+ assert repo.batches[1]["stage"] == "clean_prepared"
+ assert repo.document["processed_count"] == 0
+ asyncio.run(run())
+ service._create_investigations.assert_not_awaited()
+
+
+def test_creation_requires_explicit_project_monitoring_interval(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+
+ async def run():
+ with pytest.raises(HTTPException) as missing:
+ await service.create(create, monitoring_interval_seconds=None)
+ assert missing.value.status_code == 422
+ assert repo.document is None
+
+ asyncio.run(run())
+ query.assert_not_awaited()
+ cleaning.assert_not_awaited()
+
+
+def test_step_processes_every_observation_within_one_monitoring_interval(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+ rows = [{"time": START + timedelta(minutes=minute), "device_id": "P1",
+ "monitored_value": 20., "source_hash": f"h{minute}"} for minute in (0, 5, 10, 15, 20)]
+
+ async def raw_page(_connection, _devices, _start, _end, through, _limit, after=None, **_):
+ return next(([row] for row in rows if row["time"] <= through
+ and (after is None or (row["time"], row["device_id"]) > after)), [])
+
+ query.side_effect = raw_page
+
+ async def run():
+ first = await service.create(create, monitoring_interval_seconds=900)
+ repo.document["warmed"] = True
+ stepping = await service.command(first.replay_id, ScadaReplayCommand(
+ request_id=uuid4(), expected_revision=1, action="step"
+ ))
+ assert stepping.status == "paused" and stepping.step_pending
+ with pytest.raises(HTTPException):
+ await service.command(first.replay_id, ScadaReplayCommand(
+ request_id=uuid4(), expected_revision=2, action="step"
+ ))
+ result = await service.process_pending_step(first.replay_id)
+ assert result.processed_count == 4
+ assert result.virtual_now == START + timedelta(minutes=15)
+ assert result.status == "paused"
+ assert not result.step_pending
+ assert result.processed_until == START + timedelta(minutes=15)
+ assert result.monitoring_count == 1
+ assert result.last_monitoring.status == "insufficient_data"
+ assert "step_target" not in repo.document
+ assert all(call.args[4] == START + timedelta(minutes=15) for call in query.await_args_list)
+
+ asyncio.run(run())
+ assert cleaning.await_count == 4
+
+
+def test_partial_final_step_checks_empty_interval_and_completes_without_fabricated_rows(monkeypatch):
+ repo, service, query, cleaning, create = setup(monkeypatch)
+ create = create.model_copy(update={"end_time": START + timedelta(minutes=22)})
+ query.side_effect = None
+ query.return_value = []
+
+ async def run():
+ first = await service.create(create, monitoring_interval_seconds=900)
+ repo.document.update(warmed=True, virtual_now=(START + timedelta(minutes=15)).isoformat())
+ await service.command(first.replay_id, ScadaReplayCommand(
+ request_id=uuid4(), expected_revision=1, action="step"
+ ))
+ result = await service.process_pending_step(first.replay_id)
+ assert result.virtual_now == create.end_time
+ assert result.processed_until == create.end_time
+ assert result.status == "completed" and not result.step_pending
+ assert result.processed_count == 0
+
+ asyncio.run(run())
+ assert query.await_args.args[4] == create.end_time
+
+
+def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(monkeypatch):
+ repo, service, *_ = setup(monkeypatch)
+ create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
+ monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
+ monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
+ monkeypatch.setattr(
+ "app.services.operational_tasks.OperationalTaskService",
+ lambda *_: SimpleNamespace(create=create_task),
+ )
+ monitoring = {
+ "status": "suspected_burst",
+ "period_end": (START + timedelta(minutes=15)).isoformat(),
+ "knowledge_cutoff": (START + timedelta(minutes=15)).isoformat(),
+ "rule_version": "burst-monitor-v1",
+ "pressure": {"hit_device_ids": ["P1", "P2"]},
+ "outlet_flow": {"hit_device_ids": ["F1"]},
+ "burst_detection": {"target_score": -0.05},
+ "task_id": None,
+ }
+ document = {"replay_id": str(uuid4())}
+
+ asyncio.run(service._create_monitoring_task(document, monitoring, {"sequence": 8}))
+
+ command = create_task.await_args.args[0]
+ assert command.workflow == "burst_report"
+ assert command.source.replay_context.batch_sequence == 8
+ assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
+ assert monitoring["task_id"] == str(create_task.return_value.task_id)