from typing import Any, List, Literal, Optional from datetime import datetime, timedelta 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_clock_duration_seconds, 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: end_datetime = start_time + timedelta(minutes=duration) time_properties = simulation.get_time(network_name) hydraulic_step_seconds = parse_clock_duration_seconds( time_properties["HYDRAULIC TIMESTEP"], field_name="HYDRAULIC TIMESTEP", ) if hydraulic_step_seconds <= 0: raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") hydraulic_step = timedelta(seconds=hydraulic_step_seconds) scada_mappings = simulation.query_corresponding_element_id_and_query_id( network_name ) current_time = start_time while current_time < end_datetime: simulation.run_simulation( name=network_name, simulation_type="realtime", modify_pattern_start_time=current_time.isoformat(timespec="seconds"), scada_mappings=scada_mappings, ) current_time += hydraulic_step # 必须用这个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