From b7872f29a9c076dce589592c35236c274021b7e3 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 3 Jun 2026 17:31:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20CLI=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=8E=B7=E5=8F=96=E6=89=80=E6=9C=89?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=92=8C=E7=AE=A1=E9=81=93=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 324 ++++++++++++++++++++----- cli/tjwater_cli/apps.py | 5 +- cli/tjwater_cli/commands_analysis.py | 10 +- cli/tjwater_cli/commands_project.py | 224 ----------------- cli/tjwater_cli/commands_readonly.py | 144 +++++++++++ cli/tjwater_cli/helping.py | 1 - cli/tjwater_cli/main.py | 2 +- cli/tjwater_cli/registry.py | 276 +++++++++++++++++---- cli/tjwater_cli_endpoint_scope.md | 91 +------ scripts/online_Analysis.py | 2 +- tests/api/test_simulation_endpoints.py | 89 +++++++ tests/unit/test_age_analysis.py | 88 +++++++ 12 files changed, 823 insertions(+), 433 deletions(-) delete mode 100644 cli/tjwater_cli/commands_project.py create mode 100644 cli/tjwater_cli/commands_readonly.py create mode 100644 tests/unit/test_age_analysis.py diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 2a84941..f372d95 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -97,14 +97,58 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): assert len(observed_runtime_ids) == 1 +def test_network_get_all_junction_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["params"] = kwargs["params"] + return [{"id": "J1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-junction-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "J1"}] + assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} + + +def test_network_get_all_pipe_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["params"] = kwargs["params"] + return [{"id": "P1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-pipe-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "P1"}] + assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} + + def test_help_outputs_json_lists_commands(): result = runner.invoke(app, ["help"]) payload = json.loads(result.stdout) assert result.exit_code == 0 assert payload["schema_version"] == "tjwater-cli/v1" - assert any(command["command"] == "project" for command in payload["commands"]) assert any(command["command"] == "analysis" for command in payload["commands"]) + assert all(command["command"] != "project" for command in payload["commands"]) assert payload["menu_level"] == 1 assert all(command["command"] != "project list" for command in payload["commands"]) @@ -137,7 +181,7 @@ def test_nested_group_help_lists_examples(): assert payload["summary"] == "漏损分析相关命令。" commands = {command["command"]: command for command in payload["commands"]} assert commands["analysis leakage identify"]["summary"] == "执行漏损识别" - assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00" + assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01" def test_analysis_help_uses_group_summaries_for_nested_groups(): @@ -150,8 +194,8 @@ def test_analysis_help_uses_group_summaries_for_nested_groups(): assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。" assert "analysis burst-location" not in commands assert "analysis risk" not in commands - assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" - assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" + assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01" + assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01" def test_bare_analysis_uses_typer_help_with_descriptions(): @@ -178,15 +222,6 @@ def test_leaf_help_outputs_json(): assert "simulation run" in payload["examples"][0] -def test_project_help_uses_legal_kind_example(): - result = runner.invoke(app, ["project", "help"]) - payload = json.loads(result.stdout) - commands = {command["command"]: command for command in payload["commands"]} - - assert result.exit_code == 0 - assert "project data" in commands["project data"]["example"] - - def test_root_help_flag_uses_typer_style_with_examples(): result = runner.invoke(app, ["--help"], prog_name="tjwater-cli") @@ -244,6 +279,214 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: assert "tjwater-cli data scheme list" in result.stdout +def test_analysis_contaminant_sends_required_scheme_name(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "analysis", + "contaminant", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "900", + "--source-node", + "N1", + "--concentration", + "10.0", + "--scheme", + "contam_case_01", + ], + ) + + assert result.exit_code == 0 + assert captured["params"] == { + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "source": "N1", + "concentration": 10.0, + "duration": 900, + "scheme_name": "contam_case_01", + } + + +def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + captured = {} + valve_path = tmp_path / "valve.json" + valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "analysis", + "flushing", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve-setting-file", + str(valve_path), + "--drainage-node", + "N1", + "--flow", + "100.0", + "--duration", + "900", + "--scheme", + "flush_case_01", + ], + ) + + assert result.exit_code == 0 + assert captured["params"] == { + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1"], + "valves_k": [0.5], + "drainage_node_ID": "N1", + "flush_flow": 100.0, + "duration": 900, + "scheme_name": "flush_case_01", + } + + +def test_analysis_valve_close_sends_required_scheme_name(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "analysis", + "valve", + "--mode", + "close", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve", + "V1", + "--duration", + "900", + "--scheme", + "valve_case_01", + ], + ) + + assert result.exit_code == 0 + assert captured["params"] == { + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1"], + "duration": 900, + "scheme_name": "valve_case_01", + } + + +def test_analysis_contaminant_requires_scheme(monkeypatch, capsys): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + + exit_code = main( + [ + "analysis", + "contaminant", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "900", + "--source-node", + "N1", + "--concentration", + "10.0", + ], + ) + + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "SCHEME_REQUIRED"' in stdout + + +def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + valve_path = tmp_path / "valve.json" + valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") + + exit_code = main( + [ + "analysis", + "flushing", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve-setting-file", + str(valve_path), + "--drainage-node", + "N1", + "--flow", + "100.0", + ], + ) + + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "SCHEME_REQUIRED"' in stdout + + +def test_analysis_valve_close_requires_scheme(monkeypatch, capsys): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + + exit_code = main( + [ + "analysis", + "valve", + "--mode", + "close", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve", + "V1", + "--duration", + "900", + ], + ) + + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "SCHEME_REQUIRED"' in stdout + + def test_main_missing_option_error_includes_usage_and_next_step(capsys): exit_code = main(["simulation", "run"]) stdout = capsys.readouterr().out @@ -265,27 +508,6 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): assert '"ok": false' not in stdout -def test_project_list_uses_auth_stdin(monkeypatch): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_PROJECT_ID", "pid") - monkeypatch.setenv("TJWATER_NETWORK", "demo") - captured = {} - - def fake_request(**kwargs): - captured.update(kwargs) - return DummyResponse(json_data=[{"project_id": "pid", "name": "Demo"}]) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke(app, ["project", "list"]) - - assert result.exit_code == 0 - assert '"ok": true' in result.stdout - assert captured["headers"]["Authorization"] == "Bearer abc" - assert captured["url"] == "http://server/api/v1/meta/projects" - - def test_simulation_run_translates_rfc3339(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") @@ -320,33 +542,9 @@ def test_simulation_run_translates_rfc3339(monkeypatch): assert "tjwater-cli data timeseries realtime nodes" in result.stdout -def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") - output = tmp_path / "demo.inp" - calls = [] +def test_removed_project_command_returns_not_found(capsys): + exit_code = main(["project", "list"]) + stdout = capsys.readouterr().out - def fake_request(**kwargs): - calls.append(kwargs["url"]) - if kwargs["url"].endswith("/dumpinp/"): - return DummyResponse(json_data=True) - return DummyResponse( - headers={"content-type": "application/octet-stream"}, - content=b"inp-content", - text="inp-content", - ) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - ["project", "export-inp", "--output", str(output)], - ) - - assert result.exit_code == 0 - assert output.read_bytes() == b"inp-content" - assert calls == [ - "http://server/api/v1/dumpinp/", - "http://server/api/v1/downloadinp/", - ] + assert exit_code == 2 + assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout diff --git a/cli/tjwater_cli/apps.py b/cli/tjwater_cli/apps.py index 6108f19..ce3ba92 100644 --- a/cli/tjwater_cli/apps.py +++ b/cli/tjwater_cli/apps.py @@ -5,7 +5,6 @@ import typer from .formatters import TJWaterGroup app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup) -project_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) @@ -30,7 +29,6 @@ data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -app.add_typer(project_app, name="project") app.add_typer(network_app, name="network") app.add_typer(component_app, name="component") component_app.add_typer(component_option_app, name="option") @@ -56,7 +54,6 @@ data_app.add_typer(data_extension_app, name="extension") data_app.add_typer(data_misc_app, name="misc") GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ - (project_app, ("project",)), (network_app, ("network",)), (component_app, ("component",)), (component_option_app, ("component", "option")), @@ -82,4 +79,4 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ (data_misc_app, ("data", "misc")), ] -TOP_LEVEL_COMMANDS = {"help", "project", "network", "component", "simulation", "analysis", "data"} +TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"} diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 231d69b..d490a43 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -106,6 +106,7 @@ def analysis_valve( element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None, duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None, ) -> None: runtime = runtime_context(ctx) network = require_network(runtime) @@ -122,6 +123,7 @@ def analysis_valve( "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "valves": valve, "duration": duration or 900, + "scheme_name": resolve_scheme(runtime, scheme, required=True), } emit_api( ctx, @@ -182,10 +184,8 @@ def analysis_flushing( "drainage_node_ID": drainage_node, "flush_flow": flow, "duration": duration or 900, + "scheme_name": resolve_scheme(runtime, scheme, required=True), } - scheme_name = resolve_scheme(runtime, scheme) - if scheme_name: - params["scheme_name"] = scheme_name emit_api( ctx, summary="冲洗分析执行成功", @@ -236,10 +236,8 @@ def analysis_contaminant( "source": source_node, "concentration": concentration, "duration": duration, + "scheme_name": resolve_scheme(runtime, scheme, required=True), } - scheme_name = resolve_scheme(runtime, scheme) - if scheme_name: - params["scheme_name"] = scheme_name if pattern: params["pattern"] = pattern emit_api( diff --git a/cli/tjwater_cli/commands_project.py b/cli/tjwater_cli/commands_project.py deleted file mode 100644 index 4470e91..0000000 --- a/cli/tjwater_cli/commands_project.py +++ /dev/null @@ -1,224 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Annotated, Any - -import typer - -from .apps import component_option_app, network_app, project_app -from .common import emit_api, runtime_context -from .core import CLIError, emit_success, request_bytes, request_json, require_network - - -@project_app.command("list") -def project_list(ctx: typer.Context) -> None: - emit_api(ctx, summary="读取项目列表成功", method="GET", path="/meta/projects", require_auth=True) - - -@project_app.command("info") -def project_info(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取项目信息成功", - method="GET", - path="/meta/project", - require_auth=True, - require_project=True, - ) - - -@project_app.command("db-health") -def project_db_health(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取数据库健康状态成功", - method="GET", - path="/meta/db/health", - require_auth=True, - require_project=True, - ) - - -@project_app.command("data") -def project_data( - ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="scada-info|scheme-list|burst-locate-result")], -) -> None: - kind_map = { - "scada-info": "/scada-info", - "scheme-list": "/scheme-list", - "burst-locate-result": "/burst-locate-result", - } - path = kind_map.get(kind) - if path is None: - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="kind must be one of: scada-info, scheme-list, burst-locate-result", - exit_code=2, - ) - emit_api( - ctx, - summary="读取项目数据成功", - method="GET", - path=path, - require_auth=True, - require_project=True, - ) - - -@project_app.command("export-inp") -def project_export_inp( - ctx: typer.Context, - output: Annotated[Path, typer.Option("--output", help="本地输出路径")], -) -> None: - runtime = runtime_context(ctx) - network = require_network(runtime) - output.parent.mkdir(parents=True, exist_ok=True) - temp_name = f"{output.stem}-{runtime.request_id}.inp" - _, duration_dump = request_json( - runtime, - method="GET", - path="/dumpinp/", - params={"network": network, "inp": temp_name}, - require_auth=True, - require_network_ctx=True, - ) - content, duration_download = request_bytes( - runtime, - method="GET", - path="/downloadinp/", - params={"name": temp_name}, - require_auth=True, - require_network_ctx=True, - ) - output.write_bytes(content) - emit_success( - summary="导出 INP 成功", - data={"output": str(output), "bytes": len(content)}, - ctx=runtime, - duration_ms=duration_dump + duration_download, - next_commands=["tjwater-cli project info"], - ) - - -@network_app.command("get-node-properties") -def network_get_node_properties( - ctx: typer.Context, - node: Annotated[str, typer.Option("--node", help="节点 ID")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取节点属性成功", - method="GET", - path="/getnodeproperties/", - params={"network": require_network(runtime), "node": node}, - require_auth=True, - require_network_ctx=True, - ) - - -@network_app.command("get-link-properties") -def network_get_link_properties( - ctx: typer.Context, - link: Annotated[str, typer.Option("--link", help="管线 ID")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取管线属性成功", - method="GET", - path="/getlinkproperties/", - params={"network": require_network(runtime), "link": link}, - require_auth=True, - require_network_ctx=True, - ) - - -def _component_option_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: - if kind == "time": - return "/gettimeschema", {} - if kind == "energy": - return "/getenergyschema/", {} - if kind == "pump-energy": - if not pump: - raise CLIError( - "CLI 参数错误", - code="PUMP_REQUIRED", - message="--pump is required when --kind pump-energy", - exit_code=2, - ) - return "/getpumpenergyschema/", {"pump": pump} - if kind == "network": - return "/getoptionschema/", {} - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="kind must be one of: time, energy, pump-energy, network", - exit_code=2, - ) - - -def _component_option_get_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: - if kind == "time": - return "/gettimeproperties/", {} - if kind == "energy": - return "/getenergyproperties/", {} - if kind == "pump-energy": - if not pump: - raise CLIError( - "CLI 参数错误", - code="PUMP_REQUIRED", - message="--pump is required when --kind pump-energy", - exit_code=2, - ) - return "/getpumpenergyproperties/", {"pump": pump} - if kind == "network": - return "/getoptionproperties/", {} - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="kind must be one of: time, energy, pump-energy, network", - exit_code=2, - ) - - -@component_option_app.command("schema") -def component_option_schema( - ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], - pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, -) -> None: - runtime = runtime_context(ctx) - path, extra = _component_option_mapping(kind, pump) - params = {"network": require_network(runtime)} | extra - emit_api( - ctx, - summary="读取选项 schema 成功", - method="GET", - path=path, - params=params, - require_auth=True, - require_network_ctx=True, - ) - - -@component_option_app.command("get") -def component_option_get( - ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], - pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, -) -> None: - runtime = runtime_context(ctx) - path, extra = _component_option_get_mapping(kind, pump) - params = {"network": require_network(runtime)} | extra - emit_api( - ctx, - summary="读取选项属性成功", - method="GET", - path=path, - params=params, - require_auth=True, - require_network_ctx=True, - ) diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py new file mode 100644 index 0000000..cddc0a8 --- /dev/null +++ b/cli/tjwater_cli/commands_readonly.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from .apps import component_option_app, network_app +from .common import emit_api, runtime_context +from .core import CLIError, require_network + + +@network_app.command("get-node-properties") +def network_get_node_properties( + ctx: typer.Context, + node: Annotated[str, typer.Option("--node", help="节点 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取节点属性成功", + method="GET", + path="/getnodeproperties/", + params={"network": require_network(runtime), "node": node}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-link-properties") +def network_get_link_properties( + ctx: typer.Context, + link: Annotated[str, typer.Option("--link", help="管线 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取管线属性成功", + method="GET", + path="/getlinkproperties/", + params={"network": require_network(runtime), "link": link}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-junction-properties") +def network_get_all_junction_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部节点属性成功", + method="GET", + path="/getalljunctionproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-pipe-properties") +def network_get_all_pipe_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部管道属性成功", + method="GET", + path="/getallpipeproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@component_option_app.command("schema") +def component_option_schema( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path = _component_option_path(kind, schema=True) + params = {"network": require_network(runtime)} + if kind == "pump-energy" and pump: + params["pump"] = pump + emit_api( + ctx, + summary="读取选项 schema 成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@component_option_app.command("get") +def component_option_get( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path = _component_option_path(kind, schema=False) + params = {"network": require_network(runtime)} + if kind == "pump-energy": + if not pump: + raise CLIError( + "CLI 参数错误", + code="PUMP_REQUIRED", + message="--pump is required when --kind pump-energy", + exit_code=2, + ) + params["pump"] = pump + emit_api( + ctx, + summary="读取选项属性成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +def _component_option_path(kind: str, *, schema: bool) -> str: + routes = { + ("time", True): "/gettimeschema", + ("time", False): "/gettimeproperties/", + ("energy", True): "/getenergyschema/", + ("energy", False): "/getenergyproperties/", + ("pump-energy", True): "/getpumpenergyschema/", + ("pump-energy", False): "/getpumpenergyproperties//", + ("network", True): "/getoptionschema/", + ("network", False): "/getoptionproperties/", + } + path = routes.get((kind, schema)) + if path is None: + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="--kind must be one of time, energy, pump-energy, network", + exit_code=2, + ) + return path diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index 1c35acd..a88a650 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -97,7 +97,6 @@ def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]: def _sample_option_value(path: tuple[str, ...], option_name: str) -> str: path_specific_samples: dict[tuple[tuple[str, ...], str], str] = { - (("project", "data"), "kind"): "scada-info", (("component", "option", "schema"), "kind"): "time", (("component", "option", "get"), "kind"): "time", (("data", "timeseries", "composite"), "kind"): "scada-simulation", diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py index 7503b31..50a7bfc 100644 --- a/cli/tjwater_cli/main.py +++ b/cli/tjwater_cli/main.py @@ -8,7 +8,7 @@ import click import typer from click.exceptions import NoArgsIsHelpError -from . import commands_analysis, commands_data, commands_project # noqa: F401 +from . import commands_analysis, commands_data, commands_readonly # noqa: F401 from .apps import app from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure from .helping import ( diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 5270696..3eb277a 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -3,7 +3,6 @@ from __future__ import annotations from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION GROUP_SUMMARIES: dict[tuple[str, ...], str] = { - ("project",): "项目与项目级元数据相关命令。", ("network",): "管网节点、管线等基础属性查询命令。", ("component",): "组件选项与配置读取命令。", ("component", "option"): "组件选项查询命令。", @@ -35,51 +34,31 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( ) COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { - ("project", "list"): CommandDoc( - path=("project", "list"), - summary="列出当前用户可访问项目", - description="调用 /meta/projects 返回项目列表。", - examples=("tjwater-cli project list",), - next_commands=("tjwater-cli project info",), - ), - ("project", "info"): CommandDoc( - path=("project", "info"), - summary="查看当前项目摘要信息。", - description="查看当前项目的基础信息。", - examples=("tjwater-cli project info",), - output="项目元数据", - ), - ("project", "db-health"): CommandDoc( - path=("project", "db-health"), - summary="检查当前项目数据库健康状态", - description="调用 /meta/db/health 返回 PostgreSQL 与 Timescale 健康状态。", - ), - ("project", "export-inp"): CommandDoc( - path=("project", "export-inp"), - summary="导出当前项目 INP 到本地文件", - description="先调用 /dumpinp/ 在服务端生成 INP,再通过 /downloadinp/ 下载到本地。", - options=( - CommandOptionDoc("output", "本地输出路径", required=True), - ), - output="本地文件路径和下载摘要", - ), - ("project", "data"): CommandDoc( - path=("project", "data"), - summary="读取当前项目业务数据", - description="kind 支持 scada-info、scheme-list、burst-locate-result。", - options=(CommandOptionDoc("kind", "数据类型", required=True),), - ), ("network", "get-node-properties"): CommandDoc( path=("network", "get-node-properties"), summary="读取节点属性", description="调用 /getnodeproperties/。", options=(CommandOptionDoc("node", "节点 ID", required=True),), + examples=("tjwater-cli network get-node-properties --node J1",), ), ("network", "get-link-properties"): CommandDoc( path=("network", "get-link-properties"), summary="读取管线属性", description="调用 /getlinkproperties/。", options=(CommandOptionDoc("link", "管线 ID", required=True),), + examples=("tjwater-cli network get-link-properties --link P1",), + ), + ("network", "get-all-junction-properties"): CommandDoc( + path=("network", "get-all-junction-properties"), + summary="读取全部节点属性", + description="调用 /getalljunctionproperties/。", + examples=("tjwater-cli network get-all-junction-properties",), + ), + ("network", "get-all-pipe-properties"): CommandDoc( + path=("network", "get-all-pipe-properties"), + summary="读取全部管道属性", + description="调用 /getallpipeproperties/。", + examples=("tjwater-cli network get-all-pipe-properties",), ), ("component", "option", "schema"): CommandDoc( path=("component", "option", "schema"), @@ -89,6 +68,12 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("kind", "选项类型", required=True), CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), ), + examples=( + "tjwater-cli component option schema --kind time", + "tjwater-cli component option schema --kind energy", + "tjwater-cli component option schema --kind pump-energy --pump PUMP1", + "tjwater-cli component option schema --kind network", + ), ), ("component", "option", "get"): CommandDoc( path=("component", "option", "get"), @@ -98,15 +83,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("kind", "选项类型", required=True), CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), ), + examples=( + "tjwater-cli component option get --kind time", + "tjwater-cli component option get --kind energy", + "tjwater-cli component option get --kind pump-energy --pump PUMP1", + "tjwater-cli component option get --kind network", + ), ), ("simulation", "run"): CommandDoc( path=("simulation", "run"), summary="触发指定绝对时间的模拟运行", - description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。", + description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续分钟数", required=True), ), + examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",), next_commands=( "tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", @@ -116,7 +108,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst"): CommandDoc( path=("analysis", "burst"), summary="执行爆管分析", - description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。", + description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续秒数", required=True), @@ -124,7 +116,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("scheme", "方案名称"), ), examples=( - "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list", ), @@ -132,201 +124,389 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "valve"): CommandDoc( path=("analysis", "valve"), summary="阀门工况分析。", - description="指定阀门采取关闭/开启等操作逻辑,并执行定时长模拟。结果写入时序库。", + description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。", options=( - CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'open'", required=True), - CommandOptionDoc(name="start-time", description="起始绝对时间,必须显式带时区偏移", required=True), - CommandOptionDoc(name="valve", description="阀门 ID(可多次指定)", required=True, repeated=True), - CommandOptionDoc(name="duration", description="模拟持续分钟数", required=True), + CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'isolation'", required=True), + CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"), + CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True), + CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True), + CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True), + CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"), + CommandOptionDoc(name="scheme", description="close 模式方案名称"), ), examples=( - "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", + "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", + "tjwater-cli analysis valve --mode isolation --element E1 --element E2", + "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3", ), ), ("analysis", "flushing"): CommandDoc( path=("analysis", "flushing"), summary="执行冲洗分析", - description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。", + description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True), + CommandOptionDoc("drainage-node", "排污节点 ID", required=True), + CommandOptionDoc("flow", "冲洗流量", required=True), + CommandOptionDoc("duration", "持续秒数,默认 900"), + CommandOptionDoc("scheme", "方案名称", required=True), + ), + examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",), ), ("analysis", "age"): CommandDoc( path=("analysis", "age"), summary="执行水龄分析", - description="调用 /age_analysis/。", + description="调用 /age_analysis/。duration 单位为秒。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续秒数", required=True), + ), + examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",), ), ("analysis", "contaminant"): CommandDoc( path=("analysis", "contaminant"), summary="执行污染物模拟", - description="调用 /contaminant_simulation/。", + description="调用 /contaminant_simulation/。duration 单位为秒。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续秒数", required=True), + CommandOptionDoc("source-node", "污染源节点 ID", required=True), + CommandOptionDoc("concentration", "浓度值", required=True), + CommandOptionDoc("pattern", "模式 ID"), + CommandOptionDoc("scheme", "方案名称", required=True), + ), + examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",), ), ("analysis", "sensor-placement", "kmeans"): CommandDoc( path=("analysis", "sensor-placement", "kmeans"), summary="执行 KMeans 传感器选址", description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。", + options=( + CommandOptionDoc("count", "传感器数量", required=True), + CommandOptionDoc("min-diameter", "最小管径,默认 0"), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",), ), ("analysis", "leakage", "identify"): CommandDoc( path=("analysis", "leakage", "identify"), summary="执行漏损识别", description="把 CLI 时间映射到 scada_start / scada_end。", + options=( + CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",), ), ("analysis", "leakage", "schemes", "list"): CommandDoc( path=("analysis", "leakage", "schemes", "list"), summary="列出漏损方案", description="调用 /leakage/schemes/。", + examples=("tjwater-cli analysis leakage schemes list",), ), ("analysis", "leakage", "schemes", "get"): CommandDoc( path=("analysis", "leakage", "schemes", "get"), summary="读取漏损方案详情", description="调用 /leakage/schemes/{scheme_name}。", + examples=("tjwater-cli analysis leakage schemes get my_scheme",), ), ("analysis", "burst-detection", "detect"): CommandDoc( path=("analysis", "burst-detection", "detect"), summary="执行爆管检测", description="调用 /burst-detection/detect/。", + options=( + CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",), ), ("analysis", "burst-detection", "schemes", "list"): CommandDoc( path=("analysis", "burst-detection", "schemes", "list"), summary="列出爆管检测方案", description="调用 /burst-detection/schemes/。", + examples=("tjwater-cli analysis burst-detection schemes list",), ), ("analysis", "burst-detection", "schemes", "get"): CommandDoc( path=("analysis", "burst-detection", "schemes", "get"), summary="读取爆管检测方案详情", description="调用 /burst-detection/schemes/{scheme_name}。", + examples=("tjwater-cli analysis burst-detection schemes get my_scheme",), ), ("analysis", "burst-location", "locate"): CommandDoc( path=("analysis", "burst-location", "locate"), summary="执行爆管定位", - description="调用 /burst-location/locate/;需要 burst-leakage。", + description="调用 /burst-location/locate/;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。", + options=( + CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), + CommandOptionDoc("burst-leakage", "爆管漏水量", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"), + CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True), + CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True), + CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"), + CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"), + CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"), + ), + examples=( + "tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01", + "tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json", + ), ), ("analysis", "burst-location", "schemes", "list"): CommandDoc( path=("analysis", "burst-location", "schemes", "list"), summary="列出爆管定位方案", description="调用 /burst-location/schemes/。", + examples=("tjwater-cli analysis burst-location schemes list",), ), ("analysis", "burst-location", "schemes", "get"): CommandDoc( path=("analysis", "burst-location", "schemes", "get"), summary="读取爆管定位方案详情", description="调用 /burst-location/schemes/{scheme_name}。", + examples=("tjwater-cli analysis burst-location schemes get my_scheme",), ), ("analysis", "risk", "pipe-now"): CommandDoc( path=("analysis", "risk", "pipe-now"), summary="读取单条管道当前风险", description="调用 /getpiperiskprobabilitynow/。", + options=(CommandOptionDoc("pipe", "管道 ID", required=True),), + examples=("tjwater-cli analysis risk pipe-now --pipe P1",), ), ("analysis", "risk", "pipe-history"): CommandDoc( path=("analysis", "risk", "pipe-history"), summary="读取单条管道历史风险", description="调用 /getpiperiskprobability/。", + options=(CommandOptionDoc("pipe", "管道 ID", required=True),), + examples=("tjwater-cli analysis risk pipe-history --pipe P1",), ), ("analysis", "risk", "network"): CommandDoc( path=("analysis", "risk", "network"), summary="读取全网风险", description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。", + examples=("tjwater-cli analysis risk network",), ), ("data", "timeseries", "realtime", "links"): CommandDoc( path=("data", "timeseries", "realtime", "links"), summary="查询实时管道时序", description="调用 /realtime/links。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + ), + examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), ("data", "timeseries", "realtime", "nodes"): CommandDoc( path=("data", "timeseries", "realtime", "nodes"), summary="查询实时节点时序", description="调用 /realtime/nodes。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + ), + examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), ("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-id-time"), summary="按元素和时间查询实时模拟结果", description="调用 /realtime/query/by-id-time。", + options=( + CommandOptionDoc("id", "元素 ID", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("time", "显式带时区的查询时间", required=True), + ), + examples=( + "tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", + "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00", + ), ), ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-time-property"), summary="按时间和属性查询实时模拟结果", description="调用 /realtime/query/by-time-property。", + options=( + CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("time", "显式带时区的查询时间", required=True), + CommandOptionDoc("property", "属性名", required=True), + ), + examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",), ), ("data", "timeseries", "scheme", "links"): CommandDoc( path=("data", "timeseries", "scheme", "links"), summary="查询方案管道时序", description="调用 /scheme/links。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + ), + examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",), ), ("data", "timeseries", "scheme", "node-field"): CommandDoc( path=("data", "timeseries", "scheme", "node-field"), summary="查询方案节点字段时序", description="调用 /scheme/nodes/{node_id}/field。", + options=( + CommandOptionDoc("node", "节点 ID", required=True), + CommandOptionDoc("field", "字段名", required=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + ), + examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",), ), ("data", "timeseries", "scheme", "simulation"): CommandDoc( path=("data", "timeseries", "scheme", "simulation"), summary="查询方案模拟数据", description="支持 by-id-time 与 by-scheme-time-property 两种查询。", + options=( + CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"), + CommandOptionDoc("time", "显式带时区的查询时间", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction"), + CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"), + ), + examples=( + "tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", + "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme", + ), ), ("data", "timeseries", "scada", "query"): CommandDoc( path=("data", "timeseries", "scada", "query"), summary="查询 SCADA 时序", description="device-id 会被转换成后端逗号分隔参数。", + options=( + CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("field", "字段名"), + ), + examples=( + "tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", + "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field flow", + ), ), ("data", "timeseries", "composite"): CommandDoc( path=("data", "timeseries", "composite"), summary="执行复合时序查询", description="kind 支持 scada-simulation、element-simulation、element-scada。", + options=( + CommandOptionDoc("kind", "复合查询类型", required=True), + CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_id,element-simulation 为 element_id:property,element-scada 为 element_id)", repeated=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"), + ), + examples=( + "tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", + "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", + "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned", + ), ), ("data", "timeseries", "composite", "pipeline-health"): CommandDoc( path=("data", "timeseries", "composite", "pipeline-health"), summary="查询管道健康预测", description="调用 /composite/pipeline-health-prediction。", + options=( + CommandOptionDoc("pipe", "管道 ID", required=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + ), + examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), ("data", "scada", "schema"): CommandDoc( path=("data", "scada", "schema"), summary="读取 SCADA schema", description="kind 支持 device、device-data、element、info。", + options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), + examples=( + "tjwater-cli data scada schema --kind device", + "tjwater-cli data scada schema --kind device-data", + "tjwater-cli data scada schema --kind element", + "tjwater-cli data scada schema --kind info", + ), ), ("data", "scada", "get"): CommandDoc( path=("data", "scada", "get"), summary="读取单条 SCADA 元数据", description="kind 支持 device、device-data、element、info。", + options=( + CommandOptionDoc("kind", "SCADA 数据类型", required=True), + CommandOptionDoc("id", "记录 ID", required=True), + ), + examples=( + "tjwater-cli data scada get --kind device --id D1", + "tjwater-cli data scada get --kind element --id E1", + ), ), ("data", "scada", "list"): CommandDoc( path=("data", "scada", "list"), summary="列出 SCADA 元数据", description="kind 支持 device、element、info;device-data 当前后端无 list 接口。", + options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), + examples=( + "tjwater-cli data scada list --kind device", + "tjwater-cli data scada list --kind element", + "tjwater-cli data scada list --kind info", + ), ), ("data", "scheme", "schema"): CommandDoc( path=("data", "scheme", "schema"), summary="读取方案 schema", description="调用 /getschemeschema/。", + examples=("tjwater-cli data scheme schema",), ), ("data", "scheme", "get"): CommandDoc( path=("data", "scheme", "get"), summary="读取单条方案", description="调用 /getscheme/。", + options=(CommandOptionDoc("name", "方案名称", required=True),), + examples=("tjwater-cli data scheme get --name my_scheme",), ), ("data", "scheme", "list"): CommandDoc( path=("data", "scheme", "list"), summary="列出方案", description="调用 /getallschemes/。", + examples=("tjwater-cli data scheme list",), ), ("data", "extension", "keys"): CommandDoc( path=("data", "extension", "keys"), summary="列出扩展数据键", description="调用 /getallextensiondatakeys/。", + examples=("tjwater-cli data extension keys",), ), ("data", "extension", "get"): CommandDoc( path=("data", "extension", "get"), summary="读取扩展数据", description="调用 /getextensiondata/。", + options=(CommandOptionDoc("key", "扩展键", required=True),), + examples=("tjwater-cli data extension get --key my_key",), ), ("data", "extension", "list"): CommandDoc( path=("data", "extension", "list"), summary="列出扩展数据", description="调用 /getallextensiondata/。", + examples=("tjwater-cli data extension list",), ), ("data", "misc", "sensor-placements"): CommandDoc( path=("data", "misc", "sensor-placements"), summary="列出传感器布置结果", description="调用 /getallsensorplacements/。", + examples=("tjwater-cli data misc sensor-placements",), ), ("data", "misc", "burst-location-results"): CommandDoc( path=("data", "misc", "burst-location-results"), summary="列出爆管定位结果", description="调用 /getallburstlocateresults/。", + examples=("tjwater-cli data misc burst-location-results",), ), } diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 9b87f47..c441c74 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -7,7 +7,6 @@ 首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 ```text -tjwater-cli project tjwater-cli network tjwater-cli component tjwater-cli simulation @@ -39,7 +38,6 @@ tjwater-cli help | 顶层命令 | 二级范围 | 说明 | |---|---|---| -| `project` | `list`、`info`、`db-health`、`export-inp`、`data` | 项目发现和只读项目数据 | | `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 | | `component` | `option` | EPANET 选项设置,只读 | | `simulation` | `run` | 模拟运行 | @@ -92,85 +90,6 @@ tjwater-cli help ## 首批 CLI 范围 -### Project - -来源: - -```text -app/api/v1/endpoints/auth.py -app/api/v1/endpoints/meta.py -app/api/v1/endpoints/project.py -app/api/v1/endpoints/project_data.py -TJWaterFrontend_Refine/src/lib/requestHeaders.ts -TJWaterFrontend_Refine/src/lib/api.ts -TJWaterFrontend_Refine/src/lib/apiFetch.ts -``` - -认证模式: - -- **Non-interactive / Agent** - - 面向 agent、脚本、多用户多 agent 并发调用。 - - 必须显式传入认证上下文。 - - 不得隐式回退到本机默认状态。 - -Agent 调用认证上下文: - -- 当前前端调用链会自动附加以下请求头: - - `Authorization: Bearer ` - - `X-Project-Id: ` - - `X-User-Id: ` -- 其中 `Authorization` 来自访问令牌,`X-Project-Id` 来自当前项目上下文,`X-User-Id` 来自当前登录用户。 -- 因此前端触发的 agent 调用,应默认支持直接消费这三个字段;不再设计额外的本地 `login` 流程。 -- CLI 侧建议提供两类显式注入方式: - - `--auth-context PATH` - - 环境变量 / 调用方 header 映射 - -认证解析优先级建议固定为: - -1. 命令行显式参数(如 `--auth-context`) -2. 调用方显式注入的环境变量 / header 映射 - -约束: - -- Agent 模式下,若未显式提供认证上下文,应返回明确错误,而不是尝试复用默认登录态。 -- `X-Project-Id` 是当前 project scope 的默认来源;CLI 命令默认直接使用该上下文,不要求重复传参。 -- `X-User-Id` 主要用于审计、结果归属和多用户隔离,不应用来替代 access token 做认证。 - -| 命令 | 覆盖接口 | 说明 | -|---|---|---| -| `tjwater-cli project list` | `GET /meta/projects` | 项目列表 | -| `tjwater-cli project info` | `GET /meta/project` | 当前 project 信息 | -| `tjwater-cli project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | -| `tjwater-cli project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | -| `tjwater-cli project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | - -暂不暴露: - -```text -POST /auth/register -POST /auth/login -POST /auth/login/simple -GET /auth/me -POST /auth/refresh -GET /listprojects/ -GET /project_info/ -GET /haveproject/ -GET /isprojectopen/ -GET /isprojectlocked/ -GET /isprojectlockedbyme/ -POST /createproject/ -POST /deleteproject/ -POST /openproject/ -POST /closeproject/ -POST /copyproject/ -POST /importinp/ -POST /readinp/ -POST /lockproject/ -POST /unlockproject/ -POST /uploadinp/ -GET /convertv3tov2/ -``` - ### Network 来源: @@ -183,6 +102,8 @@ app/api/v1/endpoints/network/*.py |---|---|---| | `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | | `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | +| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 | +| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 | 暂不暴露: @@ -275,10 +196,10 @@ app/api/v1/endpoints/risk.py |---|---|---| | `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | | `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | -| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | -| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | +| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 | +| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | | `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | -| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant_simulation/` | 污染物模拟 | | `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | | `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | | `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | @@ -440,7 +361,7 @@ POST /users/{user_id}/deactivate 输出补充约束: - 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。 -- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`,例如 `project export-inp --output PATH`。 +- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`。 - 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。 ## 输出规范 diff --git a/scripts/online_Analysis.py b/scripts/online_Analysis.py index bcf4c2e..45528ee 100644 --- a/scripts/online_Analysis.py +++ b/scripts/online_Analysis.py @@ -623,7 +623,7 @@ def age_analysis( new_name, "realtime", modify_pattern_start_time, - modify_total_duration, + duration=modify_total_duration, downloading_prohibition=True, ) # step 2. restore the base model status diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index 38f777f..d03f96b 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -269,3 +269,92 @@ def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypat ) assert response.status_code == 422 + + +def test_valve_close_endpoint_passes_scheme_name(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_valve_close_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "valve_close_analysis", fake_valve_close_analysis) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/valve_close_analysis/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1", "V2"], + "duration": 900, + "scheme_name": "valve_case_01", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured == { + "name": "demo", + "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", + "modify_total_duration": 900, + "modify_valve_opening": {"V1": 0.0, "V2": 0.0}, + "scheme_name": "valve_case_01", + } + + +def test_flushing_endpoint_passes_required_scheme_name(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 = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/flushing_analysis/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1"], + "valves_k": [0.5], + "drainage_node_ID": "N1", + "flush_flow": 100.0, + "duration": 900, + "scheme_name": "flush_case_01", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured == { + "name": "demo", + "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", + "modify_total_duration": 900, + "modify_valve_opening": {"V1": 0.5}, + "drainage_node_ID": "N1", + "flushing_flow": 100.0, + "scheme_name": "flush_case_01", + } + + +def test_contaminant_endpoint_requires_scheme_name(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/contaminant_simulation/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "source": "N1", + "concentration": 10.0, + "duration": 900, + }, + ) + + assert response.status_code == 422 diff --git a/tests/unit/test_age_analysis.py b/tests/unit/test_age_analysis.py new file mode 100644 index 0000000..240b112 --- /dev/null +++ b/tests/unit/test_age_analysis.py @@ -0,0 +1,88 @@ +import json + +from tests.conftest import install_stub, load_module_from_path + + +def _load_scenarios_module(monkeypatch): + install_stub(monkeypatch, "app.services", package=True) + install_stub(monkeypatch, "app.algorithms", package=True) + install_stub(monkeypatch, "app.algorithms.simulation", package=True) + install_stub(monkeypatch, "app.services.simulation", {}) + install_stub( + monkeypatch, + "app.algorithms.simulation.runner", + { + "run_simulation_ex": lambda *args, **kwargs: json.dumps( + {"output": {"node_results": [], "link_results": []}} + ), + "from_clock_to_seconds_2": lambda value: value, + }, + ) + install_stub(monkeypatch, "app.services.scheme_management", {"store_scheme_info": lambda *args, **kwargs: None}) + install_stub( + monkeypatch, + "app.services.tjnetwork", + { + "ChangeSet": type("ChangeSet", (), {}), + "OPTION_DEMAND_MODEL_PDA": "OPTION_DEMAND_MODEL_PDA", + "OPTION_QUALITY_CHEMICAL": "OPTION_QUALITY_CHEMICAL", + "SOURCE_TYPE_SETPOINT": "SOURCE_TYPE_SETPOINT", + "add_pattern": lambda *args, **kwargs: None, + "add_source": lambda *args, **kwargs: None, + "close_project": lambda *args, **kwargs: None, + "copy_project": lambda *args, **kwargs: None, + "delete_project": lambda *args, **kwargs: None, + "get_demand": lambda *args, **kwargs: None, + "get_emitter": lambda *args, **kwargs: None, + "get_node_links": lambda *args, **kwargs: None, + "get_option": lambda *args, **kwargs: None, + "get_pattern": lambda *args, **kwargs: None, + "get_pipe": lambda *args, **kwargs: None, + "get_source": lambda *args, **kwargs: None, + "get_time": lambda *args, **kwargs: None, + "have_project": lambda *args, **kwargs: False, + "is_junction": lambda *args, **kwargs: False, + "is_project_open": lambda *args, **kwargs: False, + "open_project": lambda *args, **kwargs: None, + "set_demand": lambda *args, **kwargs: None, + "set_emitter": lambda *args, **kwargs: None, + "set_option": lambda *args, **kwargs: None, + "set_source": lambda *args, **kwargs: None, + "set_time": lambda *args, **kwargs: None, + }, + ) + return load_module_from_path( + "tests_age_analysis_scenarios_module", + "app/algorithms/simulation/scenarios.py", + ) + + +def test_age_analysis_passes_duration_by_keyword(monkeypatch): + module = _load_scenarios_module(monkeypatch) + captured = {} + + monkeypatch.setattr(module, "copy_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "open_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "close_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "delete_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "have_project", lambda *args, **kwargs: False) + monkeypatch.setattr(module, "is_project_open", lambda *args, **kwargs: False) + + def fake_run_simulation_ex(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return json.dumps({"output": {"node_results": [], "link_results": []}}) + + monkeypatch.setattr(module, "run_simulation_ex", fake_run_simulation_ex) + + module.age_analysis("demo", "2026-06-03T07:00:00+08:00", 300) + + assert captured["args"] == ( + "age_Anal_demo", + "realtime", + "2026-06-03T07:00:00+08:00", + ) + assert captured["kwargs"] == { + "duration": 300, + "downloading_prohibition": True, + }