纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」iAOP-cockpit-layout-v1: - cockpit.ti.yaml:海绵钛四状态工艺流程(氯化→精制→还原→蒸馏), process_view 主视图声明 stages 覆盖,trend/kpi_card bind 对齐 point_dict.default.csv 的 point_id(CLF-01/RF-01/E-01/ST-01)。 - layout_validator.py:LayoutValidator 校验 widget 类型合法、12 列网格 不越界、bind point_id 在点位字典内、四状态覆盖完整(order 单调/id 唯一); 零依赖 YAML 子集解析(复制 impurity-forecast _parse_yaml_subset)。 - tests:18 用例覆盖真实资产端到端、非法类型、网格越界/负坐标/零宽、 bind 漂移、缺 process_view、缺必需状态、order 非单调、stage 重复、 $schema 头、CSV 加载、空布局。 - _sanity_check.py:冒烟验证 9 widgets 全校验通过。
501 lines
20 KiB
Python
501 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""海绵钛驾驶舱布局校验器(Issue #55 / PRD 5.5「⑤ 配置化驾驶舱」)。
|
||
|
||
PRD 5.5:切换行业模板后,驾驶舱按布局资产自动重排,无需改前端代码。
|
||
本模块把布局资产(``cockpit.ti.yaml``)落为**可校验的纯标准库资产 + 校验器**——
|
||
给定布局 YAML + 点位字典 CSV,校验:
|
||
|
||
1. **widget 类型合法**:在 PRD 5.5 ``iAOP-cockpit-layout-v1`` 允许集合内
|
||
(process_view/trend/kpi_card/alarm_panel/nl_query)。
|
||
2. **12 列网格不越界**:每个 widget ``0 ≤ x`` 且 ``x + w ≤ 12``,``y ≥ 0``、
|
||
``h > 0``;坐标为非负整数,w/h 正整数(网格对齐)。
|
||
3. **bind 的 point_id 在点位字典内**:trend/kpi_card 的 ``bind`` 必须命中
|
||
``point_dict.default.csv`` 的 ``point_id`` 列(防模板漂移)。
|
||
4. **四状态视图覆盖完整**:process_view 的 ``stages`` 必须覆盖工艺全流程
|
||
(氯化/精制/还原/蒸馏),且 order 单调递增、id 唯一。
|
||
|
||
校验产出 :class:`LayoutReport`(PASS/FAIL + 逐条 :class:`LayoutIssue`,
|
||
每条 issue 带 ``reason`` 可解释)。
|
||
|
||
设计要点
|
||
--------
|
||
- **零依赖 YAML 子集解析**:复制 impurity-forecast features.py 的
|
||
``_parse_yaml_subset``(无 pyyaml),支持 map/list/标量/行内 flow map。
|
||
- **纯标准库**:CSV 用标准库 csv,无 numpy/pyyaml 依赖。
|
||
- **换行业只改资产**:校验器对任何对齐 ``iAOP-cockpit-layout-v1`` 的布局都适用。
|
||
|
||
用法::
|
||
|
||
report = LayoutValidator(layout_yaml, point_dict_csv).validate()
|
||
if not report.passed:
|
||
for issue in report.issues:
|
||
print(issue.severity, issue.widget_id, issue.reason)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import os
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
#: iAOP-cockpit-layout-v1 允许的 widget 类型集合(对齐 resin _sanity_check)。
|
||
ALLOWED_WIDGET_TYPES = frozenset({
|
||
"process_view", "trend", "kpi_card", "alarm_panel", "nl_query",
|
||
})
|
||
|
||
#: 12 列网格(主流前端栅格标准,对齐 cockpit layout v1)。
|
||
GRID_COLUMNS = 12
|
||
|
||
#: 海绵钛四状态工艺流程(PRD 4.2:氯化 → 精制 → 还原 → 蒸馏)。
|
||
#: process_view 的 stages 必须覆盖这四个 id。
|
||
REQUIRED_STAGES = ("chlorination", "purification", "reduction", "distillation")
|
||
|
||
|
||
class LayoutError(ValueError):
|
||
"""布局资产解析/声明错误(YAML 格式错、表头缺字段等)。"""
|
||
|
||
|
||
class Severity(str, Enum):
|
||
"""问题严重度。"""
|
||
|
||
ERROR = "error" # 阻断:布局不可用(类型非法/越界/bind 缺失/状态缺失)
|
||
WARN = "warn" # 告警:可运行但不规范(重复/顺序乱)
|
||
|
||
|
||
@dataclass
|
||
class LayoutIssue:
|
||
"""单条布局校验问题(含 reason 可解释)。"""
|
||
|
||
severity: Severity
|
||
reason: str
|
||
widget_id: str = "" # 关联 widget(index 或 src/metric)
|
||
field: str = "" # 关联字段(type/x/bind/stages ...)
|
||
|
||
@property
|
||
def is_error(self) -> bool:
|
||
return self.severity is Severity.ERROR
|
||
|
||
|
||
@dataclass
|
||
class LayoutReport:
|
||
"""布局校验报告。"""
|
||
|
||
issues: List[LayoutIssue] = field(default_factory=list)
|
||
widget_count: int = 0
|
||
|
||
@property
|
||
def errors(self) -> List[LayoutIssue]:
|
||
return [i for i in self.issues if i.is_error]
|
||
|
||
@property
|
||
def passed(self) -> bool:
|
||
"""通过 = 无 ERROR(WARN 不阻断)。"""
|
||
return not any(i.is_error for i in self.issues)
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"passed": self.passed,
|
||
"widget_count": self.widget_count,
|
||
"error_count": len(self.errors),
|
||
"warn_count": len(self.issues) - len(self.errors),
|
||
"issues": [
|
||
{"severity": i.severity.value, "widget_id": i.widget_id,
|
||
"field": i.field, "reason": i.reason}
|
||
for i in self.issues
|
||
],
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class WidgetSpec:
|
||
"""单个 widget 的内存模型(从 YAML 解析)。"""
|
||
|
||
index: int # 在 widgets 列表中的位置(0 起)
|
||
type: str
|
||
x: int = 0
|
||
y: int = 0
|
||
w: int = 1
|
||
h: int = 1
|
||
bind: str = "" # trend/kpi_card 绑定的 point_id
|
||
src: str = "" # process_view 的 SVG
|
||
metric: str = "" # kpi_card 的 metric
|
||
label: str = ""
|
||
description: str = ""
|
||
stages: List[Dict[str, object]] = field(default_factory=list)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 零依赖 YAML 子集解析(复制自 impurity-forecast features.py,对齐 data-bus)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _parse_scalar(text: str) -> str:
|
||
"""去掉标量两侧引号与行内注释。"""
|
||
t = text.split(" #", 1)[0].strip()
|
||
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
|
||
return t[1:-1]
|
||
return t
|
||
|
||
|
||
def _parse_flow_value(text: str):
|
||
"""解析 ``key: value`` 右侧值,支持行内 flow map ``{k: v, k: v}``。"""
|
||
t = text.split(" #", 1)[0].strip()
|
||
if t.startswith("{") and t.endswith("}"):
|
||
inner = t[1:-1].strip()
|
||
out: Dict[str, object] = {}
|
||
if not inner:
|
||
return out
|
||
for part in inner.split(","):
|
||
if ":" not in part:
|
||
raise LayoutError(f"flow map 项不是键值对:{part!r}")
|
||
k, _, v = part.partition(":")
|
||
out[k.strip()] = _parse_scalar(v)
|
||
return out
|
||
return _parse_scalar(text)
|
||
|
||
|
||
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
|
||
out: List[Tuple[str, int]] = []
|
||
for i, ln in enumerate(lines):
|
||
s = ln.strip()
|
||
if not s or s.startswith("#"):
|
||
continue
|
||
out.append((ln, i + 1))
|
||
return out
|
||
|
||
|
||
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
|
||
"""递归解析 YAML 节点(map / list / scalar)。返回 (value, next_i)。"""
|
||
text, _ = lines[i]
|
||
# ---- list 节点 ----
|
||
if text.lstrip(" ").startswith("- "):
|
||
items: List[object] = []
|
||
while i < len(lines):
|
||
t, no = lines[i]
|
||
stripped = t.lstrip(" ")
|
||
if not stripped.startswith("- "):
|
||
break
|
||
lead_j = len(t) - len(t.lstrip(" "))
|
||
if lead_j != indent:
|
||
break
|
||
item_text = stripped[2:].strip()
|
||
if not item_text:
|
||
raise LayoutError(f"cockpit.yaml 第 {no} 行:list 项为空")
|
||
if ":" in item_text:
|
||
map_indent = len(t) - len(t.lstrip(" ")) + 2
|
||
lines[i] = (" " * map_indent + item_text, no)
|
||
v, i = _parse_node(lines, i, map_indent)
|
||
items.append(v)
|
||
else:
|
||
items.append(_parse_flow_value(item_text))
|
||
i += 1
|
||
return items, i
|
||
# ---- map 节点 ----
|
||
result: Dict[str, object] = {}
|
||
while i < len(lines):
|
||
t, no = lines[i]
|
||
lead_j = len(t) - len(t.lstrip(" "))
|
||
if lead_j < indent or t.lstrip(" ").startswith("- "):
|
||
break
|
||
if lead_j > indent:
|
||
raise LayoutError(
|
||
f"cockpit.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})")
|
||
if ":" not in t:
|
||
raise LayoutError(f"cockpit.yaml 第 {no} 行不是合法键值对:{t!r}")
|
||
key, _, rest = t.partition(":")
|
||
key = key.strip()
|
||
rest = rest.strip()
|
||
if rest:
|
||
result[key] = _parse_flow_value(rest)
|
||
i += 1
|
||
continue
|
||
if i + 1 >= len(lines):
|
||
raise LayoutError(f"cockpit.yaml 第 {no} 行 {key!r} 缺少值")
|
||
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
|
||
if sub_indent <= indent:
|
||
raise LayoutError(f"cockpit.yaml 第 {no} 行 {key!r} 缺少值(无嵌套)")
|
||
v, i = _parse_node(lines, i + 1, sub_indent)
|
||
result[key] = v
|
||
return result, i
|
||
|
||
|
||
def _load_yaml_text(text: str) -> Dict[str, object]:
|
||
"""解析 YAML 文本为 dict(顶层必须是 map)。"""
|
||
lines = _strip_comments(text.splitlines())
|
||
if not lines:
|
||
return {}
|
||
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
|
||
value, next_i = _parse_node(lines, 0, top_indent)
|
||
if not isinstance(value, dict):
|
||
raise LayoutError("cockpit.yaml 顶层必须是 map")
|
||
if next_i < len(lines):
|
||
raise LayoutError(
|
||
f"cockpit.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点")
|
||
return value
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 点位字典加载(CSV → point_id 集合)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_point_ids(csv_path: str) -> List[str]:
|
||
"""从点位字典 CSV 加载全部 point_id(保序,对齐 CSV point_id 列)。
|
||
|
||
CSV 表头对齐 core/edge-gateway point_dict schema(第二列 point_id)。
|
||
"""
|
||
if not os.path.isfile(csv_path):
|
||
raise LayoutError(f"点位字典 CSV 不存在:{csv_path}")
|
||
with open(csv_path, "r", encoding="utf-8") as fh:
|
||
rows = list(csv.reader(fh))
|
||
if not rows:
|
||
raise LayoutError(f"点位字典 CSV 为空:{csv_path}")
|
||
header = [c.strip() for c in rows[0]]
|
||
if "point_id" not in header:
|
||
raise LayoutError(
|
||
f"点位字典 CSV 表头缺 point_id 列:{header}")
|
||
col = header.index("point_id")
|
||
ids: List[str] = []
|
||
for i, row in enumerate(rows[1:], 2):
|
||
if len(row) <= col:
|
||
continue
|
||
pid = row[col].strip()
|
||
if pid:
|
||
ids.append(pid)
|
||
if not ids:
|
||
raise LayoutError(f"点位字典 CSV 无 point_id 数据行:{csv_path}")
|
||
return ids
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 校验器
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class LayoutValidator:
|
||
"""海绵钛驾驶舱布局校验器。
|
||
|
||
Args:
|
||
layout_yaml_path: 布局资产路径(cockpit.ti.yaml)。
|
||
point_dict_csv_path: 点位字典 CSV 路径(point_dict.default.csv)。
|
||
grid_columns: 网格列数(默认 12,对齐 cockpit layout v1)。
|
||
required_stages: process_view 必须覆盖的 stage id(默认海绵钛四状态)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
layout_yaml_path: str,
|
||
point_dict_csv_path: Optional[str] = None,
|
||
grid_columns: int = GRID_COLUMNS,
|
||
required_stages: Tuple[str, ...] = REQUIRED_STAGES,
|
||
) -> None:
|
||
if grid_columns <= 0:
|
||
raise LayoutError(f"grid_columns 必须 > 0,实际 {grid_columns}")
|
||
self.layout_path = layout_yaml_path
|
||
self.point_dict_path = point_dict_csv_path
|
||
self.grid_columns = int(grid_columns)
|
||
self.required_stages = tuple(required_stages)
|
||
|
||
# ------------------------------------------------------------------
|
||
def validate(self) -> LayoutReport:
|
||
"""执行全部校验,返回报告。"""
|
||
report = LayoutReport()
|
||
# 1) 解析布局 YAML
|
||
try:
|
||
with open(self.layout_path, "r", encoding="utf-8") as fh:
|
||
data = _load_yaml_text(fh.read())
|
||
except LayoutError:
|
||
raise
|
||
except OSError as exc:
|
||
raise LayoutError(f"布局 YAML 读取失败:{self.layout_path} ({exc})") from exc
|
||
|
||
# schema 头校验
|
||
schema = str(data.get("$schema", "")).strip()
|
||
if schema != "iAOP-cockpit-layout-v1":
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR,
|
||
field="$schema",
|
||
reason=f"$schema 应为 'iAOP-cockpit-layout-v1',实际 {schema!r}",
|
||
))
|
||
|
||
# 2) 解析 widgets
|
||
raw_widgets = data.get("widgets") or []
|
||
if not isinstance(raw_widgets, list):
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, field="widgets",
|
||
reason=f"widgets 必须是 list,实际 {type(raw_widgets).__name__}"))
|
||
return report
|
||
widgets = self._parse_widgets(raw_widgets, report)
|
||
report.widget_count = len(widgets)
|
||
|
||
if not widgets:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, field="widgets",
|
||
reason="布局无任何 widget"))
|
||
return report
|
||
|
||
# 3) 加载点位字典(bind 校验需要)
|
||
point_ids: Optional[set] = None
|
||
if self.point_dict_path:
|
||
try:
|
||
point_ids = set(load_point_ids(self.point_dict_path))
|
||
except LayoutError as exc:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, field="point_dict",
|
||
reason=str(exc)))
|
||
|
||
# 4) 逐 widget 校验
|
||
for w in widgets:
|
||
self._check_widget(w, point_ids, report)
|
||
|
||
# 5) process_view 四状态覆盖
|
||
self._check_process_views(widgets, report)
|
||
|
||
return report
|
||
|
||
# ------------------------------------------------------------------
|
||
def _parse_widgets(self, raw_widgets: List[object],
|
||
report: LayoutReport) -> List[WidgetSpec]:
|
||
widgets: List[WidgetSpec] = []
|
||
for idx, item in enumerate(raw_widgets):
|
||
if not isinstance(item, dict):
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=f"[{idx}]",
|
||
field="widgets",
|
||
reason=f"widgets[{idx}] 必须是 map,实际 {type(item).__name__}"))
|
||
continue
|
||
wtype = str(item.get("type", "")).strip()
|
||
widgets.append(WidgetSpec(
|
||
index=idx,
|
||
type=wtype,
|
||
x=_to_int(item.get("x"), 0),
|
||
y=_to_int(item.get("y"), 0),
|
||
w=_to_int(item.get("w"), 1),
|
||
h=_to_int(item.get("h"), 1),
|
||
bind=str(item.get("bind", "")).strip(),
|
||
src=str(item.get("src", "")).strip(),
|
||
metric=str(item.get("metric", "")).strip(),
|
||
label=str(item.get("label", "")).strip(),
|
||
description=str(item.get("description", "")).strip(),
|
||
stages=_as_list_of_dict(item.get("stages")),
|
||
))
|
||
return widgets
|
||
|
||
# ------------------------------------------------------------------
|
||
def _check_widget(self, w: WidgetSpec, point_ids: Optional[set],
|
||
report: LayoutReport) -> None:
|
||
wid = f"[{w.index}]({w.type})"
|
||
# 4a) widget 类型合法
|
||
if w.type not in ALLOWED_WIDGET_TYPES:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="type",
|
||
reason=f"非法 widget 类型 {w.type!r}(允许 {sorted(ALLOWED_WIDGET_TYPES)})"))
|
||
|
||
# 4b) 12 列网格不越界(坐标非负整数、x+w ≤ columns、h>0)
|
||
if w.x < 0 or w.y < 0:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="grid",
|
||
reason=f"坐标不能为负:x={w.x} y={w.y}"))
|
||
if w.w <= 0 or w.h <= 0:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="grid",
|
||
reason=f"w/h 必须为正整数:w={w.w} h={w.h}"))
|
||
if w.x + w.w > self.grid_columns:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="grid",
|
||
reason=f"越出 {self.grid_columns} 列网格:x={w.x}+w={w.w}"
|
||
f"={w.x + w.w} > {self.grid_columns}"))
|
||
|
||
# 4c) bind 的 point_id 必须在点位字典内(trend/kpi_card)
|
||
if w.bind:
|
||
if point_ids is not None and w.bind not in point_ids:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="bind",
|
||
reason=f"bind point_id {w.bind!r} 不在点位字典内"
|
||
f"(防模板漂移,对齐 point_dict.default.csv)"))
|
||
|
||
# ------------------------------------------------------------------
|
||
def _check_process_views(self, widgets: List[WidgetSpec],
|
||
report: LayoutReport) -> None:
|
||
"""校验 process_view 的四状态覆盖完整。"""
|
||
pv = [w for w in widgets if w.type == "process_view"]
|
||
if not pv:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, field="process_view",
|
||
reason="布局缺少 process_view(四状态工艺流程主视图必需)"))
|
||
return
|
||
|
||
covered: Dict[str, WidgetSpec] = {} # stage_id → widget
|
||
for w in pv:
|
||
wid = f"[{w.index}](process_view)"
|
||
if not w.stages:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||
reason="process_view 缺少 stages 声明(四状态覆盖必需)"))
|
||
continue
|
||
|
||
stage_ids: List[str] = []
|
||
orders: List[int] = []
|
||
seen: set = set()
|
||
for st in w.stages:
|
||
sid = str(st.get("id", "")).strip()
|
||
sname = str(st.get("name", "")).strip()
|
||
if not sid:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||
reason=f"stage 缺少 id(name={sname!r})"))
|
||
continue
|
||
if sid in seen:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.WARN, widget_id=wid, field="stages",
|
||
reason=f"stage id 重复:{sid!r}"))
|
||
continue
|
||
seen.add(sid)
|
||
stage_ids.append(sid)
|
||
covered.setdefault(sid, w)
|
||
order = st.get("order")
|
||
if order is not None:
|
||
try:
|
||
orders.append(int(order))
|
||
except (TypeError, ValueError):
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||
reason=f"stage {sid!r} order 非整数:{order!r}"))
|
||
|
||
# order 单调递增校验
|
||
if orders and len(orders) == len(stage_ids):
|
||
if orders != sorted(orders):
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||
reason=f"stage order 非单调递增:{orders}"))
|
||
|
||
# 必需四状态全覆盖
|
||
missing = [s for s in self.required_stages if s not in covered]
|
||
if missing:
|
||
report.issues.append(LayoutIssue(
|
||
severity=Severity.ERROR, field="stages",
|
||
reason=f"process_view stages 未覆盖必需四状态:{missing}"
|
||
f"(氯化/精制/还原/蒸馏)"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _to_int(value: object, default: int) -> int:
|
||
"""把 YAML 解析出的值(可能是 str/int)转为 int;失败返回 default。"""
|
||
if value is None or value == "":
|
||
return default
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
raise LayoutError(f"坐标值不是整数:{value!r}")
|
||
|
||
|
||
def _as_list_of_dict(value: object) -> List[Dict[str, object]]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
out: List[Dict[str, object]] = []
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
out.append(item)
|
||
return out
|