feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
"""A domain rejection with a stable API error payload."""
|
||||
|
||||
def __init__(self, code: str, message: str, *, refs: list | None = None,
|
||||
recovery: str = "none", status: int = 422):
|
||||
super().__init__(message)
|
||||
self.code, self.message, self.status = code, message, status
|
||||
self.refs, self.recovery = refs or [], recovery
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {"code": self.code, "message": self.message,
|
||||
"retryable": self.recovery == "retry_same_request",
|
||||
"related_refs": self.refs, "recovery_action": self.recovery,
|
||||
"retry_after_seconds": None}
|
||||
|
||||
|
||||
def require(condition: bool, code: str, message: str, **kwargs) -> None:
|
||||
if not condition:
|
||||
raise DomainError(code, message, **kwargs)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Schemas for project-scoped source GIS attributes."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
GisElementType = Literal[
|
||||
"pipes",
|
||||
"junctions",
|
||||
"valves",
|
||||
"reservoirs",
|
||||
"pumps",
|
||||
"tanks",
|
||||
"scada",
|
||||
]
|
||||
|
||||
|
||||
class GisAttributeGroup(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dataset_key: str
|
||||
dataset_title: str
|
||||
source_date: date
|
||||
layer_key: str
|
||||
layer_name: str
|
||||
source_fid: int
|
||||
match_method: Literal["node_pair", "geometry", "source"]
|
||||
match_distance_m: float | None = None
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ElementGisProperties(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
element_type: GisElementType
|
||||
element_id: str
|
||||
groups: list[GisAttributeGroup] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SourceGisFeatureProperties(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
layer_key: str
|
||||
source_fid: int
|
||||
group: GisAttributeGroup
|
||||
@@ -25,7 +25,7 @@ class SensorPlacementOptimizeRequest(BaseModel):
|
||||
pattern=r"^[^/\\\x00]+$",
|
||||
)
|
||||
run_name: str = Field(..., min_length=1, max_length=64)
|
||||
sensor_type: Literal["pressure"]
|
||||
sensor_type: Literal["pressure", "water_quality"]
|
||||
method: Literal["sensitivity", "kmeans"]
|
||||
sensor_count: int = Field(..., gt=0, le=200)
|
||||
min_diameter: int = Field(default=0, ge=0)
|
||||
@@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel):
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
status: str
|
||||
sensor_type: Literal["pressure", "water_quality"] = "pressure"
|
||||
method: Literal["sensitivity", "kmeans"] | None = None
|
||||
sensor_locations: list[str]
|
||||
sensor_points: list[SensorPointResponse]
|
||||
can_edit: bool = False
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Small strict value types shared by independent backend features."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, model_validator
|
||||
|
||||
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
StrictBool = Annotated[bool, Field(strict=True)]
|
||||
PositiveInt = Annotated[int, Field(strict=True, ge=1)]
|
||||
NonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
|
||||
|
||||
|
||||
def _datetime_wire_value(value: object) -> datetime | str:
|
||||
if not isinstance(value, (datetime, str)):
|
||||
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
|
||||
return value
|
||||
|
||||
|
||||
WireDateTime = Annotated[datetime, BeforeValidator(_datetime_wire_value)]
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
|
||||
ObjectType = Literal[
|
||||
"analysis_task",
|
||||
"goal",
|
||||
"state_snapshot",
|
||||
"action_contract",
|
||||
"validation",
|
||||
"run",
|
||||
"outcome",
|
||||
"evidence",
|
||||
"artifact",
|
||||
"operation",
|
||||
]
|
||||
|
||||
|
||||
class ObjectRef(StrictModel):
|
||||
object_type: ObjectType
|
||||
object_id: UUID
|
||||
revision: PositiveInt
|
||||
|
||||
|
||||
class TimeRange(StrictModel):
|
||||
start: WireDateTime
|
||||
end: WireDateTime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_range(self) -> "TimeRange":
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("timestamps must include a timezone")
|
||||
if self.start >= self.end:
|
||||
raise ValueError("start must be before end")
|
||||
return self
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Idempotent alert events emitted by scheduled and other backend tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
|
||||
|
||||
|
||||
class TaskAlertCommand(StrictModel):
|
||||
event_id: UUID
|
||||
alert_id: NonEmptyStr
|
||||
event_type: Literal["raised", "acknowledged", "resolved"]
|
||||
occurred_at: WorkbenchDateTime
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
severity: Literal["warning", "critical"]
|
||||
|
||||
|
||||
class TaskAlertEvent(TaskAlertCommand):
|
||||
producer_id: NonEmptyStr
|
||||
|
||||
|
||||
class TaskAlertState(StrictModel):
|
||||
alert_id: NonEmptyStr
|
||||
revision: PositiveInt
|
||||
producer_id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
severity: Literal["warning", "critical"]
|
||||
status: Literal["active", "acknowledged", "resolved"]
|
||||
updated_at: WorkbenchDateTime
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Typed read-only projections consumed by the supply workbench."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import AwareDatetime, BeforeValidator, JsonValue, model_validator
|
||||
|
||||
from app.domain.schemas.shared import (
|
||||
NonEmptyStr,
|
||||
NonNegativeInt,
|
||||
PositiveInt,
|
||||
StrictBool,
|
||||
StrictModel,
|
||||
)
|
||||
|
||||
|
||||
def _workbench_datetime_wire_value(value: object):
|
||||
from datetime import datetime
|
||||
|
||||
if not isinstance(value, (datetime, str)):
|
||||
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
|
||||
return value
|
||||
|
||||
|
||||
WorkbenchDateTime = Annotated[
|
||||
AwareDatetime, BeforeValidator(_workbench_datetime_wire_value)
|
||||
]
|
||||
ProjectionAvailability = Literal["ready", "not_configured", "degraded"]
|
||||
|
||||
|
||||
class WorkbenchRuntimeCondition(StrictModel):
|
||||
id: NonEmptyStr
|
||||
task_id: Literal["backend-schedule"]
|
||||
session_id: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal["pending", "running", "completed", "warning", "error"]
|
||||
risk_level: Literal["normal", "attention", "critical"]
|
||||
scheduled_at: WorkbenchDateTime
|
||||
updated_at: WorkbenchDateTime
|
||||
detail: NonEmptyStr
|
||||
recommendation: NonEmptyStr | None
|
||||
evidence: list[NonEmptyStr]
|
||||
model_name: Literal["TJWater scheduler"]
|
||||
|
||||
|
||||
class WorkbenchAlertSource(StrictModel):
|
||||
task_type: Literal["scheduled_task", "analysis_task", "other_task"]
|
||||
task_id: NonEmptyStr
|
||||
run_id: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeAlert(StrictModel):
|
||||
id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
severity: Literal["warning", "critical"]
|
||||
|
||||
|
||||
class WorkbenchPersistedAlert(StrictModel):
|
||||
alert_id: NonEmptyStr
|
||||
condition_id: NonEmptyStr | None
|
||||
source: WorkbenchAlertSource
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
occurred_at: WorkbenchDateTime
|
||||
severity: Literal["warning", "critical"]
|
||||
status: Literal["active", "acknowledged", "resolved"]
|
||||
producer_id: NonEmptyStr | None = None
|
||||
updated_at: WorkbenchDateTime | None = None
|
||||
|
||||
|
||||
class WorkbenchRuntimeScenario(StrictModel):
|
||||
id: NonEmptyStr
|
||||
run_id: NonEmptyStr
|
||||
name: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
status: Literal["draft", "active", "review"]
|
||||
run_type: NonEmptyStr
|
||||
run_type_label: NonEmptyStr
|
||||
run_status: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class WorkbenchRuntimeAnalysisFact(StrictModel):
|
||||
label: NonEmptyStr
|
||||
value: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeAnalysisResult(StrictModel):
|
||||
result_type: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
facts: list[WorkbenchRuntimeAnalysisFact]
|
||||
|
||||
|
||||
class WorkbenchRuntimeLatestAnalysis(StrictModel):
|
||||
run_id: NonEmptyStr
|
||||
name: NonEmptyStr
|
||||
run_type: NonEmptyStr
|
||||
run_type_label: NonEmptyStr
|
||||
status: NonEmptyStr
|
||||
status_label: NonEmptyStr
|
||||
created_by: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
started_at: WorkbenchDateTime
|
||||
results: list[WorkbenchRuntimeAnalysisResult]
|
||||
map_payload: dict[str, JsonValue] | None
|
||||
|
||||
|
||||
class WorkbenchAnalysisHistoryItem(StrictModel):
|
||||
run_id: NonEmptyStr
|
||||
name: NonEmptyStr
|
||||
run_type: NonEmptyStr
|
||||
run_type_label: NonEmptyStr
|
||||
status: NonEmptyStr
|
||||
status_label: NonEmptyStr
|
||||
created_by: NonEmptyStr
|
||||
created_at: WorkbenchDateTime
|
||||
started_at: WorkbenchDateTime
|
||||
has_results: StrictBool
|
||||
|
||||
|
||||
class WorkbenchAnalysisHistoryPage(StrictModel):
|
||||
items: list[WorkbenchAnalysisHistoryItem]
|
||||
limit: PositiveInt
|
||||
offset: NonNegativeInt
|
||||
next_offset: NonNegativeInt | None
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrderStage(StrictModel):
|
||||
id: NonEmptyStr
|
||||
sequence: PositiveInt
|
||||
title: NonEmptyStr
|
||||
status: Literal["done", "current", "pending", "blocked", "skipped"]
|
||||
owner: NonEmptyStr
|
||||
time_label: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrderSubtask(StrictModel):
|
||||
id: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal["pending", "running", "blocked", "completed", "cancelled"]
|
||||
scheduled_at: WorkbenchDateTime
|
||||
location: NonEmptyStr
|
||||
assignee: NonEmptyStr
|
||||
duration_minutes: PositiveInt
|
||||
reply_window_minutes: PositiveInt
|
||||
stages: list[WorkbenchRuntimeWorkOrderStage]
|
||||
reply_requirements: list[NonEmptyStr]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_stages(self):
|
||||
if not self.stages:
|
||||
raise ValueError("subtask stages must not be empty")
|
||||
if [stage.sequence for stage in self.stages] != list(
|
||||
range(1, len(self.stages) + 1)
|
||||
):
|
||||
raise ValueError("subtask stages must use contiguous sequence numbers")
|
||||
if len({stage.id for stage in self.stages}) != len(self.stages):
|
||||
raise ValueError("subtask stage ids must be unique")
|
||||
if sum(stage.status == "current" for stage in self.stages) > 1:
|
||||
raise ValueError("subtask stages may contain at most one current stage")
|
||||
return self
|
||||
|
||||
|
||||
class WorkbenchRuntimeWorkOrder(StrictModel):
|
||||
id: NonEmptyStr
|
||||
code: NonEmptyStr
|
||||
scheduled_at: WorkbenchDateTime
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal["pending", "running", "awaiting_reply", "completed"]
|
||||
risk_level: Literal["normal", "attention", "critical"]
|
||||
updated_at: WorkbenchDateTime
|
||||
detail: NonEmptyStr
|
||||
recommendation: NonEmptyStr | None
|
||||
duration_minutes: PositiveInt
|
||||
source: NonEmptyStr
|
||||
dispatcher: NonEmptyStr
|
||||
priority: Literal["normal", "urgent"]
|
||||
subtasks: list[WorkbenchRuntimeWorkOrderSubtask]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_subtasks(self):
|
||||
if not self.subtasks:
|
||||
raise ValueError("work order subtasks must not be empty")
|
||||
if len({subtask.id for subtask in self.subtasks}) != len(self.subtasks):
|
||||
raise ValueError("work order subtask ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class WorkbenchRuntimeSnapshot(StrictModel):
|
||||
availability: ProjectionAvailability
|
||||
source: Literal["none", "scheduled_tasks"]
|
||||
scenario_availability: ProjectionAvailability
|
||||
artifact_availability: ProjectionAvailability
|
||||
schedule_availability: ProjectionAvailability
|
||||
work_order_availability: ProjectionAvailability
|
||||
alert_availability: ProjectionAvailability
|
||||
generated_at: WorkbenchDateTime
|
||||
conditions: list[WorkbenchRuntimeCondition]
|
||||
alerts: list[WorkbenchRuntimeAlert]
|
||||
scenarios: list[WorkbenchRuntimeScenario]
|
||||
latest_analysis: WorkbenchRuntimeLatestAnalysis | None
|
||||
work_orders: list[WorkbenchRuntimeWorkOrder]
|
||||
Reference in New Issue
Block a user