feat(scada): add calibrated monitoring and incident recovery

This commit is contained in:
2026-11-07 19:44:58 +08:00
parent 26fa5d1909
commit ff8aba4964
109 changed files with 9165 additions and 629 deletions
+27 -1
View File
@@ -3,12 +3,28 @@ from __future__ import annotations
import json
import math
import re
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any, Literal
from jsonschema import Draft7Validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
CHART_SAMPLE_TIME_PATTERN = (
r"^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]"
r"(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
)
def validate_chart_sample_time(value: str) -> None:
"""Match the bounded frontend RFC3339 spelling and an actual calendar date."""
if len(value) > 64 or re.fullmatch(CHART_SAMPLE_TIME_PATTERN, value) is None:
raise ValueError("Chart sample times require RFC3339 seconds and a timezone")
if datetime.fromisoformat(value).tzinfo is None:
raise ValueError("Chart sample times require a timezone")
_catalog = json.loads(
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
)
@@ -71,8 +87,18 @@ class AnswerUiBlock(UiModel):
if element.type == "Chart":
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
raise ValueError("Chart series must match the axis")
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
if any(value is not None and not math.isfinite(value) for series in props["series"] for value in series["data"]):
raise ValueError("Chart values must be finite")
for series in props["series"]:
times = series.get("sample_times")
if times is not None:
if len(times) != len(series["data"]):
raise ValueError("Chart sample times must match the axis")
for value, at in zip(series["data"], times, strict=True):
if at is not None:
if value is None:
raise ValueError("Chart sample times require an observed value")
validate_chart_sample_time(at)
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
raise ValueError("Table row width must match its columns")
if element.type == "FollowUpForm":
+33 -3
View File
@@ -5,8 +5,10 @@ import re
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
from pydantic import Field, JsonValue, model_serializer, model_validator
from app.domain.schemas.answer_ui import (
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
)
from app.domain.schemas.task_question import TaskQuestionRequest
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
@@ -588,6 +590,7 @@ class OperationalTaskSummary(StrictModel):
execution_request_id: UUID
title: NonEmptyStr
summary: NonEmptyStr
handling_context: dict[str, JsonValue] = Field(default_factory=dict)
source: TaskSource
trigger: TaskTrigger
scheduled_for: WorkbenchDateTime | None
@@ -692,15 +695,42 @@ class AgentArtifactsResult(StrictModel):
class TaskSafeChartSeries(StrictModel):
name: Annotated[str, Field(max_length=128)]
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)]
type: Literal["line", "bar", "scatter"] | None = None
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)] | None], Field(min_length=1, max_length=100)]
sample_times: Annotated[list[Annotated[str, Field(
strict=True, max_length=64,
pattern=CHART_SAMPLE_TIME_PATTERN,
)] | None], Field(min_length=1, max_length=100)] | None = None
@model_serializer(mode="wrap")
def serialize_optional_fields(self, handler):
return {key: value for key, value in handler(self).items() if value is not None}
@model_validator(mode="after")
def validate_sample_times(self):
if self.sample_times is not None:
if len(self.sample_times) != len(self.data):
raise ValueError("chart sample_times must align with data")
for value, at in zip(self.data, self.sample_times, strict=True):
if at is not None:
if value is None:
raise ValueError("chart sample_times require an observed value")
validate_chart_sample_time(at)
return self
class TaskSafeChart(StrictModel):
title: Annotated[str, Field(max_length=200)]
chart_type: Literal["line", "bar"]
x_axis_name: Annotated[str, Field(max_length=128)] | None = None
y_axis_name: Annotated[str, Field(max_length=128)] | None = None
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)]
@model_serializer(mode="wrap")
def serialize_optional_fields(self, handler):
return {key: value for key, value in handler(self).items() if value is not None}
@model_validator(mode="after")
def validate_alignment(self):
if any(len(series.data) != len(self.x_data) for series in self.series):
+21 -1
View File
@@ -1,19 +1,39 @@
from math import isclose
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
class ScadaMonitoringWeights(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
burst_detection: float = Field(default=0.5, ge=0, le=1)
pressure: float = Field(default=0.4, ge=0, le=1)
outlet_flow: float = Field(default=0.1, ge=0, le=1)
@model_validator(mode="after")
def normalized(self):
if not isclose(self.burst_detection + self.pressure + self.outlet_flow, 1.0, abs_tol=1e-9):
raise ValueError("SCADA 三项监测权重之和必须为 1")
return self
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",
default="burst-monitor-v2",
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)
decision_policy: Literal["all_signals", "weighted"] = "weighted"
weights: ScadaMonitoringWeights = Field(default_factory=ScadaMonitoringWeights)
alert_threshold: float = Field(default=0.8, gt=0, le=1)
@model_validator(mode="after")
def distinct_roles(self):
+28 -1
View File
@@ -5,6 +5,8 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.schemas.project_settings import ScadaMonitoringSettings
class ReplayModel(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
@@ -78,7 +80,26 @@ class ScadaReplayCreate(ReplayModel):
class ScadaReplayCommand(ReplayModel):
request_id: UUID
expected_revision: int = Field(ge=1)
action: Literal["step", "stop", "retry"]
action: Literal["step", "monitor", "stop", "retry"]
at_time: datetime | None = None
@model_validator(mode="after")
def monitoring_time(self):
if self.action == "monitor":
if self.at_time is None or self.at_time.tzinfo is None:
raise ValueError("选定时刻监测需要提供带时区的 at_time")
elif self.at_time is not None:
raise ValueError("只有选定时刻监测可以指定 at_time")
return self
class ScadaMonitoringIncident(ReplayModel):
incident_id: UUID
status: Literal["active", "resolved"]
action: Literal["raised", "ongoing", "resolved"]
started_at: datetime
last_observed_at: datetime
recovery_count: int = Field(ge=0, le=2)
class ScadaMonitoringResult(ReplayModel):
@@ -94,7 +115,10 @@ class ScadaMonitoringResult(ReplayModel):
pressure: dict
outlet_flow: dict
burst_detection: dict
weighted: dict | None = None
incident: ScadaMonitoringIncident | None = None
role_gaps: list[str] = Field(default_factory=list)
anomaly_signals: list[dict] = Field(default_factory=list)
class ScadaReplaySnapshot(ReplayModel):
@@ -108,6 +132,8 @@ class ScadaReplaySnapshot(ReplayModel):
step_pending: bool = False
monitoring_count: int = 0
last_monitoring: ScadaMonitoringResult | None = None
monitoring_config: ScadaMonitoringSettings | None = None
monitoring_calibration: dict | None = None
virtual_now: datetime
delivered_until: datetime | None = None
processed_until: datetime | None = None
@@ -129,6 +155,7 @@ class ScadaReplayCapabilities(ReplayModel):
monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000)
burst_monitoring_ready: bool = False
burst_monitoring_reason: str | None = None
monitoring_config: ScadaMonitoringSettings | None = None
current: ScadaReplaySnapshot | None = None