Files
next-tjwater-server/app/api/v1/endpoints/timeseries/scada.py
T
jiang 26fa5d1909
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s
feat(scada): add project monitoring replay
2026-11-04 14:27:21 +08:00

289 lines
11 KiB
Python

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, 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
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)
monitored_value: float | None = None
cleaned_value: float | None = None
@field_validator("device_id")
@classmethod
def normalize_device_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("device_id must not be blank")
return normalized
@router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据")
async def insert_scada_data(
data: List[ScadaReadingBatchItem] = Body(
...,
min_length=1,
max_length=SCADA_BATCH_MAX_ITEMS,
description="SCADA设备监测数据列表",
),
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
批量插入SCADA监测数据
将多个设备的实时监测数据批量插入时间序列数据库。
Args:
data: SCADA设备监测数据列表,每项包含device_id、时间戳和监测值等信息
Returns:
插入成功的记录数
"""
rows = [item.model_dump() for item in data]
requested_ids = list(dict.fromkeys(item["device_id"] for item in rows))
existing_ids = await ScadaInfoRepository.get_existing_device_ids(
postgres_conn, requested_ids
)
missing_ids = [
device_id for device_id in requested_ids if device_id not in existing_ids
]
if missing_ids:
raise HTTPException(
status_code=422,
detail=f"SCADA devices do not exist in BizDB: {', '.join(missing_ids)}",
)
await ScadaRepository.insert_scada_batch(conn, rows)
return {"message": f"Inserted {len(data)} records"}
@router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据")
async def get_scada_by_ids_time_range(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
device_ids: str = Query(
..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'"
),
conn: AsyncConnection = Depends(get_timescale_connection),
):
"""
按设备ID和时间范围查询SCADA监测数据
查询多个设备在指定时间范围内的所有监测数据。
Args:
start_time: 查询开始时间
end_time: 查询结束时间
device_ids: 设备ID列表,用逗号分隔
Returns:
SCADA监测数据列表
"""
device_ids_list = (
[id.strip() for id in device_ids.split(",") if id.strip()] if device_ids else []
)
return await ScadaRepository.get_scada_by_ids_time_range(
conn, device_ids_list, start_time, end_time
)
@router.get(
"/timeseries/scada-readings/latest",
response_model=ScadaLatestReadingsResponse,
summary="获取SCADA设备最新数据",
)
async def get_latest_scada_readings(
limit: int = Query(100, ge=1, le=500, description="返回设备数上限"),
offset: int = Query(0, ge=0, description="设备目录偏移量"),
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
) -> ScadaLatestReadingsResponse:
"""Read one latest persisted row per project device without applying a time window."""
devices = await ScadaInfoRepository.get_scadas(postgres_conn)
selected_devices = devices[offset : offset + limit]
latest_by_device = await ScadaRepository.get_latest_scada_readings(
conn,
[device["device_id"] for device in selected_devices],
)
items = []
for device in selected_devices:
reading = latest_by_device.get(device["device_id"])
items.append(
{
"device_id": device["device_id"],
"device_type": device["device_type"],
"measurement_unit": device["measurement_unit"],
"transmission_mode": device["transmission_mode"],
"transmission_frequency": device["transmission_frequency"],
"observed_at": reading["time"] if reading else None,
"monitored_value": reading["monitored_value"] if reading else None,
"cleaned_value": reading["cleaned_value"] if reading else None,
}
)
observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None]
return ScadaLatestReadingsResponse.model_validate(
{
"items": items,
"total": len(devices),
"limit": limit,
"offset": offset,
"observed_count": len(observed_times),
"latest_observed_at": max(observed_times) if observed_times else None,
}
)
@router.get(
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
)
async def get_scada_field_by_ids_time_range(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
field: str = Query(..., description="要查询的字段名称"),
device_ids: str = Query(
..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'"
),
conn: AsyncConnection = Depends(get_timescale_connection),
):
"""
按设备ID、字段和时间范围查询特定SCADA数据
查询多个设备在指定时间范围内的特定字段监测数据。
Args:
start_time: 查询开始时间
end_time: 查询结束时间
field: 字段名称
device_ids: 设备ID列表,用逗号分隔
Returns:
SCADA字段数据列表
Raises:
HTTPException: 当字段不存在或查询参数无效时返回400错误
"""
try:
device_ids_list = (
[id.strip() for id in device_ids.split(",") if id.strip()]
if device_ids
else []
)
return await ScadaRepository.get_scada_field_by_id_time_range(
conn, device_ids_list, start_time, end_time, field
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段")
async def update_scada_field(
device_id: str = Path(..., description="设备ID"),
time: datetime = Query(..., description="更新数据的时间戳"),
field: str = Query(..., description="要更新的字段名称"),
value: float = Query(..., description="更新的字段值"),
conn: AsyncConnection = Depends(get_timescale_connection),
):
"""
更新指定设备的字段值
更新SCADA设备在特定时间的某个字段监测数据。
Args:
device_id: 设备ID
time: 数据时间戳
field: 字段名称
value: 字段新值
Returns:
更新结果信息
Raises:
HTTPException: 当字段不存在或更新失败时返回400错误
"""
try:
await ScadaRepository.update_scada_field(conn, time, device_id, field, value)
return {"message": "Updated successfully"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据")
async def delete_scada_data(
device_id: str = Query(..., description="设备ID"),
start_time: datetime = Query(..., description="删除开始时间"),
end_time: datetime = Query(..., description="删除结束时间"),
conn: AsyncConnection = Depends(get_timescale_connection),
):
"""
删除指定设备和时间范围内的SCADA数据
删除在指定时间范围内的特定设备监测数据。
Args:
device_id: 设备ID
start_time: 删除开始时间
end_time: 删除结束时间
Returns:
删除结果信息
"""
await ScadaRepository.delete_scada_by_id_time_range(
conn, device_id, start_time, end_time
)
return {"message": "Deleted successfully"}