Compare commits
2 Commits
b7872f29a9
...
7efaeb41e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7efaeb41e8 | |||
| 9a7aad2d36 |
@@ -245,6 +245,33 @@ def test_leaf_help_flag_includes_usage_and_example():
|
|||||||
assert "DURATION" in result.stdout
|
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):
|
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
|
||||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
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
|
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):
|
def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
|
||||||
exit_code = main(["analysis"])
|
exit_code = main(["analysis"])
|
||||||
stdout = capsys.readouterr().out
|
stdout = capsys.readouterr().out
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from .core import (
|
|||||||
require_username,
|
require_username,
|
||||||
resolve_scheme,
|
resolve_scheme,
|
||||||
)
|
)
|
||||||
|
from .option_types import DataSource, ValveMode
|
||||||
|
|
||||||
|
|
||||||
@simulation_app.command("run")
|
@simulation_app.command("run")
|
||||||
@@ -100,7 +101,7 @@ def analysis_burst(
|
|||||||
@analysis_app.command("valve")
|
@analysis_app.command("valve")
|
||||||
def analysis_valve(
|
def analysis_valve(
|
||||||
ctx: typer.Context,
|
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,
|
start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None,
|
||||||
valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None,
|
valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None,
|
||||||
element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None,
|
element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None,
|
||||||
@@ -110,7 +111,7 @@ def analysis_valve(
|
|||||||
) -> None:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
network = require_network(runtime)
|
network = require_network(runtime)
|
||||||
if mode == "close":
|
if mode == ValveMode.CLOSE:
|
||||||
if not start_time or not valve:
|
if not start_time or not valve:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -135,7 +136,7 @@ def analysis_valve(
|
|||||||
require_network_ctx=True,
|
require_network_ctx=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if mode == "isolation":
|
if mode == ValveMode.ISOLATION:
|
||||||
if not element:
|
if not element:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -156,12 +157,7 @@ def analysis_valve(
|
|||||||
require_network_ctx=True,
|
require_network_ctx=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
raise CLIError(
|
raise AssertionError(f"unreachable valve mode: {mode}")
|
||||||
"CLI 参数错误",
|
|
||||||
code="INVALID_MODE",
|
|
||||||
message="--mode must be close or isolation",
|
|
||||||
exit_code=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@analysis_app.command("flushing")
|
@analysis_app.command("flushing")
|
||||||
@@ -397,7 +393,7 @@ def analysis_burst_location_locate(
|
|||||||
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||||
burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")],
|
burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")],
|
||||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
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,
|
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,
|
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,
|
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 = {
|
body = {
|
||||||
"network": require_network(runtime),
|
"network": require_network(runtime),
|
||||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
"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_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(),
|
"scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||||
"burst_leakage": burst_leakage,
|
"burst_leakage": burst_leakage,
|
||||||
|
|||||||
@@ -16,12 +16,56 @@ from .apps import (
|
|||||||
)
|
)
|
||||||
from .common import emit_api, runtime_context
|
from .common import emit_api, runtime_context
|
||||||
from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme
|
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:
|
def _scheme_type_option(scheme_type: str | None) -> str:
|
||||||
return scheme_type or "simulation"
|
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")
|
@data_timeseries_realtime_app.command("links")
|
||||||
def data_realtime_links(
|
def data_realtime_links(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
@@ -66,7 +110,7 @@ def data_realtime_nodes(
|
|||||||
def data_realtime_simulation_by_id_time(
|
def data_realtime_simulation_by_id_time(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
id: Annotated[str, typer.Option("--id", help="元素 ID")],
|
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="查询时间")],
|
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||||
) -> None:
|
) -> None:
|
||||||
emit_api(
|
emit_api(
|
||||||
@@ -76,7 +120,7 @@ def data_realtime_simulation_by_id_time(
|
|||||||
path="/realtime/query/by-id-time",
|
path="/realtime/query/by-id-time",
|
||||||
params={
|
params={
|
||||||
"id": id,
|
"id": id,
|
||||||
"type": type,
|
"type": type.value,
|
||||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||||
},
|
},
|
||||||
require_auth=True,
|
require_auth=True,
|
||||||
@@ -87,17 +131,18 @@ def data_realtime_simulation_by_id_time(
|
|||||||
@data_timeseries_realtime_app.command("simulation-by-time-property")
|
@data_timeseries_realtime_app.command("simulation-by-time-property")
|
||||||
def data_realtime_simulation_by_time_property(
|
def data_realtime_simulation_by_time_property(
|
||||||
ctx: typer.Context,
|
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="查询时间")],
|
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:
|
) -> None:
|
||||||
|
property = _validate_element_property(type, property, option_name="--property")
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
summary="读取实时属性聚合数据成功",
|
summary="读取实时属性聚合数据成功",
|
||||||
method="GET",
|
method="GET",
|
||||||
path="/realtime/query/by-time-property",
|
path="/realtime/query/by-time-property",
|
||||||
params={
|
params={
|
||||||
"type": type,
|
"type": type.value,
|
||||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||||
"property": property,
|
"property": property,
|
||||||
},
|
},
|
||||||
@@ -135,13 +180,14 @@ def data_scheme_links(
|
|||||||
def data_scheme_node_field(
|
def data_scheme_node_field(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
node: Annotated[str, typer.Option("--node", help="节点 ID")],
|
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="开始时间")],
|
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
|
field = _validate_node_field(field, option_name="--field")
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
summary="读取方案节点字段成功",
|
summary="读取方案节点字段成功",
|
||||||
@@ -162,22 +208,22 @@ def data_scheme_node_field(
|
|||||||
@data_timeseries_scheme_app.command("simulation")
|
@data_timeseries_scheme_app.command("simulation")
|
||||||
def data_scheme_simulation(
|
def data_scheme_simulation(
|
||||||
ctx: typer.Context,
|
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: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||||
id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None,
|
id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None,
|
||||||
time: Annotated[str, typer.Option("--time", help="查询时间")] = "",
|
time: Annotated[str, typer.Option("--time", help="查询时间")] = "",
|
||||||
type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe",
|
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE,
|
||||||
property: Annotated[str | None, typer.Option("--property", help="属性名")] = None,
|
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:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
params = {
|
params = {
|
||||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||||
"scheme_type": _scheme_type_option(scheme_type),
|
"scheme_type": _scheme_type_option(scheme_type),
|
||||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
"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:
|
if not id:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -196,7 +242,7 @@ def data_scheme_simulation(
|
|||||||
require_project=True,
|
require_project=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if query == "by-scheme-time-property":
|
if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY:
|
||||||
if not property:
|
if not property:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -204,6 +250,7 @@ def data_scheme_simulation(
|
|||||||
message="--property is required for --query by-scheme-time-property",
|
message="--property is required for --query by-scheme-time-property",
|
||||||
exit_code=2,
|
exit_code=2,
|
||||||
)
|
)
|
||||||
|
property = _validate_element_property(type, property, option_name="--property")
|
||||||
params["property"] = property
|
params["property"] = property
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -215,12 +262,7 @@ def data_scheme_simulation(
|
|||||||
require_project=True,
|
require_project=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
raise CLIError(
|
raise AssertionError(f"unreachable query variant: {query}")
|
||||||
"CLI 参数错误",
|
|
||||||
code="INVALID_QUERY",
|
|
||||||
message="--query must be by-id-time or by-scheme-time-property",
|
|
||||||
exit_code=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@data_timeseries_scada_app.command("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,可重复")],
|
device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")],
|
||||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||||
end_time: Annotated[str, typer.Option("--end-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:
|
) -> None:
|
||||||
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
|
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
|
||||||
params = {
|
params = {
|
||||||
@@ -238,6 +280,7 @@ def data_scada_query(
|
|||||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||||
}
|
}
|
||||||
if field:
|
if field:
|
||||||
|
field = _validate_scada_field(field, option_name="--field")
|
||||||
params["field"] = field
|
params["field"] = field
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -253,7 +296,7 @@ def data_scada_query(
|
|||||||
@data_timeseries_composite_app.callback(invoke_without_command=True)
|
@data_timeseries_composite_app.callback(invoke_without_command=True)
|
||||||
def data_timeseries_composite(
|
def data_timeseries_composite(
|
||||||
ctx: typer.Context,
|
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,
|
feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None,
|
||||||
start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None,
|
start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None,
|
||||||
end_time: Annotated[str | None, typer.Option("--end-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(),
|
"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(),
|
"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:
|
if not feature:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -300,7 +343,7 @@ def data_timeseries_composite(
|
|||||||
require_project=True,
|
require_project=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if kind == "element-simulation":
|
if kind == CompositeKind.ELEMENT_SIMULATION:
|
||||||
if not feature:
|
if not feature:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -323,7 +366,7 @@ def data_timeseries_composite(
|
|||||||
require_project=True,
|
require_project=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if kind == "element-scada":
|
if kind == CompositeKind.ELEMENT_SCADA:
|
||||||
if not feature or len(feature) != 1:
|
if not feature or len(feature) != 1:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
@@ -343,12 +386,7 @@ def data_timeseries_composite(
|
|||||||
require_project=True,
|
require_project=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
raise CLIError(
|
raise AssertionError(f"unreachable composite kind: {kind}")
|
||||||
"CLI 参数错误",
|
|
||||||
code="INVALID_KIND",
|
|
||||||
message="--kind must be scada-simulation, element-simulation, or element-scada",
|
|
||||||
exit_code=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@data_timeseries_composite_app.command("pipeline-health")
|
@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")
|
@data_scada_app.command("schema")
|
||||||
def data_scada_schema(
|
def data_scada_schema(
|
||||||
ctx: typer.Context,
|
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:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
path, _ = _scada_mapping(kind, "schema")
|
path, _ = _scada_mapping(kind.value, "schema")
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
summary="读取 SCADA schema 成功",
|
summary="读取 SCADA schema 成功",
|
||||||
@@ -420,11 +458,11 @@ def data_scada_schema(
|
|||||||
@data_scada_app.command("get")
|
@data_scada_app.command("get")
|
||||||
def data_scada_get(
|
def data_scada_get(
|
||||||
ctx: typer.Context,
|
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")],
|
id: Annotated[str, typer.Option("--id", help="记录 ID")],
|
||||||
) -> None:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
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}
|
params = {"network": require_network(runtime), meta["id_param"]: id}
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -440,10 +478,10 @@ def data_scada_get(
|
|||||||
@data_scada_app.command("list")
|
@data_scada_app.command("list")
|
||||||
def data_scada_list(
|
def data_scada_list(
|
||||||
ctx: typer.Context,
|
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:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
path, _ = _scada_mapping(kind, "list")
|
path, _ = _scada_mapping(kind.value, "list")
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
summary="读取 SCADA 列表成功",
|
summary="读取 SCADA 列表成功",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import typer
|
|||||||
from .apps import component_option_app, network_app
|
from .apps import component_option_app, network_app
|
||||||
from .common import emit_api, runtime_context
|
from .common import emit_api, runtime_context
|
||||||
from .core import CLIError, require_network
|
from .core import CLIError, require_network
|
||||||
|
from .option_types import ComponentOptionKind
|
||||||
|
|
||||||
|
|
||||||
@network_app.command("get-node-properties")
|
@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")
|
@component_option_app.command("schema")
|
||||||
def component_option_schema(
|
def component_option_schema(
|
||||||
ctx: typer.Context,
|
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,
|
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
path = _component_option_path(kind, schema=True)
|
path = _component_option_path(kind.value, schema=True)
|
||||||
params = {"network": require_network(runtime)}
|
params = {"network": require_network(runtime)}
|
||||||
if kind == "pump-energy" and pump:
|
if kind == ComponentOptionKind.PUMP_ENERGY and pump:
|
||||||
params["pump"] = pump
|
params["pump"] = pump
|
||||||
emit_api(
|
emit_api(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -96,13 +97,13 @@ def component_option_schema(
|
|||||||
@component_option_app.command("get")
|
@component_option_app.command("get")
|
||||||
def component_option_get(
|
def component_option_get(
|
||||||
ctx: typer.Context,
|
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,
|
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
runtime = runtime_context(ctx)
|
runtime = runtime_context(ctx)
|
||||||
path = _component_option_path(kind, schema=False)
|
path = _component_option_path(kind.value, schema=False)
|
||||||
params = {"network": require_network(runtime)}
|
params = {"network": require_network(runtime)}
|
||||||
if kind == "pump-energy":
|
if kind == ComponentOptionKind.PUMP_ENERGY:
|
||||||
if not pump:
|
if not pump:
|
||||||
raise CLIError(
|
raise CLIError(
|
||||||
"CLI 参数错误",
|
"CLI 参数错误",
|
||||||
|
|||||||
@@ -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}")
|
||||||
+12
-12
@@ -314,7 +314,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
|||||||
description="调用 /realtime/query/by-id-time。",
|
description="调用 /realtime/query/by-id-time。",
|
||||||
options=(
|
options=(
|
||||||
CommandOptionDoc("id", "元素 ID", required=True),
|
CommandOptionDoc("id", "元素 ID", required=True),
|
||||||
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True),
|
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||||
),
|
),
|
||||||
examples=(
|
examples=(
|
||||||
@@ -325,11 +325,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
|||||||
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
||||||
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
||||||
summary="按时间和属性查询实时模拟结果",
|
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=(
|
options=(
|
||||||
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True),
|
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||||
CommandOptionDoc("time", "显式带时区的查询时间", 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",),
|
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(
|
("data", "timeseries", "scheme", "node-field"): CommandDoc(
|
||||||
path=("data", "timeseries", "scheme", "node-field"),
|
path=("data", "timeseries", "scheme", "node-field"),
|
||||||
summary="查询方案节点字段时序",
|
summary="查询方案节点字段时序",
|
||||||
description="调用 /scheme/nodes/{node_id}/field。",
|
description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。",
|
||||||
options=(
|
options=(
|
||||||
CommandOptionDoc("node", "节点 ID", required=True),
|
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("start-time", "显式带时区的开始时间", required=True),
|
||||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
CommandOptionDoc("scheme", "方案名称"),
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
@@ -362,15 +362,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
|||||||
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
||||||
path=("data", "timeseries", "scheme", "simulation"),
|
path=("data", "timeseries", "scheme", "simulation"),
|
||||||
summary="查询方案模拟数据",
|
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=(
|
options=(
|
||||||
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
|
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
|
||||||
CommandOptionDoc("scheme", "方案名称"),
|
CommandOptionDoc("scheme", "方案名称"),
|
||||||
CommandOptionDoc("scheme-type", "方案类型"),
|
CommandOptionDoc("scheme-type", "方案类型"),
|
||||||
CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"),
|
CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"),
|
||||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||||
CommandOptionDoc("type", "元素类型:pipe 或 junction"),
|
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"),
|
||||||
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"),
|
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"),
|
||||||
),
|
),
|
||||||
examples=(
|
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-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(
|
("data", "timeseries", "scada", "query"): CommandDoc(
|
||||||
path=("data", "timeseries", "scada", "query"),
|
path=("data", "timeseries", "scada", "query"),
|
||||||
summary="查询 SCADA 时序",
|
summary="查询 SCADA 时序",
|
||||||
description="device-id 会被转换成后端逗号分隔参数。",
|
description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。",
|
||||||
options=(
|
options=(
|
||||||
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
|
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
|
||||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||||
CommandOptionDoc("field", "字段名"),
|
CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"),
|
||||||
),
|
),
|
||||||
examples=(
|
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 --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(
|
("data", "timeseries", "composite"): CommandDoc(
|
||||||
|
|||||||
@@ -168,3 +168,4 @@ zmq==0.0.0
|
|||||||
pymoo==0.6.1.6
|
pymoo==0.6.1.6
|
||||||
scikit-learn==1.6.1
|
scikit-learn==1.6.1
|
||||||
scipy==1.15.2
|
scipy==1.15.2
|
||||||
|
pyclipper==1.4.0
|
||||||
Reference in New Issue
Block a user