From 9a7aad2d36a098825fd99ec90887765de9689181 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 5 Jun 2026 13:43:32 +0800 Subject: [PATCH] fix(cli): constrain timeseries option values --- cli/tests/unit/test_tjwater_cli.py | 100 +++++++++++++++++++++++++ cli/tjwater_cli/commands_analysis.py | 18 ++--- cli/tjwater_cli/commands_data.py | 108 ++++++++++++++++++--------- cli/tjwater_cli/commands_readonly.py | 13 ++-- cli/tjwater_cli/option_types.py | 81 ++++++++++++++++++++ cli/tjwater_cli/registry.py | 24 +++--- 6 files changed, 280 insertions(+), 64 deletions(-) create mode 100644 cli/tjwater_cli/option_types.py diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index f372d95..c2ed9a8 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -245,6 +245,33 @@ def test_leaf_help_flag_includes_usage_and_example(): assert "DURATION" in result.stdout +def test_realtime_simulation_help_clarifies_type_values(): + result = runner.invoke( + app, + ["data", "timeseries", "realtime", "simulation-by-id-time", "--help"], + prog_name="tjwater-cli", + ) + + assert result.exit_code == 0 + assert "links/nodes 是子命令" in result.stdout + assert "pipe" in result.stdout + assert "junction" in result.stdout + + +def test_realtime_property_help_lists_supported_fields(): + result = runner.invoke( + app, + ["data", "timeseries", "realtime", "simulation-by-time-property", "--help"], + prog_name="tjwater-cli", + ) + + assert result.exit_code == 0 + assert "flow" in result.stdout + assert "pressure" in result.stdout + assert "actual_demand" in result.stdout + assert "velocity" in result.stdout + + def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") @@ -498,6 +525,79 @@ def test_main_missing_option_error_includes_usage_and_next_step(capsys): assert '"tjwater-cli help simulation run"' in stdout +def test_main_invalid_enum_value_is_rejected_before_request(capsys): + exit_code = main( + [ + "data", + "timeseries", + "realtime", + "simulation-by-id-time", + "--id", + "J1", + "--type", + "links", + "--time", + "2025-01-02T03:30:00+08:00", + ] + ) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"summary": "参数无效"' in stdout + assert '"code": "INVALID_PARAMETER"' in stdout + assert "links" in stdout + assert "pipe" in stdout + assert "junction" in stdout + + +def test_main_invalid_pipe_property_is_rejected_before_request(capsys): + exit_code = main( + [ + "data", + "timeseries", + "realtime", + "simulation-by-time-property", + "--type", + "pipe", + "--time", + "2025-01-02T03:30:00+08:00", + "--property", + "pressure", + ] + ) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "INVALID_PROPERTY"' in stdout + assert "flow" in stdout + assert "velocity" in stdout + + +def test_main_invalid_scada_field_is_rejected_before_request(capsys): + exit_code = main( + [ + "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", + ] + ) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "INVALID_FIELD"' in stdout + assert "monitored_value" in stdout + assert "cleaned_value" in stdout + + def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): exit_code = main(["analysis"]) stdout = capsys.readouterr().out diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index d490a43..1c2592e 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -31,6 +31,7 @@ from .core import ( require_username, resolve_scheme, ) +from .option_types import DataSource, ValveMode @simulation_app.command("run") @@ -100,7 +101,7 @@ def analysis_burst( @analysis_app.command("valve") def analysis_valve( ctx: typer.Context, - mode: Annotated[str, typer.Option("--mode", help="close|isolation")], + mode: Annotated[ValveMode, typer.Option("--mode", help="分析模式,仅支持 close|isolation")], start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None, valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None, element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, @@ -110,7 +111,7 @@ def analysis_valve( ) -> None: runtime = runtime_context(ctx) network = require_network(runtime) - if mode == "close": + if mode == ValveMode.CLOSE: if not start_time or not valve: raise CLIError( "CLI 参数错误", @@ -135,7 +136,7 @@ def analysis_valve( require_network_ctx=True, ) return - if mode == "isolation": + if mode == ValveMode.ISOLATION: if not element: raise CLIError( "CLI 参数错误", @@ -156,12 +157,7 @@ def analysis_valve( require_network_ctx=True, ) return - raise CLIError( - "CLI 参数错误", - code="INVALID_MODE", - message="--mode must be close or isolation", - exit_code=2, - ) + raise AssertionError(f"unreachable valve mode: {mode}") @analysis_app.command("flushing") @@ -397,7 +393,7 @@ def analysis_burst_location_locate( end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")], scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring", + data_source: Annotated[DataSource, typer.Option("--data-source", help="数据来源,仅支持 monitoring|simulation")] = DataSource.MONITORING, pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None, flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None, pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None, @@ -410,7 +406,7 @@ def analysis_burst_location_locate( body = { "network": require_network(runtime), "scheme_name": resolve_scheme(runtime, scheme, required=True), - "data_source": data_source, + "data_source": data_source.value, "scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), "burst_leakage": burst_leakage, diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py index 4491630..2c780a6 100644 --- a/cli/tjwater_cli/commands_data.py +++ b/cli/tjwater_cli/commands_data.py @@ -16,12 +16,56 @@ from .apps import ( ) from .common import emit_api, runtime_context from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme +from .option_types import ( + CompositeKind, + ElementType, + JUNCTION_TIMESERIES_FIELDS, + SCADA_TIMESERIES_FIELDS, + ScadaListKind, + ScadaSchemaKind, + SimulationQuery, + timeseries_fields_for_element_type, +) def _scheme_type_option(scheme_type: str | None) -> str: return scheme_type or "simulation" +def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str: + valid_fields = timeseries_fields_for_element_type(element_type) + if property_name not in valid_fields: + raise CLIError( + "CLI 参数错误", + code="INVALID_PROPERTY", + message=f"{option_name} for --type {element_type.value} must be one of: {', '.join(valid_fields)}", + exit_code=2, + ) + return property_name + + +def _validate_node_field(field_name: str, *, option_name: str) -> str: + if field_name not in JUNCTION_TIMESERIES_FIELDS: + raise CLIError( + "CLI 参数错误", + code="INVALID_FIELD", + message=f"{option_name} must be one of: {', '.join(JUNCTION_TIMESERIES_FIELDS)}", + exit_code=2, + ) + return field_name + + +def _validate_scada_field(field_name: str, *, option_name: str) -> str: + if field_name not in SCADA_TIMESERIES_FIELDS: + raise CLIError( + "CLI 参数错误", + code="INVALID_FIELD", + message=f"{option_name} must be one of: {', '.join(SCADA_TIMESERIES_FIELDS)}", + exit_code=2, + ) + return field_name + + @data_timeseries_realtime_app.command("links") def data_realtime_links( ctx: typer.Context, @@ -66,7 +110,7 @@ def data_realtime_nodes( def data_realtime_simulation_by_id_time( ctx: typer.Context, id: Annotated[str, typer.Option("--id", help="元素 ID")], - type: Annotated[str, typer.Option("--type", help="pipe|junction")], + type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")], time: Annotated[str, typer.Option("--time", help="查询时间")], ) -> None: emit_api( @@ -76,7 +120,7 @@ def data_realtime_simulation_by_id_time( path="/realtime/query/by-id-time", params={ "id": id, - "type": type, + "type": type.value, "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), }, require_auth=True, @@ -87,17 +131,18 @@ def data_realtime_simulation_by_id_time( @data_timeseries_realtime_app.command("simulation-by-time-property") def data_realtime_simulation_by_time_property( ctx: typer.Context, - type: Annotated[str, typer.Option("--type", help="pipe|junction")], + type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")], time: Annotated[str, typer.Option("--time", help="查询时间")], - property: Annotated[str, typer.Option("--property", help="属性名")], + property: Annotated[str, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")], ) -> None: + property = _validate_element_property(type, property, option_name="--property") emit_api( ctx, summary="读取实时属性聚合数据成功", method="GET", path="/realtime/query/by-time-property", params={ - "type": type, + "type": type.value, "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), "property": property, }, @@ -135,13 +180,14 @@ def data_scheme_links( def data_scheme_node_field( ctx: typer.Context, node: Annotated[str, typer.Option("--node", help="节点 ID")], - field: Annotated[str, typer.Option("--field", help="字段名")], + field: Annotated[str, typer.Option("--field", help="字段名,仅支持 actual_demand|total_head|pressure|quality")], start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, ) -> None: runtime = runtime_context(ctx) + field = _validate_node_field(field, option_name="--field") emit_api( ctx, summary="读取方案节点字段成功", @@ -162,22 +208,22 @@ def data_scheme_node_field( @data_timeseries_scheme_app.command("simulation") def data_scheme_simulation( ctx: typer.Context, - query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")], + query: Annotated[SimulationQuery, typer.Option("--query", help="查询模式,仅支持 by-id-time|by-scheme-time-property")], scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None, time: Annotated[str, typer.Option("--time", help="查询时间")] = "", - type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe", - property: Annotated[str | None, typer.Option("--property", help="属性名")] = None, + type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE, + property: Annotated[str | None, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")] = None, ) -> None: runtime = runtime_context(ctx) params = { "scheme_name": resolve_scheme(runtime, scheme, required=True), "scheme_type": _scheme_type_option(scheme_type), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), - "type": type, + "type": type.value, } - if query == "by-id-time": + if query == SimulationQuery.BY_ID_TIME: if not id: raise CLIError( "CLI 参数错误", @@ -196,7 +242,7 @@ def data_scheme_simulation( require_project=True, ) return - if query == "by-scheme-time-property": + if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY: if not property: raise CLIError( "CLI 参数错误", @@ -204,6 +250,7 @@ def data_scheme_simulation( message="--property is required for --query by-scheme-time-property", exit_code=2, ) + property = _validate_element_property(type, property, option_name="--property") params["property"] = property emit_api( ctx, @@ -215,12 +262,7 @@ def data_scheme_simulation( require_project=True, ) return - raise CLIError( - "CLI 参数错误", - code="INVALID_QUERY", - message="--query must be by-id-time or by-scheme-time-property", - exit_code=2, - ) + raise AssertionError(f"unreachable query variant: {query}") @data_timeseries_scada_app.command("query") @@ -229,7 +271,7 @@ def data_scada_query( device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")], start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], - field: Annotated[str | None, typer.Option("--field", help="字段名")] = None, + field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None, ) -> None: path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range" params = { @@ -238,6 +280,7 @@ def data_scada_query( "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), } if field: + field = _validate_scada_field(field, option_name="--field") params["field"] = field emit_api( ctx, @@ -253,7 +296,7 @@ def data_scada_query( @data_timeseries_composite_app.callback(invoke_without_command=True) def data_timeseries_composite( ctx: typer.Context, - kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None, + kind: Annotated[CompositeKind | None, typer.Option("--kind", help="复合查询类型,仅支持 scada-simulation|element-simulation|element-scada")] = None, feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None, start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None, end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None, @@ -277,7 +320,7 @@ def data_timeseries_composite( "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), } - if kind == "scada-simulation": + if kind == CompositeKind.SCADA_SIMULATION: if not feature: raise CLIError( "CLI 参数错误", @@ -300,7 +343,7 @@ def data_timeseries_composite( require_project=True, ) return - if kind == "element-simulation": + if kind == CompositeKind.ELEMENT_SIMULATION: if not feature: raise CLIError( "CLI 参数错误", @@ -323,7 +366,7 @@ def data_timeseries_composite( require_project=True, ) return - if kind == "element-scada": + if kind == CompositeKind.ELEMENT_SCADA: if not feature or len(feature) != 1: raise CLIError( "CLI 参数错误", @@ -343,12 +386,7 @@ def data_timeseries_composite( require_project=True, ) return - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="--kind must be scada-simulation, element-simulation, or element-scada", - exit_code=2, - ) + raise AssertionError(f"unreachable composite kind: {kind}") @data_timeseries_composite_app.command("pipeline-health") @@ -402,10 +440,10 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: @data_scada_app.command("schema") def data_scada_schema( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], + kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], ) -> None: runtime = runtime_context(ctx) - path, _ = _scada_mapping(kind, "schema") + path, _ = _scada_mapping(kind.value, "schema") emit_api( ctx, summary="读取 SCADA schema 成功", @@ -420,11 +458,11 @@ def data_scada_schema( @data_scada_app.command("get") def data_scada_get( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], + kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], id: Annotated[str, typer.Option("--id", help="记录 ID")], ) -> None: runtime = runtime_context(ctx) - path, meta = _scada_mapping(kind, "get") + path, meta = _scada_mapping(kind.value, "get") params = {"network": require_network(runtime), meta["id_param"]: id} emit_api( ctx, @@ -440,10 +478,10 @@ def data_scada_get( @data_scada_app.command("list") def data_scada_list( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="device|element|info")], + kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|element|info;device-data 无 list 接口")], ) -> None: runtime = runtime_context(ctx) - path, _ = _scada_mapping(kind, "list") + path, _ = _scada_mapping(kind.value, "list") emit_api( ctx, summary="读取 SCADA 列表成功", diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py index cddc0a8..7b4677c 100644 --- a/cli/tjwater_cli/commands_readonly.py +++ b/cli/tjwater_cli/commands_readonly.py @@ -7,6 +7,7 @@ import typer from .apps import component_option_app, network_app from .common import emit_api, runtime_context from .core import CLIError, require_network +from .option_types import ComponentOptionKind @network_app.command("get-node-properties") @@ -74,13 +75,13 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None: @component_option_app.command("schema") def component_option_schema( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + kind: Annotated[ComponentOptionKind, 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) + path = _component_option_path(kind.value, schema=True) params = {"network": require_network(runtime)} - if kind == "pump-energy" and pump: + if kind == ComponentOptionKind.PUMP_ENERGY and pump: params["pump"] = pump emit_api( ctx, @@ -96,13 +97,13 @@ def component_option_schema( @component_option_app.command("get") def component_option_get( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + kind: Annotated[ComponentOptionKind, 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) + path = _component_option_path(kind.value, schema=False) params = {"network": require_network(runtime)} - if kind == "pump-energy": + if kind == ComponentOptionKind.PUMP_ENERGY: if not pump: raise CLIError( "CLI 参数错误", diff --git a/cli/tjwater_cli/option_types.py b/cli/tjwater_cli/option_types.py new file mode 100644 index 0000000..a642e32 --- /dev/null +++ b/cli/tjwater_cli/option_types.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from enum import Enum + + +class ElementType(str, Enum): + PIPE = "pipe" + JUNCTION = "junction" + + +class SimulationQuery(str, Enum): + BY_ID_TIME = "by-id-time" + BY_SCHEME_TIME_PROPERTY = "by-scheme-time-property" + + +class CompositeKind(str, Enum): + SCADA_SIMULATION = "scada-simulation" + ELEMENT_SIMULATION = "element-simulation" + ELEMENT_SCADA = "element-scada" + + +class ComponentOptionKind(str, Enum): + TIME = "time" + ENERGY = "energy" + PUMP_ENERGY = "pump-energy" + NETWORK = "network" + + +class ValveMode(str, Enum): + CLOSE = "close" + ISOLATION = "isolation" + + +class DataSource(str, Enum): + MONITORING = "monitoring" + SIMULATION = "simulation" + + +class ScadaSchemaKind(str, Enum): + DEVICE = "device" + DEVICE_DATA = "device-data" + ELEMENT = "element" + INFO = "info" + + +class ScadaListKind(str, Enum): + DEVICE = "device" + ELEMENT = "element" + INFO = "info" + + +PIPE_TIMESERIES_FIELDS: tuple[str, ...] = ( + "flow", + "friction", + "headloss", + "quality", + "reaction", + "setting", + "status", + "velocity", +) + +JUNCTION_TIMESERIES_FIELDS: tuple[str, ...] = ( + "actual_demand", + "total_head", + "pressure", + "quality", +) + +SCADA_TIMESERIES_FIELDS: tuple[str, ...] = ( + "monitored_value", + "cleaned_value", +) + + +def timeseries_fields_for_element_type(element_type: ElementType) -> tuple[str, ...]: + if element_type == ElementType.PIPE: + return PIPE_TIMESERIES_FIELDS + if element_type == ElementType.JUNCTION: + return JUNCTION_TIMESERIES_FIELDS + raise AssertionError(f"unreachable element type: {element_type}") diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 3eb277a..69d35a4 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -314,7 +314,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { description="调用 /realtime/query/by-id-time。", options=( CommandOptionDoc("id", "元素 ID", required=True), - CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True), ), examples=( @@ -325,11 +325,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-time-property"), summary="按时间和属性查询实时模拟结果", - description="调用 /realtime/query/by-time-property。", + description="调用 /realtime/query/by-time-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", options=( - CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True), - CommandOptionDoc("property", "属性名", required=True), + CommandOptionDoc("property", "属性名;会按 type 校验可选值", required=True), ), examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",), ), @@ -348,10 +348,10 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scheme", "node-field"): CommandDoc( path=("data", "timeseries", "scheme", "node-field"), summary="查询方案节点字段时序", - description="调用 /scheme/nodes/{node_id}/field。", + description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。", options=( CommandOptionDoc("node", "节点 ID", required=True), - CommandOptionDoc("field", "字段名", required=True), + CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), CommandOptionDoc("scheme", "方案名称"), @@ -362,15 +362,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scheme", "simulation"): CommandDoc( path=("data", "timeseries", "scheme", "simulation"), summary="查询方案模拟数据", - description="支持 by-id-time 与 by-scheme-time-property 两种查询。", + description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", 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 时必需)"), + CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"), + CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"), ), 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", @@ -380,16 +380,16 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scada", "query"): CommandDoc( path=("data", "timeseries", "scada", "query"), summary="查询 SCADA 时序", - description="device-id 会被转换成后端逗号分隔参数。", + description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。", options=( CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - CommandOptionDoc("field", "字段名"), + CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"), ), 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", + "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 monitored_value", ), ), ("data", "timeseries", "composite"): CommandDoc(