Files
iAOP/core/template-console/point_importer.py
T

319 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""⑤.7 点位字典 CSV 导入 + 自动校验页面 —— issue #63 / PRD ⑤.7。
配置台的"导入页面"要解决:实施工程师拿着 DCS 点表(Excel 转 CSV)粘进配置台,
**一次性看到所有问题**(表头错/量纲错/重复点号/采样率非正/协议非法/OPC 节点
格式错),而不是改一条报一条。本模块是导入页面的后端引擎。
**复用而非重造**:点位字典的 schema/加载/校验(量纲/数据类型/采样率/重复点号/
协议)已由内核 ``core/edge-gateway/point_dict``(loader/validator/schema)实现
并被边缘网关正式使用。本模块在其基础上增加**配置台专属**校验维度:
1. OPC 节点格式校验(OPC UA 节点须形如 ``ns=<数字>;s=<名>`` 或 PLC 寄存器
``holding:<数字>`` / ``coil:<数字>``,与 simulator/opcua 驱动约定一致);
2. 表头列顺序严格对齐(实施工程师照表填列,列序错位是高频错误);
3. 行级结果聚合为 ``ImportRowIssue``(行号 + 严重级别 + 问题 + 修复建议),
供配置台前端逐行渲染、按严重级别过滤;
4. 模板选择(resin/ti):不同行业模板的合法量纲集合不同(如树脂含 rpm/mmol·g⁻¹),
导入时按模板切换校验基线。
零运行时依赖:复用 ``point_dict`` 子包(纯标准库 csv/dataclass)。
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple
# 复用内核 edge-gateway 的点位字典加载/校验(schema/validator/loader)
# 在 tests/_bootstrap.py 中已把 core/edge-gateway 加入 sys.path,
# 故此处以顶层包 point_dict 引用(与 edge-gateway 自身测试一致)。
from point_dict import ( # noqa: E402
CSV_HEADERS,
VALID_PROTOCOLS,
VALID_UNITS,
Point,
PointDict,
load_point_dict_csv,
)
from point_dict import schema as _pd_schema # noqa: E402
class Severity(str, Enum):
"""问题严重级别(配置台前端据此着色/过滤)。"""
ERROR = "error" # 阻断:不修复无法入库
WARN = "warn" # 警告:可入库但建议复核(如 OPC 节点为空)
class TemplateKind(str, Enum):
"""行业模板(决定合法量纲等校验基线)。"""
TI = "ti" # 氯化/化工通用(templates/ti-cl4)
RESIN = "resin" # 吸附树脂(templates/resin,含 rpm / mmol/g)
# OPC 节点格式(与 simulator/opcua/s7 驱动约定一致):
# ns=2;s=CLF.Temp —— OPC UA 节点(namespace + 字符串 id)
# holding:40010 / coil:1 —— Modbus 寄存器(保持/线圈 + 地址)
_OPC_UA_RE = re.compile(r"^ns=\d+;s=[^\s,]+$")
_MODBUS_RE = re.compile(r"^(holding|coil|input|discrete):(\d+)$")
def _unit_set(template: TemplateKind) -> set:
"""按模板返回合法量纲集合(树脂含 rpm/mmol·g⁻¹ 等扩展)。"""
base = set(VALID_UNITS)
if template == TemplateKind.RESIN:
# VALID_UNITS 已含树脂扩展(rpm/mmol/g),直接复用
return base
# ti 模板:移除树脂专属量纲,避免化工模板误用树脂量纲
base.discard("rpm")
base.discard("mmol/g")
return base
@dataclass
class ImportRowIssue:
"""导入页一行的问题(行号 + 严重级别 + 问题 + 修复建议,可解释)。"""
row: int # CSV 行号(表头=1,数据从 2 起)
severity: Severity
code: str # 错误码(对齐 point_dict.validator 的 code + 本模块扩展)
message: str # 问题描述
suggestion: str = "" # 修复建议(供配置台"一键修复"提示)
def to_dict(self) -> dict:
return {
"row": self.row, "severity": self.severity.value,
"code": self.code, "message": self.message,
"suggestion": self.suggestion,
}
@dataclass
class ImportReport:
"""导入校验报告(配置台导入页面数据模型)。"""
template: TemplateKind
total_rows: int = 0 # 数据行数
issues: List[ImportRowIssue] = field(default_factory=list)
loaded_points: int = 0 # 成功加载的点数
file_path: str = ""
@property
def ok(self) -> bool:
"""无 ERROR 级问题即可入库(WARN 不阻断)。"""
return not any(i.severity == Severity.ERROR for i in self.issues)
@property
def error_count(self) -> int:
return sum(1 for i in self.issues if i.severity == Severity.ERROR)
@property
def warn_count(self) -> int:
return sum(1 for i in self.issues if i.severity == Severity.WARN)
def summary(self) -> str:
"""人类可读汇总(配置台导入结果横幅)。"""
status = "通过" if self.ok else "未通过"
return (f"导入校验{status}:{self.loaded_points} 点 / "
f"{self.total_rows} 行,错误 {self.error_count},警告 {self.warn_count}")
def to_dict(self) -> dict:
return {
"template": self.template.value,
"total_rows": self.total_rows,
"loaded_points": self.loaded_points,
"ok": self.ok,
"error_count": self.error_count,
"warn_count": self.warn_count,
"summary": self.summary(),
"issues": [i.to_dict() for i in self.issues],
}
# ---------------------------------------------------------------------------
# 校验扩展
# ---------------------------------------------------------------------------
def _validate_opc_node(point: Point) -> List[ImportRowIssue]:
"""OPC 节点格式校验(配置台扩展维度)。
约定(与驱动注册表对齐):
- 协议 opcua:节点须匹配 ``ns=<数字>;s=<名>``;
- 协议 modbus:节点须匹配 ``holding/coil/input/discrete:<数字>``;
- 协议 simulator:节点可空,或任意上述格式(演示用,宽松);
- 节点为空:WARN(可入库但运行时无法采集,建议补全)。
"""
out: List[ImportRowIssue] = []
node = (point.opc_node or "").strip()
if not node:
out.append(ImportRowIssue(
row=point.row_number, severity=Severity.WARN, code="empty_opc_node",
message=f"第{point.row_number}行 opcNode 为空",
suggestion="运行时无法采集,建议补全 OPC UA 节点或 PLC 寄存器地址",
))
return out
proto = (point.protocol or "").lower()
ok_ua = bool(_OPC_UA_RE.match(node))
ok_mb = bool(_MODBUS_RE.match(node))
if proto == "opcua" and not ok_ua:
out.append(ImportRowIssue(
row=point.row_number, severity=Severity.ERROR, code="bad_opc_node",
message=(f"第{point.row_number}行 opcNode '{node}' 不符合 OPC UA "
f"格式 ns=<ns>;s=<name>"),
suggestion="示例:ns=2;s=CLF.Temp",
))
elif proto == "modbus" and not ok_mb:
out.append(ImportRowIssue(
row=point.row_number, severity=Severity.ERROR, code="bad_opc_node",
message=(f"第{point.row_number}行 opcNode '{node}' 不符合 Modbus "
f"格式 holding/coil/input/discrete:<addr>"),
suggestion="示例:holding:40010",
))
elif proto not in ("opcua", "modbus") and not (ok_ua or ok_mb):
# simulator/s7/... 节点为空已 WARN;非空但格式都不符则 WARN(宽松)
out.append(ImportRowIssue(
row=point.row_number, severity=Severity.WARN, code="bad_opc_node",
message=(f"第{point.row_number}行 opcNode '{node}' 既非 OPC UA 也非 "
f"Modbus 格式"),
suggestion="确认节点格式或清空(演示协议可空)",
))
return out
def _validate_header_order(headers: List[str]) -> List[ImportRowIssue]:
"""表头列顺序严格对齐(列序错位是实施工程师高频错误)。"""
out: List[ImportRowIssue] = []
if not headers:
out.append(ImportRowIssue(
row=1, severity=Severity.ERROR, code="empty_header",
message="CSV 缺少表头行",
suggestion=f"表头应为:{','.join(CSV_HEADERS)}",
))
return out
missing = [h for h in CSV_HEADERS if h not in headers]
for h in missing:
out.append(ImportRowIssue(
row=1, severity=Severity.ERROR, code="missing_column",
message=f"表头缺少必填列:{h}",
suggestion=f"补列 {h}(完整表头:{','.join(CSV_HEADERS)})",
))
if headers[: len(CSV_HEADERS)] != CSV_HEADERS and not missing:
out.append(ImportRowIssue(
row=1, severity=Severity.WARN, code="bad_column_order",
message=f"表头列顺序与标准不一致:{headers}",
suggestion=f"标准顺序:{','.join(CSV_HEADERS)}",
))
return out
def _convert_validator_issues(report: "object", severity_for: Dict[str, Severity]) -> List[ImportRowIssue]:
"""把内核 validator.ValidationReport.issues 转成 ImportRowIssue。"""
out: List[ImportRowIssue] = []
for it in getattr(report, "issues", []):
sev = severity_for.get(it.code, Severity.ERROR)
out.append(ImportRowIssue(
row=it.row, severity=sev, code=it.code, message=it.message,
))
return out
# 内核 validator 错误码 → 严重级别映射
_SEVERITY_MAP: Dict[str, Severity] = {
"missing_column": Severity.ERROR,
"missing_field": Severity.ERROR,
"bad_unit": Severity.ERROR,
"bad_data_type": Severity.ERROR,
"bad_sample_rate": Severity.ERROR,
"bad_protocol": Severity.ERROR,
"dup_point": Severity.ERROR,
}
# ---------------------------------------------------------------------------
# 导入入口
# ---------------------------------------------------------------------------
def import_csv(
path: str,
template: TemplateKind = TemplateKind.TI,
extra_unit_check: bool = True,
) -> Tuple[PointDict, ImportReport]:
"""导入并校验点位字典 CSV(配置台导入页面后端入口)。
Args:
path: CSV 文件路径(UTF-8,9 列表头);
template: 行业模板(决定合法量纲集合,resin/ti);
extra_unit_check: 是否按模板收窄量纲集合做额外校验。
Returns:
(PointDict, ImportReport):加载的点位模型 + 校验报告。
报告 ``ok`` 为 True 即可入库;WARN 不阻断。
"""
report = ImportReport(template=template, file_path=path)
# 1) 表头校验(先读表头行)
import csv as _csv
with open(path, "r", encoding="utf-8-sig") as fh:
reader = _csv.reader(fh)
rows = list(reader)
headers = [c.strip() for c in rows[0]] if rows else []
report.issues.extend(_validate_header_order(headers))
# 2) 加载 + 内核校验(量纲/数据类型/采样率/重复点号/协议)
point_dict = load_point_dict_csv(path)
report.loaded_points = len(point_dict)
report.total_rows = len(point_dict.points)
from point_dict.validator import validate_point_dict
kernel_report = validate_point_dict(point_dict, headers)
report.issues.extend(_convert_validator_issues(kernel_report, _SEVERITY_MAP))
# 3) 模板级量纲收窄(resin 才允许 rpm/mmol·g⁻¹)
if extra_unit_check:
allowed_units = _unit_set(template)
for p in point_dict.points:
if p.unit and p.unit not in allowed_units:
# 内核 validator 已按全集校验过;这里只补充模板级差异提示
if p.unit in ("rpm", "mmol/g") and template == TemplateKind.TI:
report.issues.append(ImportRowIssue(
row=p.row_number, severity=Severity.ERROR,
code="template_unit_mismatch",
message=(f"第{p.row_number}行 量纲 '{p.unit}' 为树脂模板专属,"
f"当前导入的是 {template.value} 模板"),
suggestion="切换模板为 resin,或修正量纲",
))
# 4) OPC 节点格式校验(配置台扩展)
for p in point_dict.points:
report.issues.extend(_validate_opc_node(p))
# 行号排序,便于配置台逐行展示
report.issues.sort(key=lambda i: (i.row, i.code))
return point_dict, report
def import_csv_string(
content: str,
template: TemplateKind = TemplateKind.TI,
encoding: str = "utf-8",
) -> Tuple[PointDict, ImportReport]:
"""从 CSV 文本导入(配置台粘贴框场景,落临时文件后复用 import_csv)。"""
import tempfile
tmp = tempfile.NamedTemporaryFile(
mode="w", encoding=encoding, suffix=".csv", delete=False)
try:
tmp.write(content)
tmp.flush()
tmp.close()
return import_csv(tmp.name, template=template)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass