|
|
@@ -0,0 +1,666 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+r"""交付文档图表构建器(需求分析 / 设计说明 / 数据要求说明 三份 docx 的插图)。
|
|
|
+
|
|
|
+## 为什么单开一个器
|
|
|
+三份交付文档要求"文表图并茂"。图里出现的每个数字都必须能追到仓库里的真件,所以图**不手画**:
|
|
|
+本器从 `src/version.py` 的 HISTORY、`configs/portal_pages.yaml`、`configs/serve.json`、
|
|
|
+`data/raw/<场>/**`、`outputs/<场>/**` 与 `guanlan.py check` / `rebuild_all.py --dry-run` 的**实跑输出**
|
|
|
+里取数,落到 `docs/figures/*.png`。纯示意图(架构/链路/时序)也在图注里写明"依据"。
|
|
|
+
|
|
|
+## 落点与口径
|
|
|
+ docs/figures/fig-req-01..04 / fig-des-01..06 / fig-dat-01..04 .png
|
|
|
+ docs/src/_guanlan_check.txt (验收门实跑原文,图的取证底稿)
|
|
|
+ docs/src/_rebuild_plan.txt (重算链 dry-run 原文,图的取证底稿)
|
|
|
+
|
|
|
+## 用法
|
|
|
+ python scripts/delivery_docs_figures.py # 全画
|
|
|
+ python scripts/delivery_docs_figures.py --only req # 只画某一组 (req/des/dat)
|
|
|
+ python scripts/delivery_docs_figures.py --check # 只报缺哪些图
|
|
|
+
|
|
|
+退出码: 0 全部生成 · 5 有图没生成(源件缺或绘图失败,逐条打印原因)
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import json
|
|
|
+import pathlib
|
|
|
+import re
|
|
|
+import subprocess
|
|
|
+import sys
|
|
|
+import time
|
|
|
+
|
|
|
+ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
|
+sys.path.insert(0, str(ROOT))
|
|
|
+
|
|
|
+import matplotlib # noqa: E402
|
|
|
+matplotlib.use('Agg')
|
|
|
+import matplotlib.pyplot as plt # noqa: E402
|
|
|
+from matplotlib.patches import FancyArrowPatch, FancyBboxPatch # noqa: E402
|
|
|
+
|
|
|
+from src import paths as P # noqa: E402
|
|
|
+
|
|
|
+FIG_DIR = ROOT / 'docs' / 'figures'
|
|
|
+SRC_DIR = ROOT / 'docs' / 'src'
|
|
|
+CJK = 'Microsoft YaHei'
|
|
|
+plt.rcParams['font.sans-serif'] = [CJK, 'SimHei']
|
|
|
+plt.rcParams['axes.unicode_minus'] = False
|
|
|
+plt.rcParams['figure.dpi'] = 200
|
|
|
+
|
|
|
+WARN: list[str] = []
|
|
|
+VERSION = __import__('importlib').import_module('src.version').VERSION
|
|
|
+
|
|
|
+
|
|
|
+# ── 通用绘图元件 ────────────────────────────────────────────────────────────────
|
|
|
+def canvas(w: float, h: float):
|
|
|
+ fig, ax = plt.subplots(figsize=(w, h))
|
|
|
+ ax.set_xlim(0, 100)
|
|
|
+ ax.set_ylim(0, 100)
|
|
|
+ ax.axis('off')
|
|
|
+ return fig, ax
|
|
|
+
|
|
|
+
|
|
|
+def box(ax, x, y, w, h, text, fc='#eef3fb', ec='#3b6ea5', fs=10, bold=False, align='center'):
|
|
|
+ ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0.6,rounding_size=1.4',
|
|
|
+ linewidth=1.2, edgecolor=ec, facecolor=fc))
|
|
|
+ ax.text(x + w / 2, y + h / 2, text, ha='center', va='center', fontsize=fs,
|
|
|
+ wrap=True, fontweight='bold' if bold else 'normal', linespacing=1.5)
|
|
|
+
|
|
|
+
|
|
|
+def arrow(ax, p, q, color='#555555', style='-|>', lw=1.3, rad=0.0):
|
|
|
+ ax.add_patch(FancyArrowPatch(p, q, arrowstyle=style, mutation_scale=12,
|
|
|
+ linewidth=lw, color=color,
|
|
|
+ connectionstyle=f'arc3,rad={rad}'))
|
|
|
+
|
|
|
+
|
|
|
+def save(fig, name: str):
|
|
|
+ """落盘前统一做一次**字形兜底**:把 YaHei 缺字的符号换成等价可显字符。
|
|
|
+
|
|
|
+ 实逮(2026-09-22): 图里引用的原文(如 `configs/portal_pages.yaml` 的说明)含 `⇒`(U+21D2),
|
|
|
+ matplotlib 报 `Glyph 8658 missing from font(s) Microsoft YaHei` 并画成方框 —— 交付文档里不能有方框。
|
|
|
+ """
|
|
|
+ swap = {'⇒': '→', '⇐': '←', '↔': '↔', '⇔': '↔', '✓': '√', '✗': '×', '≥': '≥'}
|
|
|
+ import matplotlib.text as _mt
|
|
|
+ for t in fig.findobj(_mt.Text):
|
|
|
+ s = t.get_text()
|
|
|
+ if any(k in s for k in swap):
|
|
|
+ for k, v in swap.items():
|
|
|
+ s = s.replace(k, v)
|
|
|
+ t.set_text(s)
|
|
|
+ FIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
+ out = FIG_DIR / name
|
|
|
+ fig.tight_layout()
|
|
|
+ fig.savefig(out, bbox_inches='tight', facecolor='white')
|
|
|
+ plt.close(fig)
|
|
|
+ print(' ✓', out.relative_to(ROOT).as_posix(), '%.0f KB' % (out.stat().st_size / 1024))
|
|
|
+
|
|
|
+
|
|
|
+def barh(ax, labels, values, color='#3b6ea5', fmt='{:.0f}', xlabel=''):
|
|
|
+ y = range(len(labels))
|
|
|
+ ax.barh(list(y), values, color=color, height=0.62)
|
|
|
+ ax.set_yticks(list(y))
|
|
|
+ ax.set_yticklabels(labels, fontsize=9)
|
|
|
+ ax.invert_yaxis()
|
|
|
+ for i, v in enumerate(values):
|
|
|
+ ax.text(v, i, ' ' + fmt.format(v), va='center', fontsize=8.5)
|
|
|
+ ax.set_xlabel(xlabel, fontsize=9)
|
|
|
+ ax.tick_params(axis='x', labelsize=8.5)
|
|
|
+ for s in ('top', 'right'):
|
|
|
+ ax.spines[s].set_visible(False)
|
|
|
+
|
|
|
+
|
|
|
+# ── 取数(全部真读,不猜)────────────────────────────────────────────────────────
|
|
|
+def version_history():
|
|
|
+ from src import version as V
|
|
|
+ return list(V.HISTORY), V.VERSION
|
|
|
+
|
|
|
+
|
|
|
+def portal_pages():
|
|
|
+ import yaml
|
|
|
+ d = yaml.safe_load((P.config('portal_pages.yaml')).read_text(encoding='utf-8'))
|
|
|
+ pages = d.get('pages') or d.get('entries') or []
|
|
|
+ if isinstance(d, dict) and not pages:
|
|
|
+ for k, v in d.items():
|
|
|
+ if isinstance(v, list) and v and isinstance(v[0], dict):
|
|
|
+ pages = v
|
|
|
+ break
|
|
|
+ return pages
|
|
|
+
|
|
|
+
|
|
|
+def serve_cfg():
|
|
|
+ return json.loads((P.config('serve.json')).read_text(encoding='utf-8'))
|
|
|
+
|
|
|
+
|
|
|
+def raw_station_stats():
|
|
|
+ """→ [(族, 件数, GB)],实测 data/raw/<场站>/ 下一层目录。
|
|
|
+
|
|
|
+ 场站目录名以**场配置**为准(`windscada.config.farm()['raw_station']`),`P.station_dir()` 只是兜底
|
|
|
+ (它按场配置**名**拼,实测本场是 `data/raw/如东` 而非 `data/raw/rudong`)。
|
|
|
+ """
|
|
|
+ from src.windscada.config import farm as _farm
|
|
|
+ home = P.RAW_ROOT / str(_farm().get('raw_station') or '如东')
|
|
|
+ out = []
|
|
|
+ if not home.exists():
|
|
|
+ WARN.append(f'data/raw 场站目录不在位: {home}')
|
|
|
+ return out
|
|
|
+ for d in sorted(p for p in home.iterdir() if p.is_dir()):
|
|
|
+ fs = [p for p in d.rglob('*') if p.is_file()]
|
|
|
+ out.append((d.name, len(fs), sum(p.stat().st_size for p in fs) / 1e9))
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def md_tables(path: pathlib.Path):
|
|
|
+ """→ [ [ [cell,...], ... ] ]:把 markdown 里的表逐个解析成二维列表(不含表头分隔行)。"""
|
|
|
+ if not path.exists():
|
|
|
+ WARN.append('源件不在位: %s' % path.relative_to(ROOT).as_posix())
|
|
|
+ return []
|
|
|
+ lines = path.read_text(encoding='utf-8').splitlines()
|
|
|
+ tables, i = [], 0
|
|
|
+ while i < len(lines):
|
|
|
+ if lines[i].startswith('|') and i + 1 < len(lines) and re.match(r'^\|[\s:\-|]+\|$', lines[i + 1].strip()):
|
|
|
+ rows = []
|
|
|
+ j = i + 2
|
|
|
+ while j < len(lines) and lines[j].strip().startswith('|'):
|
|
|
+ rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
|
|
|
+ j += 1
|
|
|
+ tables.append(rows)
|
|
|
+ i = j
|
|
|
+ else:
|
|
|
+ i += 1
|
|
|
+ return tables
|
|
|
+
|
|
|
+
|
|
|
+def md_find_table(path: pathlib.Path, must_cols, first_cell=None):
|
|
|
+ """找**表头含指定列**(列名在本器里以首行单元格匹配)的表。"""
|
|
|
+ lines = path.read_text(encoding='utf-8').splitlines() if path.exists() else []
|
|
|
+ for i, l in enumerate(lines):
|
|
|
+ if not l.startswith('|') or not all(c in l for c in must_cols):
|
|
|
+ continue
|
|
|
+ rows, j = [], i + 2
|
|
|
+ while j < len(lines) and lines[j].strip().startswith('|'):
|
|
|
+ rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
|
|
|
+ j += 1
|
|
|
+ if not rows:
|
|
|
+ continue
|
|
|
+ if first_cell is not None and rows[0][0] != first_cell:
|
|
|
+ continue
|
|
|
+ return rows
|
|
|
+ return []
|
|
|
+
|
|
|
+
|
|
|
+def ensure_evidence(refresh=False):
|
|
|
+ """把两张取证底稿跑出来(已存在且未 --refresh 时复用)。"""
|
|
|
+ SRC_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
+ py = str(ROOT / '.venv' / 'Scripts' / 'python.exe')
|
|
|
+ chk, plan = SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'
|
|
|
+ if refresh or not chk.exists():
|
|
|
+ t0 = time.time()
|
|
|
+ r = subprocess.run([py, '-X', 'utf8', 'guanlan.py', 'check'], cwd=str(ROOT),
|
|
|
+ capture_output=True, timeout=1800)
|
|
|
+ chk.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
|
|
|
+ print(' · guanlan.py check 底稿写入 (%.0fs, rc=%d)' % (time.time() - t0, r.returncode))
|
|
|
+ if refresh or not plan.exists():
|
|
|
+ r = subprocess.run([py, '-X', 'utf8', 'scripts/rebuild_all.py', '--dry-run', '--skip-scada'],
|
|
|
+ cwd=str(ROOT), capture_output=True, timeout=600)
|
|
|
+ plan.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
|
|
|
+ print(' · rebuild_all --dry-run 底稿写入')
|
|
|
+
|
|
|
+
|
|
|
+# ── 需求分析组 ─────────────────────────────────────────────────────────────────
|
|
|
+def fig_req_01():
|
|
|
+ hist, ver = version_history()
|
|
|
+ fig, ax = plt.subplots(figsize=(9.2, 0.62 * len(hist) + 1.4))
|
|
|
+ ax.axis('off')
|
|
|
+ n = len(hist)
|
|
|
+ ax.set_ylim(-0.6, n - 0.4)
|
|
|
+ ax.set_xlim(0, 10)
|
|
|
+ ax.plot([0.7, 0.7], [0, n - 1], color='#3b6ea5', lw=2.2, zorder=1)
|
|
|
+ lvl_cn = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号'}
|
|
|
+ for i, h in enumerate(hist):
|
|
|
+ y = n - 1 - i
|
|
|
+ ax.scatter([0.7], [y], s=90, color='#3b6ea5', zorder=3)
|
|
|
+ title = h.get('title', '')
|
|
|
+ title = re.sub(r'(.*?)', '', title)
|
|
|
+ title = title[:46] + ('…' if len(title) > 46 else '')
|
|
|
+ ax.text(1.05, y + 0.18, f"v{h.get('version')} {h.get('date')} [{lvl_cn.get(h.get('level'), '')}版本]",
|
|
|
+ fontsize=9.5, fontweight='bold', va='center')
|
|
|
+ ax.text(1.05, y - 0.20, title, fontsize=9, color='#333333', va='center')
|
|
|
+ ax.text(0.7, n - 0.15, '需求演进(自下而上)', fontsize=10, fontweight='bold', ha='center')
|
|
|
+ ax.set_title(f'观澜·如东样板 v2 需求演进时间轴(当前 v{ver};依据 src/version.py 的 HISTORY,'
|
|
|
+ f'共 {n} 条)', fontsize=11)
|
|
|
+ save(fig, 'fig-req-01-需求演进时间轴.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_req_02():
|
|
|
+ fig, ax = canvas(9.4, 6.2)
|
|
|
+ roles = [('值班运行', 88, '今日态势/部件问题/该问题页/报警与故障统计'),
|
|
|
+ ('点检与检修', 65, '机组页/振动与融合面/油样与工单/报告导出'),
|
|
|
+ ('场站管理', 42, '发电性能/可靠性(MTBF·MTBO·MDT)/可用率/限电'),
|
|
|
+ ('研发与交付', 19, '本体与机制链/数据重算/运行体检/版本与打包')]
|
|
|
+ for name, y, pages in roles:
|
|
|
+ box(ax, 3, y, 20, 16, name, fc='#e8f1e4', ec='#5b8c4a', fs=11, bold=True)
|
|
|
+ box(ax, 30, y, 66, 16, pages, fc='#eef3fb', ec='#3b6ea5', fs=10)
|
|
|
+ arrow(ax, (23.5, y + 8), (29.4, y + 8))
|
|
|
+ ax.text(50, 94, '角色 — 场景 — 页面关系(依据 docs/说明书_观澜如东样板v2_v0.2.md 与 configs/portal_pages.yaml)',
|
|
|
+ ha='center', fontsize=11, fontweight='bold')
|
|
|
+ save(fig, 'fig-req-02-角色场景页面.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_req_03():
|
|
|
+ pages = portal_pages()
|
|
|
+ if not pages:
|
|
|
+ WARN.append('portal_pages.yaml 未解析出页面条目')
|
|
|
+ return
|
|
|
+ from collections import Counter
|
|
|
+ kinds = Counter(str(p.get('kind', '未标')) for p in pages)
|
|
|
+ order = ['data-derived', 'source-derived', 'frozen-delivery', 'static', 'shipped']
|
|
|
+ ks = [k for k in order if k in kinds] + [k for k in kinds if k not in order]
|
|
|
+ vals = [kinds[k] for k in ks]
|
|
|
+ fig, ax = plt.subplots(figsize=(8.6, 0.62 * len(ks) + 2.0))
|
|
|
+ cn = {'data-derived': 'data-derived(随数据变,须重算)',
|
|
|
+ 'source-derived': 'source-derived(随源代码变)',
|
|
|
+ 'frozen-delivery': 'frozen-delivery(冻结交付件)',
|
|
|
+ 'static': 'static(静态)', 'shipped': 'shipped(随包件)'}
|
|
|
+ barh(ax, [cn.get(k, k) for k in ks], vals, xlabel='页面/子页数(共 %d 项)' % len(pages))
|
|
|
+ ax.set_title('门户页面归口分布(依据 configs/portal_pages.yaml)', fontsize=11)
|
|
|
+ save(fig, 'fig-req-03-页面归口分布.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_req_04():
|
|
|
+ """验收门分类 —— **与《需求分析》§9.1 表同源**(直接解析那张表,避免图文数字打架)。"""
|
|
|
+ rows = md_find_table(SRC_DIR / f'需求分析_观澜_{VERSION}.md', ('分类', '判定条数'))
|
|
|
+ if not rows:
|
|
|
+ WARN.append('需求分析 §9.1 的分类表未解析到,图 fig-req-04 用实跑输出兜底')
|
|
|
+ ensure_evidence()
|
|
|
+ txt = (SRC_DIR / '_guanlan_check.txt').read_text(encoding='utf-8', errors='replace')
|
|
|
+ marks = re.findall(r'^\s*\[(OK|FAIL|--|!|i|\?)\]\s*([^—\n]+)', txt, re.M)
|
|
|
+ cats = {'通过': sum(1 for m, _ in marks if m == 'OK'),
|
|
|
+ '需处置': sum(1 for m, _ in marks if m == 'FAIL'),
|
|
|
+ '跳过或提示': sum(1 for m, _ in marks if m not in ('OK', 'FAIL'))}
|
|
|
+ ks, vals, note = list(cats), list(cats.values()), '依据 guanlan.py check 实跑输出'
|
|
|
+ else:
|
|
|
+ ks, vals, note = [], [], '依据《需求分析》§9.1 表(同源于 guanlan.py check 实跑输出)'
|
|
|
+ for r in rows:
|
|
|
+ if r[0] == '合计':
|
|
|
+ break
|
|
|
+ ks.append('%s(%s)' % (r[0], (r[2][:22] + '…') if len(r) > 2 and len(r[2]) > 22 else (r[2] if len(r) > 2 else '')))
|
|
|
+ vals.append(int(re.sub(r'[^0-9]', '', r[1]) or 0))
|
|
|
+ fig, ax = plt.subplots(figsize=(9.6, 0.62 * len(ks) + 1.9))
|
|
|
+ barh(ax, ks, vals, xlabel='判定条数(合计 %d 条)' % sum(vals))
|
|
|
+ ax.set_title('验收门分类与数量(%s)' % note, fontsize=11)
|
|
|
+ save(fig, 'fig-req-04-验收门分类.png')
|
|
|
+
|
|
|
+
|
|
|
+# ── 设计说明组 ─────────────────────────────────────────────────────────────────
|
|
|
+def fig_des_01():
|
|
|
+ fig, ax = canvas(9.6, 7.4)
|
|
|
+ ax.text(50, 96, '观澜·如东样板 v2 总体架构(离线单包,无外网依赖)', ha='center',
|
|
|
+ fontsize=11.5, fontweight='bold')
|
|
|
+ box(ax, 4, 84, 92, 9, '使用侧:浏览器(门户 / 分析总览 /detail/v2 / 本体网 / 运维控制台 /ops)',
|
|
|
+ fc='#f6f2e8', ec='#b08a3e', fs=10)
|
|
|
+ box(ax, 24, 71, 52, 9, '接入层:组件网关 28084(前缀改写 + data-abs 豁免 + 静态件)',
|
|
|
+ fc='#eef3fb', ec='#3b6ea5', fs=10)
|
|
|
+ box(ax, 3, 56, 18, 11, '分析组件\n18033 /detail', fs=9)
|
|
|
+ box(ax, 23, 56, 17, 11, '振动组件\n18020 /cms', fs=9)
|
|
|
+ box(ax, 42, 56, 16, 11, '仿真台\n18791·18792', fs=9)
|
|
|
+ box(ax, 60, 56, 15, 11, '三维 viewer\n64292', fs=9)
|
|
|
+ box(ax, 77, 56, 20, 11, '本机模型档\nOllama 11434\n(可选,未装即降级)', fs=9,
|
|
|
+ fc='#f4eef6', ec='#7d5ba6')
|
|
|
+ for x in (12, 31.5, 50, 67.5, 87):
|
|
|
+ arrow(ax, (50, 71), (x, 67.4), color='#8a8a8a')
|
|
|
+ box(ax, 3, 41, 94, 10, '产物层 outputs/<场>/:windscada 判级与曲线 · slim10min 窄仓 · 振动六层链 · '
|
|
|
+ '本体对象库 · SOP/findings · guanlan 契约', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
|
|
|
+ arrow(ax, (50, 56), (50, 51.4))
|
|
|
+ box(ax, 3, 27, 94, 9, '摄入与构建层:rebuild_all.py 23 步(place_raw_data → 10min/1min 摄入 → '
|
|
|
+ '10 个 SCADA 构建器 → 振动摄入 → 本体六步 → 审计)', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
|
|
|
+ arrow(ax, (50, 41), (50, 36.4))
|
|
|
+ box(ax, 3, 13, 46, 9, '输入层 data/raw/<场>/:scada_10min·scada_1min·scada_mdb\n故障报警·风机故障记录·'
|
|
|
+ 'windcms·m5_cms_tcm·油样报告', fc='#f6f2e8', ec='#b08a3e', fs=9)
|
|
|
+ box(ax, 51, 13, 46, 9, '共享资料:西门子 4.0 技术资料(本体 kb_ingest 读)\n配置 configs/:serve.json·'
|
|
|
+ 'models.json·portal_pages.yaml', fc='#f6f2e8', ec='#b08a3e', fs=9)
|
|
|
+ arrow(ax, (26, 23), (26, 26.6))
|
|
|
+ arrow(ax, (74, 23), (74, 26.6))
|
|
|
+ box(ax, 3, 2, 94, 7, '贯穿全链的守卫:单一真源(src/paths.py·src/version.py) · 审计门(guanlan.py check / '
|
|
|
+ '7 个审计器) · 逐件来源台账(_provenance.json)', fc='#ffffff', ec='#555555', fs=9)
|
|
|
+ save(fig, 'fig-des-01-总体架构.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_des_02():
|
|
|
+ """serve.json 是**扁平**结构(`gateway`/`detail`/… 直接是端口整数),不是嵌套 dict。"""
|
|
|
+ cfg = serve_cfg()
|
|
|
+ host, pub = cfg.get('host', '?'), cfg.get('public_host', '')
|
|
|
+ gw_port = cfg.get('gateway', '?')
|
|
|
+ comps = [(k, cfg.get(k)) for k in ('detail', 'cms', 'sim', 'sim_sys', 'viewer')]
|
|
|
+ fig, ax = canvas(9.6, 5.2)
|
|
|
+ box(ax, 30, 82, 40, 12, '网关 gateway\n%s:%s(对外唯一入口)' % (pub or host, gw_port),
|
|
|
+ fc='#eef3fb', ec='#3b6ea5', fs=10, bold=True)
|
|
|
+ n = max(1, len(comps))
|
|
|
+ for i, (name, port) in enumerate(comps):
|
|
|
+ x = 3 + i * (94 / n)
|
|
|
+ w = 94 / n - 3
|
|
|
+ box(ax, x, 52, w, 14, '%s\n%s:%s' % (name, host, port), fs=9)
|
|
|
+ arrow(ax, (50, 82), (x + w / 2, 66.6), color='#8a8a8a')
|
|
|
+ box(ax, 3, 30, 94, 12, '绑定与自启:guanlan 服务(Windows 服务 / systemd)· start.bat 静默启动 · '
|
|
|
+ 'run/*.pid 运行态 · logs/ 统一落点', fc='#f6f2e8', ec='#b08a3e', fs=9)
|
|
|
+ arrow(ax, (50, 52), (50, 42.6))
|
|
|
+ box(ax, 3, 12, 94, 12, '端口冲突处置:netstat -ano 找占用进程 → 按端口清理 → 重启服务\n'
|
|
|
+ '(service_ctl stop 不杀子进程,只按端口杀会留下孤儿实例)', fc='#ffffff',
|
|
|
+ ec='#555555', fs=9)
|
|
|
+ arrow(ax, (50, 30), (50, 24.6))
|
|
|
+ ax.text(50, 96, '端口与进程拓扑(依据 configs/serve.json 实读)', ha='center', fontsize=11.5,
|
|
|
+ fontweight='bold')
|
|
|
+ save(fig, 'fig-des-02-端口与进程.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_des_03():
|
|
|
+ ensure_evidence()
|
|
|
+ txt = (SRC_DIR / '_rebuild_plan.txt').read_text(encoding='utf-8', errors='replace')
|
|
|
+ steps = []
|
|
|
+ for line in txt.splitlines():
|
|
|
+ m = re.match(r'^\s{2}(\S.*?)\s{2,}(\S.*)$', line)
|
|
|
+ if m and not line.strip().startswith('('):
|
|
|
+ steps.append((m.group(1).strip(), m.group(2).strip()))
|
|
|
+ if not steps:
|
|
|
+ WARN.append('rebuild_all --dry-run 未解析出步骤')
|
|
|
+ return
|
|
|
+ fig, ax = plt.subplots(figsize=(9.6, 0.42 * len(steps) + 1.8))
|
|
|
+ ax.axis('off')
|
|
|
+ ax.set_xlim(0, 10)
|
|
|
+ ax.set_ylim(-0.5, len(steps) - 0.2)
|
|
|
+ for i, (name, cmd) in enumerate(steps):
|
|
|
+ y = len(steps) - 1 - i
|
|
|
+ ax.add_patch(plt.Rectangle((0.2, y - 0.34), 1.75, 0.68, facecolor='#eef3fb',
|
|
|
+ edgecolor='#3b6ea5', lw=0.9))
|
|
|
+ ax.text(1.07, y, name[:22], fontsize=8.6, ha='center', va='center')
|
|
|
+ ax.text(2.15, y, cmd[:92], fontsize=8.2, va='center', color='#333333', family='monospace')
|
|
|
+ if i < len(steps) - 1:
|
|
|
+ ax.annotate('', xy=(1.07, y - 0.42), xytext=(1.07, y - 0.30),
|
|
|
+ arrowprops=dict(arrowstyle='-|>', color='#8a8a8a', lw=0.9))
|
|
|
+ ax.set_title('重算链步骤(依据 scripts/rebuild_all.py --dry-run --skip-scada,共 %d 步)' % len(steps),
|
|
|
+ fontsize=11)
|
|
|
+ save(fig, 'fig-des-03-重算链.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_des_04():
|
|
|
+ fig, ax = canvas(9.6, 6.4)
|
|
|
+ ax.text(50, 96, '判级矩阵与四源融合数据流(每条判据都能指到输入列)', ha='center',
|
|
|
+ fontsize=11.5, fontweight='bold')
|
|
|
+ srcs = ['SCADA 10min\n(slim10min 48 列)', 'CMS 振动\n(六层链)', '状态·故障·告警\n(台账)', '油样与工单\n(现场记录)']
|
|
|
+ for i, s in enumerate(srcs):
|
|
|
+ box(ax, 1.5 + i * 24.6, 78, 22, 12, s, fc='#f6f2e8', ec='#b08a3e', fs=9)
|
|
|
+ box(ax, 6, 58, 40, 12, '七系统判级矩阵\n变桨·偏航·主轴承·齿轮箱·发电机·变流器·主控与传感网',
|
|
|
+ fc='#eef3fb', ec='#3b6ea5', fs=9.5)
|
|
|
+ box(ax, 52, 58, 42, 12, '融合面四源对齐\n振动 × SCADA × 台账 × 人工\n(逐台判级 + 证据链)',
|
|
|
+ fc='#eef3fb', ec='#3b6ea5', fs=9.5)
|
|
|
+ for x in (12, 36, 62, 86):
|
|
|
+ arrow(ax, (x, 78), (26 if x < 50 else 73, 70.6), color='#8a8a8a')
|
|
|
+ box(ax, 6, 40, 88, 11, '时间窗:判级与曲线按所选时间窗重算(月/年/自定义起止日期,含两端);'
|
|
|
+ '温度与停机台账按真窗取月', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
|
|
|
+ arrow(ax, (26, 58), (26, 51.6))
|
|
|
+ arrow(ax, (73, 58), (73, 51.6))
|
|
|
+ outs = ['部件问题清单\n(按系统×机组)', '该问题页\n(单问题+完整依据)', '发电性能与可靠性\n(MTBF/MTBO/MDT)',
|
|
|
+ '控制参数一致性\n(P/ω 封顶·K 聚类)', '报告导出\n(docx/md/页面)', '本体机制链\n与决策台']
|
|
|
+ for i, o in enumerate(outs):
|
|
|
+ x = 2 + (i % 3) * 32.3
|
|
|
+ y = 24 if i < 3 else 8
|
|
|
+ box(ax, x, y, 30, 12, o, fc='#f4eef6', ec='#7d5ba6', fs=9)
|
|
|
+ arrow(ax, (50, 40), (50, 36.6), color='#5b8c4a')
|
|
|
+ box(ax, 2, 21.5, 96, 2.6, '', fc='#ffffff', ec='#ffffff')
|
|
|
+ ax.text(50, 36, '产物(outputs/<场>/…,逐件登记生成端与输入)', ha='center', fontsize=9, color='#444444')
|
|
|
+ save(fig, 'fig-des-04-判级与融合.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_des_05():
|
|
|
+ fig, ax = canvas(9.6, 5.6)
|
|
|
+ ax.text(50, 96, '时间窗按窗重算与 pending 契约(实测:冷窗首查 pending,约 30 s 后落位)',
|
|
|
+ ha='center', fontsize=11.5, fontweight='bold')
|
|
|
+ lanes = [('前端 app.js', 82), ('服务 windscada_serve.py', 60), ('按窗缓存 _WIN_CACHE', 38), ('重算线程(_HEAVY_LOCK 串行)', 16)]
|
|
|
+ for name, y in lanes:
|
|
|
+ box(ax, 2, y, 24, 11, name, fc='#eef3fb', ec='#3b6ea5', fs=9)
|
|
|
+ ax.plot([26.5, 97], [y + 5.5, y + 5.5], color='#d0d0d0', lw=0.9, zorder=0)
|
|
|
+ msgs = [(82, 39, 'GET /api/fleet?win=2026-07'),
|
|
|
+ (60, 82, 'win 未命中 → 起后台线程,先回上一份口径并标 pending=1'),
|
|
|
+ (38, 60, '缺 → 入队 sysmx/curves/m9'),
|
|
|
+ (16, 38, '算(变桨/偏航/蓄能/温度 + 曲线 + M9)'),
|
|
|
+ (38, 16, '算完写入缓存'),
|
|
|
+ (60, 38, '5 s 轮询再取 → 命中'),
|
|
|
+ (82, 60, 'pending=0,页面换成该时间窗口径')]
|
|
|
+ for y1, y2, text in msgs:
|
|
|
+ arrow(ax, (36, y1 + 5.5), (36, y2 + 5.5), color='#3b6ea5')
|
|
|
+ ax.text(38.5, (y1 + y2) / 2 + 5.5, text, fontsize=8.8, va='center')
|
|
|
+ ax.text(50, 5, '为什么这样:算得慢(每面 5~120 s),不能把页面卡住;宁可先给旧口径并**如实标注**,也不静默等待。',
|
|
|
+ ha='center', fontsize=9, color='#444444')
|
|
|
+ save(fig, 'fig-des-05-时间窗与pending.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_des_06():
|
|
|
+ fig, ax = canvas(9.0, 6.0)
|
|
|
+ cx, cy, r = 50, 50, 30
|
|
|
+ items = [('改代码/配置', '#eef3fb'), ('重算 rebuild_all 23 步', '#e8f1e4'),
|
|
|
+ ('审计器 7 件(反向/页面/配置/链路/版本…)', '#f6f2e8'),
|
|
|
+ ('guanlan.py check 全绿', '#f4eef6'), ('打包 pack_dist + 开箱验证', '#eef3fb'),
|
|
|
+ ('装到目标机 install.ps1', '#e8f1e4')]
|
|
|
+ import math
|
|
|
+ for i, (t, fc) in enumerate(items):
|
|
|
+ a = math.radians(90 - i * 360 / len(items))
|
|
|
+ x, y = cx + r * math.cos(a), cy + r * math.sin(a)
|
|
|
+ box(ax, x - 15, y - 6, 30, 12, t, fc=fc, ec='#3b6ea5', fs=9)
|
|
|
+ a2 = math.radians(90 - (i + 1) * 360 / len(items))
|
|
|
+ arrow(ax, (cx + (r - 6) * math.cos(a), cy + (r - 6) * math.sin(a)),
|
|
|
+ (cx + (r - 6) * math.cos(a2), cy + (r - 6) * math.sin(a2)),
|
|
|
+ color='#8a8a8a', rad=-0.25)
|
|
|
+ box(ax, 38, 44, 24, 12, '版本纪律\n单一真源 src/version.py', fc='#ffffff', ec='#555555', fs=9, bold=True)
|
|
|
+ ax.text(50, 96, '质量门与审计闭环(每环都能独立重跑、逐条给退出码)', ha='center', fontsize=11.5,
|
|
|
+ fontweight='bold')
|
|
|
+ save(fig, 'fig-des-06-质量门闭环.png')
|
|
|
+
|
|
|
+
|
|
|
+# ── 数据要求说明组 ─────────────────────────────────────────────────────────────
|
|
|
+def fig_dat_01():
|
|
|
+ stats = raw_station_stats()
|
|
|
+ if not stats:
|
|
|
+ return
|
|
|
+ stats = sorted(stats, key=lambda x: -x[1])
|
|
|
+ labels = ['%s\n(%d 件)' % (s[0], s[1]) for s in stats]
|
|
|
+ fig, axes = plt.subplots(1, 2, figsize=(10.6, 0.55 * len(stats) + 2.2))
|
|
|
+ barh(axes[0], labels, [s[1] for s in stats], xlabel='件数')
|
|
|
+ axes[0].set_title('件数', fontsize=10)
|
|
|
+ barh(axes[1], labels, [s[2] for s in stats], color='#b08a3e', fmt='{:.2f}', xlabel='体量 GB')
|
|
|
+ axes[1].set_title('体量(GB)', fontsize=10)
|
|
|
+ tot_n = sum(s[1] for s in stats)
|
|
|
+ tot_g = sum(s[2] for s in stats)
|
|
|
+ fig.suptitle('输入数据族件数与体量(依据实测 data/raw/如东 下一层目录;合计 %d 件 / %.1f GB)'
|
|
|
+ % (tot_n, tot_g), fontsize=11)
|
|
|
+ save(fig, 'fig-dat-01-数据族体量.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_dat_02():
|
|
|
+ fig, ax = canvas(9.6, 5.8)
|
|
|
+ ax.text(50, 96, '数据 → 功能 → 产物 流向(只列主干,逐族对应见正文表)', ha='center',
|
|
|
+ fontsize=11.5, fontweight='bold')
|
|
|
+ left = ['scada_10min / 1min\nscada_mdb(归档)', '故障报警 / 风机故障记录', 'windcms / m5_cms_tcm',
|
|
|
+ '油样报告', '西门子 4.0 技术资料']
|
|
|
+ for i, t in enumerate(left):
|
|
|
+ box(ax, 2, 78 - i * 15, 26, 12, t, fc='#f6f2e8', ec='#b08a3e', fs=9)
|
|
|
+ mid = ['判级矩阵与时间窗重算', '发电性能与可靠性', '故障与停机台账', '振动六层链与融合面',
|
|
|
+ '本体对象库与问答']
|
|
|
+ for i, t in enumerate(mid):
|
|
|
+ box(ax, 37, 78 - i * 15, 26, 12, t, fc='#eef3fb', ec='#3b6ea5', fs=9)
|
|
|
+ right = ['部件问题 / 该问题页', '可靠性表(MTBF·MTBO·MDT)', '停机台账与五态', '融合面逐台判级',
|
|
|
+ '机制链 / 决策台 / 报告']
|
|
|
+ for i, t in enumerate(right):
|
|
|
+ box(ax, 72, 78 - i * 15, 26, 12, t, fc='#e8f1e4', ec='#5b8c4a', fs=9)
|
|
|
+ for i in range(5):
|
|
|
+ y = 84 - i * 15
|
|
|
+ arrow(ax, (28.4, y), (36.4, y), color='#8a8a8a')
|
|
|
+ arrow(ax, (63.4, y), (71.4, y), color='#8a8a8a')
|
|
|
+ ax.text(15, 8, '输入层', ha='center', fontsize=10, fontweight='bold')
|
|
|
+ ax.text(50, 8, '功能与算法层', ha='center', fontsize=10, fontweight='bold')
|
|
|
+ ax.text(85, 8, '产物与页面层', ha='center', fontsize=10, fontweight='bold')
|
|
|
+ save(fig, 'fig-dat-02-数据到产物.png')
|
|
|
+
|
|
|
+
|
|
|
+# 收资要求 v3 的 21 项 → 观澜侧到位状态(依据 docs/输入数据放置指导_v0.1.md 的体检结论、
|
|
|
+# configs/contracts/siemens_swt40_rudong.yaml 的 materials 节、以及 data/raw/如东 实测)
|
|
|
+REQ_STATUS = [
|
|
|
+ ('1', '10分钟SCADA数据', '已在位', 'scada_10min(38 件 / 14.7 GB)+ scada_mdb 归档'),
|
|
|
+ ('2', '秒级SCADA高频数据', '部分', 'scada_1min 38 件在位;1 s/5 s 原生高频未见交付'),
|
|
|
+ ('3', '测风塔数据', '未到位', 'mast: FALSE —— 测风塔目录零交付(须催缴)'),
|
|
|
+ ('4', '功率曲线', '部分', '合同担保曲线为图片级(须描点提取)'),
|
|
|
+ ('5', '机型参数', '已在位', 'contracts/siemens_swt40_rudong.yaml meta + 理论功率'),
|
|
|
+ ('6', '机位分布数据', '已在位', '风机坐标.xlsx(38 台 + 升压站)'),
|
|
|
+ ('7', '状态、事件、故障、告警', '已在位', '故障报警 + 风机故障记录(代码表 2793 条)'),
|
|
|
+ ('8', '机舱CMS振动数据', '已在位', 'windcms 25,693 件 / 153.8 GB + 厂家报告'),
|
|
|
+ ('9', '机组统计数据/运行数据', '已在位', 'SCADA 统计 + sum 层(DailySummary)'),
|
|
|
+ ('10', '保护定值及控制参数', '部分', '控制参数由 M9 反演;定值表未成册交付'),
|
|
|
+ ('11', '机组故障录波数据', '未到位', 'fastlog 仅 4 台、10 Hz 事件快照'),
|
|
|
+ ('12', '可研报告及微观选址', '未到位', '目录未见交付(可研另有史料塔记录)'),
|
|
|
+ ('13', '风电场简介', '部分', '场站元信息散见合同与技术资料'),
|
|
|
+ ('14', '风电场坐标、机位布置图', '已在位', '坐标件在位;布置图含于技术资料'),
|
|
|
+ ('15', '风机技术协议及合同', '已在位', '西门子 4.0 技术资料 + 担保曲线附件'),
|
|
|
+ ('16', '并网至今生产报表', '部分', '日粒度发电账 555 天 + 全寿命月账(2016-06 起)'),
|
|
|
+ ('17', '周围已建风电场生产报表', '未到位', '未见交付(尾流先验缺外部对照)'),
|
|
|
+ ('18', '运维记录', '已在位', '检修记录 2025+2026 / 油样 2025'),
|
|
|
+ ('19', '大部件更换记录', '部分', '出保备件台账在位;更换台账不完整'),
|
|
|
+ ('20', '图纸、技术文件', '已在位', '西门子 4.0 技术资料(电气/液压/逻辑)'),
|
|
|
+ ('21', '设备参数', '部分', '主轴/齿轮箱/发电机型号与齿数部分在位,明细待补'),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def fig_dat_03():
|
|
|
+ """收资 21 项到位状态 —— **与《数据要求说明》§8.1 表同源**(解析那张表,图文不打架)。"""
|
|
|
+ from collections import Counter
|
|
|
+ rows = md_find_table(SRC_DIR / f'数据要求说明_观澜_{VERSION}.md', ('序号', '状态'), first_cell='1')
|
|
|
+ items = []
|
|
|
+ if rows and len(rows) >= 21:
|
|
|
+ for r in rows[:21]:
|
|
|
+ no, name, status = r[0], r[1], r[3] if len(r) > 3 else ''
|
|
|
+ if '已在位' in status:
|
|
|
+ st = '已在位'
|
|
|
+ elif '部分' in status:
|
|
|
+ st = '部分'
|
|
|
+ elif '未到位' in status:
|
|
|
+ st = '未到位'
|
|
|
+ else:
|
|
|
+ st = '未标'
|
|
|
+ note = r[5] if len(r) > 5 else ''
|
|
|
+ qual = '(依契约记载)' if '记载' in status else ''
|
|
|
+ items.append((no, name, st, qual + note[:34]))
|
|
|
+ else:
|
|
|
+ WARN.append('数据要求说明 §8.1 的 21 项表未解析到,图 fig-dat-03 用内置判定兜底')
|
|
|
+ items = REQ_STATUS
|
|
|
+ cnt = Counter(r[2] for r in items)
|
|
|
+ order = ['已在位', '部分', '未到位']
|
|
|
+ colors = {'已在位': '#5b8c4a', '部分': '#d9a441', '未到位': '#c0504d', '未标': '#b8b8b8'}
|
|
|
+ fig, axes = plt.subplots(1, 2, figsize=(11.0, 5.8), gridspec_kw={'width_ratios': [1, 1.6]})
|
|
|
+ a = axes[0]
|
|
|
+ vals = [cnt.get(k, 0) for k in order]
|
|
|
+ a.bar(order, vals, color=[colors[k] for k in order], width=0.55)
|
|
|
+ for i, v in enumerate(vals):
|
|
|
+ a.text(i, v, ' %d 项' % v, ha='center', va='bottom', fontsize=10)
|
|
|
+ a.set_ylim(0, max(vals) + 2)
|
|
|
+ a.set_title('21 类收资项到位状态(合计 %d 项)' % len(items), fontsize=10.5)
|
|
|
+ a.set_ylabel('项数', fontsize=9)
|
|
|
+ for s in ('top', 'right'):
|
|
|
+ a.spines[s].set_visible(False)
|
|
|
+ b = axes[1]
|
|
|
+ b.axis('off')
|
|
|
+ b.set_xlim(0, 10)
|
|
|
+ b.set_ylim(0, len(items))
|
|
|
+ for i, (no, name, st, note) in enumerate(items):
|
|
|
+ y = len(items) - 1 - i
|
|
|
+ b.add_patch(plt.Rectangle((0, y - 0.4), 0.9, 0.8, facecolor='#f0f0f0', edgecolor='none'))
|
|
|
+ b.text(0.45, y, no, ha='center', va='center', fontsize=8)
|
|
|
+ b.add_patch(plt.Rectangle((0.95, y - 0.4), 0.38, 0.8, facecolor=colors.get(st, '#b8b8b8'),
|
|
|
+ edgecolor='none'))
|
|
|
+ b.text(1.45, y, name[:16], fontsize=8.4, va='center')
|
|
|
+ b.text(5.3, y, note[:36], fontsize=7.6, va='center', color='#444444')
|
|
|
+ b.set_title('逐项判定(状态与缺口取自《数据要求说明》§8.1 表)', fontsize=10.5)
|
|
|
+ fig.suptitle('收资要求 v3 的 21 类数据在观澜的到位状态', fontsize=11.5)
|
|
|
+ save(fig, 'fig-dat-03-收资项覆盖.png')
|
|
|
+
|
|
|
+
|
|
|
+def fig_dat_04():
|
|
|
+ import pandas as pd
|
|
|
+ store = P.store('rudong')
|
|
|
+ out = []
|
|
|
+ for fn, label in (('temp_monthly.parquet', '温度月轨迹 temp_monthly'),
|
|
|
+ ('alarms.parquet', '报警台账 alarms')):
|
|
|
+ f = store / fn
|
|
|
+ if not f.exists():
|
|
|
+ WARN.append('缺产物 %s' % f.relative_to(ROOT).as_posix())
|
|
|
+ continue
|
|
|
+ df = pd.read_parquet(f, columns=['month'] if fn.startswith('temp') else None)
|
|
|
+ col = 'month' if 'month' in df.columns else df.columns[0]
|
|
|
+ g = df.groupby(df[col].astype(str)).size()
|
|
|
+ out.append((label, g))
|
|
|
+ if not out:
|
|
|
+ return
|
|
|
+ months = sorted({m for _, g in out for m in g.index if re.match(r'^\d{4}-\d{2}', str(m))})
|
|
|
+ fig, ax = plt.subplots(figsize=(11.0, 4.6))
|
|
|
+ w = 0.4
|
|
|
+ import numpy as np
|
|
|
+ x = np.arange(len(months))
|
|
|
+ for i, (label, g) in enumerate(out):
|
|
|
+ vals = [int(g.get(m, 0)) for m in months]
|
|
|
+ ax.bar(x + (i - 0.5) * w, vals, width=w, label=label)
|
|
|
+ ax.set_xticks(x)
|
|
|
+ ax.set_xticklabels([m[2:] for m in months], rotation=90, fontsize=7.5)
|
|
|
+ ax.set_ylabel('行数 / 事件数', fontsize=9)
|
|
|
+ ax.legend(fontsize=9)
|
|
|
+ ax.set_title('关键数据的时间覆盖(依据 outputs/rudong/windscada 的 temp_monthly 与 alarms 实读)',
|
|
|
+ fontsize=11)
|
|
|
+ for s in ('top', 'right'):
|
|
|
+ ax.spines[s].set_visible(False)
|
|
|
+ save(fig, 'fig-dat-04-时间覆盖.png')
|
|
|
+
|
|
|
+
|
|
|
+GROUPS = {
|
|
|
+ 'req': [fig_req_01, fig_req_02, fig_req_03, fig_req_04],
|
|
|
+ 'des': [fig_des_01, fig_des_02, fig_des_03, fig_des_04, fig_des_05, fig_des_06],
|
|
|
+ 'dat': [fig_dat_01, fig_dat_02, fig_dat_03, fig_dat_04],
|
|
|
+}
|
|
|
+EXPECTED = ['fig-req-01-需求演进时间轴.png', 'fig-req-02-角色场景页面.png',
|
|
|
+ 'fig-req-03-页面归口分布.png', 'fig-req-04-验收门分类.png',
|
|
|
+ 'fig-des-01-总体架构.png', 'fig-des-02-端口与进程.png', 'fig-des-03-重算链.png',
|
|
|
+ 'fig-des-04-判级与融合.png', 'fig-des-05-时间窗与pending.png', 'fig-des-06-质量门闭环.png',
|
|
|
+ 'fig-dat-01-数据族体量.png', 'fig-dat-02-数据到产物.png', 'fig-dat-03-收资项覆盖.png',
|
|
|
+ 'fig-dat-04-时间覆盖.png']
|
|
|
+
|
|
|
+
|
|
|
+def main() -> int:
|
|
|
+ ap = argparse.ArgumentParser()
|
|
|
+ ap.add_argument('--only', default=None, choices=sorted(GROUPS))
|
|
|
+ ap.add_argument('--refresh', action='store_true',
|
|
|
+ help='重跑取证底稿(guanlan.py check / rebuild_all --dry-run)后再画图')
|
|
|
+ ap.add_argument('--check', action='store_true', help='只报缺哪些图')
|
|
|
+ a = ap.parse_args()
|
|
|
+ if a.refresh:
|
|
|
+ for p in (SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'):
|
|
|
+ p.unlink(missing_ok=True)
|
|
|
+ if a.check:
|
|
|
+ miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
|
|
|
+ print('图表体检: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
|
|
|
+ for f in miss:
|
|
|
+ print(' ✗', f)
|
|
|
+ return 0 if not miss else 5
|
|
|
+ groups = [a.only] if a.only else sorted(GROUPS)
|
|
|
+ for g in groups:
|
|
|
+ print('── %s 组 ──' % g)
|
|
|
+ for fn in GROUPS[g]:
|
|
|
+ try:
|
|
|
+ fn()
|
|
|
+ except Exception as e: # noqa: BLE001
|
|
|
+ WARN.append('%s 失败: %s' % (fn.__name__, e))
|
|
|
+ print(' ✗', fn.__name__, e)
|
|
|
+ miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
|
|
|
+ if WARN:
|
|
|
+ print('\n告警/缺件:')
|
|
|
+ for w in WARN:
|
|
|
+ print(' -', w)
|
|
|
+ print('\n图表: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
|
|
|
+ return 0 if not miss else 5
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == '__main__':
|
|
|
+ raise SystemExit(main())
|