From 2581631b5140623fb971cf6aa088e026352b9197 Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 17 Aug 2026 18:28:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20=E6=94=AF=E6=8C=81=E5=86=B2?= =?UTF-8?q?=E6=B4=97=E9=98=80=E9=97=A8=E7=8A=B6=E6=80=81=E4=B8=8E=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/algorithms/simulation/scenarios.py | 4 + app/api/v1/endpoints/simulation.py | 89 +++++++++-- app/services/simulation.py | 31 +++- contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 95 +++++++++--- tests/api/test_simulation_endpoints.py | 140 ++++++++++++++++++ tests/unit/test_scheme_simulation_timestep.py | 65 ++++++++ 7 files changed, 397 insertions(+), 29 deletions(-) diff --git a/app/algorithms/simulation/scenarios.py b/app/algorithms/simulation/scenarios.py index 28f9954..2a36544 100644 --- a/app/algorithms/simulation/scenarios.py +++ b/app/algorithms/simulation/scenarios.py @@ -316,6 +316,7 @@ def flushing_analysis( flushing_flow: float = 0, scheme_name: str = None, username: str | None = None, + valve_control: dict[str, dict] = None, ) -> None: """ 管道冲洗模拟 @@ -323,6 +324,7 @@ def flushing_analysis( :param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00' :param modify_total_duration: 模拟总历时,秒 :param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度 + :param valve_control: dict中可分别指定阀门的status、setting和k :param drainage_node_ID: 冲洗排放口所在节点ID :param flushing_flow: 冲洗水量,传入参数单位为m3/h :param scheme_name: 方案名称 @@ -334,6 +336,7 @@ def flushing_analysis( scheme_detail: dict = { "duration": modify_total_duration, "valve_opening": modify_valve_opening, + "valve_control": valve_control, "drainage_node_ID": drainage_node_ID, "flushing_flow": flushing_flow, } @@ -450,6 +453,7 @@ def flushing_analysis( modify_pattern_start_time=modify_pattern_start_time, modify_total_duration=modify_total_duration, modify_valve_opening=modify_valve_opening, + valve_control=valve_control, scheme_type="flushing_analysis", scheme_name=scheme_name, ) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 3720639..b561359 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, List, Literal, Optional from datetime import datetime, timedelta import json import threading @@ -302,12 +302,20 @@ async def valve_isolation_endpoint( return result -@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") +@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") async def fastapi_flushing_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"), - valves: List[str] = Query(..., description="要开启的阀门ID列表"), - valves_k: List[float] = Query(..., description="对应各阀门的开度列表(0-1)"), + 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秒"), @@ -319,8 +327,10 @@ async def fastapi_flushing_analysis( - **network**: 管网名称(或数据库名称) - **start_time**: 冲洗开始时间 - - **valves**: 要开启的阀门ID列表 - - **valves_k**: 各阀门的开度列表(0-1,与valves对应) + - **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) @@ -328,14 +338,75 @@ async def fastapi_flushing_analysis( 支持多阀联合冲洗操作。 """ - valve_opening = { - valve_id: float(valves_k[idx]) for idx, valve_id in enumerate(valves) - } + 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, diff --git a/app/services/simulation.py b/app/services/simulation.py index f567930..d56776c 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -686,6 +686,28 @@ def get_history_pattern_info(project_name, pattern_name): return flow_list, factor_list +def _apply_valve_control( + project_name: str, valve_control: dict[str, dict] +) -> None: + """Apply explicit valve status, setting, and opening controls.""" + for valve_name, control in valve_control.items(): + valve_status = get_status(project_name, valve_name) + if "status" in control: + valve_status["status"] = control["status"] + if "setting" in control: + valve_status["setting"] = control["setting"] + if "k" in control: + valve_k = control["k"] + if valve_k == 0: + valve_status["status"] = "CLOSED" + else: + valve_status["setting"] = 0.1036 * pow(valve_k, -3.105) + + cs = ChangeSet() + cs.append(valve_status) + set_status(project_name, cs) + + # 2025/01/11 def run_simulation( name: str, @@ -701,6 +723,7 @@ def run_simulation( modify_valve_opening: dict[str, float] = None, scheme_type: str = None, scheme_name: str = None, + valve_control: dict[str, dict] = None, ) -> None: """ 传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果 @@ -715,6 +738,7 @@ def run_simulation( :param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern :param modify_variable_pump_pattern: dict中包含多个水泵模式,str为变频水泵的id,list为修改后的pattern :param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度 + :param valve_control: dict中可分别指定阀门的status、setting和k;存在时优先于modify_valve_opening :param scheme_type: 模拟方案类型 :param scheme_name:模拟方案名称 :return: @@ -1200,8 +1224,11 @@ def run_simulation( cs = ChangeSet() cs.append(pump_pattern) set_pattern(name_c, cs) - # 修改阀门(valve)的状态setting和status - if modify_valve_opening: + # 显式阀门控制沿用 run_simulation_ex 的处理顺序和覆盖规则。 + if valve_control is not None: + _apply_valve_control(name_c, valve_control) + # 保留原开度参数逻辑,兼容现有方案调用。 + elif modify_valve_opening: for valve_name in modify_valve_opening.keys(): if not np.isnan(modify_valve_opening[valve_name]): valve_status = get_status(name_c, valve_name) diff --git a/contracts/manifest.json b/contracts/manifest.json index cd3362f..4f0aab1 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f" + "sha256": "9ad12d3cd789fd42c341faec5b859d76bceeabc74399e3991e62ace1129e69a2" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index cd08f0e..81b3eb5 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -11038,7 +11038,7 @@ }, "/api/v1/flushing-analyses": { "post": { - "description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", + "description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", "operationId": "post_flushing_analyses", "parameters": [ { @@ -11053,31 +11053,92 @@ } }, { - "description": "要开启的阀门ID列表", + "description": "参与控制的阀门ID列表(可选)", "in": "query", "name": "valves", - "required": true, + "required": false, "schema": { - "description": "要开启的阀门ID列表", - "items": { - "type": "string" - }, - "title": "Valves", - "type": "array" + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "参与控制的阀门ID列表(可选)", + "title": "Valves" } }, { - "description": "对应各阀门的开度列表(0-1)", + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", "in": "query", "name": "valves_k", - "required": true, + "required": false, "schema": { - "description": "对应各阀门的开度列表(0-1)", - "items": { - "type": "number" - }, - "title": "Valves K", - "type": "array" + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", + "title": "Valves K" + } + }, + { + "description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)", + "in": "query", + "name": "valve_statuses", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "enum": [ + "OPEN", + "CLOSED", + "ACTIVE" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)", + "title": "Valve Statuses" + } + }, + { + "description": "对应各阀门的设置值列表(ACTIVE状态下必填)", + "in": "query", + "name": "valve_settings", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的设置值列表(ACTIVE状态下必填)", + "title": "Valve Settings" } }, { diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index b9e563f..a4dab84 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -383,6 +383,7 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", "modify_total_duration": 900, "modify_valve_opening": {"V1": 0.5}, + "valve_control": None, "drainage_node_ID": "N1", "flushing_flow": 100.0, "scheme_name": "flush_case_01", @@ -390,6 +391,145 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): } +def test_flushing_endpoint_allows_omitting_valves(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_flushing_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "drainage_node_ID": "N1", + "scheme_name": "flush_without_valves", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured["modify_valve_opening"] is None + assert captured["valve_control"] is None + assert captured["drainage_node_ID"] == "N1" + + +def test_flushing_endpoint_passes_explicit_valve_control(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_flushing_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params=[ + ("network", "demo"), + ("start_time", "2025-01-02T03:04:05+08:00"), + ("valves", "V1"), + ("valves", "V2"), + ("valve_statuses", "ACTIVE"), + ("valve_statuses", "CLOSED"), + ("valve_settings", "2.5"), + ("valve_settings", ""), + ("drainage_node_ID", "N1"), + ("scheme_name", "flush_with_valve_control"), + ], + ) + + assert response.status_code == 200 + assert captured["modify_valve_opening"] is None + assert captured["valve_control"] == { + "V1": {"status": "ACTIVE", "setting": "2.5"}, + "V2": {"status": "CLOSED"}, + } + + +def test_flushing_endpoint_requires_setting_for_active_valve(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": "V1", + "valve_statuses": "ACTIVE", + "drainage_node_ID": "N1", + "scheme_name": "flush_without_active_setting", + }, + ) + + assert response.status_code == 422 + + +def test_flushing_endpoint_rejects_mixed_valve_control_modes(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": "V1", + "valves_k": 0.5, + "valve_statuses": "ACTIVE", + "valve_settings": "2.5", + "drainage_node_ID": "N1", + "scheme_name": "flush_with_mixed_controls", + }, + ) + + assert response.status_code == 422 + + +def test_flushing_endpoint_rejects_settings_without_statuses(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": "V1", + "valves_k": 0.5, + "valve_settings": "2.5", + "drainage_node_ID": "N1", + "scheme_name": "flush_with_orphan_settings", + }, + ) + + assert response.status_code == 422 + + +def test_flushing_endpoint_requires_drainage_node(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "scheme_name": "flush_without_drainage_node", + }, + ) + + assert response.status_code == 422 + + def test_contaminant_endpoint_passes_current_username(monkeypatch): module = _load_simulation_module(monkeypatch) captured = {} diff --git a/tests/unit/test_scheme_simulation_timestep.py b/tests/unit/test_scheme_simulation_timestep.py index 72c37c6..8057b0a 100644 --- a/tests/unit/test_scheme_simulation_timestep.py +++ b/tests/unit/test_scheme_simulation_timestep.py @@ -1,3 +1,4 @@ +import inspect import json from datetime import timedelta @@ -7,6 +8,70 @@ from app.infra.db.timescaledb.repositories.scheme import SchemeRepository from app.services.time_api import parse_utc_time +def test_run_simulation_exposes_explicit_valve_control(): + from app.services import simulation + + parameters = inspect.signature(simulation.run_simulation).parameters + + assert "valve_control" in parameters + + +def test_apply_valve_control_matches_run_simulation_ex_semantics(monkeypatch): + from app.services import simulation + + updates: dict[str, dict] = {} + + monkeypatch.setattr( + simulation, + "get_status", + lambda project_name, valve_name: { + "link": valve_name, + "status": "OPEN", + "setting": 1.0, + }, + ) + monkeypatch.setattr( + simulation, + "set_status", + lambda project_name, changeset: updates.update( + { + changeset.operations[0]["link"]: changeset.operations[0].copy() + } + ), + ) + + simulation._apply_valve_control( + "demo", + { + "V-status": {"status": "ACTIVE"}, + "V-setting": {"setting": 2.5}, + "V-closed": {"status": "ACTIVE", "setting": 9.0, "k": 0}, + "V-k": {"status": "ACTIVE", "setting": 9.0, "k": 0.5}, + }, + ) + + assert updates["V-status"] == { + "link": "V-status", + "status": "ACTIVE", + "setting": 1.0, + } + assert updates["V-setting"] == { + "link": "V-setting", + "status": "OPEN", + "setting": 2.5, + } + assert updates["V-closed"] == { + "link": "V-closed", + "status": "CLOSED", + "setting": 9.0, + } + assert updates["V-k"] == { + "link": "V-k", + "status": "ACTIVE", + "setting": 0.1036 * pow(0.5, -3.105), + } + + def _node_result(periods: int) -> list[dict]: return [ {