From 775ecb8a5841251adc93284883411770894a76ed Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 16 Jul 2026 14:16:06 +0800 Subject: [PATCH] fix(simulation): use hydraulic timestep --- app/algorithms/simulation/runner.py | 15 +++-------- app/api/v1/endpoints/simulation.py | 16 ++++++++++-- app/services/simulation.py | 14 ++++++----- app/services/time_api.py | 35 ++++++++++++++++++++++++++ tests/api/test_simulation_endpoints.py | 35 ++++++++++++++++++++++++++ tests/unit/test_time_api.py | 26 +++++++++++++++++++ 6 files changed, 121 insertions(+), 20 deletions(-) diff --git a/app/algorithms/simulation/runner.py b/app/algorithms/simulation/runner.py index 004caba..b59fa55 100644 --- a/app/algorithms/simulation/runner.py +++ b/app/algorithms/simulation/runner.py @@ -29,6 +29,7 @@ import pytz import requests import time import app.services.project_info as project_info +from app.services.time_api import parse_clock_duration_seconds url_path = 'http://10.101.15.16:9000/loong' # 内网 # url_path = 'http://183.64.62.100:9057/loong' # 外网 @@ -551,21 +552,11 @@ def from_clock_to_seconds (clock: str)->int: return hr*3600+mnt*60+seconds def from_clock_to_seconds_2 (clock: str)->int: - str_format="%H:%M:%S" - dt=datetime.strptime(clock,str_format) - hr=dt.hour - mnt=dt.minute - seconds=dt.second - return hr*3600+mnt*60+seconds + return parse_clock_duration_seconds(clock) def from_clock_to_seconds_3 (clock: str)->int: - str_format = "%H:%M" # 更新时间格式以适应 "小时:分钟" 格式 - dt = datetime.strptime(clock,str_format) - hr = dt.hour - mnt = dt.minute - seconds = dt.second - return hr * 3600 + mnt * 60 + return parse_clock_duration_seconds(clock) ###convert datetimestring diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index c8ad1f4..3a758cd 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -35,7 +35,11 @@ from app.services.simulation_ops import ( daily_scheduling_simulation, ) from app.services.valve_isolation import analyze_valve_isolation -from app.services.time_api import parse_aware_time, parse_utc_time +from app.services.time_api import ( + parse_aware_time, + parse_clock_duration_seconds, + parse_utc_time, +) from pydantic import BaseModel, Field, field_validator router = APIRouter() @@ -118,6 +122,14 @@ 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) current_time = start_time while current_time < end_datetime: simulation.run_simulation( @@ -125,7 +137,7 @@ def run_simulation_manually_by_date( simulation_type="realtime", modify_pattern_start_time=current_time.isoformat(timespec="seconds"), ) - current_time += timedelta(minutes=15) + current_time += hydraulic_step # 必须用这个PlainTextResponse,不然每个key都有引号 diff --git a/app/services/simulation.py b/app/services/simulation.py index ee49c09..5e910e9 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -34,7 +34,7 @@ import psycopg import logging import app.services.globals as globals import app.services.project_info as project_info -from app.services.time_api import parse_beijing_time +from app.services.time_api import parse_beijing_time, parse_clock_duration_seconds from app.core.config import get_pgconn_string from app.infra.db.timescaledb.internal_queries import ( InternalQueries as TimescaleInternalQueries, @@ -757,11 +757,13 @@ def run_simulation( # 获取水力模拟步长,如’0:15:00‘ globals.hydraulic_timestep = dic_time["HYDRAULIC TIMESTEP"] - # 将时间字符串转换为 timedelta 对象 - time_obj = datetime.strptime(globals.hydraulic_timestep, "%H:%M:%S") - # 转换为分钟浮点数 - globals.PATTERN_TIME_STEP = float( - time_obj.hour * 60 + time_obj.minute + time_obj.second / 60 + # 转换为分钟浮点数,兼容 EPANET 的 H:MM 和 H:MM:SS 写法 + globals.PATTERN_TIME_STEP = ( + parse_clock_duration_seconds( + globals.hydraulic_timestep, + field_name="HYDRAULIC TIMESTEP", + ) + / 60 ) # 对输入的时间参数进行处理 pattern_start_time = convert_time_format(modify_pattern_start_time) diff --git a/app/services/time_api.py b/app/services/time_api.py index 00904e2..569626a 100644 --- a/app/services/time_api.py +++ b/app/services/time_api.py @@ -89,6 +89,41 @@ def to_time_range(dt: datetime, delta: float) -> tuple[datetime, datetime]: return (start_time, end_time) + +def parse_clock_duration_seconds(clock: str, field_name: str = "duration") -> int: + """ + Parse EPANET-style clock durations into seconds. + + Accepted formats include H:MM, HH:MM, H:MM:SS, and HH:MM:SS. + """ + if not isinstance(clock, str): + raise ValueError(f"{field_name} must be a string clock duration.") + + parts = clock.strip().split(":") + if len(parts) not in (2, 3): + raise ValueError( + f"{field_name} must use H:MM or H:MM:SS format, got {clock!r}." + ) + + try: + values = [int(part) for part in parts] + except ValueError as exc: + raise ValueError( + f"{field_name} must contain numeric clock parts, got {clock!r}." + ) from exc + + if any(value < 0 for value in values): + raise ValueError(f"{field_name} must not contain negative values.") + + hours, minutes = values[0], values[1] + seconds = values[2] if len(values) == 3 else 0 + if minutes >= 60 or seconds >= 60: + raise ValueError( + f"{field_name} minutes and seconds must be less than 60, got {clock!r}." + ) + + return hours * 3600 + minutes * 60 + seconds + def parse_beijing_date_range(query_date: str) -> tuple[datetime, datetime]: ''' 将一个日期字符串,转换成 start/end 时间段,传进来的日期被认为是北京时间 diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index d03f96b..e022f92 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -19,11 +19,18 @@ def _load_simulation_module(monkeypatch): timezone.utc ) + def parse_clock_duration_seconds(value, field_name="duration"): + parts = [int(part) for part in value.split(":")] + hours, minutes = parts[0], parts[1] + seconds = parts[2] if len(parts) == 3 else 0 + return hours * 3600 + minutes * 60 + seconds + install_stub( monkeypatch, "app.services.time_api", { "parse_aware_time": parse_aware_time, + "parse_clock_duration_seconds": parse_clock_duration_seconds, "parse_utc_time": parse_utc_time, }, ) @@ -31,6 +38,7 @@ def _load_simulation_module(monkeypatch): monkeypatch, "app.services.simulation", { + "get_time": lambda name: {"HYDRAULIC TIMESTEP": "0:15:00"}, "run_simulation": lambda **kwargs: None, "query_corresponding_element_id_and_query_id": lambda name: None, "query_corresponding_pattern_id_and_query_id": lambda name: None, @@ -227,6 +235,33 @@ def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch): ] +def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured_calls = [] + + monkeypatch.setattr( + module.simulation, + "get_time", + lambda name: {"HYDRAULIC TIMESTEP": "1:00"}, + ) + monkeypatch.setattr( + module.simulation, + "run_simulation", + lambda **kwargs: captured_calls.append(kwargs), + ) + + module.run_simulation_manually_by_date( + "demo", + datetime(2025, 1, 1, 16, 0, 0, tzinfo=timezone.utc), + 120, + ) + + assert [call["modify_pattern_start_time"] for call in captured_calls] == [ + "2025-01-01T16:00:00+00:00", + "2025-01-01T17:00:00+00:00", + ] + + def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch): module = _load_simulation_module(monkeypatch) captured = {} diff --git a/tests/unit/test_time_api.py b/tests/unit/test_time_api.py index ef1bbfb..319c316 100644 --- a/tests/unit/test_time_api.py +++ b/tests/unit/test_time_api.py @@ -43,3 +43,29 @@ def test_utc_now_returns_timezone_aware_utc_datetime(): assert result.tzinfo == timezone.utc assert result.utcoffset() == timedelta(0) + + +@pytest.mark.parametrize( + ("clock", "expected_seconds"), + [ + ("1:00", 3600), + ("01:00", 3600), + ("0:05", 300), + ("0:05:00", 300), + ("24:00", 86400), + ], +) +def test_parse_clock_duration_seconds_accepts_epanet_clock_formats( + clock, expected_seconds +): + module = _load_time_api_module() + + assert module.parse_clock_duration_seconds(clock) == expected_seconds + + +@pytest.mark.parametrize("clock", ["bad", "1:60", "1:00:60", "-1:00"]) +def test_parse_clock_duration_seconds_rejects_invalid_clock_formats(clock): + module = _load_time_api_module() + + with pytest.raises(ValueError): + module.parse_clock_duration_seconds(clock)