diff --git a/contracts/manifest.json b/contracts/manifest.json index bebdee3..b94ab11 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -7,7 +7,7 @@ }, "server": { "file": "server-v1.openapi.json", - "sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367" + "sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 0e9bc10..5b68dce 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -491,6 +491,65 @@ "title": "Body_patch_admin_projects_project_id_model_imports", "type": "object" }, + "Body_post_admin_project_provisions": { + "properties": { + "code": { + "maxLength": 50, + "minLength": 1, + "title": "Code", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "file": { + "description": "EPANET INP 模型文件", + "format": "binary", + "title": "File", + "type": "string" + }, + "gs_workspace": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gs Workspace" + }, + "map_zoom": { + "default": 14, + "maximum": 22.0, + "minimum": 1.0, + "title": "Map Zoom", + "type": "integer" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "code", + "file" + ], + "title": "Body_post_admin_project_provisions", + "type": "object" + }, "Body_post_admin_projects_project_id_model_imports": { "properties": { "file": { @@ -923,63 +982,231 @@ "title": "BurstLocationRequestRest", "type": "object" }, - "DailySchedulingAnalysisRest": { + "ElementHistoryQuery": { "properties": { - "pump_control": { - "description": "泵控制策略", - "title": "Pump Control", - "type": "object" + "elements": { + "items": { + "$ref": "#/components/schemas/ElementHistoryTarget" + }, + "maxItems": 200, + "minItems": 1, + "title": "Elements", + "type": "array" }, - "reservoir_id": { - "description": "水库ID", - "title": "Reservoir Id", + "end_time": { + "format": "date-time", + "title": "End Time", "type": "string" }, - "start_time": { - "description": "开始时间", - "title": "Start Time", - "type": "string" + "mode": { + "$ref": "#/components/schemas/HistoryMode", + "default": "observed" }, - "tank_id": { - "description": "水箱ID", - "title": "Tank Id", - "type": "string" - }, - "time_delta": { + "run_id": { "anyOf": [ { - "type": "integer" + "format": "uuid", + "type": "string" }, { "type": "null" } ], - "default": 300, - "description": "时间步长 (秒)", - "title": "Time Delta" + "title": "Run Id" }, - "water_plant_output_id": { - "description": "水厂出水ID", - "title": "Water Plant Output Id", + "start_time": { + "format": "date-time", + "title": "Start Time", "type": "string" } }, "required": [ "start_time", - "pump_control", - "reservoir_id", - "tank_id", - "water_plant_output_id" + "end_time", + "elements" ], - "title": "DailySchedulingAnalysisRest", + "title": "ElementHistoryQuery", "type": "object" }, + "ElementHistoryResponse": { + "properties": { + "series": { + "items": { + "$ref": "#/components/schemas/ElementHistorySeries" + }, + "title": "Series", + "type": "array" + } + }, + "required": [ + "series" + ], + "title": "ElementHistoryResponse", + "type": "object" + }, + "ElementHistorySeries": { + "properties": { + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Id" + }, + "display_unit": { + "description": "Recommended UI display unit", + "title": "Display Unit", + "type": "string" + }, + "element_id": { + "title": "Element Id", + "type": "string" + }, + "element_type": { + "$ref": "#/components/schemas/HistoryElementType" + }, + "metric": { + "$ref": "#/components/schemas/HistoryMetric" + }, + "points": { + "items": { + "$ref": "#/components/schemas/HistoryPoint" + }, + "title": "Points", + "type": "array" + }, + "source": { + "$ref": "#/components/schemas/HistorySource" + }, + "source_unit": { + "description": "Unit used by the returned point values", + "title": "Source Unit", + "type": "string" + }, + "unit_inferred": { + "default": false, + "title": "Unit Inferred", + "type": "boolean" + } + }, + "required": [ + "element_id", + "element_type", + "metric", + "source", + "source_unit", + "display_unit", + "points" + ], + "title": "ElementHistorySeries", + "type": "object" + }, + "ElementHistoryTarget": { + "properties": { + "device_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Device Ids" + }, + "element_id": { + "maxLength": 128, + "minLength": 1, + "title": "Element Id", + "type": "string" + }, + "element_type": { + "$ref": "#/components/schemas/HistoryElementType" + } + }, + "required": [ + "element_id", + "element_type" + ], + "title": "ElementHistoryTarget", + "type": "object" + }, + "HistoryElementType": { + "enum": [ + "pipe", + "junction" + ], + "title": "HistoryElementType", + "type": "string" + }, + "HistoryMetric": { + "enum": [ + "flow", + "pressure" + ], + "title": "HistoryMetric", + "type": "string" + }, + "HistoryMode": { + "enum": [ + "observed", + "realtime_comparison", + "analysis_comparison" + ], + "title": "HistoryMode", + "type": "string" + }, + "HistoryPoint": { + "properties": { + "time": { + "format": "date-time", + "title": "Time", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "time", + "value" + ], + "title": "HistoryPoint", + "type": "object" + }, + "HistorySource": { + "enum": [ + "scada_raw", + "scada_cleaned", + "realtime_simulation", + "analysis_simulation" + ], + "title": "HistorySource", + "type": "string" + }, "JsonValue": {}, "LeakageIdentifyRequestRest": { "properties": { "dma_count": { "anyOf": [ { + "minimum": 1.0, "type": "integer" }, { @@ -992,26 +1219,29 @@ "duration": { "default": 24, "description": "持续时间(小时)", + "exclusiveMinimum": 0.0, + "maximum": 168.0, "title": "Duration", "type": "number" }, "max_gen": { "default": 100, "description": "最大代数", + "maximum": 1000.0, + "minimum": 1.0, "title": "Max Gen", "type": "integer" }, "n_workers": { "default": 4, - "description": "工作线程数", + "description": "工作进程数", + "maximum": 4.0, + "minimum": 1.0, "title": "N Workers", "type": "integer" }, "observed_pressure_data": { "anyOf": [ - { - "type": "string" - }, { "additionalProperties": { "items": {}, @@ -1029,15 +1259,9 @@ "type": "null" } ], - "description": "观测的压力数据", + "description": "观测的压力数据;文件路径不属于公共 API 输入", "title": "Observed Pressure Data" }, - "output_dir": { - "default": "db_inp", - "description": "输出目录", - "title": "Output Dir", - "type": "string" - }, "output_flow_unit": { "default": "m3/s", "description": "输出流量单位", @@ -1047,12 +1271,15 @@ "pop_size": { "default": 50, "description": "种群大小", + "maximum": 1000.0, + "minimum": 2.0, "title": "Pop Size", "type": "integer" }, "q_sum": { "default": 0.2, "description": "总流量(m3/s)", + "minimum": 0.0, "title": "Q Sum", "type": "number" }, @@ -1118,12 +1345,15 @@ "start_time": { "default": 0, "description": "起始时间(小时)", + "minimum": 0.0, "title": "Start Time", "type": "number" }, "timestep": { "default": 5, "description": "时间步长(分钟)", + "exclusiveMinimum": 0.0, + "maximum": 1440.0, "title": "Timestep", "type": "number" } @@ -1549,6 +1779,37 @@ "title": "Page[ProjectSummaryResponse]", "type": "object" }, + "Page_ScadaDeviceResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ScadaDeviceResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[ScadaDeviceResponse]", + "type": "object" + }, "Page_SensorPlacementSchemeResponse_": { "properties": { "items": { @@ -1663,16 +1924,10 @@ "type": "object" }, "scheme_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], "description": "方案名称", - "title": "Scheme Name" + "minLength": 1, + "title": "Scheme Name", + "type": "string" }, "start_time": { "description": "开始时间", @@ -1694,7 +1949,8 @@ }, "required": [ "start_time", - "pump_control" + "pump_control", + "scheme_name" ], "title": "PressureRegulationRest", "type": "object" @@ -1888,50 +2144,6 @@ "title": "ProjectDatabaseUpsertRequest", "type": "object" }, - "ProjectManagementRest": { - "properties": { - "pump_control": { - "description": "泵控制策略", - "title": "Pump Control", - "type": "object" - }, - "region_demand": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ], - "description": "区域需水量控制", - "title": "Region Demand" - }, - "start_time": { - "description": "开始时间", - "title": "Start Time", - "type": "string" - }, - "tank_init_level": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ], - "description": "水箱初始水位", - "title": "Tank Init Level" - } - }, - "required": [ - "start_time", - "pump_control" - ], - "title": "ProjectManagementRest", - "type": "object" - }, "ProjectMemberCreateRequest": { "properties": { "project_role": { @@ -2079,6 +2291,46 @@ "title": "ProjectMetaResponse", "type": "object" }, + "ProjectProvisionResponse": { + "properties": { + "business_database": { + "title": "Business Database", + "type": "string" + }, + "geoserver_layers": { + "items": { + "type": "string" + }, + "title": "Geoserver Layers", + "type": "array" + }, + "geoserver_workspace": { + "title": "Geoserver Workspace", + "type": "string" + }, + "model_template_database": { + "title": "Model Template Database", + "type": "string" + }, + "project": { + "$ref": "#/components/schemas/AdminProjectResponse" + }, + "timescale_database": { + "title": "Timescale Database", + "type": "string" + } + }, + "required": [ + "project", + "business_database", + "model_template_database", + "timescale_database", + "geoserver_workspace", + "geoserver_layers" + ], + "title": "ProjectProvisionResponse", + "type": "object" + }, "ProjectSummaryResponse": { "properties": { "code": { @@ -2129,26 +2381,6 @@ "title": "ProjectSummaryResponse", "type": "object" }, - "PumpFailureState": { - "properties": { - "pump_status": { - "description": "泵状态字典", - "title": "Pump Status", - "type": "object" - }, - "time": { - "description": "故障发生时间", - "title": "Time", - "type": "string" - } - }, - "required": [ - "time", - "pump_status" - ], - "title": "PumpFailureState", - "type": "object" - }, "RealtimeLinkBatchItem": { "properties": { "flow": { @@ -2340,6 +2572,128 @@ "title": "RunSimulationManuallyByDateRest", "type": "object" }, + "ScadaDeviceResponse": { + "description": "Project SCADA metadata keyed by the canonical device identifier.", + "properties": { + "api_query_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Query Id" + }, + "device_id": { + "title": "Device Id", + "type": "string" + }, + "device_type": { + "title": "Device Type", + "type": "string" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "link_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Link Id" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "measurement_unit": { + "title": "Measurement Unit", + "type": "string" + }, + "node_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Node Id" + }, + "reliability": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reliability" + }, + "transmission_frequency": { + "title": "Transmission Frequency", + "type": "string" + }, + "transmission_mode": { + "title": "Transmission Mode", + "type": "string" + }, + "x": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Y" + } + }, + "required": [ + "device_id", + "device_type", + "measurement_unit", + "transmission_mode", + "transmission_frequency" + ], + "title": "ScadaDeviceResponse", + "type": "object" + }, "ScadaReadingBatchItem": { "properties": { "cleaned_value": { @@ -2382,51 +2736,6 @@ "title": "ScadaReadingBatchItem", "type": "object" }, - "SchedulingAnalysisRest": { - "properties": { - "pump_control": { - "description": "泵控制策略", - "title": "Pump Control", - "type": "object" - }, - "start_time": { - "description": "开始时间", - "title": "Start Time", - "type": "string" - }, - "tank_id": { - "description": "水箱ID", - "title": "Tank Id", - "type": "string" - }, - "time_delta": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": 300, - "description": "时间步长 (秒)", - "title": "Time Delta" - }, - "water_plant_output_id": { - "description": "水厂出水ID", - "title": "Water Plant Output Id", - "type": "string" - } - }, - "required": [ - "start_time", - "pump_control", - "tank_id", - "water_plant_output_id" - ], - "title": "SchedulingAnalysisRest", - "type": "object" - }, "SensorPlacementExportRequest": { "properties": { "adjustment_status": { @@ -2892,6 +3201,102 @@ ] } }, + "/api/v1/admin/project-provisions": { + "post": { + "operationId": "post_admin_project_provisions", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_post_admin_project_provisions" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectProvisionResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "创建完整供水项目", + "tags": [ + "Model Administration" + ] + } + }, "/api/v1/admin/projects": { "get": { "operationId": "get_admin_projects", @@ -3003,6 +3408,7 @@ ] }, "post": { + "description": "仅用于登记已经由外部流程完整创建的资源。新项目应调用 POST /admin/project-provisions。", "operationId": "post_admin_projects", "requestBody": { "content": { @@ -3091,7 +3497,7 @@ "OAuth2PasswordBearer": [] } ], - "summary": "Create Admin Project", + "summary": "仅登记已有项目元数据", "tags": [ "Metadata Admin" ] @@ -6536,6 +6942,7 @@ "items": { "type": "string" }, + "minItems": 1, "title": "Burst Id", "type": "array" } @@ -6550,6 +6957,7 @@ "items": { "type": "number" }, + "minItems": 1, "title": "Burst Size", "type": "array" } @@ -6561,6 +6969,7 @@ "required": true, "schema": { "description": "模拟总时长(秒)", + "exclusiveMinimum": 0, "title": "Modify Total Duration", "type": "integer" } @@ -6572,6 +6981,7 @@ "required": true, "schema": { "description": "分析方案名称", + "minLength": 1, "title": "Scheme Name", "type": "string" } @@ -7944,116 +8354,6 @@ ] } }, - "/api/v1/daily-scheduling-analyses": { - "post": { - "description": "对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。", - "operationId": "post_daily_scheduling_analyses", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DailySchedulingAnalysisRest", - "description": "日排程分析参数" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Daily Scheduling Analyses", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "日排程分析", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/demands/properties": { "get": { "description": "获取指定水网中节点的需水量属性信息", @@ -9769,105 +10069,6 @@ ] } }, - "/api/v1/inp-runs": { - "post": { - "description": "运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。", - "operationId": "post_inp_runs", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Inp Runs", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "运行INP文件", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/junctions": { "delete": { "description": "从供水网络中删除指定的节点。", @@ -18150,116 +18351,6 @@ ] } }, - "/api/v1/outputs": { - "get": { - "description": "导出指定路径的模拟输出文件内容。参数应为绝对路径。", - "operationId": "get_outputs", - "parameters": [ - { - "description": "模拟输出文件的绝对路径", - "in": "query", - "name": "output", - "required": true, - "schema": { - "description": "模拟输出文件的绝对路径", - "title": "Output", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Outputs", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "导出模拟输出", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/patterns": { "delete": { "description": "从网络中删除指定的模式", @@ -21769,236 +21860,6 @@ ] } }, - "/api/v1/pressure-regulation-calculations": { - "post": { - "description": "对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。", - "operationId": "post_pressure_regulation_calculations", - "parameters": [ - { - "description": "目标节点ID", - "in": "query", - "name": "target_node", - "required": true, - "schema": { - "description": "目标节点ID", - "title": "Target Node", - "type": "string" - } - }, - { - "description": "目标压力值(kPa)", - "in": "query", - "name": "target_pressure", - "required": true, - "schema": { - "description": "目标压力值(kPa)", - "title": "Target Pressure", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "压力调节(基础)", - "tags": [ - "Simulation Control" - ] - } - }, - "/api/v1/project-managements": { - "post": { - "description": "高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。", - "operationId": "post_project_managements", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectManagementRest", - "description": "项目管理控制参数" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Project Managements", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "项目管理(高级)", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/project-return-dict-runs": { "post": { "description": "基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。", @@ -22711,116 +22572,6 @@ ] } }, - "/api/v1/pump-failure-events": { - "post": { - "description": "记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。", - "operationId": "post_pump_failure_events", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PumpFailureState", - "description": "泵故障状态信息" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Pump Failure Events", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "泵故障管理", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/pumps": { "delete": { "description": "从网络中删除指定的水泵", @@ -27331,7 +27082,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" + "$ref": "#/components/schemas/Page_ScadaDeviceResponse_" } } }, @@ -27409,17 +27160,15 @@ ] } }, - "/api/v1/scada-devices/detail": { + "/api/v1/scada-devices/{device_id}": { "get": { - "operationId": "get_scada_devices_detail", + "operationId": "get_scada_devices_device_id", "parameters": [ { - "description": "SCADA 设备 ID", - "in": "query", + "in": "path", "name": "device_id", "required": true, "schema": { - "description": "SCADA 设备 ID", "title": "Device Id", "type": "string" } @@ -27439,8 +27188,7 @@ "content": { "application/json": { "schema": { - "title": "Response Get Scada Devices Detail", - "type": "object" + "$ref": "#/components/schemas/ScadaDeviceResponse" } } }, @@ -27518,104 +27266,6 @@ ] } }, - "/api/v1/scada-info/database-view": { - "get": { - "description": "使用连接池查询所有SCADA信息", - "operationId": "get_scada_info_database_view", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA信息", - "tags": [ - "Project Data" - ] - } - }, "/api/v1/scada-properties": { "get": { "description": "获取指定SCADA点的属性信息", @@ -27726,116 +27376,6 @@ ] } }, - "/api/v1/scheduling-analyses": { - "post": { - "description": "对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。", - "operationId": "post_scheduling_analyses", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SchedulingAnalysisRest", - "description": "排程分析参数" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Scheduling Analyses", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "排程分析", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/sensor-placement-candidates/{node_id}": { "get": { "operationId": "get_sensor_placement_candidates_node_id", @@ -35829,58 +35369,10 @@ ] } }, - "/api/v1/timeseries/views/element-scada-readings": { - "get": { - "description": "获取link/node关联的SCADA监测值\n\n根据传入的link/node id,匹配SCADA信息,\n如果存在关联的SCADA device_id,获取实际的监测数据。\n\nArgs:\n element_id: 管网元素ID\n start_time: 查询开始时间\n end_time: 查询结束时间\n use_cleaned: 是否使用清洗后的数据,默认为False使用原始数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n 管网元素关联的SCADA监测数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误", - "operationId": "get_timeseries_views_element_scada_readings", + "/api/v1/timeseries/views/element-history/query": { + "post": { + "operationId": "post_timeseries_views_element_history_query", "parameters": [ - { - "description": "管网元素ID(管道或节点)", - "in": "query", - "name": "element_id", - "required": true, - "schema": { - "description": "管网元素ID(管道或节点)", - "title": "Element Id", - "type": "string" - } - }, - { - "description": "查询开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "查询开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "查询结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "查询结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "description": "是否使用清洗后的数据", - "in": "query", - "name": "use_cleaned", - "required": false, - "schema": { - "default": false, - "description": "是否使用清洗后的数据", - "title": "Use Cleaned", - "type": "boolean" - } - }, { "in": "header", "name": "X-Project-Id", @@ -35891,12 +35383,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElementHistoryQuery" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/ElementHistoryResponse" } } }, @@ -35968,311 +35470,7 @@ "OAuth2PasswordBearer": [] } ], - "summary": "获取管网元素关联的SCADA监测数据", - "tags": [ - "TimescaleDB - Composite" - ] - } - }, - "/api/v1/timeseries/views/element-simulations": { - "get": { - "description": "获取link/node模拟值\n\n根据传入的featureInfos,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n feature_infos: 格式为 \"element_id1:type1,element_id2:type2\"\n 例如: \"P1:pipe,J1:junction\"\n run_id: 分析运行 ID,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n \nReturns:\n 管网元素的模拟数据\n \nRaises:\n HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误", - "operationId": "get_timeseries_views_element_simulations", - "parameters": [ - { - "description": "查询开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "查询开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "查询结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "查询结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "description": "特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点)", - "in": "query", - "name": "feature_infos", - "required": true, - "schema": { - "description": "特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点)", - "title": "Feature Infos", - "type": "string" - } - }, - { - "description": "分析运行 ID;为空时查询实时数据", - "in": "query", - "name": "run_id", - "required": false, - "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "分析运行 ID;为空时查询实时数据", - "title": "Run Id" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取管网元素的模拟数据", - "tags": [ - "TimescaleDB - Composite" - ] - } - }, - "/api/v1/timeseries/views/scada-simulations": { - "get": { - "description": "获取SCADA关联的link/node模拟值\n\n根据传入的SCADA device_ids,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n device_ids: SCADA设备ID列表,用逗号分隔\n run_id: 分析运行 ID,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n SCADA关联的模拟数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误", - "operationId": "get_timeseries_views_scada_simulations", - "parameters": [ - { - "description": "查询开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "查询开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "查询结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "查询结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "description": "SCADA设备ID列表,逗号分隔", - "in": "query", - "name": "device_ids", - "required": true, - "schema": { - "description": "SCADA设备ID列表,逗号分隔", - "title": "Device Ids", - "type": "string" - } - }, - { - "description": "分析运行 ID;为空时查询实时数据", - "in": "query", - "name": "run_id", - "required": false, - "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "分析运行 ID;为空时查询实时数据", - "title": "Run Id" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA关联的模拟数据", + "summary": "批量查询管网元素历史数据", "tags": [ "TimescaleDB - Composite" ] @@ -39439,126 +38637,6 @@ ] } }, - "/api/v1/water-age-analyses": { - "post": { - "description": "高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。", - "operationId": "post_water_age_analyses", - "parameters": [ - { - "description": "分析开始时间(ISO 8601格式)", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "分析开始时间(ISO 8601格式)", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "模拟持续时间(秒)", - "in": "query", - "name": "duration", - "required": true, - "schema": { - "description": "模拟持续时间(秒)", - "title": "Duration", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "水龄分析(高级)", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/web-searches": { "post": { "description": "调用 Bocha Web Search API 获取实时网页搜索结果", diff --git a/public/three-dimensional/zjb/v29/asset-inspector.mjs b/public/three-dimensional/zjb/v29/asset-inspector.mjs index 7e538f9..c85cd6b 100644 --- a/public/three-dimensional/zjb/v29/asset-inspector.mjs +++ b/public/three-dimensional/zjb/v29/asset-inspector.mjs @@ -102,10 +102,10 @@ export function createAssetInspector({model,networkRoot,scene,style,onLocate,onS if(item.cad)pressureBasis='接入节点压力,非立管上端压力'; const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据'; sections.push({title:'运行结果',fields:fields([ - ['运行状态',status(result?.status)],['压力',value(pressure,'mH₂O')], + ['运行状态',status(result?.status)],['压力',value(pressure,'m')], ...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]), - ...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'mH₂O')]]:[]), - ...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'L/s')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]), + ...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'m')]]:[]), + ...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'m³/h')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]), ])}); sections.push({title:'来源说明',fields:fields([ ['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'], diff --git a/public/three-dimensional/zjb/v29/network-style.mjs b/public/three-dimensional/zjb/v29/network-style.mjs index 2b2f300..245f140 100644 --- a/public/three-dimensional/zjb/v29/network-style.mjs +++ b/public/three-dimensional/zjb/v29/network-style.mjs @@ -4,7 +4,7 @@ export const DEFAULT_STYLE=Object.freeze({scale:8,mode:'uniform',color:'#098ed0' const finite=v=>typeof v==='number'&&Number.isFinite(v); export function validateResults(input,model){ if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。'); - if(input.units?.velocity!=='m/s'||input.units?.pressure!=='mH2O'||input.units?.flow!=='L/s')throw Error('结果单位须明确为 m/s、mH2O、L/s。'); + if(input.units?.velocity!=='m/s'||input.units?.pressure!=='m'||input.units?.flow!=='m³/h')throw Error('结果单位须明确为 m/s、m、m³/h。'); const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id)); for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){ if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。'); diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index 56fb3d0..4f8fb02 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { apiFetch } from "@/lib/apiFetch"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; +import { + ElementHistoryResult, + ElementHistorySeries, + ElementHistoryTarget, + fetchElementHistory, + historySeriesKey, + TimeSeriesPoint, + toTimeSeriesPoints, +} from "@/lib/elementHistory"; +import { + FLOW_DISPLAY_UNIT, + PRESSURE_DISPLAY_UNIT, +} from "@/utils/units"; dayjs.extend(utc); dayjs.extend(timezone); @@ -50,13 +63,6 @@ type IUser = { name?: string; }; -export interface TimeSeriesPoint { - /** ISO8601 时间戳 */ - timestamp: string; - /** 每个设备对应的值 */ - values: Record; -} - export interface SCADADataPanelProps { /** 选中的设备 ID 列表 */ deviceIds: string[]; @@ -90,164 +96,75 @@ const panelHeaderActionSx = { }, }; -/** - * 从后端 API 获取 SCADA 数据 - */ +interface ScadaDeviceMetadata { + device_id: string; + device_type: string; + node_id: string | null; + link_id: string | null; +} + +/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */ const fetchFromBackend = async ( deviceIds: string[], range: { from: Date; to: Date }, -): Promise => { +): Promise => { if (deviceIds.length === 0) { - return []; + return { points: [], series: [] }; } - - const device_ids = deviceIds.join(","); - const start_time = dayjs(range.from).toISOString(); - const end_time = dayjs(range.to).toISOString(); - // 清洗数据接口 - const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`; - // 原始数据 - const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`; - // 模拟数据接口 - const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`; - try { - // 优先查询清洗数据和模拟数据 - const [cleaningRes, simulationRes] = await Promise.all([ - apiFetch(cleaningDataUrl) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - apiFetch(simulationDataUrl) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - ]); - - const cleaningData = transformBackendData(cleaningRes, deviceIds); - const simulationData = transformBackendData(simulationRes, deviceIds); - - // 如果清洗数据有数据,返回清洗和模拟数据 - if (cleaningData.length > 0) { - return mergeTimeSeriesData( - cleaningData, - simulationData, - deviceIds, - "clean", - "sim", - ); - } else { - // 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据 - const rawRes = await apiFetch(rawDataUrl) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null); - const rawData = transformBackendData(rawRes, deviceIds); - return mergeTimeSeriesData( - simulationData, - rawData, - deviceIds, - "sim", - "raw", - ); - } - } catch (error) { - console.error("[SCADADataPanel] 从后端获取数据失败:", error); - throw error; - } -}; - -/** - * 转换后端数据格式 - * 根据实际后端返回的数据结构进行调整 - */ -const transformBackendData = ( - backendData: any, - deviceIds: string[], -): TimeSeriesPoint[] => { - // 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] } - if (backendData && !Array.isArray(backendData)) { - // 检查是否是设备ID为键的对象格式 - const hasDeviceKeys = deviceIds.some((id) => id in backendData); - - if (hasDeviceKeys) { - // 获取所有时间点的集合 - const timeMap = new Map>(); - - deviceIds.forEach((deviceId) => { - const deviceData = backendData[deviceId]; - if (Array.isArray(deviceData)) { - deviceData.forEach((item: any) => { - const timestamp = item.time || item.timestamp || item._time; - if (timestamp) { - if (!timeMap.has(timestamp)) { - timeMap.set(timestamp, {}); - } - const values = timeMap.get(timestamp)!; - values[deviceId] = - typeof item.value === "number" ? item.value : null; - } - }); - } - }); - - // 转换为 TimeSeriesPoint 数组并按时间排序 - const result = Array.from(timeMap.entries()).map( - ([timestamp, values]) => ({ - timestamp, - values, - }), - ); - - result.sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ); - - return result; - } - } - // 默认返回空数组 - console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData); - return []; -}; - -/** - * 合并两个时间序列数据,为每个设备添加后缀 - */ -const mergeTimeSeriesData = ( - data1: TimeSeriesPoint[], - data2: TimeSeriesPoint[], - deviceIds: string[], - suffix1: string, - suffix2: string, -): TimeSeriesPoint[] => { - const timeMap = new Map>(); - - const processData = (data: TimeSeriesPoint[], suffix: string) => { - data.forEach((point) => { - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {}); - } - const values = timeMap.get(point.timestamp)!; - deviceIds.forEach((deviceId) => { - const value = point.values[deviceId]; - if (value !== undefined) { - values[`${deviceId}_${suffix}`] = value; - } - }); - }); - }; - - processData(data1, suffix1); - processData(data2, suffix2); - - const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({ - timestamp, - values, - })); - - result.sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + const metadataResponse = await apiFetch( + `${config.BACKEND_URL}/api/v1/scada-devices`, ); + if (!metadataResponse.ok) { + throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`); + } + const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[]; + const requested = new Set(deviceIds); + const devices = allDevices.filter((device) => requested.has(device.device_id)); + const missing = deviceIds.filter( + (deviceId) => !devices.some((device) => device.device_id === deviceId), + ); + if (missing.length > 0) { + throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`); + } - return result; + const targetsByElement = new Map(); + devices.forEach((device) => { + const isFlow = ["pipe_flow", "flow"].includes( + device.device_type.toLowerCase(), + ); + const elementId = isFlow ? device.link_id : device.node_id; + const elementType = isFlow ? "pipe" : "junction"; + if (!elementId) { + throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`); + } + const key = `${elementType}:${elementId}`; + const target = targetsByElement.get(key) ?? { + element_id: elementId, + element_type: elementType, + device_ids: [], + }; + target.device_ids!.push(device.device_id); + targetsByElement.set(key, target); + }); + const targets = Array.from(targetsByElement.values()); + const result = await fetchElementHistory( + targets, + range, + "realtime_comparison", + ); + const expandedSeries = result.series.flatMap((series) => { + if (series.device_id || series.source.startsWith("scada_")) return [series]; + const target = targets.find( + (item) => + item.element_id === series.element_id && + item.element_type === series.element_type, + ); + return (target?.device_ids ?? []).map((deviceId) => ({ + ...series, + device_id: deviceId, + })); + }); + return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) }; }; const formatTimestamp = (timestamp: string) => @@ -337,83 +254,7 @@ const SCADADataPanel: React.FC = ({ const { open } = useNotification(); const { data: user } = useGetIdentity(); - const customFetcher = useMemo(() => { - if (!showCleaning) { - return fetchFromBackend; - } - - return async ( - deviceIds: string[], - range: { from: Date; to: Date }, - ): Promise => { - const device_ids = deviceIds.join(","); - const start_time = dayjs(range.from).toISOString(); - const end_time = dayjs(range.to).toISOString(); - - // 清洗数据接口 - const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`; - // 原始数据 - const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`; - // 模拟数据接口 - const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`; - try { - const [cleanRes, rawRes, simRes] = await Promise.all([ - apiFetch(cleaningDataUrl) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - apiFetch(rawDataUrl) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - apiFetch(simulationDataUrl) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - ]); - - const timeMap = new Map>(); - - const processData = (data: any, suffix: string) => { - if (!data) return; - deviceIds.forEach((deviceId) => { - const deviceData = data[deviceId]; - if (Array.isArray(deviceData)) { - deviceData.forEach((item: any) => { - const timestamp = item.time || item.timestamp || item._time; - if (timestamp) { - if (!timeMap.has(timestamp)) { - timeMap.set(timestamp, {}); - } - const values = timeMap.get(timestamp)!; - values[`${deviceId}_${suffix}`] = - typeof item.value === "number" ? item.value : null; - } - }); - } - }); - }; - - processData(cleanRes, "clean"); - processData(rawRes, "raw"); - processData(simRes, "sim"); - - const result = Array.from(timeMap.entries()).map( - ([timestamp, values]) => ({ - timestamp, - values, - }), - ); - - result.sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ); - - return result; - } catch (error) { - console.error("[SCADADataPanel] 获取三种数据失败:", error); - throw error; - } - }; - }, [showCleaning]); + const customFetcher = fetchFromBackend; const [from, setFrom] = useState(() => { if (start_time) { @@ -435,6 +276,7 @@ const SCADADataPanel: React.FC = ({ }); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); + const [historySeries, setHistorySeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(true); @@ -473,11 +315,30 @@ const SCADADataPanel: React.FC = ({ () => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning), [timeSeries, deviceIds, fractionDigits, showCleaning], ); + const seriesByKey = useMemo( + () => + new Map(historySeries.map((series) => [historySeriesKey(series), series])), + [historySeries], + ); + const hasFlowSeries = historySeries.some((item) => item.metric === "flow"); + const hasPressureSeries = historySeries.some( + (item) => item.metric === "pressure", + ); + const unitForKey = useCallback( + (key: string) => seriesByKey.get(key)?.display_unit ?? "", + [seriesByKey], + ); + const axisForKey = useCallback( + (key: string) => + seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0, + [hasFlowSeries, seriesByKey], + ); const handleFetch = useCallback( async (reason: string) => { if (!hasDevices) { setTimeSeries([]); + setHistorySeries([]); setLoadingState("idle"); setError(null); return; @@ -491,7 +352,8 @@ const SCADADataPanel: React.FC = ({ from: rangeFrom.toDate(), to: rangeTo.toDate(), }); - setTimeSeries(result); + setTimeSeries(result.points); + setHistorySeries(result.series); setLoadingState("success"); } catch (err) { setError(err instanceof Error ? err.message : "未知错误"); @@ -583,6 +445,7 @@ const SCADADataPanel: React.FC = ({ handleFetch("device-change"); } else { setTimeSeries([]); + setHistorySeries([]); } }, [deviceIdsKey, handleFetch, hasDevices]); @@ -610,7 +473,7 @@ const SCADADataPanel: React.FC = ({ return deviceIds.flatMap((id) => [ { field: `${id}_raw`, - headerName: `${id} (原始)`, + headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`, minWidth: 140, flex: 1, valueFormatter: (value: any) => { @@ -623,7 +486,7 @@ const SCADADataPanel: React.FC = ({ }, { field: `${id}_clean`, - headerName: `${id} (清洗)`, + headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`, minWidth: 140, flex: 1, valueFormatter: (value: any) => { @@ -636,7 +499,7 @@ const SCADADataPanel: React.FC = ({ }, { field: `${id}_sim`, - headerName: `${id} (模拟)`, + headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`, minWidth: 140, flex: 1, valueFormatter: (value: any) => { @@ -652,7 +515,7 @@ const SCADADataPanel: React.FC = ({ // 单一数据源模式:只显示选中的数据源 return deviceIds.map((id) => ({ field: `${id}_${selectedSource}`, - headerName: id, + headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`, minWidth: 140, flex: 1, valueFormatter: (value: any) => { @@ -688,7 +551,7 @@ const SCADADataPanel: React.FC = ({ if (hasData) { cols.push({ field: fieldKey, - headerName: `${deviceName} (${name})`, + headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`, minWidth: 140, flex: 1, valueFormatter: (value: any) => { @@ -708,7 +571,14 @@ const SCADADataPanel: React.FC = ({ })(); return [...base, ...dynamic]; - }, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]); + }, [ + deviceIds, + fractionDigits, + showCleaning, + selectedSource, + dataset, + unitForKey, + ]); const rows = useMemo( () => @@ -766,8 +636,9 @@ const SCADADataPanel: React.FC = ({ if (selectedSource === "all") { return deviceIds.flatMap((id, index) => [ { - name: `${id} (原始)`, + name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`, type: "line", + yAxisIndex: axisForKey(`${id}_raw`), symbol: "none", connectNulls: true, sampling: "lttb", @@ -775,8 +646,9 @@ const SCADADataPanel: React.FC = ({ data: dataset.map((item) => item[`${id}_raw`]), }, { - name: `${id} (清洗)`, + name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`, type: "line", + yAxisIndex: axisForKey(`${id}_clean`), symbol: "none", connectNulls: true, sampling: "lttb", @@ -784,8 +656,9 @@ const SCADADataPanel: React.FC = ({ data: dataset.map((item) => item[`${id}_clean`]), }, { - name: `${id} (模拟)`, + name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`, type: "line", + yAxisIndex: axisForKey(`${id}_sim`), symbol: "none", connectNulls: true, sampling: "lttb", @@ -795,8 +668,9 @@ const SCADADataPanel: React.FC = ({ ]); } else { return deviceIds.map((id, index) => ({ - name: id, + name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`, type: "line", + yAxisIndex: axisForKey(`${id}_${selectedSource}`), symbol: "none", connectNulls: true, sampling: "lttb", @@ -820,8 +694,9 @@ const SCADADataPanel: React.FC = ({ : suffix === "clean" ? "清洗" : "模拟" - })`, + }) [${unitForKey(key)}]`, type: "line", + yAxisIndex: axisForKey(key), symbol: "none", connectNulls: true, sampling: "lttb", @@ -908,10 +783,26 @@ const SCADADataPanel: React.FC = ({ boundaryGap: false, data: xData, }, - yAxis: { - type: "value", - scale: true, - }, + yAxis: [ + ...(hasFlowSeries + ? [ + { + type: "value", + scale: true, + name: `流量 (${FLOW_DISPLAY_UNIT})`, + }, + ] + : []), + ...(hasPressureSeries + ? [ + { + type: "value", + scale: true, + name: `压力 (${PRESSURE_DISPLAY_UNIT})`, + }, + ] + : []), + ], dataZoom: [ { type: "inside", diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts index 51e7fb1..f96b81b 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts +++ b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts @@ -24,14 +24,8 @@ const range = { describe("fetchHistoryData", () => { beforeEach(() => jest.clearAllMocks()); - it("queries SCADA readings once per selected network element", async () => { - jest.mocked(apiFetch).mockImplementation(async (input) => { - const url = new URL(String(input)); - const elementId = url.searchParams.get("element_id") ?? ""; - return jsonResponse({ - [elementId]: [{ time: range.from.toISOString(), value: 1 }], - }); - }); + it("queries all selected elements in one batch request", async () => { + jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); await fetchHistoryData( [ @@ -42,16 +36,20 @@ describe("fetchHistoryData", () => { "none", ); - const elementIds = jest - .mocked(apiFetch) - .mock.calls.map(([input]) => - new URL(String(input)).searchParams.get("element_id"), - ); - expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]); + expect(apiFetch).toHaveBeenCalledTimes(1); + const [url, init] = jest.mocked(apiFetch).mock.calls[0]; + expect(String(url)).toContain("/element-history/query"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + mode: "observed", + elements: [ + { element_id: "J-1", element_type: "junction" }, + { element_id: "J-2", element_type: "junction" }, + ], + }); }); it("uses the analysis run ID for historical scheme simulation data", async () => { - jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] })); + jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); await fetchHistoryData( [["P-1", "pipe"]], @@ -60,15 +58,12 @@ describe("fetchHistoryData", () => { "99dd4142-368b-54cb-bfca-d59ee48f6298", ); - const simulationUrls = jest - .mocked(apiFetch) - .mock.calls.map(([input]) => new URL(String(input))) - .filter((url) => url.pathname.endsWith("/element-simulations")); - - expect(simulationUrls).toHaveLength(2); - expect( - simulationUrls.map((url) => url.searchParams.get("run_id")), - ).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]); + expect(apiFetch).toHaveBeenCalledTimes(1); + const [, init] = jest.mocked(apiFetch).mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + mode: "analysis_comparison", + run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298", + }); }); it("rejects oversized element selections before issuing requests", async () => { @@ -84,17 +79,8 @@ describe("fetchHistoryData", () => { expect(apiFetch).not.toHaveBeenCalled(); }); - it("limits concurrent SCADA requests for multi-element history", async () => { - let activeRequests = 0; - let peakRequests = 0; - jest.mocked(apiFetch).mockImplementation(async (input) => { - activeRequests += 1; - peakRequests = Math.max(peakRequests, activeRequests); - await new Promise((resolve) => setTimeout(resolve, 0)); - activeRequests -= 1; - const elementId = new URL(String(input)).searchParams.get("element_id") ?? ""; - return jsonResponse({ [elementId]: [] }); - }); + it("does not create N+1 requests for multi-element history", async () => { + jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); await fetchHistoryData( Array.from( @@ -105,6 +91,6 @@ describe("fetchHistoryData", () => { "none", ); - expect(peakRequests).toBeLessThanOrEqual(8); + expect(apiFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index faf630c..83c2b8e 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -34,20 +34,23 @@ import timezone from "dayjs/plugin/timezone"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; -import config from "@/config/config"; -import { apiFetch } from "@/lib/apiFetch"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; +import { + ElementHistoryResult, + ElementHistorySeries, + fetchElementHistory, + historySeriesKey, + historySeriesLabel, + TimeSeriesPoint, +} from "@/lib/elementHistory"; +import { + FLOW_DISPLAY_UNIT, + PRESSURE_DISPLAY_UNIT, +} from "@/utils/units"; dayjs.extend(utc); dayjs.extend(timezone); -export interface TimeSeriesPoint { - /** ISO8601 时间戳 */ - timestamp: string; - /** 每个设备对应的值 */ - values: Record; -} - export interface SCADADataPanelProps { /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ featureInfos: [string, string][]; @@ -73,7 +76,6 @@ type LoadingState = "idle" | "loading" | "success" | "error"; const MAX_HISTORY_ELEMENTS = 200; const MAX_HISTORY_ELEMENT_ID_LENGTH = 128; -const HISTORY_SCADA_CONCURRENCY = 4; const panelHeaderActionSx = { color: "primary.contrastText", @@ -83,48 +85,6 @@ const panelHeaderActionSx = { }, }; -const buildApiUrl = ( - path: string, - params: Record, -) => { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - searchParams.set(key, String(value)); - }); - return `${config.BACKEND_URL}${path}?${searchParams.toString()}`; -}; - -const fetchOptionalJson = async (url: string, signal?: AbortSignal) => { - const response = await apiFetch(url, { signal }); - if (response.status === 404) return null; - if (!response.ok) { - throw new Error(`历史数据请求失败: HTTP ${response.status}`); - } - return response.json(); -}; - -const mapWithConcurrency = async ( - items: Input[], - limit: number, - mapper: (item: Input, index: number) => Promise, -): Promise => { - const results = new Array(items.length); - let nextIndex = 0; - const workerCount = Math.min(limit, items.length); - - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const currentIndex = nextIndex; - nextIndex += 1; - results[currentIndex] = await mapper(items[currentIndex], currentIndex); - } - }), - ); - - return results; -}; - /** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */ export const fetchHistoryData = async ( featureInfos: [string, string][], @@ -132,9 +92,9 @@ export const fetchHistoryData = async ( type: "realtime" | "scheme" | "none", schemeRunId?: string, signal?: AbortSignal, -): Promise => { +): Promise => { if (featureInfos.length === 0) { - return []; + return { points: [], series: [] }; } if (featureInfos.length > MAX_HISTORY_ELEMENTS) { throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`); @@ -149,285 +109,36 @@ export const fetchHistoryData = async ( } const uniqueFeatureInfos = Array.from( - new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(), - ); - const featureIds = uniqueFeatureInfos.map(([id]) => id); - - const start_time = dayjs(range.from).toISOString(); - const end_time = dayjs(range.to).toISOString(); - - // 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2 - const feature_infos = uniqueFeatureInfos - .map(([id, type]) => `${id}:${type}`) - .join(","); - - const fetchElementScadaData = async (useCleaned: boolean) => { - const results = await mapWithConcurrency( - featureIds, - HISTORY_SCADA_CONCURRENCY, - (elementId) => - fetchOptionalJson( - buildApiUrl("/api/v1/timeseries/views/element-scada-readings", { - element_id: elementId, - start_time, - end_time, - use_cleaned: useCleaned, - }), - signal, - ), - ); - return Object.assign({}, ...results.filter(Boolean)); - }; - - const simulationDataUrl = buildApiUrl( - "/api/v1/timeseries/views/element-simulations", - { feature_infos, start_time, end_time }, + new Map( + featureInfos.map((featureInfo) => [featureInfo.join(":"), featureInfo]), + ).values(), ); if (type === "scheme" && !schemeRunId) { throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据"); } - const schemeSimulationDataUrl = schemeRunId - ? buildApiUrl("/api/v1/timeseries/views/element-simulations", { - feature_infos, - start_time, - end_time, - run_id: schemeRunId, - }) - : null; - try { - if (type === "none") { - // 查询清洗值和监测值 - const [cleanedRes, rawRes] = await Promise.all([ - fetchElementScadaData(true), - fetchElementScadaData(false), - ]); - - const cleanedData = transformBackendData(cleanedRes, featureIds); - // 如果清洗数据有值,则不显示原始监测值 - const rawData = - cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); - - return mergeTimeSeriesData( - cleanedData, - rawData, - featureIds, - "clean", - "raw" - ); - } else if (type === "scheme") { - // 查询策略模拟值、实时模拟值、清洗值和监测值 - const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([ - fetchElementScadaData(true), - fetchElementScadaData(false), - fetchOptionalJson(simulationDataUrl, signal), - fetchOptionalJson(schemeSimulationDataUrl!, signal), - ]); - - const cleanedData = transformBackendData(cleanedRes, featureIds); - // 如果清洗数据有值,则不显示原始监测值 - const rawData = - cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); - const simulationData = transformBackendData(simulationRes, featureIds); - const schemeSimData = transformBackendData(schemeSimRes, featureIds); - - return mergeMultipleTimeSeriesData( - [ - { data: cleanedData, suffix: "clean" }, - { data: rawData, suffix: "raw" }, - { data: simulationData, suffix: "sim" }, - { data: schemeSimData, suffix: "scheme_sim" }, - ], - featureIds - ); - } else { - // realtime: 查询模拟值、清洗值和监测值 - const [cleanedRes, rawRes, simulationRes] = await Promise.all([ - fetchElementScadaData(true), - fetchElementScadaData(false), - fetchOptionalJson(simulationDataUrl, signal), - ]); - - const cleanedData = transformBackendData(cleanedRes, featureIds); - // 如果清洗数据有值,则不显示原始监测值 - const rawData = - cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); - const simulationData = transformBackendData(simulationRes, featureIds); - - // 合并三组数据 - const timeMap = new Map>(); - - [cleanedData, rawData, simulationData].forEach((data, index) => { - const suffix = ["clean", "raw", "sim"][index]; - data.forEach((point) => { - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {}); - } - const values = timeMap.get(point.timestamp)!; - featureIds.forEach((deviceId) => { - const value = point.values[deviceId]; - if (value !== undefined) { - values[`${deviceId}_${suffix}`] = value; - } - }); - }); - }); - - const result = Array.from(timeMap.entries()).map( - ([timestamp, values]) => ({ - timestamp, - values, - }) - ); - - result.sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - - return result; - } + return await fetchElementHistory( + uniqueFeatureInfos.map(([id, elementType]) => ({ + element_id: id.trim(), + element_type: elementType.toLowerCase() as "pipe" | "junction", + })), + range, + type === "none" + ? "observed" + : type === "scheme" + ? "analysis_comparison" + : "realtime_comparison", + schemeRunId, + signal, + ); } catch (error) { - console.error("[SCADADataPanel] 从后端获取数据失败:", error); + console.error("[HistoryDataPanel] 从后端获取数据失败:", error); throw error; } }; -/** - * 转换后端数据格式 - * 根据实际后端返回的数据结构进行调整 - */ -const transformBackendData = ( - backendData: any, - deviceIds: string[] -): TimeSeriesPoint[] => { - // 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] } - if (backendData && !Array.isArray(backendData)) { - // 检查是否是设备ID为键的对象格式 - const hasDeviceKeys = deviceIds.some((id) => id in backendData); - - if (hasDeviceKeys) { - // 获取所有时间点的集合 - const timeMap = new Map>(); - - deviceIds.forEach((deviceId) => { - const deviceData = backendData[deviceId]; - if (Array.isArray(deviceData)) { - deviceData.forEach((item: any) => { - const timestamp = item.time || item.timestamp || item._time; - if (timestamp) { - if (!timeMap.has(timestamp)) { - timeMap.set(timestamp, {}); - } - const values = timeMap.get(timestamp)!; - values[deviceId] = - typeof item.value === "number" ? item.value : null; - } - }); - } - }); - - // 转换为 TimeSeriesPoint 数组并按时间排序 - const result = Array.from(timeMap.entries()).map( - ([timestamp, values]) => ({ - timestamp, - values, - }) - ); - - result.sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - - return result; - } - } - // 默认返回空数组 - console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData); - return []; -}; - -/** - * 合并两个时间序列数据,为每个设备添加后缀 - */ -const mergeTimeSeriesData = ( - data1: TimeSeriesPoint[], - data2: TimeSeriesPoint[], - deviceIds: string[], - suffix1: string, - suffix2: string -): TimeSeriesPoint[] => { - const timeMap = new Map>(); - - const processData = (data: TimeSeriesPoint[], suffix: string) => { - data.forEach((point) => { - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {}); - } - const values = timeMap.get(point.timestamp)!; - deviceIds.forEach((deviceId) => { - const value = point.values[deviceId]; - if (value !== undefined) { - values[`${deviceId}_${suffix}`] = value; - } - }); - }); - }; - - processData(data1, suffix1); - processData(data2, suffix2); - - const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({ - timestamp, - values, - })); - - result.sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - - return result; -}; - -const mergeMultipleTimeSeriesData = ( - datasets: Array<{ - data: TimeSeriesPoint[]; - suffix: string; - }>, - deviceIds: string[] -): TimeSeriesPoint[] => { - const timeMap = new Map>(); - - datasets.forEach(({ data, suffix }) => { - data.forEach((point) => { - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {}); - } - const values = timeMap.get(point.timestamp)!; - deviceIds.forEach((deviceId) => { - const value = point.values[deviceId]; - if (value !== undefined) { - values[`${deviceId}_${suffix}`] = value; - } - }); - }); - }); - - const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({ - timestamp, - values, - })); - - result.sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - - return result; -}; - const formatTimestamp = (timestamp: string) => dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm"); @@ -443,7 +154,7 @@ const ensureValidRange = ( const buildDataset = ( points: TimeSeriesPoint[], - deviceIds: string[], + series: ElementHistorySeries[], fractionDigits: number ) => { return points.map((point) => { @@ -452,19 +163,17 @@ const buildDataset = ( label: formatTimestamp(point.timestamp), }; - deviceIds.forEach((id) => { - ["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => { - const key = `${id}_${suffix}`; - const value = point.values[key]; - if (value !== undefined && value !== null) { - entry[key] = - typeof value === "number" - ? Number.isFinite(value) - ? parseFloat(value.toFixed(fractionDigits)) - : null - : value ?? null; - } - }); + series.forEach((metadata) => { + const key = historySeriesKey(metadata); + const value = point.values[key]; + if (value !== undefined && value !== null) { + entry[key] = + typeof value === "number" + ? Number.isFinite(value) + ? parseFloat(value.toFixed(fractionDigits)) + : null + : value ?? null; + } }); return entry; @@ -521,11 +230,9 @@ const SCADADataPanel: React.FC = ({ }); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); + const [historySeries, setHistorySeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); - const [selectedSource, setSelectedSource] = useState< - "raw" | "clean" | "sim" | "all" - >(() => (featureInfos.length === 1 ? "all" : "clean")); const draggableRef = useRef(null); const requestControllerRef = useRef(null); @@ -559,8 +266,8 @@ const SCADADataPanel: React.FC = ({ ); const dataset = useMemo( - () => buildDataset(timeSeries, deviceIds, fractionDigits), - [timeSeries, deviceIds, fractionDigits] + () => buildDataset(timeSeries, historySeries, fractionDigits), + [timeSeries, historySeries, fractionDigits] ); const handleFetch = useCallback( @@ -568,6 +275,7 @@ const SCADADataPanel: React.FC = ({ if (!hasDevices) { requestControllerRef.current?.abort(); setTimeSeries([]); + setHistorySeries([]); setLoadingState("idle"); setError(null); return; @@ -591,7 +299,8 @@ const SCADADataPanel: React.FC = ({ requestController.signal, ); if (requestControllerRef.current !== requestController) return; - setTimeSeries(result); + setTimeSeries(result.points); + setHistorySeries(result.series); setLoadingState("success"); } catch (err) { if ( @@ -620,16 +329,10 @@ const SCADADataPanel: React.FC = ({ handleFetch("device-change"); } else { setTimeSeries([]); + setHistorySeries([]); } }, [featureInfosKey, handleFetch, hasDevices]); - // 当设备数量变化时,调整数据源选择 - useEffect(() => { - if (featureInfos.length > 1 && selectedSource === "all") { - setSelectedSource("clean"); - } - }, [featureInfos.length, selectedSource]); - const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [ { @@ -643,45 +346,33 @@ const SCADADataPanel: React.FC = ({ const dynamic = (() => { const cols: GridColDef[] = []; - deviceIds.forEach((id) => { - // 为每个设备的每种数据类型创建列 - const suffixes = [ - { key: "clean", name: "清洗值" }, - { key: "raw", name: "监测值" }, - { key: "sim", name: "实时模拟值" }, - { key: "scheme_sim", name: "方案模拟值" }, - ]; - - suffixes.forEach(({ key, name }) => { - const fieldKey = `${id}_${key}`; - // 检查是否有该字段的数据 - const hasData = dataset.some( - (item) => item[fieldKey] !== null && item[fieldKey] !== undefined - ); - - if (hasData) { - cols.push({ - field: fieldKey, - headerName: `${id} (${name})`, - minWidth: 140, - flex: 1, - valueFormatter: (value: any) => { - if (value === null || value === undefined) return "--"; - if (Number.isFinite(Number(value))) { - return Number(value).toFixed(fractionDigits); - } - return String(value); - }, - }); - } - }); + historySeries.forEach((metadata) => { + const fieldKey = historySeriesKey(metadata); + const hasData = dataset.some( + (item) => item[fieldKey] !== null && item[fieldKey] !== undefined, + ); + if (hasData) { + cols.push({ + field: fieldKey, + headerName: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`, + minWidth: 180, + flex: 1, + valueFormatter: (value: any) => { + if (value === null || value === undefined) return "--"; + if (Number.isFinite(Number(value))) { + return Number(value).toFixed(fractionDigits); + } + return String(value); + }, + }); + } }); return cols; })(); return [...base, ...dynamic]; - }, [deviceIds, fractionDigits, dataset]); + }, [historySeries, fractionDigits, dataset]); const rows = useMemo( () => @@ -733,91 +424,57 @@ const SCADADataPanel: React.FC = ({ ]; const xData = dataset.map((item) => item.label); + const hasFlowSeries = historySeries.some((item) => item.metric === "flow"); + const hasPressureSeries = historySeries.some( + (item) => item.metric === "pressure", + ); const getSeries = () => { - return deviceIds.flatMap((id, index) => { - const series = []; - ["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => { - const key = `${id}_${suffix}`; - const hasData = dataset.some( - (item) => item[key] !== null && item[key] !== undefined - ); - if (hasData) { - const displayName = - suffix === "clean" - ? "清洗值" - : suffix === "raw" - ? "监测值" - : suffix === "sim" - ? "实时模拟" - : "方案模拟"; - - series.push({ - name: `${id} (${displayName})`, - type: "line", - symbol: - suffix === "clean" - ? "circle" - : suffix === "raw" + return historySeries.flatMap((metadata, index) => { + const key = historySeriesKey(metadata); + const hasSeriesData = dataset.some( + (item) => item[key] !== null && item[key] !== undefined, + ); + if (!hasSeriesData) return []; + const isObserved = metadata.source.startsWith("scada_"); + return [ + { + name: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`, + type: "line", + yAxisIndex: + metadata.metric === "pressure" && hasFlowSeries ? 1 : 0, + symbol: + metadata.source === "scada_cleaned" + ? "circle" + : metadata.source === "scada_raw" ? "diamond" : "none", - symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0, - showSymbol: suffix === "clean" || suffix === "raw", - sampling: "lttb", - connectNulls: suffix !== "clean" && suffix !== "raw", - itemStyle: { - color: colors[(index * 4 + sIndex) % colors.length], - }, - data: dataset.map((item) => item[key]), - lineStyle: - suffix === "clean" || suffix === "raw" - ? { width: 0 } - : undefined, - areaStyle: - suffix === "clean" || suffix === "raw" - ? undefined - : { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { - offset: 0, - color: colors[(index * 4 + sIndex) % colors.length], - }, - { - offset: 1, - color: "rgba(255, 255, 255, 0)", - }, - ]), - opacity: 0.3, - }, - }); - } - }); - // 如果没有任何数据,则使用fallback - if (series.length === 0) { - series.push({ - name: id, - type: "line", - symbol: "none", + symbolSize: isObserved ? 7 : 0, + showSymbol: isObserved, sampling: "lttb", - connectNulls: true, - itemStyle: { color: colors[index % colors.length] }, - data: dataset.map((item) => item[id]), - areaStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { - offset: 0, - color: colors[index % colors.length], - }, - { - offset: 1, - color: "rgba(255, 255, 255, 0)", - }, - ]), - opacity: 0.3, + connectNulls: !isObserved, + itemStyle: { + color: colors[index % colors.length], }, - }); - } - return series; + data: dataset.map((item) => item[key]), + lineStyle: isObserved ? { width: 0 } : undefined, + areaStyle: isObserved + ? undefined + : { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: colors[index % colors.length], + }, + { + offset: 1, + color: "rgba(255, 255, 255, 0)", + }, + ]), + opacity: 0.3, + }, + }, + ]; }); }; const option = { @@ -853,10 +510,26 @@ const SCADADataPanel: React.FC = ({ boundaryGap: false, data: xData, }, - yAxis: { - type: "value", - scale: true, - }, + yAxis: [ + ...(hasFlowSeries + ? [ + { + type: "value", + scale: true, + name: `流量 (${FLOW_DISPLAY_UNIT})`, + }, + ] + : []), + ...(hasPressureSeries + ? [ + { + type: "value", + scale: true, + name: `压力 (${PRESSURE_DISPLAY_UNIT})`, + }, + ] + : []), + ], dataZoom: [ { type: "inside", diff --git a/src/components/olmap/core/Controls/PropertyPanel.test.tsx b/src/components/olmap/core/Controls/PropertyPanel.test.tsx new file mode 100644 index 0000000..94104cc --- /dev/null +++ b/src/components/olmap/core/Controls/PropertyPanel.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import type Feature from "ol/Feature"; + +import PropertyPanel from "./PropertyPanel"; +import { buildFeatureProperties } from "./toolbarFeatureHelpers"; + +jest.mock("react-draggable", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock("ol/Feature", () => ({ + __esModule: true, + default: class Feature {}, +})); + +describe("PropertyPanel junction demands", () => { + it("shows a readable demand table instead of raw GeoServer JSON", () => { + const feature = { + getId: () => "junctions.n_001", + getProperties: () => ({ + id: "n_001", + elevation: 42, + base_demand: 0, + demands: + '[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]', + }), + } as unknown as Feature; + const panelData = buildFeatureProperties(feature, {}); + + render(); + + expect(screen.getByText("PAT_BASE")).toBeInTheDocument(); + expect(screen.getAllByText("0.000 m³/h")).toHaveLength(2); + expect(screen.getByText("未分类")).toBeInTheDocument(); + expect(screen.queryByText(/\[{"category"/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index b6c1ccf..411f5bc 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -835,6 +835,7 @@ const Toolbar: React.FC = ({ onSave: handleValveSettingSave, } : undefined, + data?.resultUnits, ), [ selectedFeature, @@ -848,6 +849,7 @@ const Toolbar: React.FC = ({ isValveStatusLoading, isValveStatusSaving, isValvePropertiesLoading, + data?.resultUnits, isValveSettingSaving, handleValveStatusSave, handleValveSettingSave, diff --git a/src/components/olmap/core/Controls/toolbarFeatureHelpers.test.ts b/src/components/olmap/core/Controls/toolbarFeatureHelpers.test.ts index acb01a7..3aefc40 100644 --- a/src/components/olmap/core/Controls/toolbarFeatureHelpers.test.ts +++ b/src/components/olmap/core/Controls/toolbarFeatureHelpers.test.ts @@ -173,6 +173,52 @@ describe("getSimulationElementType", () => { }); describe("buildFeatureProperties simulation values by hydraulic type", () => { + it("renders GeoServer demand JSON as a readable unit-aware table", () => { + const junction = createFeature("junctions", "n_001", { + base_demand: 0, + demands: + '[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]', + }); + + const result = buildFeatureProperties( + junction, + {}, + undefined, + undefined, + { flow: "LPS", pressure: "METERS", velocity: "m/s" }, + ); + + expect(result.properties).toEqual( + expect.arrayContaining([ + { + type: "table", + label: "需水配置", + columns: ["序号", "基础需水量", "模式", "类别"], + rows: [[1, "0.000 m³/h", "PAT_BASE", "未分类"]], + }, + ]), + ); + }); + + it("uses the selected project's MLD model unit for demand values", () => { + const junction = createFeature("junctions", "J1", { base_demand: 1 }); + + const result = buildFeatureProperties( + junction, + { actual_demand: 2, pressure: 18 }, + undefined, + undefined, + { flow: "MLD", pressure: "METERS", velocity: "m/s" }, + ); + + expect(result.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "基本需水量", value: "41.667" }), + expect.objectContaining({ label: "实际需水量", value: "83.333" }), + ]), + ); + }); + it("shows link simulation results for a point-rendered pump", () => { const pump = createFeature("pumps", "P1", { node1: "J1", diff --git a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts index ed2e508..5abdc13 100644 --- a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts +++ b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts @@ -1,6 +1,13 @@ import Feature from "ol/Feature"; -import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; +import { + DEFAULT_NETWORK_RESULT_UNITS, + FLOW_DISPLAY_UNIT, + PRESSURE_DISPLAY_UNIT, + type NetworkResultUnits, + VELOCITY_DISPLAY_UNIT, + toModelDisplayValue, +} from "@utils/units"; import { getValveSettingHelperText, VALVE_STATUS_OPTIONS, @@ -163,11 +170,70 @@ export const inferHistoryFeatureInfos = ( }) .filter(Boolean) as [string, string][]; +type DemandEntry = { + sequence_no?: unknown; + base_demand?: unknown; + demand?: unknown; + pattern_id?: unknown; + pattern?: unknown; + category?: unknown; +}; + +const parseDemandEntries = (value: unknown): DemandEntry[] => { + let parsed = value; + if (typeof parsed === "string") { + try { + parsed = JSON.parse(parsed); + } catch { + return []; + } + } + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is DemandEntry => + typeof entry === "object" && entry !== null, + ); +}; + +const buildDemandProperty = ( + value: unknown, + resultUnits: NetworkResultUnits, +): ToolbarPropertyItem => { + const entries = parseDemandEntries(value); + if (entries.length === 0) { + return { label: "需水配置", value: "未配置" }; + } + + return { + type: "table", + label: "需水配置", + columns: ["序号", "基础需水量", "模式", "类别"], + rows: entries.map((entry, index) => { + const sourceDemand = Number(entry.base_demand ?? entry.demand); + const demand = Number.isFinite(sourceDemand) + ? `${toModelDisplayValue( + sourceDemand, + "base_demand", + resultUnits, + ).toFixed(3)} ${FLOW_DISPLAY_UNIT}` + : "未设置"; + const sequence = Number(entry.sequence_no); + return [ + Number.isInteger(sequence) ? sequence + 1 : index + 1, + demand, + String(entry.pattern_id ?? entry.pattern ?? "无"), + String(entry.category ?? "未分类"), + ]; + }), + }; +}; + export const buildFeatureProperties = ( highlightFeature: Feature | undefined, computedProperties: Record, valveStatus?: ValveStatusPropertyOptions, valveSetting?: ValveSettingPropertyOptions, + resultUnits: NetworkResultUnits = DEFAULT_NETWORK_RESULT_UNITS, ): ToolbarPropertyPanelData => { if (!highlightFeature) return {}; @@ -182,12 +248,12 @@ export const buildFeatureProperties = ( { key: "reaction", label: "反应", unit: "1/d" }, { key: "setting", label: "设置", unit: "" }, { key: "status", label: "状态", unit: "" }, - { key: "velocity", label: "流速", unit: "m/s" }, + { key: "velocity", label: "流速", unit: VELOCITY_DISPLAY_UNIT }, ]; const nodeComputedFields = [ { key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` }, { key: "total_head", label: "水头", unit: "m" }, - { key: "pressure", label: "压力", unit: "m" }, + { key: "pressure", label: "压力", unit: PRESSURE_DISPLAY_UNIT }, { key: "quality", label: "水质", unit: "mg/L" }, ]; @@ -200,7 +266,10 @@ export const buildFeatureProperties = ( let value = computedProperties[key]; if (key === "flow" && value !== undefined) { - value = toM3h(value, "lps"); + value = toModelDisplayValue(value, key, resultUnits); + } + if (key === "velocity" && value !== undefined) { + value = toModelDisplayValue(value, key, resultUnits); } if ( key === "unit_headloss" && @@ -226,7 +295,10 @@ export const buildFeatureProperties = ( let value = computedProperties[key]; if (key === "actual_demand") { - value = toM3h(value, "lps"); + value = toModelDisplayValue(value, key, resultUnits); + } + if (key === "pressure") { + value = toModelDisplayValue(value, key, resultUnits); } result.properties?.push({ label, @@ -273,14 +345,15 @@ export const buildFeatureProperties = ( { label: "基本需水量", value: Number.isFinite(Number(properties.base_demand)) - ? toM3h(Number(properties.base_demand), "lps").toFixed(3) + ? toModelDisplayValue( + Number(properties.base_demand), + "base_demand", + resultUnits, + ).toFixed(3) : properties.base_demand, - unit: "m³/h", - }, - { - label: "需水配置", - value: properties.demands, + unit: FLOW_DISPLAY_UNIT, }, + buildDemandProperty(properties.demands, resultUnits), ], }; diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index d168c7e..10d9869 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -5,7 +5,10 @@ import type { FlatStyleLike } from "ol/style/flat"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { config } from "@/config/config"; -import { isLpsFlowProperty, toM3h } from "@utils/units"; +import { + type NetworkResultUnits, + toModelDisplayValue, +} from "@utils/units"; import { LayerStyleController } from "../layerStyleController"; import { useData, useMap } from "../MapComponent"; @@ -56,11 +59,15 @@ const configsEqual = (left?: StyleConfig, right?: StyleConfig) => const hasSameTemplate = (left: StyleConfig, right: StyleConfig) => left.property === right.property && left.segments === right.segments; -const normalizeComputedStyleValue = (property: string, value: unknown) => { +const normalizeComputedStyleValue = ( + property: string, + value: unknown, + resultUnits?: NetworkResultUnits, +) => { const numericValue = Number(value); if (!Number.isFinite(numericValue)) return Number.NaN; - const displayValue = isLpsFlowProperty(property) - ? toM3h(numericValue, "lps") + const displayValue = resultUnits + ? toModelDisplayValue(numericValue, property, resultUnits) : numericValue; return property === "flow" ? Math.abs(displayValue) : displayValue; }; @@ -140,6 +147,7 @@ export const useStyleEditor = ({ const elevationRange = data?.elevationRange; const diameterRange = data?.diameterRange; const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; + const resultUnits = data?.resultUnits; const setJunctionText = data?.setJunctionText; const setPipeText = data?.setPipeText; const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; @@ -311,10 +319,18 @@ export const useStyleEditor = ({ } const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData; return (records || []) - .map((item: any) => normalizeComputedStyleValue(property, item.value)) + .map((item: any) => + normalizeComputedStyleValue(property, item.value, resultUnits), + ) .filter(Number.isFinite); }, - [currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange], + [ + currentJunctionCalData, + currentPipeCalData, + diameterRange, + elevationRange, + resultUnits, + ], ); const syncAuxiliaryLayers = useCallback( @@ -425,7 +441,11 @@ export const useStyleEditor = ({ records.forEach((record: any) => { const id = record.ID ?? record.id; if (id === undefined || id === null) return; - const value = normalizeComputedStyleValue(nextConfig.property, record.value); + const value = normalizeComputedStyleValue( + nextConfig.property, + record.value, + resultUnits, + ); if (Number.isFinite(value)) stateById.set(String(id), value); }); const committed = await controller.applyRuntime(options, stateById); @@ -465,6 +485,7 @@ export const useStyleEditor = ({ getDataForMap, getMapKey, getRenderLayersById, + resultUnits, syncContoursForStyle, upsertLayerStyleState, ], diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 0a5d98d..407d137 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -23,7 +23,11 @@ import { TextLayer } from "@deck.gl/layers"; import { TripsLayer } from "@deck.gl/geo-layers"; import { CollisionFilterExtension } from "@deck.gl/extensions"; import { ContourLayer } from "deck.gl"; -import { isLpsFlowProperty, toM3h } from "@utils/units"; +import { + type NetworkResultUnits, + toModelDisplayValue, +} from "@utils/units"; +import { useNetworkResultUnits } from "@/hooks/useNetworkResultUnits"; import { usePathname } from "next/navigation"; import { cleanupTransientMapResources, @@ -125,6 +129,7 @@ interface DataContextType { elevationRange?: [number, number]; forceStyleAutoApplyVersion?: number; setForceStyleAutoApplyVersion?: React.Dispatch>; + resultUnits?: NetworkResultUnits; } // 跨组件传递 @@ -138,14 +143,13 @@ const mergeJunctionValues = ( features: any[], records: any[], property: string, + resultUnits: NetworkResultUnits, ) => { const recordsById = indexCalculationRecords(records); return features.map((feature) => { const record = recordsById.get(String(feature.id)); if (!record) return feature; - const value = isLpsFlowProperty(property) - ? toM3h(record.value, "lps") - : record.value; + const value = toModelDisplayValue(record.value, property, resultUnits); return { ...feature, [property]: value }; }); }; @@ -154,13 +158,14 @@ const mergePipeValues = ( features: any[], records: any[], property: string, + resultUnits: NetworkResultUnits, ) => { const recordsById = indexCalculationRecords(records); const isFlow = property === "flow"; return features.map((feature) => { const record = recordsById.get(String(feature.id)); if (!record) return feature; - const value = isFlow ? toM3h(record.value, "lps") : record.value; + const value = toModelDisplayValue(record.value, property, resultUnits); const reverseFlow = isFlow && record.value < 0; return { ...feature, @@ -202,6 +207,7 @@ const MapComponent: React.FC = ({ children }) => { const MAP_URL = config.MAP_URL; const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const { durationMinutes, stepMinutes } = useTimelineTimeConfig(); + const resultUnits = useNetworkResultUnits(project?.networkName); const mapRef = useRef(null); const canvasRef = useRef(null); @@ -288,37 +294,52 @@ const MapComponent: React.FC = ({ children }) => { // 实时合并计算结果到基础地理数据中 const mergedJunctionData = useMemo( () => - mergeJunctionValues(junctionData, currentJunctionCalData, junctionText), - [junctionData, currentJunctionCalData, junctionText], + mergeJunctionValues( + junctionData, + currentJunctionCalData, + junctionText, + resultUnits, + ), + [junctionData, currentJunctionCalData, junctionText, resultUnits], ); const mergedPipeData = useMemo( - () => mergePipeValues(pipeData, currentPipeCalData, pipeText), - [pipeData, currentPipeCalData, pipeText], + () => mergePipeValues(pipeData, currentPipeCalData, pipeText, resultUnits), + [pipeData, currentPipeCalData, pipeText, resultUnits], ); const mergedPipeFragments = useMemo( - () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText), - [pipeFragments, currentPipeCalData, pipeText], + () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText, resultUnits), + [pipeFragments, currentPipeCalData, pipeText, resultUnits], ); const mergedCompareJunctionData = useMemo( () => isCompareMode - ? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText) + ? mergeJunctionValues( + junctionData, + compareJunctionCalData, + junctionText, + resultUnits, + ) : [], - [isCompareMode, junctionData, compareJunctionCalData, junctionText], + [isCompareMode, junctionData, compareJunctionCalData, junctionText, resultUnits], ); const mergedComparePipeData = useMemo( () => isCompareMode - ? mergePipeValues(pipeData, comparePipeCalData, pipeText) + ? mergePipeValues(pipeData, comparePipeCalData, pipeText, resultUnits) : [], - [isCompareMode, pipeData, comparePipeCalData, pipeText], + [isCompareMode, pipeData, comparePipeCalData, pipeText, resultUnits], ); const mergedComparePipeFragments = useMemo( () => isCompareMode - ? mergePipeValues(pipeFragments, comparePipeCalData, pipeText) + ? mergePipeValues( + pipeFragments, + comparePipeCalData, + pipeText, + resultUnits, + ) : [], - [isCompareMode, pipeFragments, comparePipeCalData, pipeText], + [isCompareMode, pipeFragments, comparePipeCalData, pipeText, resultUnits], ); const [diameterRange, setDiameterRange] = useState< @@ -1163,6 +1184,7 @@ const MapComponent: React.FC = ({ children }) => { elevationRange, forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion, + resultUnits, }} > diff --git a/src/components/threeDimensional/sceneData.test.ts b/src/components/threeDimensional/sceneData.test.ts index 371440f..e1065c0 100644 --- a/src/components/threeDimensional/sceneData.test.ts +++ b/src/components/threeDimensional/sceneData.test.ts @@ -76,7 +76,7 @@ describe("buildSceneFrame", () => { }); expect(frame.payload?.links["P-1"]).toEqual({ velocity: 0.82, - flow: -18.4, + flow: -66.24, direction: -1, status: "open", }); @@ -196,7 +196,12 @@ describe("buildSceneFrame", () => { const devices = await fetchPressureDevices(new AbortController().signal); expect(devices).toEqual([ - { device_id: "S-1", device_type: "pressure", node_id: "J-1" }, + { + device_id: "S-1", + device_type: "pressure", + node_id: "J-1", + measurement_unit: "m", + }, ]); expect(mockApiFetch).toHaveBeenCalledWith( expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"), @@ -228,6 +233,10 @@ describe("buildSceneFrame", () => { }, ], }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }), + }) .mockResolvedValueOnce({ ok: false, status: 503, diff --git a/src/components/threeDimensional/sceneData.ts b/src/components/threeDimensional/sceneData.ts index ae70a3a..ec844ae 100644 --- a/src/components/threeDimensional/sceneData.ts +++ b/src/components/threeDimensional/sceneData.ts @@ -1,5 +1,11 @@ import { apiFetch } from "@/lib/apiFetch"; +import { fetchNetworkResultUnits } from "@/hooks/useNetworkResultUnits"; import { config } from "@config/config"; +import { + DEFAULT_NETWORK_RESULT_UNITS, + type NetworkResultUnits, + toDisplayValue, +} from "@/utils/units"; export const ZJB_PROJECT_CODE = "zjb"; export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23"; @@ -32,8 +38,8 @@ export type SceneResultsPayload = { modelId: string; units: { velocity: "m/s"; - pressure: "mH2O"; - flow: "L/s"; + pressure: "m"; + flow: "m³/h"; }; timestamp: string; nodes: Record; @@ -61,6 +67,7 @@ export type PressureDevice = { device_id: string; device_type: string; node_id: string; + measurement_unit?: string; }; type RawPressureDevice = Omit & { @@ -192,6 +199,7 @@ export const buildSceneFrame = ({ linkRows, pressureDevices, scadaRows, + simulationUnits = DEFAULT_NETWORK_RESULT_UNITS, }: { queryTime: Date; model: SceneModelIndex; @@ -199,6 +207,7 @@ export const buildSceneFrame = ({ linkRows: RealtimeLinkRow[]; pressureDevices: PressureDevice[]; scadaRows: ScadaReadingRow[]; + simulationUnits?: NetworkResultUnits; }): SceneFrame => { const selectedTime = queryTime.toISOString(); const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows); @@ -223,7 +232,14 @@ export const buildSceneFrame = ({ return; } if (isFiniteNumber(row.pressure)) { - nodes[id] = { pressure: row.pressure, source: "simulation" }; + nodes[id] = { + pressure: toDisplayValue( + row.pressure, + "pressure", + simulationUnits.pressure, + )!, + source: "simulation", + }; } }); @@ -234,9 +250,15 @@ export const buildSceneFrame = ({ return; } const result: SceneLinkResult = {}; - if (isFiniteNumber(row.velocity)) result.velocity = row.velocity; + if (isFiniteNumber(row.velocity)) { + result.velocity = toDisplayValue( + row.velocity, + "velocity", + simulationUnits.velocity, + )!; + } if (isFiniteNumber(row.flow)) { - result.flow = row.flow; + result.flow = toDisplayValue(row.flow, "flow", simulationUnits.flow)!; result.direction = Math.sign(row.flow) as -1 | 0 | 1; } const status = normalizeLinkStatus(row.status); @@ -260,7 +282,11 @@ export const buildSceneFrame = ({ if (!isFiniteNumber(value)) return; const simulationPressure = nodes[device.node_id]?.pressure; nodes[device.node_id] = { - pressure: value, + pressure: toDisplayValue( + value, + "pressure", + device.measurement_unit || "m", + )!, source: "scada", deviceId: device.device_id, ...(simulationPressure === undefined ? {} : { simulationPressure }), @@ -274,7 +300,7 @@ export const buildSceneFrame = ({ resultTime, payload: { modelId: model.modelId, - units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" }, + units: { velocity: "m/s", pressure: "m", flow: "m³/h" }, timestamp: resultTime, nodes, links, @@ -301,18 +327,23 @@ const readJson = async (url: string, signal: AbortSignal): Promise => { }; export const fetchPressureDevices = async (signal: AbortSignal) => { - const page = await readJson>( + const response = await readJson | RawPressureDevice[]>( `${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`, signal, ); - return page.items + const items = Array.isArray(response) ? response : response.items; + return items .filter( (device): device is PressureDevice => device.device_type?.trim().toLowerCase() === "pressure" && typeof device.node_id === "string" && device.node_id.trim().length > 0, ) - .map((device) => ({ ...device, node_id: device.node_id.trim() })); + .map((device) => ({ + ...device, + node_id: device.node_id.trim(), + measurement_unit: device.measurement_unit?.trim() || "m", + })); }; export const fetchSceneFrame = async ({ @@ -337,7 +368,7 @@ export const fetchSceneFrame = async ({ pressureDevices.map((device) => device.device_id).join(","), ); - const [nodeRows, linkRows] = await Promise.all([ + const [nodeRows, linkRows, networkOptions] = await Promise.all([ readJson( `${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`, signal, @@ -346,6 +377,7 @@ export const fetchSceneFrame = async ({ `${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`, signal, ), + fetchNetworkResultUnits(ZJB_PROJECT_CODE, signal), ]); let scadaRows: ScadaReadingRow[] = []; @@ -370,6 +402,7 @@ export const fetchSceneFrame = async ({ linkRows: Array.isArray(linkRows) ? linkRows : [], pressureDevices, scadaRows, + simulationUnits: networkOptions, }); return { ...frame, warnings }; }; diff --git a/src/generated/serverApi.ts b/src/generated/serverApi.ts index 58b806c..542698a 100644 --- a/src/generated/serverApi.ts +++ b/src/generated/serverApi.ts @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/project-provisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 创建完整供水项目 */ + post: operations["post_admin_project_provisions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/projects": { parameters: { query?: never; @@ -31,7 +48,10 @@ export interface paths { /** List Admin Projects */ get: operations["get_admin_projects"]; put?: never; - /** Create Admin Project */ + /** + * 仅登记已有项目元数据 + * @description 仅用于登记已经由外部流程完整创建的资源。新项目应调用 POST /admin/project-provisions。 + */ post: operations["post_admin_projects"]; delete?: never; options?: never; @@ -649,26 +669,6 @@ export interface paths { patch: operations["patch_curves_properties"]; trace?: never; }; - "/api/v1/daily-scheduling-analyses": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 日排程分析 - * @description 对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。 - */ - post: operations["post_daily_scheduling_analyses"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/demands/properties": { parameters: { query?: never; @@ -917,26 +917,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/inp-runs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 运行INP文件 - * @description 运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。 - */ - post: operations["post_inp_runs"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/junctions": { parameters: { query?: never; @@ -2179,26 +2159,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/outputs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 导出模拟输出 - * @description 导出指定路径的模拟输出文件内容。参数应为绝对路径。 - */ - get: operations["get_outputs"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/patterns": { parameters: { query?: never; @@ -2604,46 +2564,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/pressure-regulation-calculations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 压力调节(基础) - * @description 对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。 - */ - post: operations["post_pressure_regulation_calculations"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/project-managements": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 项目管理(高级) - * @description 高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。 - */ - post: operations["post_project_managements"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/project-return-dict-runs": { parameters: { query?: never; @@ -2784,26 +2704,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/pump-failure-events": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 泵故障管理 - * @description 记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。 - */ - post: operations["post_pump_failure_events"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/pumps": { parameters: { query?: never; @@ -3295,7 +3195,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/scada-devices/detail": { + "/api/v1/scada-devices/{device_id}": { parameters: { query?: never; header?: never; @@ -3303,27 +3203,7 @@ export interface paths { cookie?: never; }; /** 获取 SCADA 设备 */ - get: operations["get_scada_devices_detail"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/scada-info/database-view": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 获取SCADA信息 - * @description 使用连接池查询所有SCADA信息 - */ - get: operations["get_scada_info_database_view"]; + get: operations["get_scada_devices_device_id"]; put?: never; post?: never; delete?: never; @@ -3352,26 +3232,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/scheduling-analyses": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 排程分析 - * @description 对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。 - */ - post: operations["post_scheduling_analyses"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/sensor-placement-candidates/{node_id}": { parameters: { query?: never; @@ -4429,111 +4289,17 @@ export interface paths { patch: operations["patch_timeseries_scada_readings_device_id_field"]; trace?: never; }; - "/api/v1/timeseries/views/element-scada-readings": { + "/api/v1/timeseries/views/element-history/query": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * 获取管网元素关联的SCADA监测数据 - * @description 获取link/node关联的SCADA监测值 - * - * 根据传入的link/node id,匹配SCADA信息, - * 如果存在关联的SCADA device_id,获取实际的监测数据。 - * - * Args: - * element_id: 管网元素ID - * start_time: 查询开始时间 - * end_time: 查询结束时间 - * use_cleaned: 是否使用清洗后的数据,默认为False使用原始数据 - * timescale_conn: TimescaleDB连接 - * postgres_conn: PostgreSQL连接 - * - * Returns: - * 管网元素关联的SCADA监测数据 - * - * Raises: - * HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误 - */ - get: operations["get_timeseries_views_element_scada_readings"]; + get?: never; put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/timeseries/views/element-simulations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 获取管网元素的模拟数据 - * @description 获取link/node模拟值 - * - * 根据传入的featureInfos,找到关联的link/node, - * 并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。 - * - * Args: - * start_time: 查询开始时间 - * end_time: 查询结束时间 - * feature_infos: 格式为 "element_id1:type1,element_id2:type2" - * 例如: "P1:pipe,J1:junction" - * run_id: 分析运行 ID,若为空则查询实时数据 - * timescale_conn: TimescaleDB连接 - * - * Returns: - * 管网元素的模拟数据 - * - * Raises: - * HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误 - */ - get: operations["get_timeseries_views_element_simulations"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/timeseries/views/scada-simulations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 获取SCADA关联的模拟数据 - * @description 获取SCADA关联的link/node模拟值 - * - * 根据传入的SCADA device_ids,找到关联的link/node, - * 并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。 - * - * Args: - * start_time: 查询开始时间 - * end_time: 查询结束时间 - * device_ids: SCADA设备ID列表,用逗号分隔 - * run_id: 分析运行 ID,若为空则查询实时数据 - * timescale_conn: TimescaleDB连接 - * postgres_conn: PostgreSQL连接 - * - * Returns: - * SCADA关联的模拟数据 - * - * Raises: - * HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误 - */ - get: operations["get_timeseries_views_scada_simulations"]; - put?: never; - post?: never; + /** 批量查询管网元素历史数据 */ + post: operations["post_timeseries_views_element_history_query"]; delete?: never; options?: never; head?: never; @@ -4904,26 +4670,6 @@ export interface paths { patch: operations["patch_visual_elements_properties"]; trace?: never; }; - "/api/v1/water-age-analyses": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 水龄分析(高级) - * @description 高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。 - */ - post: operations["post_water_age_analyses"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/web-searches": { parameters: { query?: never; @@ -5100,6 +4846,28 @@ export interface components { */ file: string; }; + /** Body_post_admin_project_provisions */ + Body_post_admin_project_provisions: { + /** Code */ + code: string; + /** Description */ + description?: string | null; + /** + * File + * Format: binary + * @description EPANET INP 模型文件 + */ + file: string; + /** Gs Workspace */ + gs_workspace?: string | null; + /** + * Map Zoom + * @default 14 + */ + map_zoom: number; + /** Name */ + name: string; + }; /** Body_post_admin_projects_project_id_model_imports */ Body_post_admin_projects_project_id_model_imports: { /** @@ -5291,40 +5059,95 @@ export interface components { */ use_scada_flow: boolean; }; - /** DailySchedulingAnalysisRest */ - DailySchedulingAnalysisRest: { + /** ElementHistoryQuery */ + ElementHistoryQuery: { + /** Elements */ + elements: components["schemas"]["ElementHistoryTarget"][]; /** - * Pump Control - * @description 泵控制策略 + * End Time + * Format: date-time */ - pump_control: Record; - /** - * Reservoir Id - * @description 水库ID - */ - reservoir_id: string; + end_time: string; + /** @default observed */ + mode: components["schemas"]["HistoryMode"]; + /** Run Id */ + run_id?: string | null; /** * Start Time - * @description 开始时间 + * Format: date-time */ start_time: string; - /** - * Tank Id - * @description 水箱ID - */ - tank_id: string; - /** - * Time Delta - * @description 时间步长 (秒) - * @default 300 - */ - time_delta: number | null; - /** - * Water Plant Output Id - * @description 水厂出水ID - */ - water_plant_output_id: string; }; + /** ElementHistoryResponse */ + ElementHistoryResponse: { + /** Series */ + series: components["schemas"]["ElementHistorySeries"][]; + }; + /** ElementHistorySeries */ + ElementHistorySeries: { + /** Device Id */ + device_id?: string | null; + /** + * Display Unit + * @description Recommended UI display unit + */ + display_unit: string; + /** Element Id */ + element_id: string; + element_type: components["schemas"]["HistoryElementType"]; + metric: components["schemas"]["HistoryMetric"]; + /** Points */ + points: components["schemas"]["HistoryPoint"][]; + source: components["schemas"]["HistorySource"]; + /** + * Source Unit + * @description Unit used by the returned point values + */ + source_unit: string; + /** + * Unit Inferred + * @default false + */ + unit_inferred: boolean; + }; + /** ElementHistoryTarget */ + ElementHistoryTarget: { + /** Device Ids */ + device_ids?: string[] | null; + /** Element Id */ + element_id: string; + element_type: components["schemas"]["HistoryElementType"]; + }; + /** + * HistoryElementType + * @enum {string} + */ + HistoryElementType: "pipe" | "junction"; + /** + * HistoryMetric + * @enum {string} + */ + HistoryMetric: "flow" | "pressure"; + /** + * HistoryMode + * @enum {string} + */ + HistoryMode: "observed" | "realtime_comparison" | "analysis_comparison"; + /** HistoryPoint */ + HistoryPoint: { + /** + * Time + * Format: date-time + */ + time: string; + /** Value */ + value: number | null; + }; + /** + * HistorySource + * @enum {string} + */ + HistorySource: "scada_raw" | "scada_cleaned" | "realtime_simulation" | "analysis_simulation"; JsonValue: unknown; /** LeakageIdentifyRequestRest */ LeakageIdentifyRequestRest: { @@ -5347,23 +5170,17 @@ export interface components { max_gen: number; /** * N Workers - * @description 工作线程数 + * @description 工作进程数 * @default 4 */ n_workers: number; /** * Observed Pressure Data - * @description 观测的压力数据 + * @description 观测的压力数据;文件路径不属于公共 API 输入 */ - observed_pressure_data?: string | { + observed_pressure_data?: { [key: string]: unknown[]; } | Record[] | null; - /** - * Output Dir - * @description 输出目录 - * @default db_inp - */ - output_dir: string; /** * Output Flow Unit * @description 输出流量单位 @@ -5581,6 +5398,17 @@ export interface components { /** Total */ total: number; }; + /** Page[ScadaDeviceResponse] */ + Page_ScadaDeviceResponse_: { + /** Items */ + items: components["schemas"]["ScadaDeviceResponse"][]; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + /** Total */ + total: number; + }; /** Page[SensorPlacementSchemeResponse] */ Page_SensorPlacementSchemeResponse_: { /** Items */ @@ -5631,7 +5459,7 @@ export interface components { * Scheme Name * @description 方案名称 */ - scheme_name?: string | null; + scheme_name: string; /** * Start Time * @description 开始时间 @@ -5729,29 +5557,6 @@ export interface components { */ pool_min_size: number; }; - /** ProjectManagementRest */ - ProjectManagementRest: { - /** - * Pump Control - * @description 泵控制策略 - */ - pump_control: Record; - /** - * Region Demand - * @description 区域需水量控制 - */ - region_demand?: Record | null; - /** - * Start Time - * @description 开始时间 - */ - start_time: string; - /** - * Tank Init Level - * @description 水箱初始水位 - */ - tank_init_level?: Record | null; - }; /** ProjectMemberCreateRequest */ ProjectMemberCreateRequest: { /** @@ -5822,6 +5627,20 @@ export interface components { /** Status */ status: string; }; + /** ProjectProvisionResponse */ + ProjectProvisionResponse: { + /** Business Database */ + business_database: string; + /** Geoserver Layers */ + geoserver_layers: string[]; + /** Geoserver Workspace */ + geoserver_workspace: string; + /** Model Template Database */ + model_template_database: string; + project: components["schemas"]["AdminProjectResponse"]; + /** Timescale Database */ + timescale_database: string; + }; /** ProjectSummaryResponse */ ProjectSummaryResponse: { /** Code */ @@ -5842,19 +5661,6 @@ export interface components { /** Status */ status: string; }; - /** PumpFailureState */ - PumpFailureState: { - /** - * Pump Status - * @description 泵状态字典 - */ - pump_status: Record; - /** - * Time - * @description 故障发生时间 - */ - time: string; - }; /** RealtimeLinkBatchItem */ RealtimeLinkBatchItem: { /** Flow */ @@ -5912,6 +5718,38 @@ export interface components { */ start_time: string; }; + /** + * ScadaDeviceResponse + * @description Project SCADA metadata keyed by the canonical device identifier. + */ + ScadaDeviceResponse: { + /** Api Query Id */ + api_query_id?: string | null; + /** Device Id */ + device_id: string; + /** Device Type */ + device_type: string; + /** Latitude */ + latitude?: number | null; + /** Link Id */ + link_id?: string | null; + /** Longitude */ + longitude?: number | null; + /** Measurement Unit */ + measurement_unit: string; + /** Node Id */ + node_id?: string | null; + /** Reliability */ + reliability?: number | null; + /** Transmission Frequency */ + transmission_frequency: string; + /** Transmission Mode */ + transmission_mode: string; + /** X */ + x?: number | null; + /** Y */ + y?: number | null; + }; /** ScadaReadingBatchItem */ ScadaReadingBatchItem: { /** Cleaned Value */ @@ -5926,35 +5764,6 @@ export interface components { */ time: string; }; - /** SchedulingAnalysisRest */ - SchedulingAnalysisRest: { - /** - * Pump Control - * @description 泵控制策略 - */ - pump_control: Record; - /** - * Start Time - * @description 开始时间 - */ - start_time: string; - /** - * Tank Id - * @description 水箱ID - */ - tank_id: string; - /** - * Time Delta - * @description 时间步长 (秒) - * @default 300 - */ - time_delta: number | null; - /** - * Water Plant Output Id - * @description 水厂出水ID - */ - water_plant_output_id: string; - }; /** SensorPlacementExportRequest */ SensorPlacementExportRequest: { /** Adjustment Status */ @@ -6186,6 +5995,84 @@ export interface operations { }; }; }; + post_admin_project_provisions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_post_admin_project_provisions"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectProvisionResponse"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Insufficient permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Validation error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Dependency unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; get_admin_projects: { parameters: { query?: { @@ -9613,86 +9500,6 @@ export interface operations { }; }; }; - post_daily_scheduling_analyses: { - parameters: { - query?: never; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["DailySchedulingAnalysisRest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; get_demands_properties: { parameters: { query: { @@ -10835,82 +10642,6 @@ export interface operations { }; }; }; - post_inp_runs: { - parameters: { - query?: never; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; get_junctions: { parameters: { query?: { @@ -16920,85 +16651,6 @@ export interface operations { }; }; }; - get_outputs: { - parameters: { - query: { - /** @description 模拟输出文件的绝对路径 */ - output: string; - }; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; get_patterns: { parameters: { query?: { @@ -19407,167 +19059,6 @@ export interface operations { }; }; }; - post_pressure_regulation_calculations: { - parameters: { - query: { - /** @description 目标节点ID */ - target_node: string; - /** @description 目标压力值(kPa) */ - target_pressure: number; - }; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["JsonValue"]; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; - post_project_managements: { - parameters: { - query?: never; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ProjectManagementRest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; post_project_return_dict_runs: { parameters: { query?: never; @@ -20104,86 +19595,6 @@ export interface operations { }; }; }; - post_pump_failure_events: { - parameters: { - query?: never; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["PumpFailureState"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; get_pumps: { parameters: { query?: { @@ -23322,7 +22733,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Page_dict_str__Any__"]; + "application/json": components["schemas"]["Page_ScadaDeviceResponse_"]; }; }; /** @description Authentication required */ @@ -23381,92 +22792,15 @@ export interface operations { }; }; }; - get_scada_devices_detail: { - parameters: { - query: { - /** @description SCADA 设备 ID */ - device_id: string; - }; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; - get_scada_info_database_view: { + get_scada_devices_device_id: { parameters: { query?: never; header: { "X-Project-Id": string; }; - path?: never; + path: { + device_id: string; + }; cookie?: never; }; requestBody?: never; @@ -23477,7 +22811,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["JsonValue"]; + "application/json": components["schemas"]["ScadaDeviceResponse"]; }; }; /** @description Authentication required */ @@ -23615,86 +22949,6 @@ export interface operations { }; }; }; - post_scheduling_analyses: { - parameters: { - query?: never; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SchedulingAnalysisRest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; get_sensor_placement_candidates_node_id: { parameters: { query?: never; @@ -29108,25 +28362,20 @@ export interface operations { }; }; }; - get_timeseries_views_element_scada_readings: { + post_timeseries_views_element_history_query: { parameters: { - query: { - /** @description 管网元素ID(管道或节点) */ - element_id: string; - /** @description 查询开始时间 */ - start_time: string; - /** @description 查询结束时间 */ - end_time: string; - /** @description 是否使用清洗后的数据 */ - use_cleaned?: boolean; - }; + query?: never; header: { "X-Project-Id": string; }; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ElementHistoryQuery"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -29134,177 +28383,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["JsonValue"]; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; - get_timeseries_views_element_simulations: { - parameters: { - query: { - /** @description 查询开始时间 */ - start_time: string; - /** @description 查询结束时间 */ - end_time: string; - /** @description 特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点) */ - feature_infos: string; - /** @description 分析运行 ID;为空时查询实时数据 */ - run_id?: string | null; - }; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["JsonValue"]; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; - get_timeseries_views_scada_simulations: { - parameters: { - query: { - /** @description 查询开始时间 */ - start_time: string; - /** @description 查询结束时间 */ - end_time: string; - /** @description SCADA设备ID列表,逗号分隔 */ - device_ids: string; - /** @description 分析运行 ID;为空时查询实时数据 */ - run_id?: string | null; - }; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["JsonValue"]; + "application/json": components["schemas"]["ElementHistoryResponse"]; }; }; /** @description Authentication required */ @@ -31526,87 +30605,6 @@ export interface operations { }; }; }; - post_water_age_analyses: { - parameters: { - query: { - /** @description 分析开始时间(ISO 8601格式) */ - start_time: string; - /** @description 模拟持续时间(秒) */ - duration: number; - }; - header: { - "X-Project-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": string; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Insufficient permission */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Resource conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Validation error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": components["schemas"]["ProblemDetails"]; - }; - }; - /** @description Dependency unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; post_web_searches: { parameters: { query?: never; diff --git a/src/hooks/useNetworkResultUnits.ts b/src/hooks/useNetworkResultUnits.ts new file mode 100644 index 0000000..e1cd4fd --- /dev/null +++ b/src/hooks/useNetworkResultUnits.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; + +import config from "@/config/config"; +import { apiFetch } from "@/lib/apiFetch"; +import { + DEFAULT_NETWORK_RESULT_UNITS, + type NetworkResultUnits, + networkResultUnitsFromOptions, +} from "@/utils/units"; + +export const fetchNetworkResultUnits = async ( + networkName: string, + signal?: AbortSignal, +) => { + const query = new URLSearchParams({ network: networkName }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/network-options?${query}`, + { signal }, + ); + if (!response.ok) { + throw new Error(`模型单位请求失败: HTTP ${response.status}`); + } + return networkResultUnitsFromOptions( + (await response.json()) as Record, + ); +}; + +export const useNetworkResultUnits = (networkName?: string | null) => { + const [loaded, setLoaded] = useState<{ + networkName: string; + units: NetworkResultUnits; + }>(); + + useEffect(() => { + if (!networkName) return; + const controller = new AbortController(); + void fetchNetworkResultUnits(networkName, controller.signal) + .then((units) => setLoaded({ networkName, units })) + .catch((error) => { + if (controller.signal.aborted) return; + console.error("[units] 获取项目模型单位失败:", error); + setLoaded({ networkName, units: DEFAULT_NETWORK_RESULT_UNITS }); + }); + return () => controller.abort(); + }, [networkName]); + + return loaded && loaded.networkName === networkName + ? loaded.units + : DEFAULT_NETWORK_RESULT_UNITS; +}; diff --git a/src/lib/elementHistory.test.ts b/src/lib/elementHistory.test.ts new file mode 100644 index 0000000..44933f3 --- /dev/null +++ b/src/lib/elementHistory.test.ts @@ -0,0 +1,35 @@ +import { toTimeSeriesPoints, type ElementHistorySeries } from "./elementHistory"; + +describe("element history display conversion", () => { + it("converts every series through the shared unit utility", () => { + const series: ElementHistorySeries[] = [ + { + element_id: "P-1", + element_type: "pipe", + device_id: null, + metric: "flow", + source: "realtime_simulation", + source_unit: "MLD", + display_unit: "m³/h", + unit_inferred: false, + points: [{ time: "2026-09-01T00:00:00Z", value: 2 }], + }, + { + element_id: "J-1", + element_type: "junction", + device_id: "pressure-1", + metric: "pressure", + source: "scada_cleaned", + source_unit: "KPA", + display_unit: "m", + unit_inferred: false, + points: [{ time: "2026-09-01T00:00:00Z", value: 10 }], + }, + ]; + + const points = toTimeSeriesPoints(series); + expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z"); + expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334); + expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213); + }); +}); diff --git a/src/lib/elementHistory.ts b/src/lib/elementHistory.ts new file mode 100644 index 0000000..52b23ee --- /dev/null +++ b/src/lib/elementHistory.ts @@ -0,0 +1,126 @@ +import config from "@/config/config"; +import { apiFetch } from "@/lib/apiFetch"; +import { toDisplayValue } from "@/utils/units"; + +export type HistoryElementType = "pipe" | "junction"; +export type HistoryMode = + | "observed" + | "realtime_comparison" + | "analysis_comparison"; +export type HistoryMetric = "flow" | "pressure"; +export type HistorySource = + | "scada_raw" + | "scada_cleaned" + | "realtime_simulation" + | "analysis_simulation"; + +export interface ElementHistoryTarget { + element_id: string; + element_type: HistoryElementType; + device_ids?: string[]; +} + +export interface ElementHistorySeries { + element_id: string; + element_type: HistoryElementType; + device_id: string | null; + metric: HistoryMetric; + source: HistorySource; + source_unit: string; + display_unit: string; + unit_inferred: boolean; + points: Array<{ time: string; value: number | null }>; +} + +export interface TimeSeriesPoint { + timestamp: string; + values: Record; +} + +export interface ElementHistoryResult { + points: TimeSeriesPoint[]; + series: ElementHistorySeries[]; +} + +const SOURCE_SUFFIX: Record = { + scada_raw: "raw", + scada_cleaned: "clean", + realtime_simulation: "sim", + analysis_simulation: "scheme_sim", +}; + +export const historySeriesKey = (series: ElementHistorySeries) => + `${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`; + +export const historySourceLabel = (source: HistorySource) => { + switch (source) { + case "scada_raw": + return "原始监测"; + case "scada_cleaned": + return "清洗监测"; + case "realtime_simulation": + return "实时模拟"; + case "analysis_simulation": + return "方案模拟"; + } +}; + +export const historySeriesLabel = (series: ElementHistorySeries) => + `${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`; + +export const toTimeSeriesPoints = ( + seriesList: ElementHistorySeries[], +): TimeSeriesPoint[] => { + const timeMap = new Map>(); + seriesList.forEach((series) => { + const key = historySeriesKey(series); + series.points.forEach((point) => { + const values = timeMap.get(point.time) ?? {}; + values[key] = toDisplayValue( + point.value, + series.metric, + series.source_unit, + ); + timeMap.set(point.time, values); + }); + }); + return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort( + (left, right) => + new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(), + ); +}; + +export const fetchElementHistory = async ( + elements: ElementHistoryTarget[], + range: { from: Date; to: Date }, + mode: HistoryMode, + runId?: string, + signal?: AbortSignal, +): Promise => { + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/timeseries/views/element-history/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + start_time: range.from.toISOString(), + end_time: range.to.toISOString(), + mode, + ...(runId ? { run_id: runId } : {}), + elements, + }), + signal, + }, + ); + if (!response.ok) { + const problem = await response.json().catch(() => null); + const detail = + typeof problem?.detail === "string" + ? problem.detail + : `HTTP ${response.status}`; + throw new Error(`历史数据请求失败: ${detail}`); + } + const payload = (await response.json()) as { series?: ElementHistorySeries[] }; + const series = Array.isArray(payload.series) ? payload.series : []; + return { series, points: toTimeSeriesPoints(series) }; +}; diff --git a/src/utils/units.test.ts b/src/utils/units.test.ts index 0659f94..b7507e9 100644 --- a/src/utils/units.test.ts +++ b/src/utils/units.test.ts @@ -1,4 +1,11 @@ -import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units"; +import { + FLOW_DISPLAY_UNIT, + metricForResultProperty, + networkResultUnitsFromOptions, + toDisplayValue, + toM3h, + toModelDisplayValue, +} from "./units"; describe("flow display units", () => { it("uses cubic meters per hour as the flow display unit", () => { @@ -10,10 +17,38 @@ describe("flow display units", () => { expect(toM3h(10, "L/s")).toBe(36); }); - it("recognizes computed properties that arrive from the backend in L/s", () => { - expect(isLpsFlowProperty("flow")).toBe(true); - expect(isLpsFlowProperty("actual_demand")).toBe(true); - expect(isLpsFlowProperty("actualdemand")).toBe(true); - expect(isLpsFlowProperty("pressure")).toBe(false); + it("centralizes model and SCADA conversion for all UI consumers", () => { + expect(toDisplayValue(2, "flow", "MLD")).toBeCloseTo(83.3333333334); + expect(toDisplayValue(10, "pressure", "KPA")).toBeCloseTo(1.019716213); + expect(toDisplayValue(2, "velocity", "ft/s")).toBeCloseTo(0.6096); + expect(toDisplayValue(36, "flow", "m3/h")).toBe(36); + }); + + it("maps result properties to their physical metrics", () => { + expect(metricForResultProperty("flow")).toBe("flow"); + expect(metricForResultProperty("base_demand")).toBe("flow"); + expect(metricForResultProperty("actual_demand")).toBe("flow"); + expect(metricForResultProperty("pressure")).toBe("pressure"); + expect(metricForResultProperty("velocity")).toBe("velocity"); + expect(metricForResultProperty("headloss")).toBeNull(); + }); + + it("resolves legacy and v3 model options in one place", () => { + expect( + networkResultUnitsFromOptions({ + FLOW_UNITS: "MLD", + PRESSURE_UNITS: "METERS", + }), + ).toEqual({ flow: "MLD", pressure: "METERS", velocity: "m/s" }); + expect(networkResultUnitsFromOptions({ UNITS: "GPM", PRESSURE: "PSI" })) + .toEqual({ flow: "GPM", pressure: "PSI", velocity: "ft/s" }); + }); + + it("converts model properties using project result units", () => { + const units = { flow: "MLD", pressure: "KPA", velocity: "ft/s" }; + expect(toModelDisplayValue(2, "flow", units)).toBeCloseTo(83.3333333334); + expect(toModelDisplayValue(10, "pressure", units)).toBeCloseTo(1.019716213); + expect(toModelDisplayValue(2, "velocity", units)).toBeCloseTo(0.6096); + expect(toModelDisplayValue(3, "headloss", units)).toBe(3); }); }); diff --git a/src/utils/units.ts b/src/utils/units.ts index f90c721..a61cc22 100644 --- a/src/utils/units.ts +++ b/src/utils/units.ts @@ -1,17 +1,132 @@ export const FLOW_DISPLAY_UNIT = "m³/h"; -const M3H_FACTOR = 3600; -const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]); +export const PRESSURE_DISPLAY_UNIT = "m"; +export const VELOCITY_DISPLAY_UNIT = "m/s"; +export type MeasurementMetric = "flow" | "pressure" | "velocity"; +export interface NetworkResultUnits { + flow: string; + pressure: string; + velocity: string; +} -export const isLpsFlowProperty = (property: string) => - LPS_FLOW_PROPERTIES.has(property); +export const DEFAULT_NETWORK_RESULT_UNITS: NetworkResultUnits = { + flow: "LPS", + pressure: "MTR", + velocity: "m/s", +}; + +const M3H_FACTOR = 3600; +const FLOW_PROPERTIES = new Set([ + "flow", + "demand", + "base_demand", + "actual_demand", + "actualdemand", +]); +const PRESSURE_PROPERTIES = new Set(["pressure"]); +const VELOCITY_PROPERTIES = new Set(["velocity"]); +const IMPERIAL_FLOW_UNITS = new Set(["CFS", "GPM", "MGD", "IMGD", "AFD"]); + +export const metricForResultProperty = ( + property: string, +): MeasurementMetric | null => { + const normalizedProperty = property.trim().toLowerCase(); + if (FLOW_PROPERTIES.has(normalizedProperty)) return "flow"; + if (PRESSURE_PROPERTIES.has(normalizedProperty)) return "pressure"; + if (VELOCITY_PROPERTIES.has(normalizedProperty)) return "velocity"; + return null; +}; + +export const networkResultUnitsFromOptions = ( + options: Record, +): NetworkResultUnits => { + const flow = options.FLOW_UNITS || options.UNITS || "LPS"; + const pressure = options.PRESSURE_UNITS || options.PRESSURE || "MTR"; + return { + flow, + pressure, + velocity: IMPERIAL_FLOW_UNITS.has(flow.trim().toUpperCase()) + ? "ft/s" + : "m/s", + }; +}; export const toM3h = (value: number, sourceUnit: string = "m³/s") => { if (!Number.isFinite(value)) return Number.NaN; - const normalizedUnit = sourceUnit.trim().toLowerCase(); - if (normalizedUnit === "m³/h") return value; - if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value * 3.6; - if (normalizedUnit === "m³/s") return value * M3H_FACTOR; - return value * M3H_FACTOR; + const normalizedUnit = sourceUnit.trim().toUpperCase().replace("³", "3"); + const factors: Record = { + CFS: 101.9406477312, + GPM: 0.22712470704, + MGD: 157.725491, + IMGD: 189.4204167, + AFD: 51.39507656, + LPS: 3.6, + "L/S": 3.6, + LPM: 0.06, + MLD: 41.6666666667, + CMH: 1, + "M3/H": 1, + CMD: 1 / 24, + "M3/D": 1 / 24, + "M3/S": M3H_FACTOR, + }; + const factor = factors[normalizedUnit]; + if (factor === undefined) throw new Error(`不支持的流量单位: ${sourceUnit}`); + return value * factor; +}; + +export const toMeters = (value: number, sourceUnit: string = "m") => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toUpperCase(); + const factors: Record = { + METERS: 1, + METRES: 1, + MTR: 1, + M: 1, + MH2O: 1, + "M H2O": 1, + KPA: 0.1019716213, + PSI: 0.7032496149, + }; + const factor = factors[normalizedUnit]; + if (factor === undefined) throw new Error(`不支持的压力单位: ${sourceUnit}`); + return value * factor; +}; + +export const toMetersPerSecond = ( + value: number, + sourceUnit: string = "m/s", +) => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toUpperCase(); + if (["M/S", "MPS"].includes(normalizedUnit)) return value; + if (["FT/S", "FPS"].includes(normalizedUnit)) return value * 0.3048; + throw new Error(`不支持的流速单位: ${sourceUnit}`); +}; + +export const toDisplayValue = ( + value: number | null | undefined, + metric: MeasurementMetric, + sourceUnit: string, +) => { + if (value === null || value === undefined) return null; + switch (metric) { + case "flow": + return toM3h(value, sourceUnit); + case "pressure": + return toMeters(value, sourceUnit); + case "velocity": + return toMetersPerSecond(value, sourceUnit); + } +}; + +export const toModelDisplayValue = ( + value: number, + property: string, + units: NetworkResultUnits, +) => { + const metric = metricForResultProperty(property); + if (!metric) return value; + return toDisplayValue(value, metric, units[metric])!; }; export const toM3s = (value: number, sourceUnit: string = "m³/h") => {