fix(simulation): export ACTIVE valves correctly for EPANET 2
The legacy online simulation path still exported internal ACTIVE states as STATUS keywords, which EPANET 2 rejects. Preserve numeric settings and EPANET 3 output while passing the format through both exporters. Surface the original engine error inside the model transaction so failed runs roll back before result storage. Regression guard: six failures reproduced on the deployed baseline, including native input validation. All 34 focused export, service and endpoint checks pass after the fix; the real project also returns native node and link results.
This commit is contained in:
@@ -120,7 +120,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
|
|||||||
file.write('\n'.join(inp_out_demand(project)))
|
file.write('\n'.join(inp_out_demand(project)))
|
||||||
|
|
||||||
elif name == STATUS:
|
elif name == STATUS:
|
||||||
file.write('\n'.join(inp_out_status(project)))
|
file.write('\n'.join(inp_out_status(project, version=version)))
|
||||||
|
|
||||||
elif name == PATTERNS:
|
elif name == PATTERNS:
|
||||||
if version == '3':
|
if version == '3':
|
||||||
@@ -232,7 +232,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
|
|||||||
inp += '\n'.join(inp_out_demand(project))
|
inp += '\n'.join(inp_out_demand(project))
|
||||||
|
|
||||||
elif name == STATUS:
|
elif name == STATUS:
|
||||||
inp += '\n'.join(inp_out_status(project))
|
inp += '\n'.join(inp_out_status(project, version=version))
|
||||||
|
|
||||||
elif name == PATTERNS:
|
elif name == PATTERNS:
|
||||||
if version == '3':
|
if version == '3':
|
||||||
|
|||||||
@@ -93,14 +93,16 @@ def inp_in_status(line: str) -> str:
|
|||||||
return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});")
|
return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});")
|
||||||
|
|
||||||
|
|
||||||
def inp_out_status(name: str) -> list[str]:
|
def inp_out_status(name: str, version: str = '2') -> list[str]:
|
||||||
lines = []
|
lines = []
|
||||||
objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id')
|
objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id')
|
||||||
for obj in objs:
|
for obj in objs:
|
||||||
link = obj['link']
|
link = obj['link']
|
||||||
status = obj['status'] if obj['status'] != None else ''
|
status = obj['status'] if obj['status'] != None else ''
|
||||||
setting = obj['setting'] if obj['setting'] != None else ''
|
setting = obj['setting'] if obj['setting'] != None else ''
|
||||||
if status != '':
|
# EPANET 2 valves use their configured setting by default; ACTIVE is
|
||||||
|
# an internal state, not a valid keyword in its [STATUS] section.
|
||||||
|
if status != '' and not (version == '2' and status.upper() == LINK_STATUS_ACTIVE):
|
||||||
lines.append(f'{link} {status}')
|
lines.append(f'{link} {status}')
|
||||||
if setting != '':
|
if setting != '':
|
||||||
lines.append(f'{link} {setting}')
|
lines.append(f'{link} {setting}')
|
||||||
|
|||||||
@@ -490,6 +490,23 @@ def run_simulation(
|
|||||||
set_status(name_c, cs)
|
set_status(name_c, cs)
|
||||||
# 运行并返回结果
|
# 运行并返回结果
|
||||||
result_data = json.loads(run_project(name_c))
|
result_data = json.loads(run_project(name_c))
|
||||||
|
if result_data.get("simulation_result") == "failed":
|
||||||
|
report = result_data.get("report") or ""
|
||||||
|
logging.error("EPANET failed for project %s:\n%s", name_c, report)
|
||||||
|
lines = report.splitlines()
|
||||||
|
error_index = next(
|
||||||
|
(i for i, line in enumerate(lines) if line.strip().startswith("Error ")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
detail = (
|
||||||
|
" ".join(line.strip() for line in lines[error_index:error_index + 2])[:600]
|
||||||
|
if error_index is not None
|
||||||
|
else "引擎未返回错误详情,请查看服务日志"
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"EPANET 计算失败:{detail}")
|
||||||
|
output_data = result_data.get("output")
|
||||||
|
if not isinstance(output_data, dict):
|
||||||
|
raise RuntimeError("run_project did not return JSON output content")
|
||||||
refresh_materialized_views_after_commit(name_c)
|
refresh_materialized_views_after_commit(name_c)
|
||||||
time_cost_end = time.perf_counter()
|
time_cost_end = time.perf_counter()
|
||||||
print(
|
print(
|
||||||
@@ -498,9 +515,6 @@ def run_simulation(
|
|||||||
time_cost_end - time_cost_start,
|
time_cost_end - time_cost_start,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
output_data = result_data.get("output")
|
|
||||||
if not isinstance(output_data, dict):
|
|
||||||
raise RuntimeError("run_project did not return JSON output content")
|
|
||||||
node_result = output_data.get("node_results")
|
node_result = output_data.get("node_results")
|
||||||
link_result = output_data.get("link_results")
|
link_result = output_data.get("link_results")
|
||||||
if node_result is None or link_result is None:
|
if node_result is None or link_result is None:
|
||||||
|
|||||||
@@ -277,3 +277,51 @@ def test_extended_simulation_marks_run_failed_when_result_storage_fails(monkeypa
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert [call[1]["status"] for call in lifecycle_calls] == ["failed"]
|
assert [call[1]["status"] for call in lifecycle_calls] == ["failed"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("has_output", [False, True])
|
||||||
|
def test_engine_failure_preserves_report_and_rolls_back_model_changes(monkeypatch, has_output):
|
||||||
|
from app.services import simulation
|
||||||
|
|
||||||
|
transaction_outcome = []
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def project_transaction(_name):
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception:
|
||||||
|
transaction_outcome.append("rollback")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
transaction_outcome.append("commit")
|
||||||
|
|
||||||
|
monkeypatch.setattr(simulation, "project_transaction", project_transaction)
|
||||||
|
monkeypatch.setattr(simulation, "get_time", lambda _: {
|
||||||
|
"HYDRAULIC TIMESTEP": "0:15:00",
|
||||||
|
"DURATION": "0:00",
|
||||||
|
"PATTERN START": "0:00",
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(simulation, "set_time", lambda *_: None)
|
||||||
|
result = {
|
||||||
|
"simulation_result": "failed",
|
||||||
|
"report": "EPANET report\n Error 202: illegal numeric value ACTIVE in [STATUS] section:\n V1 ACTIVE\n",
|
||||||
|
}
|
||||||
|
if has_output:
|
||||||
|
result["output"] = {"node_results": [], "link_results": [], "times": {"num_periods": 1}}
|
||||||
|
monkeypatch.setattr(simulation, "run_project", lambda _: json.dumps(result))
|
||||||
|
refresh = Mock()
|
||||||
|
store = Mock()
|
||||||
|
monkeypatch.setattr(simulation, "refresh_materialized_views_after_commit", refresh)
|
||||||
|
monkeypatch.setattr(simulation.TimescaleInternalStorage, "store_realtime_simulation", store)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="EPANET.*Error 202"):
|
||||||
|
simulation.run_simulation(
|
||||||
|
name="demo",
|
||||||
|
simulation_type="realtime",
|
||||||
|
modify_pattern_start_time="2026-07-30T04:30:00Z",
|
||||||
|
scada_mappings=_empty_scada_mappings(simulation),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transaction_outcome == ["rollback"]
|
||||||
|
refresh.assert_not_called()
|
||||||
|
store.assert_not_called()
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import ctypes
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.native.wndb.model import status
|
||||||
|
|
||||||
|
|
||||||
|
def test_epanet2_preserves_valve_setting_without_emitting_active(monkeypatch):
|
||||||
|
monkeypatch.setattr(status, "read_all", lambda *_: [
|
||||||
|
{"link": "V1", "status": "ACTIVE", "setting": None},
|
||||||
|
{"link": "V2", "status": "ACTIVE", "setting": 2.5},
|
||||||
|
{"link": "V3", "status": "CLOSED", "setting": None},
|
||||||
|
{"link": "P1", "status": "OPEN", "setting": 0},
|
||||||
|
])
|
||||||
|
assert status.inp_out_status("model") == ["V2 2.5", "V3 CLOSED", "P1 OPEN", "P1 0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_epanet3_retains_explicit_active_status(monkeypatch):
|
||||||
|
monkeypatch.setattr(status, "read_all", lambda *_: [
|
||||||
|
{"link": "V1", "status": "ACTIVE", "setting": 100},
|
||||||
|
])
|
||||||
|
assert status.inp_out_status("model", version="3") == ["V1 ACTIVE", "V1 100"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", ["2", "3"])
|
||||||
|
def test_file_and_text_exporters_forward_the_selected_format(monkeypatch, tmp_path, version):
|
||||||
|
from app.native.wndb.inp import exporter
|
||||||
|
|
||||||
|
monkeypatch.setattr(exporter, "have_project", lambda _: True)
|
||||||
|
monkeypatch.setattr(exporter, "section_name", [exporter.STATUS])
|
||||||
|
monkeypatch.setattr(exporter, "section_names_for_epanetv2", [exporter.STATUS])
|
||||||
|
monkeypatch.setattr(status, "read_all", lambda *_: [
|
||||||
|
{"link": "V1", "status": "ACTIVE", "setting": 100},
|
||||||
|
])
|
||||||
|
path = tmp_path / "model.inp"
|
||||||
|
exporter.dump_inp("model", str(path), version)
|
||||||
|
text = exporter.export_inp("model", version).operations[0]["inp"]
|
||||||
|
assert "V1 100" in path.read_text() and "V1 100" in text
|
||||||
|
assert ("V1 ACTIVE" in path.read_text()) == (version == "3")
|
||||||
|
assert ("V1 ACTIVE" in text) == (version == "3")
|
||||||
|
|
||||||
|
|
||||||
|
def test_exported_active_valve_passes_bundled_epanet2_input_validation(monkeypatch, tmp_path):
|
||||||
|
library = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux/libepanet2.so"
|
||||||
|
if platform.system() != "Linux" or not library.exists():
|
||||||
|
pytest.skip("Bundled Linux EPANET library is unavailable")
|
||||||
|
monkeypatch.setattr(status, "read_all", lambda *_: [
|
||||||
|
{"link": "V1", "status": "ACTIVE", "setting": None},
|
||||||
|
])
|
||||||
|
inp = tmp_path / "valve.inp"
|
||||||
|
inp.write_text(
|
||||||
|
"[JUNCTIONS]\nJ1 0 1\nJ2 0 1\n[RESERVOIRS]\nR1 100\n"
|
||||||
|
"[PIPES]\nP1 R1 J1 100 300 100 0 OPEN\n"
|
||||||
|
"[VALVES]\nV1 J1 J2 600 TCV 100 0\n[STATUS]\n"
|
||||||
|
+ "\n".join(status.inp_out_status("model"))
|
||||||
|
+ "\n[OPTIONS]\nUNITS LPS\n[END]\n"
|
||||||
|
)
|
||||||
|
lib = ctypes.CDLL(str(library))
|
||||||
|
lib.EN_createproject.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
|
||||||
|
lib.EN_open.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p]
|
||||||
|
lib.EN_deleteproject.argtypes = [ctypes.c_void_p]
|
||||||
|
handle = ctypes.c_void_p()
|
||||||
|
assert lib.EN_createproject(ctypes.byref(handle)) == 0
|
||||||
|
try:
|
||||||
|
code = lib.EN_open(handle, str(inp).encode(), str(tmp_path / "valve.rpt").encode(), b"")
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
lib.EN_deleteproject(handle)
|
||||||
Reference in New Issue
Block a user