Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
415 lines
18 KiB
Python
415 lines
18 KiB
Python
from typing import Any, List, Literal, Optional
|
||
from datetime import datetime
|
||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||
from fastapi.responses import PlainTextResponse
|
||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||
import app.services.simulation as simulation
|
||
from app.services.tjnetwork import (
|
||
run_project,
|
||
run_project_return_dict,
|
||
)
|
||
from app.services.simulation_scenarios import (
|
||
burst_analysis,
|
||
valve_close_analysis,
|
||
flushing_analysis,
|
||
contaminant_simulation,
|
||
pressure_regulation,
|
||
)
|
||
from app.services.valve_isolation import analyze_valve_isolation
|
||
from app.domain.time import (
|
||
parse_aware_time,
|
||
parse_utc_time,
|
||
)
|
||
from pydantic import BaseModel, Field, field_validator
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class RunSimulationManuallyByDate(BaseModel):
|
||
name: str = Field(..., description="管网名称(或数据库名称)")
|
||
start_time: str = Field(..., description="开始时间 (ISO 8601 / RFC3339,必须显式带时区)")
|
||
duration: int = Field(..., gt=0, description="持续时间 (分钟)")
|
||
|
||
@field_validator("start_time")
|
||
@classmethod
|
||
def validate_start_time_timezone(cls, value: str) -> str:
|
||
parse_aware_time(value, field_name="start_time")
|
||
return value
|
||
|
||
|
||
class PressureRegulation(BaseModel):
|
||
network: str = Field(..., description="管网名称(或数据库名称)")
|
||
start_time: str = Field(..., description="开始时间")
|
||
pump_control: dict = Field(..., description="泵控制策略")
|
||
tank_init_level: Optional[dict] = Field(None, description="水箱初始水位")
|
||
duration: Optional[int] = Field(900, description="持续时间 (秒)")
|
||
scheme_name: str = Field(..., min_length=1, description="方案名称")
|
||
|
||
|
||
def run_simulation_manually_by_date(
|
||
network_name: str, start_time: datetime, duration: int
|
||
) -> None:
|
||
simulation.run_online_simulation(
|
||
name=network_name, start_time=start_time, duration_seconds=duration * 60,
|
||
)
|
||
|
||
|
||
# 必须用这个PlainTextResponse,不然每个key都有引号
|
||
@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
|
||
def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
|
||
"""
|
||
运行项目模拟
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
|
||
运行指定管网项目的标准水力模拟并返回文本报告。
|
||
"""
|
||
return run_project(network)
|
||
|
||
|
||
# DingZQ, 2025-02-04, 返回dict[str, Any]
|
||
# output 和 report
|
||
# output 是 json
|
||
# report 是 text
|
||
@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
|
||
def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||
"""
|
||
运行项目模拟(返回字典)
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
|
||
返回字典包含:
|
||
- output: JSON格式的模拟输出数据
|
||
- report: 文本格式的模拟报告
|
||
|
||
运行指定管网项目的标准水力模拟并返回字典结果。
|
||
"""
|
||
return run_project_return_dict(network)
|
||
|
||
|
||
# Analysis Endpoints
|
||
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
|
||
def fastapi_burst_analysis(
|
||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
|
||
burst_ID: list[str] = Query(..., min_length=1, description="爆管节点/管段ID列表"),
|
||
burst_size: list[float] = Query(..., min_length=1, description="对应各爆管点的爆管流量大小列表(L/s)"),
|
||
modify_total_duration: int = Query(..., gt=0, description="模拟总时长(秒)"),
|
||
scheme_name: str = Query(..., min_length=1, description="分析方案名称"),
|
||
username: str = Depends(get_current_keycloak_username),
|
||
) -> str:
|
||
"""
|
||
爆管分析(高级版本)
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
- **modify_pattern_start_time**: 模式修改开始时间
|
||
- **burst_ID**: 爆管节点/管段ID列表
|
||
- **burst_size**: 爆管流量大小列表(与burst_ID对应)
|
||
- **modify_total_duration**: 模拟总时长(秒)
|
||
- **scheme_name**: 分析方案名称
|
||
|
||
支持在指定时间修改泵控制模式和阀门开度。
|
||
"""
|
||
if len(burst_ID) != len(burst_size):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="burst_id 与 burst_size 的数量必须一致",
|
||
)
|
||
burst_analysis(
|
||
name=network,
|
||
modify_pattern_start_time=modify_pattern_start_time,
|
||
burst_ID=burst_ID,
|
||
burst_size=burst_size,
|
||
modify_total_duration=modify_total_duration,
|
||
scheme_name=scheme_name,
|
||
username=username,
|
||
)
|
||
return "success"
|
||
|
||
|
||
@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
|
||
def fastapi_valve_close_analysis(
|
||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
|
||
valves: List[str] = Query(..., description="要关闭的阀门ID列表"),
|
||
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||
scheme_name: str = Query(..., description="阀门关闭方案名称"),
|
||
) -> str:
|
||
"""
|
||
阀门关闭分析(高级版本)
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
- **start_time**: 阀门关闭开始时间
|
||
- **valves**: 要关闭的阀门ID列表
|
||
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||
- **scheme_name**: 阀门关闭方案名称
|
||
|
||
支持同时关闭多个阀门进行分析。
|
||
"""
|
||
result = valve_close_analysis(
|
||
name=network,
|
||
modify_pattern_start_time=start_time,
|
||
modify_total_duration=duration or 900,
|
||
modify_valve_opening={valve_id: 0.0 for valve_id in valves},
|
||
scheme_name=scheme_name,
|
||
)
|
||
return result or "success"
|
||
|
||
|
||
@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
|
||
def valve_isolation_endpoint(
|
||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
||
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
||
):
|
||
"""
|
||
阀门隔离分析
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
- **accident_element**: 发生事故的管段/节点ID列表
|
||
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
||
|
||
返回隔离方案,包括:
|
||
- must_close_valves: 必须关闭的阀门列表
|
||
- optional_valves: 可选关闭的阀门列表
|
||
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
|
||
- affected_node_count: 受影响的节点总数
|
||
- isolatable: 是否可以有效隔离
|
||
"""
|
||
# result = {
|
||
# "accident_element": "P461309",
|
||
# "accident_elements": ["P461309"],
|
||
# "affected_nodes": [
|
||
# "J316629_A",
|
||
# "J317037_B",
|
||
# "J317060_B",
|
||
# "J408189_B",
|
||
# "J499996",
|
||
# "J524940",
|
||
# "J535933",
|
||
# "J58841",
|
||
# ],
|
||
# "isolatable": True,
|
||
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
||
# "optional_valves": [],
|
||
# }
|
||
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
||
return result
|
||
|
||
|
||
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
|
||
def fastapi_flushing_analysis(
|
||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
|
||
valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"),
|
||
valves_k: List[float] | None = Query(
|
||
None, description="对应各阀门的开度列表(0-1,可选,与valves同时提供)"
|
||
),
|
||
valve_statuses: List[Literal["OPEN", "CLOSED", "ACTIVE"]] | None = Query(
|
||
None, description="对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)"
|
||
),
|
||
valve_settings: List[str] | None = Query(
|
||
None, description="对应各阀门的设置值列表(ACTIVE状态下必填)"
|
||
),
|
||
drainage_node_ID: str = Query(..., description="排污节点ID"),
|
||
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
|
||
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||
scheme_name: str = Query(..., description="冲洗方案名称"),
|
||
username: str = Depends(get_current_keycloak_username),
|
||
) -> str:
|
||
"""
|
||
冲洗分析(高级版本)
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
- **start_time**: 冲洗开始时间
|
||
- **valves**: 参与控制的阀门ID列表(可选)
|
||
- **valves_k**: 各阀门的开度列表(0-1,可选,与valves同时提供)
|
||
- **valve_statuses**: 各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)
|
||
- **valve_settings**: 各阀门的设置值列表(ACTIVE状态下必填)
|
||
- **drainage_node_ID**: 排污节点ID
|
||
- **flush_flow**: 冲洗流量(L/s)
|
||
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||
- **scheme_name**: 冲洗方案名称
|
||
|
||
支持多阀联合冲洗操作。
|
||
"""
|
||
valve_opening = None
|
||
valve_control = None
|
||
if valve_statuses is not None and valves_k is not None:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="valve_statuses 和 valves_k 不能同时提供",
|
||
)
|
||
if valve_settings is not None and valve_statuses is None:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="valve_settings 必须与 valve_statuses 同时提供",
|
||
)
|
||
if valves is None:
|
||
if (
|
||
valves_k is not None
|
||
or valve_statuses is not None
|
||
or valve_settings is not None
|
||
):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="阀门控制参数必须与 valves 同时提供",
|
||
)
|
||
elif valve_statuses is not None:
|
||
if len(valves) != len(valve_statuses):
|
||
raise HTTPException(
|
||
status_code=422, detail="valves 和 valve_statuses 的数量必须一致"
|
||
)
|
||
if valve_settings is not None and len(valves) != len(valve_settings):
|
||
raise HTTPException(
|
||
status_code=422, detail="valves 和 valve_settings 的数量必须一致"
|
||
)
|
||
|
||
settings = valve_settings or [""] * len(valves)
|
||
valve_control = {}
|
||
for valve_id, raw_status, raw_setting in zip(
|
||
valves, valve_statuses, settings
|
||
):
|
||
status = raw_status
|
||
setting = raw_setting.strip()
|
||
if status == "ACTIVE" and not setting:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"ACTIVE 状态的阀门 {valve_id} 必须提供设置值",
|
||
)
|
||
|
||
control: dict[str, str] = {"status": status}
|
||
if status == "ACTIVE":
|
||
control["setting"] = setting
|
||
valve_control[valve_id] = control
|
||
elif valves_k is not None:
|
||
if len(valves) != len(valves_k):
|
||
raise HTTPException(
|
||
status_code=422, detail="valves 和 valves_k 的数量必须一致"
|
||
)
|
||
valve_opening = {
|
||
valve_id: float(valve_k)
|
||
for valve_id, valve_k in zip(valves, valves_k)
|
||
}
|
||
else:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="提供 valves 时必须同时提供 valve_statuses 或 valves_k",
|
||
)
|
||
result = flushing_analysis(
|
||
name=network,
|
||
modify_pattern_start_time=start_time,
|
||
modify_total_duration=duration or 900,
|
||
modify_valve_opening=valve_opening,
|
||
valve_control=valve_control,
|
||
drainage_node_ID=drainage_node_ID,
|
||
flushing_flow=flush_flow,
|
||
scheme_name=scheme_name,
|
||
username=username,
|
||
)
|
||
return result or "success"
|
||
|
||
|
||
@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
|
||
def fastapi_contaminant_simulation(
|
||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
|
||
source: str = Query(..., description="污染源节点ID"),
|
||
concentration: float = Query(..., description="污染浓度(mg/L)"),
|
||
duration: int = Query(..., description="模拟持续时间(秒)"),
|
||
scheme_name: str = Query(..., description="模拟方案名称"),
|
||
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
|
||
username: str = Depends(get_current_keycloak_username),
|
||
) -> str:
|
||
"""
|
||
污染物模拟
|
||
|
||
- **network**: 管网名称(或数据库名称)
|
||
- **start_time**: 污染开始时间
|
||
- **source**: 污染源节点ID
|
||
- **concentration**: 污染浓度(mg/L)
|
||
- **duration**: 模拟持续时间(秒)
|
||
- **scheme_name**: 模拟方案名称
|
||
- **pattern**: 污染源模式ID(可选)
|
||
|
||
用于评估管网中污染物的传播和影响范围。
|
||
"""
|
||
result = contaminant_simulation(
|
||
name=network,
|
||
modify_pattern_start_time=start_time,
|
||
scheme_name=scheme_name,
|
||
modify_total_duration=duration,
|
||
source=source,
|
||
concentration=concentration,
|
||
source_pattern=pattern,
|
||
username=username,
|
||
)
|
||
return result or "success"
|
||
|
||
|
||
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
|
||
def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
|
||
"""
|
||
压力调节(高级版本)
|
||
|
||
请求体参数:
|
||
- **network**: 管网名称(或数据库名称)
|
||
- **start_time**: 控制开始时间
|
||
- **pump_control**: 泵控制策略字典
|
||
- **tank_init_level**: 水箱初始水位字典(可选)
|
||
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||
- **scheme_name**: 控制方案名称
|
||
|
||
支持固定泵和变速泵的独立控制。
|
||
"""
|
||
item = data.model_dump()
|
||
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
|
||
item["network"]
|
||
)
|
||
fixed_pumps = set(scada_mappings.fixed_pumps)
|
||
variable_pumps = set(scada_mappings.variable_pumps)
|
||
fixed_pump_pattern: dict[str, list] = {}
|
||
variable_pump_pattern: dict[str, list] = {}
|
||
for pump_id, values in item["pump_control"].items():
|
||
if pump_id in variable_pumps:
|
||
variable_pump_pattern[pump_id] = values
|
||
else:
|
||
fixed_pump_pattern[pump_id] = values
|
||
pressure_regulation(
|
||
name=item["network"],
|
||
modify_pattern_start_time=item["start_time"],
|
||
modify_total_duration=item["duration"] or 900,
|
||
modify_tank_initial_level=item["tank_init_level"],
|
||
modify_fixed_pump_pattern=fixed_pump_pattern or None,
|
||
modify_variable_pump_pattern=variable_pump_pattern or None,
|
||
scheme_name=item["scheme_name"],
|
||
scada_mappings=scada_mappings,
|
||
)
|
||
return "success"
|
||
|
||
|
||
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
||
def fastapi_run_simulation_manually_by_date(
|
||
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
||
) -> dict[str, str]:
|
||
"""
|
||
手动运行日期指定模拟
|
||
|
||
请求体参数:
|
||
- **name**: 管网名称(或数据库名称)
|
||
- **start_time**: 开始时间(ISO 8601 / RFC3339,必须显式带时区)
|
||
- **duration**: 模拟持续时间(分钟)
|
||
|
||
系统将从指定时间开始,按15分钟间隔多次运行模拟。
|
||
每次模拟间隔15分钟,直至达到指定的总持续时间。
|
||
"""
|
||
item = data.model_dump()
|
||
try:
|
||
start_time = parse_utc_time(item["start_time"], field_name="start_time")
|
||
run_simulation_manually_by_date(
|
||
item["name"], start_time, item["duration"]
|
||
)
|
||
return {"status": "success"}
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|