feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user