fix(simulation): align epanet output handling

This commit is contained in:
2026-06-11 10:29:04 +08:00
parent 21929b44fc
commit 67ba9c2ac6
5 changed files with 185 additions and 263 deletions
+52 -179
View File
@@ -8,7 +8,6 @@ from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Bod
from fastapi.responses import PlainTextResponse
import app.services.simulation as simulation
import app.services.globals as globals
from app.infra.cache.redis_client import redis_client
from app.services.tjnetwork import (
run_project,
run_project_return_dict,
@@ -28,6 +27,7 @@ from app.algorithms.sensor import (
pressure_sensor_placement_sensitivity,
pressure_sensor_placement_kmeans,
)
from app.services.network_import import network_update
from app.services.simulation_ops import (
project_management,
@@ -35,16 +35,22 @@ from app.services.simulation_ops import (
daily_scheduling_simulation,
)
from app.services.valve_isolation import analyze_valve_isolation
from pydantic import BaseModel, Field
from app.services.time_api import parse_aware_time, parse_utc_time
from pydantic import BaseModel, Field, field_validator
router = APIRouter()
class RunSimulationManuallyByDate(BaseModel):
name: str = Field(..., description="管网名称(或数据库名称)")
simulation_date: str = Field(..., description="模拟基准日期 (YYYY-MM-DD)")
start_time: str = Field(..., description="开始时间 (HH:MM 或 HH:MM:SS)")
duration: int = 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 BurstAnalysis(BaseModel):
@@ -109,63 +115,37 @@ class PressureSensorPlacement(BaseModel):
def run_simulation_manually_by_date(
network_name: str, base_date: datetime, start_time: str, duration: int
network_name: str, start_time: datetime, duration: int
) -> None:
time_parts = list(map(int, start_time.split(":")))
if len(time_parts) == 2:
start_hour, start_minute = time_parts
start_second = 0
elif len(time_parts) == 3:
start_hour, start_minute, start_second = time_parts
else:
raise ValueError("Invalid start_time format. Use HH:MM or HH:MM:SS")
start_datetime = base_date.replace(
hour=start_hour, minute=start_minute, second=start_second
)
end_datetime = start_datetime + timedelta(minutes=duration)
current_time = start_datetime
end_datetime = start_time + timedelta(minutes=duration)
current_time = start_time
while current_time < end_datetime:
iso_time = current_time.strftime("%Y-%m-%dT%H:%M:%S") + "+08:00"
simulation.run_simulation(
name=network_name,
simulation_type="realtime",
modify_pattern_start_time=iso_time,
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
)
current_time += timedelta(minutes=15)
# 必须用这个PlainTextResponse,不然每个key都有引号
@router.get("/runproject/", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。使用分布式锁机制确保同时只有一个模拟任务运行。")
@router.get("/runproject/", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
async def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
"""
运行项目模拟
- **network**: 管网名称(或数据库名称)
使用分布式锁机制确保同一时间只有一个模拟任务在运行。如果已有任务在运行,返回409状态码
运行指定管网项目的标准水力模拟并返回文本报告
"""
lock_key = "exclusive_api_lock"
timeout = 120 # 锁自动过期时间(秒)
# 尝试获取锁(NX=True: 不存在时设置,EX=timeout: 过期时间)
acquired = redis_client.set(lock_key, "locked", nx=True, ex=timeout)
if not acquired:
raise HTTPException(status_code=409, detail="is in simulation")
else:
try:
return run_project(network)
finally:
# 手动释放锁(可选,依赖过期时间自动释放更安全)
redis_client.delete(lock_key)
return run_project(network)
# DingZQ, 2025-02-04, 返回dict[str, Any]
# output 和 report
# output 是 json
# report 是 text
@router.get("/runprojectreturndict/", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。使用分布式锁机制确保同时只有一个模拟任务运行。")
@router.get("/runprojectreturndict/", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
async def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""
运行项目模拟(返回字典)
@@ -176,22 +156,9 @@ async def run_project_return_dict_endpoint(network: str = Query(..., description
- output: JSON格式的模拟输出数据
- report: 文本格式的模拟报告
使用分布式锁机制确保同一时间只有一个模拟任务在运行
运行指定管网项目的标准水力模拟并返回字典结果
"""
lock_key = "exclusive_api_lock"
timeout = 120 # 锁自动过期时间(秒)
# 尝试获取锁(NX=True: 不存在时设置,EX=timeout: 过期时间)
acquired = redis_client.set(lock_key, "locked", nx=True, ex=timeout)
if not acquired:
raise HTTPException(status_code=409, detail="is in simulation")
else:
try:
return run_project_return_dict(network)
finally:
# 手动释放锁(可选,依赖过期时间自动释放更安全)
redis_client.delete(lock_key)
return run_project_return_dict(network)
# put in inp folder, name without extension
@@ -221,26 +188,6 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输
# Analysis Endpoints
@router.get("/burstanalysis/", summary="爆管分析(基础)", description="对管网中的爆管事件进行分析,包括爆管对管网压力和流量的影响。此为基础版本,接收简化的查询参数。")
async def burst_analysis_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe_id: str = Query(..., description="管段ID"),
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"),
burst_flow: float = Query(..., description="爆管流量大小(L/s"),
):
"""
爆管分析(基础版本)
- **network**: 管网名称(或数据库名称)
- **pipe_id**: 管段ID
- **start_time**: 分析开始时间
- **end_time**: 分析结束时间
- **burst_flow**: 爆管流量大小
"""
return burst_analysis(network, pipe_id, start_time, end_time, burst_flow)
@router.get("/burst_analysis/", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
async def fastapi_burst_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
@@ -273,30 +220,13 @@ async def fastapi_burst_analysis(
return "success"
@router.get("/valvecloseanalysis/", summary="阀门关闭分析(基础)", description="对管网中的阀门关闭事件进行分析,评估关闭阀门对管网的影响。此为基础版本。")
async def valve_close_analysis_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve_id: str = Query(..., description="阀门ID"),
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"),
):
"""
阀门关闭分析(基础版本)
- **network**: 管网名称(或数据库名称)
- **valve_id**: 阀门ID
- **start_time**: 分析开始时间
- **end_time**: 分析结束时间
"""
return valve_close_analysis(network, valve_id, start_time, end_time)
@router.get("/valve_close_analysis/", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
async 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:
"""
阀门关闭分析(高级版本)
@@ -305,6 +235,7 @@ async def fastapi_valve_close_analysis(
- **start_time**: 阀门关闭开始时间
- **valves**: 要关闭的阀门ID列表
- **duration**: 模拟持续时间(秒,可选,默认900)
- **scheme_name**: 阀门关闭方案名称
支持同时关闭多个阀门进行分析。
"""
@@ -313,6 +244,7 @@ async def fastapi_valve_close_analysis(
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"
@@ -336,47 +268,27 @@ async def valve_isolation_endpoint(
- affected_nodes: 受影响的节点列表
- 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 = {
# "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.get("/flushinganalysis/", summary="冲洗分析(基础)", description="对管网的冲洗操作进行分析,评估冲洗流量和持续时间对管网的影响。此为基础版本。")
async def flushing_analysis_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe_id: str = Query(..., description="要冲洗的管段ID"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
duration: float = Query(..., description="冲洗持续时间(分钟)"),
flow: float = Query(..., description="冲洗流量(L/s"),
):
"""
冲洗分析(基础版本)
- **network**: 管网名称(或数据库名称)
- **pipe_id**: 要冲洗的管段ID
- **start_time**: 冲洗开始时间
- **duration**: 冲洗持续时间(分钟)
- **flow**: 冲洗流量(L/s
"""
return flushing_analysis(network, pipe_id, start_time, duration, flow)
@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
async def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
@@ -386,7 +298,7 @@ async def fastapi_flushing_analysis(
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 | None = Query(None, description="冲洗方案名称(可选)"),
scheme_name: str = Query(..., description="冲洗方案名称"),
) -> str:
"""
冲洗分析(高级版本)
@@ -398,7 +310,7 @@ async def fastapi_flushing_analysis(
- **drainage_node_ID**: 排污节点ID
- **flush_flow**: 冲洗流量(L/s
- **duration**: 模拟持续时间(秒,可选,默认900)
- **scheme_name**: 冲洗方案名称(可选)
- **scheme_name**: 冲洗方案名称
支持多阀联合冲洗操作。
"""
@@ -424,7 +336,7 @@ async def fastapi_contaminant_simulation(
source: str = Query(..., description="污染源节点ID"),
concentration: float = Query(..., description="污染浓度(mg/L"),
duration: int = Query(..., description="模拟持续时间(秒)"),
scheme_name: str | None = Query(None, description="模拟方案名称(可选)"),
scheme_name: str = Query(..., description="模拟方案名称"),
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
) -> str:
"""
@@ -435,7 +347,7 @@ async def fastapi_contaminant_simulation(
- **source**: 污染源节点ID
- **concentration**: 污染浓度(mg/L
- **duration**: 模拟持续时间(秒)
- **scheme_name**: 模拟方案名称(可选)
- **scheme_name**: 模拟方案名称
- **pattern**: 污染源模式ID(可选)
用于评估管网中污染物的传播和影响范围。
@@ -452,23 +364,10 @@ async def fastapi_contaminant_simulation(
return result or "success"
@router.get("/ageanalysis/", summary="水龄分析(基础)", description="对管网中的水体停留时间(水龄)进行分析。此为基础版本。")
async def age_analysis_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
"""
水龄分析(基础版本)
- **network**: 管网名称(或数据库名称)
分析管网中各节点的水体停留时间。
"""
return age_analysis(network)
@router.get("/age_analysis/", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
async def fastapi_age_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"),
duration: int = Query(..., description="模拟持续时间(秒)"),
) -> str:
"""
@@ -476,7 +375,6 @@ async def fastapi_age_analysis(
- **network**: 管网名称(或数据库名称)
- **start_time**: 分析开始时间
- **end_time**: 分析结束时间(可选)
- **duration**: 模拟持续时间(秒)
分析指定时间段内管网中各节点的水体停留时间。
@@ -546,18 +444,6 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
return "success"
@router.get("/projectmanagement/", summary="项目管理(基础)", description="对管网项目进行基础的管理操作。此为基础版本。")
async def project_management_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
"""
项目管理(基础版本)
- **network**: 管网名称(或数据库名称)
进行基础的项目管理操作。
"""
return project_management(network)
@router.post("/project_management/", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
"""
@@ -659,18 +545,6 @@ async def fastapi_network_project(file: UploadFile = File(..., description="INP
return run_inp(temp_file_name)
@router.get("/networkupdate/", summary="管网更新(基础)", description="对指定管网项目进行基础的更新操作。此为基础版本。")
async def network_update_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
"""
管网更新(基础版本)
- **network**: 管网名称(或数据库名称)
进行管网的基础更新操作。
"""
return network_update(network)
@router.post("/network_update/", summary="管网更新(高级)", description="通过上传更新文件对管网进行高级的更新操作。系统将处理更新文件并应用到数据库。")
async def fastapi_network_update(file: UploadFile = File(..., description="包含管网更新信息的文件")) -> str:
"""
@@ -889,7 +763,7 @@ async def fastapi_pressure_sensor_placement(
return "success"
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的日期、开始时间和持续时间,手动运行水力模拟。系统将自动查询管网参数并执行模拟")
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间")
async def fastapi_run_simulation_manually_by_date(
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
) -> dict[str, str]:
@@ -898,14 +772,13 @@ async def fastapi_run_simulation_manually_by_date(
请求体参数:
- **name**: 管网名称(或数据库名称)
- **simulation_date**: 模拟基准日期(YYYY-MM-DD格式
- **start_time**: 开始时间(HH:MM或HH:MM:SS格式)
- **start_time**: 开始时间(ISO 8601 / RFC3339,必须显式带时区
- **duration**: 模拟持续时间(分钟)
系统将从指定日期和时间开始,按15分钟间隔多次运行模拟。
系统将从指定时间开始,按15分钟间隔多次运行模拟。
每次模拟间隔15分钟,直至达到指定的总持续时间。
"""
item = data.dict()
item = data.model_dump()
try:
simulation.query_corresponding_element_id_and_query_id(item["name"])
simulation.query_corresponding_pattern_id_and_query_id(item["name"])
@@ -932,10 +805,10 @@ async def fastapi_run_simulation_manually_by_date(
globals.source_outflow_region_id,
globals.realtime_region_pipe_flow_and_demand_id,
)
base_date = datetime.strptime(item["simulation_date"], "%Y-%m-%d")
start_time = parse_utc_time(item["start_time"], field_name="start_time")
run_simulation_manually_by_date(
item["name"], base_date, item["start_time"], item["duration"]
item["name"], start_time, item["duration"]
)
return {"status": "success"}
except Exception as exc:
return {"status": "error", "message": str(exc)}
raise HTTPException(status_code=500, detail=str(exc)) from exc