Author SHA1 Message Date
jiang 2581631b51 feat(simulation): 支持冲洗阀门状态与设置值
Generic Container CI/CD / test-build-publish (push) Successful in 2m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-17 18:28:57 +08:00
jiang c250e97b87 ci(backend): block releases missing frontend API contract 2026-08-11 11:11:39 +08:00
8 changed files with 403 additions and 29 deletions
+6
View File
@@ -13,6 +13,12 @@ jobs:
image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend
dockerfile: Dockerfile dockerfile: Dockerfile
build_context: . build_context: .
test_command: |
test -f app/api/v1/endpoints/access.py
grep -Fq 'api_router.include_router(access.router' app/api/v1/router.py
grep -Fq '@router.get("/projects"' app/api/v1/endpoints/meta.py
grep -Fq '@router.get("/projects/current"' app/api/v1/endpoints/project.py
grep -Fq '@router.post("/audit-events"' app/api/v1/endpoints/audit.py
deploy_service: backend deploy_service: backend
deploy_host: 192.168.1.114 deploy_host: 192.168.1.114
secrets: secrets:
+4
View File
@@ -316,6 +316,7 @@ def flushing_analysis(
flushing_flow: float = 0, flushing_flow: float = 0,
scheme_name: str = None, scheme_name: str = None,
username: str | None = None, username: str | None = None,
valve_control: dict[str, dict] = None,
) -> None: ) -> None:
""" """
管道冲洗模拟 管道冲洗模拟
@@ -323,6 +324,7 @@ def flushing_analysis(
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00' :param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param modify_total_duration: 模拟总历时,秒 :param modify_total_duration: 模拟总历时,秒
:param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度 :param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param valve_control: dict中可分别指定阀门的status、setting和k
:param drainage_node_ID: 冲洗排放口所在节点ID :param drainage_node_ID: 冲洗排放口所在节点ID
:param flushing_flow: 冲洗水量,传入参数单位为m3/h :param flushing_flow: 冲洗水量,传入参数单位为m3/h
:param scheme_name: 方案名称 :param scheme_name: 方案名称
@@ -334,6 +336,7 @@ def flushing_analysis(
scheme_detail: dict = { scheme_detail: dict = {
"duration": modify_total_duration, "duration": modify_total_duration,
"valve_opening": modify_valve_opening, "valve_opening": modify_valve_opening,
"valve_control": valve_control,
"drainage_node_ID": drainage_node_ID, "drainage_node_ID": drainage_node_ID,
"flushing_flow": flushing_flow, "flushing_flow": flushing_flow,
} }
@@ -450,6 +453,7 @@ def flushing_analysis(
modify_pattern_start_time=modify_pattern_start_time, modify_pattern_start_time=modify_pattern_start_time,
modify_total_duration=modify_total_duration, modify_total_duration=modify_total_duration,
modify_valve_opening=modify_valve_opening, modify_valve_opening=modify_valve_opening,
valve_control=valve_control,
scheme_type="flushing_analysis", scheme_type="flushing_analysis",
scheme_name=scheme_name, scheme_name=scheme_name,
) )
+80 -9
View File
@@ -1,4 +1,4 @@
from typing import Any, List, Optional from typing import Any, List, Literal, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
import threading import threading
@@ -302,12 +302,20 @@ async def valve_isolation_endpoint(
return result 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( async def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"), network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"), start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
valves: List[str] = Query(..., description="要开启的阀门ID列表"), valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"),
valves_k: List[float] = Query(..., description="对应各阀门的开度列表(0-1"), 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"), drainage_node_ID: str = Query(..., description="排污节点ID"),
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"), flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"), duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
@@ -319,8 +327,10 @@ async def fastapi_flushing_analysis(
- **network**: 管网名称(或数据库名称) - **network**: 管网名称(或数据库名称)
- **start_time**: 冲洗开始时间 - **start_time**: 冲洗开始时间
- **valves**: 要开启的阀门ID列表 - **valves**: 参与控制的阀门ID列表(可选)
- **valves_k**: 各阀门的开度列表(0-1,与valves对应 - **valves_k**: 各阀门的开度列表(0-1,可选,与valves同时提供
- **valve_statuses**: 各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)
- **valve_settings**: 各阀门的设置值列表(ACTIVE状态下必填)
- **drainage_node_ID**: 排污节点ID - **drainage_node_ID**: 排污节点ID
- **flush_flow**: 冲洗流量(L/s - **flush_flow**: 冲洗流量(L/s
- **duration**: 模拟持续时间(秒,可选,默认900) - **duration**: 模拟持续时间(秒,可选,默认900)
@@ -328,14 +338,75 @@ async def fastapi_flushing_analysis(
支持多阀联合冲洗操作。 支持多阀联合冲洗操作。
""" """
valve_opening = { valve_opening = None
valve_id: float(valves_k[idx]) for idx, valve_id in enumerate(valves) 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( result = flushing_analysis(
name=network, name=network,
modify_pattern_start_time=start_time, modify_pattern_start_time=start_time,
modify_total_duration=duration or 900, modify_total_duration=duration or 900,
modify_valve_opening=valve_opening, modify_valve_opening=valve_opening,
valve_control=valve_control,
drainage_node_ID=drainage_node_ID, drainage_node_ID=drainage_node_ID,
flushing_flow=flush_flow, flushing_flow=flush_flow,
scheme_name=scheme_name, scheme_name=scheme_name,
+29 -2
View File
@@ -686,6 +686,28 @@ def get_history_pattern_info(project_name, pattern_name):
return flow_list, factor_list 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 # 2025/01/11
def run_simulation( def run_simulation(
name: str, name: str,
@@ -701,6 +723,7 @@ def run_simulation(
modify_valve_opening: dict[str, float] = None, modify_valve_opening: dict[str, float] = None,
scheme_type: str = None, scheme_type: str = None,
scheme_name: str = None, scheme_name: str = None,
valve_control: dict[str, dict] = None,
) -> None: ) -> None:
""" """
传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果 传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果
@@ -715,6 +738,7 @@ def run_simulation(
:param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern :param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern
:param modify_variable_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 modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param valve_control: dict中可分别指定阀门的status、setting和k;存在时优先于modify_valve_opening
:param scheme_type: 模拟方案类型 :param scheme_type: 模拟方案类型
:param scheme_name:模拟方案名称 :param scheme_name:模拟方案名称
:return: :return:
@@ -1200,8 +1224,11 @@ def run_simulation(
cs = ChangeSet() cs = ChangeSet()
cs.append(pump_pattern) cs.append(pump_pattern)
set_pattern(name_c, cs) set_pattern(name_c, cs)
# 修改阀门(valve)的状态setting和status # 显式阀门控制沿用 run_simulation_ex 的处理顺序和覆盖规则。
if modify_valve_opening: 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(): for valve_name in modify_valve_opening.keys():
if not np.isnan(modify_valve_opening[valve_name]): if not np.isnan(modify_valve_opening[valve_name]):
valve_status = get_status(name_c, valve_name) valve_status = get_status(name_c, valve_name)
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f" "sha256": "9ad12d3cd789fd42c341faec5b859d76bceeabc74399e3991e62ace1129e69a2"
} }
} }
} }
+78 -17
View File
@@ -11038,7 +11038,7 @@
}, },
"/api/v1/flushing-analyses": { "/api/v1/flushing-analyses": {
"post": { "post": {
"description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", "description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
"operationId": "post_flushing_analyses", "operationId": "post_flushing_analyses",
"parameters": [ "parameters": [
{ {
@@ -11053,31 +11053,92 @@
} }
}, },
{ {
"description": "要开启的阀门ID列表", "description": "参与控制的阀门ID列表(可选)",
"in": "query", "in": "query",
"name": "valves", "name": "valves",
"required": true, "required": false,
"schema": { "schema": {
"description": "要开启的阀门ID列表", "anyOf": [
"items": { {
"type": "string" "items": {
}, "type": "string"
"title": "Valves", },
"type": "array" "type": "array"
},
{
"type": "null"
}
],
"description": "参与控制的阀门ID列表(可选)",
"title": "Valves"
} }
}, },
{ {
"description": "对应各阀门的开度列表(0-1", "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供",
"in": "query", "in": "query",
"name": "valves_k", "name": "valves_k",
"required": true, "required": false,
"schema": { "schema": {
"description": "对应各阀门的开度列表(0-1", "anyOf": [
"items": { {
"type": "number" "items": {
}, "type": "number"
"title": "Valves K", },
"type": "array" "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"
} }
}, },
{ {
+140
View File
@@ -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_pattern_start_time": "2025-01-02T03:04:05+08:00",
"modify_total_duration": 900, "modify_total_duration": 900,
"modify_valve_opening": {"V1": 0.5}, "modify_valve_opening": {"V1": 0.5},
"valve_control": None,
"drainage_node_ID": "N1", "drainage_node_ID": "N1",
"flushing_flow": 100.0, "flushing_flow": 100.0,
"scheme_name": "flush_case_01", "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): def test_contaminant_endpoint_passes_current_username(monkeypatch):
module = _load_simulation_module(monkeypatch) module = _load_simulation_module(monkeypatch)
captured = {} captured = {}
@@ -1,3 +1,4 @@
import inspect
import json import json
from datetime import timedelta 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 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]: def _node_result(periods: int) -> list[dict]:
return [ return [
{ {