delivery_docs_figures.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""交付文档图表构建器(需求分析 / 系统设计说明 / 数据要求说明 三份 docx 的插图)。
  4. ## 为什么单开一个器
  5. 三份交付文档要求"文表图并茂"。图里出现的每个数字都必须能追到仓库里的真件,所以图**不手画**:
  6. 本器从 `src/version.py` 的 HISTORY、`configs/portal_pages.yaml`、`configs/serve.json`、
  7. `data/raw/<场>/**`、`outputs/<场>/**` 与 `guanlan.py check` / `rebuild_all.py --dry-run` 的**实跑输出**
  8. 里取数,落到 `docs/figures/*.png`。纯示意图(架构/链路/时序)也在图注里写明"依据"。
  9. ## 落点与口径
  10. docs/figures/fig-req-01..04 / fig-des-01..06 / fig-dat-01..04 .png
  11. docs/src/_guanlan_check.txt (验收门实跑原文,图的取证底稿)
  12. docs/src/_rebuild_plan.txt (重算链 dry-run 原文,图的取证底稿)
  13. ## 用法
  14. python scripts/delivery_docs_figures.py # 全画
  15. python scripts/delivery_docs_figures.py --only req # 只画某一组 (req/des/dat)
  16. python scripts/delivery_docs_figures.py --check # 只报缺哪些图
  17. 退出码: 0 全部生成 · 5 有图没生成(源件缺或绘图失败,逐条打印原因)
  18. """
  19. from __future__ import annotations
  20. try:
  21. from app_common.app_common_guanlan.api import install_root as _install_root
  22. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  23. from pathlib import Path as _P
  24. def _install_root(_f): return _P(_f).resolve().parents[4]
  25. import argparse
  26. import json
  27. import pathlib
  28. import re
  29. import subprocess
  30. import sys
  31. import time
  32. ROOT = _install_root(__file__)
  33. sys.path.insert(0, str(ROOT))
  34. import matplotlib # noqa: E402
  35. matplotlib.use('Agg')
  36. import matplotlib.pyplot as plt # noqa: E402
  37. from matplotlib.patches import FancyArrowPatch, FancyBboxPatch # noqa: E402
  38. from src import paths as P # noqa: E402
  39. FIG_DIR = ROOT / 'docs' / 'figures'
  40. SRC_DIR = ROOT / 'docs' / 'src'
  41. CJK = 'Microsoft YaHei'
  42. plt.rcParams['font.sans-serif'] = [CJK, 'SimHei']
  43. plt.rcParams['axes.unicode_minus'] = False
  44. plt.rcParams['figure.dpi'] = 200
  45. WARN: list[str] = []
  46. VERSION = __import__('importlib').import_module('src.version').VERSION
  47. # ── 通用绘图元件 ────────────────────────────────────────────────────────────────
  48. def canvas(w: float, h: float):
  49. fig, ax = plt.subplots(figsize=(w, h))
  50. ax.set_xlim(0, 100)
  51. ax.set_ylim(0, 100)
  52. ax.axis('off')
  53. return fig, ax
  54. def box(ax, x, y, w, h, text, fc='#eef3fb', ec='#3b6ea5', fs=10, bold=False, align='center'):
  55. ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0.6,rounding_size=1.4',
  56. linewidth=1.2, edgecolor=ec, facecolor=fc))
  57. ax.text(x + w / 2, y + h / 2, text, ha='center', va='center', fontsize=fs,
  58. wrap=True, fontweight='bold' if bold else 'normal', linespacing=1.5)
  59. def arrow(ax, p, q, color='#555555', style='-|>', lw=1.3, rad=0.0):
  60. ax.add_patch(FancyArrowPatch(p, q, arrowstyle=style, mutation_scale=12,
  61. linewidth=lw, color=color,
  62. connectionstyle=f'arc3,rad={rad}'))
  63. def save(fig, name: str):
  64. """落盘前统一做一次**字形兜底**:把 YaHei 缺字的符号换成等价可显字符。
  65. 实逮(2026-09-22): 图里引用的原文(如 `configs/portal_pages.yaml` 的说明)含 `⇒`(U+21D2),
  66. matplotlib 报 `Glyph 8658 missing from font(s) Microsoft YaHei` 并画成方框 —— 交付文档里不能有方框。
  67. """
  68. swap = {'⇒': '→', '⇐': '←', '↔': '↔', '⇔': '↔', '✓': '√', '✗': '×', '≥': '≥'}
  69. import matplotlib.text as _mt
  70. for t in fig.findobj(_mt.Text):
  71. s = t.get_text()
  72. if any(k in s for k in swap):
  73. for k, v in swap.items():
  74. s = s.replace(k, v)
  75. t.set_text(s)
  76. FIG_DIR.mkdir(parents=True, exist_ok=True)
  77. out = FIG_DIR / name
  78. fig.tight_layout()
  79. fig.savefig(out, bbox_inches='tight', facecolor='white')
  80. plt.close(fig)
  81. print(' ✓', out.relative_to(ROOT).as_posix(), '%.0f KB' % (out.stat().st_size / 1024))
  82. def barh(ax, labels, values, color='#3b6ea5', fmt='{:.0f}', xlabel=''):
  83. y = range(len(labels))
  84. ax.barh(list(y), values, color=color, height=0.62)
  85. ax.set_yticks(list(y))
  86. ax.set_yticklabels(labels, fontsize=9)
  87. ax.invert_yaxis()
  88. for i, v in enumerate(values):
  89. ax.text(v, i, ' ' + fmt.format(v), va='center', fontsize=8.5)
  90. ax.set_xlabel(xlabel, fontsize=9)
  91. ax.tick_params(axis='x', labelsize=8.5)
  92. for s in ('top', 'right'):
  93. ax.spines[s].set_visible(False)
  94. # ── 取数(全部真读,不猜)────────────────────────────────────────────────────────
  95. def version_history():
  96. from src import version as V
  97. return list(V.HISTORY), V.VERSION
  98. def portal_pages():
  99. import yaml
  100. d = yaml.safe_load((P.config('portal_pages.yaml')).read_text(encoding='utf-8'))
  101. pages = d.get('pages') or d.get('entries') or []
  102. if isinstance(d, dict) and not pages:
  103. for k, v in d.items():
  104. if isinstance(v, list) and v and isinstance(v[0], dict):
  105. pages = v
  106. break
  107. return pages
  108. def serve_cfg():
  109. return json.loads((P.config('serve.json')).read_text(encoding='utf-8'))
  110. def raw_station_stats():
  111. """→ [(族, 件数, GB)],实测 data/raw/<场站>/ 下一层目录。
  112. 场站目录名以**场配置**为准(`windscada.config.farm()['raw_station']`),`P.station_dir()` 只是兜底。
  113. ★对外文档口径(用户令 2026-09-22): 真实场站名只用于**取数**,不进图题 —— 图里一律写「样本场」。
  114. """
  115. from src.windscada.config import farm as _farm
  116. home = P.RAW_ROOT / str(_farm().get('raw_station') or P.farm())
  117. out = []
  118. if not home.exists():
  119. WARN.append(f'data/raw 场站目录不在位: {home}')
  120. return out
  121. for d in sorted(p for p in home.iterdir() if p.is_dir()):
  122. fs = [p for p in d.rglob('*') if p.is_file()]
  123. out.append((d.name, len(fs), sum(p.stat().st_size for p in fs) / 1e9))
  124. return out
  125. def md_tables(path: pathlib.Path):
  126. """→ [ [ [cell,...], ... ] ]:把 markdown 里的表逐个解析成二维列表(不含表头分隔行)。"""
  127. if not path.exists():
  128. WARN.append('源件不在位: %s' % path.relative_to(ROOT).as_posix())
  129. return []
  130. lines = path.read_text(encoding='utf-8').splitlines()
  131. tables, i = [], 0
  132. while i < len(lines):
  133. if lines[i].startswith('|') and i + 1 < len(lines) and re.match(r'^\|[\s:\-|]+\|$', lines[i + 1].strip()):
  134. rows = []
  135. j = i + 2
  136. while j < len(lines) and lines[j].strip().startswith('|'):
  137. rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
  138. j += 1
  139. tables.append(rows)
  140. i = j
  141. else:
  142. i += 1
  143. return tables
  144. def md_find_table(path: pathlib.Path, must_cols, first_cell=None):
  145. """找**表头含指定列**的表 → (表头单元格, 数据行)。列位置一律**按表头名定位**,不写死列号。"""
  146. lines = path.read_text(encoding='utf-8').splitlines() if path.exists() else []
  147. for i, l in enumerate(lines):
  148. if not l.startswith('|') or not all(c in l for c in must_cols):
  149. continue
  150. head = [c.strip() for c in l.strip().strip('|').split('|')]
  151. rows, j = [], i + 2
  152. while j < len(lines) and lines[j].strip().startswith('|'):
  153. rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
  154. j += 1
  155. if not rows:
  156. continue
  157. if first_cell is not None and rows[0][0] != first_cell:
  158. continue
  159. return head, rows
  160. return [], []
  161. def col_of(head, *names):
  162. """按表头名取列号(取第一个命中的;找不到返回 None)。"""
  163. for n in names:
  164. for i, h in enumerate(head):
  165. if n in h:
  166. return i
  167. return None
  168. def ensure_evidence(refresh=False):
  169. """把两张取证底稿跑出来(已存在且未 --refresh 时复用)。"""
  170. SRC_DIR.mkdir(parents=True, exist_ok=True)
  171. py = str(ROOT / '.venv' / 'Scripts' / 'python.exe')
  172. chk, plan = SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'
  173. if refresh or not chk.exists():
  174. t0 = time.time()
  175. r = subprocess.run([py, '-X', 'utf8', 'guanlan.py', 'check'], cwd=str(ROOT),
  176. capture_output=True, timeout=1800)
  177. chk.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
  178. print(' · guanlan.py check 底稿写入 (%.0fs, rc=%d)' % (time.time() - t0, r.returncode))
  179. if refresh or not plan.exists():
  180. r = subprocess.run([py, '-X', 'utf8', 'scripts/rebuild_all.py', '--dry-run', '--skip-scada'],
  181. cwd=str(ROOT), capture_output=True, timeout=600)
  182. plan.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
  183. print(' · rebuild_all --dry-run 底稿写入')
  184. # ── 需求分析组 ─────────────────────────────────────────────────────────────────
  185. def fig_req_01():
  186. hist, _ver = version_history()
  187. fig, ax = plt.subplots(figsize=(9.2, 0.62 * len(hist) + 1.4))
  188. ax.axis('off')
  189. n = len(hist)
  190. ax.set_ylim(-0.6, n - 0.4)
  191. ax.set_xlim(0, 10)
  192. ax.plot([0.7, 0.7], [0, n - 1], color='#3b6ea5', lw=2.2, zorder=1)
  193. lvl_cn = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号'}
  194. for i, h in enumerate(hist):
  195. y = n - 1 - i
  196. ax.scatter([0.7], [y], s=90, color='#3b6ea5', zorder=3)
  197. title = h.get('title', '')
  198. title = re.sub(r'(.*?)', '', title)
  199. title = title[:46] + ('…' if len(title) > 46 else '')
  200. ax.text(1.05, y + 0.18, f"v{h.get('version')} {h.get('date')} [{lvl_cn.get(h.get('level'), '')}版本]",
  201. fontsize=9.5, fontweight='bold', va='center')
  202. ax.text(1.05, y - 0.20, title, fontsize=9, color='#333333', va='center')
  203. ax.text(0.7, n - 0.15, '需求演进(自下而上)', fontsize=10, fontweight='bold', ha='center')
  204. ax.set_title(f'观澜 v2 需求演进时间轴(样本场;依据 src/version.py 的 HISTORY,共 {n} 条)', fontsize=11)
  205. save(fig, 'fig-req-01-需求演进时间轴.png')
  206. def fig_req_02():
  207. fig, ax = canvas(9.4, 6.2)
  208. roles = [('值班运行', 88, '今日态势/部件问题/该问题页/报警与故障统计'),
  209. ('点检与检修', 65, '机组页/振动与融合面/油样与工单/报告导出'),
  210. ('场站管理', 42, '发电性能/可靠性(MTBF·MTBO·MDT)/可用率/限电'),
  211. ('研发与交付', 19, '本体与机制链/数据重算/运行体检/版本与打包')]
  212. for name, y, pages in roles:
  213. box(ax, 3, y, 20, 16, name, fc='#e8f1e4', ec='#5b8c4a', fs=11, bold=True)
  214. box(ax, 30, y, 66, 16, pages, fc='#eef3fb', ec='#3b6ea5', fs=10)
  215. arrow(ax, (23.5, y + 8), (29.4, y + 8))
  216. ax.text(50, 94, '角色 — 场景 — 页面关系(依据 系统使用说明书 与 configs/portal_pages.yaml)',
  217. ha='center', fontsize=11, fontweight='bold')
  218. save(fig, 'fig-req-02-角色场景页面.png')
  219. def fig_req_03():
  220. pages = portal_pages()
  221. if not pages:
  222. WARN.append('portal_pages.yaml 未解析出页面条目')
  223. return
  224. from collections import Counter
  225. kinds = Counter(str(p.get('kind', '未标')) for p in pages)
  226. order = ['data-derived', 'source-derived', 'frozen-delivery', 'static', 'shipped']
  227. ks = [k for k in order if k in kinds] + [k for k in kinds if k not in order]
  228. vals = [kinds[k] for k in ks]
  229. fig, ax = plt.subplots(figsize=(8.6, 0.62 * len(ks) + 2.0))
  230. cn = {'data-derived': 'data-derived(随数据变,须重算)',
  231. 'source-derived': 'source-derived(随源代码变)',
  232. 'frozen-delivery': 'frozen-delivery(冻结交付件)',
  233. 'static': 'static(静态)', 'shipped': 'shipped(随包件)'}
  234. barh(ax, [cn.get(k, k) for k in ks], vals, xlabel='页面/子页数(共 %d 项)' % len(pages))
  235. ax.set_title('门户页面归口分布(依据 configs/portal_pages.yaml)', fontsize=11)
  236. save(fig, 'fig-req-03-页面归口分布.png')
  237. def fig_req_04():
  238. """验收门分类 —— **与《需求分析》§9.1 表同源**(直接解析那张表,避免图文数字打架)。"""
  239. head, rows = md_find_table(SRC_DIR / f'需求分析_观澜_{VERSION}.md', ('分类', '判定条数'))
  240. if not rows:
  241. WARN.append('需求分析 §9.1 的分类表未解析到,图 fig-req-04 用实跑输出兜底')
  242. ensure_evidence()
  243. txt = (SRC_DIR / '_guanlan_check.txt').read_text(encoding='utf-8', errors='replace')
  244. marks = re.findall(r'^\s*\[(OK|FAIL|--|!|i|\?)\]\s*([^—\n]+)', txt, re.M)
  245. cats = {'通过': sum(1 for m, _ in marks if m == 'OK'),
  246. '需处置': sum(1 for m, _ in marks if m == 'FAIL'),
  247. '跳过或提示': sum(1 for m, _ in marks if m not in ('OK', 'FAIL'))}
  248. ks, vals, note = list(cats), list(cats.values()), '依据 guanlan.py check 实跑输出'
  249. else:
  250. ks, vals, note = [], [], '依据《需求分析》§9.1 表(同源于 guanlan.py check 实跑输出)'
  251. c_name, c_n = col_of(head, '分类') or 0, col_of(head, '判定条数') or 1
  252. c_gate = col_of(head, '代表门')
  253. for r in rows:
  254. if r[c_name] == '合计':
  255. break
  256. gate = r[c_gate] if c_gate is not None and c_gate < len(r) else ''
  257. ks.append('%s(%s)' % (r[c_name], (gate[:22] + '…') if len(gate) > 22 else gate))
  258. vals.append(int(re.sub(r'[^0-9]', '', r[c_n]) or 0))
  259. print(' §9.1 门分类:', dict(zip(ks, vals)) if ks else '—')
  260. fig, ax = plt.subplots(figsize=(9.6, 0.62 * len(ks) + 1.9))
  261. barh(ax, ks, vals, xlabel='判定条数(合计 %d 条)' % sum(vals))
  262. ax.set_title('验收门分类与数量(%s)' % note, fontsize=11)
  263. save(fig, 'fig-req-04-验收门分类.png')
  264. # ── 系统设计说明组 ─────────────────────────────────────────────────────────────────
  265. def fig_des_01():
  266. fig, ax = canvas(9.6, 7.4)
  267. ax.text(50, 96, '观澜 v2 风电场智能分析系统 总体架构(离线单包,无外网依赖)', ha='center',
  268. fontsize=11.5, fontweight='bold')
  269. box(ax, 4, 84, 92, 9, '使用侧:浏览器(门户 / 分析总览 /detail/v2 / 本体网 / 运维控制台 /ops)',
  270. fc='#f6f2e8', ec='#b08a3e', fs=10)
  271. box(ax, 24, 71, 52, 9, '接入层:组件网关 28084(前缀改写 + data-abs 豁免 + 静态件)',
  272. fc='#eef3fb', ec='#3b6ea5', fs=10)
  273. box(ax, 3, 56, 18, 11, '分析组件\n18033 /detail', fs=9)
  274. box(ax, 23, 56, 17, 11, '振动组件\n18020 /cms', fs=9)
  275. box(ax, 42, 56, 16, 11, '仿真台\n18791·18792', fs=9)
  276. box(ax, 60, 56, 15, 11, '三维 viewer\n64292', fs=9)
  277. box(ax, 77, 56, 20, 11, '本机模型档\nOllama 11434\n(可选,未装即降级)', fs=9,
  278. fc='#f4eef6', ec='#7d5ba6')
  279. for x in (12, 31.5, 50, 67.5, 87):
  280. arrow(ax, (50, 71), (x, 67.4), color='#8a8a8a')
  281. box(ax, 3, 41, 94, 10, '产物层 outputs/<场>/:windscada 判级与曲线 · slim10min 窄仓 · 振动六层链 · '
  282. '本体对象库 · SOP/findings · guanlan 契约', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
  283. arrow(ax, (50, 56), (50, 51.4))
  284. box(ax, 3, 27, 94, 9, '摄入与构建层:rebuild_all.py 23 步(place_raw_data → 10min/1min 摄入 → '
  285. '10 个 SCADA 构建器 → 振动摄入 → 本体六步 → 审计)', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
  286. arrow(ax, (50, 41), (50, 36.4))
  287. box(ax, 3, 13, 46, 9, '输入层 data/raw/<场>/:scada_10min·scada_1min·scada_mdb\n故障报警·风机故障记录·'
  288. 'windcms·m5_cms_tcm·油样报告', fc='#f6f2e8', ec='#b08a3e', fs=9)
  289. box(ax, 51, 13, 46, 9, '共享技术资料(OEM 资料,本体 kb_ingest 读)\n配置 configs/:serve.json·'
  290. 'models.json·portal_pages.yaml', fc='#f6f2e8', ec='#b08a3e', fs=9)
  291. arrow(ax, (26, 23), (26, 26.6))
  292. arrow(ax, (74, 23), (74, 26.6))
  293. box(ax, 3, 2, 94, 7, '贯穿全链的守卫:单一真源(src/paths.py·src/version.py) · 审计门(guanlan.py check / '
  294. '7 个审计器) · 逐件来源台账(_provenance.json)', fc='#ffffff', ec='#555555', fs=9)
  295. save(fig, 'fig-des-01-总体架构.png')
  296. def fig_des_02():
  297. """serve.json 是**扁平**结构(`gateway`/`detail`/… 直接是端口整数),不是嵌套 dict。"""
  298. cfg = serve_cfg()
  299. host, pub = cfg.get('host', '?'), cfg.get('public_host', '')
  300. gw_port = cfg.get('gateway', '?')
  301. comps = [(k, cfg.get(k)) for k in ('detail', 'cms', 'sim', 'sim_sys', 'viewer')]
  302. fig, ax = canvas(9.6, 5.2)
  303. box(ax, 30, 82, 40, 12, '网关 gateway\n%s:%s(对外唯一入口)' % (pub or host, gw_port),
  304. fc='#eef3fb', ec='#3b6ea5', fs=10, bold=True)
  305. n = max(1, len(comps))
  306. for i, (name, port) in enumerate(comps):
  307. x = 3 + i * (94 / n)
  308. w = 94 / n - 3
  309. box(ax, x, 52, w, 14, '%s\n%s:%s' % (name, host, port), fs=9)
  310. arrow(ax, (50, 82), (x + w / 2, 66.6), color='#8a8a8a')
  311. box(ax, 3, 30, 94, 12, '绑定与自启:guanlan 服务(Windows 服务 / systemd)· start.bat 静默启动 · '
  312. 'run/*.pid 运行态 · logs/ 统一落点', fc='#f6f2e8', ec='#b08a3e', fs=9)
  313. arrow(ax, (50, 52), (50, 42.6))
  314. box(ax, 3, 12, 94, 12, '端口冲突处置:netstat -ano 找占用进程 → 按端口清理 → 重启服务\n'
  315. '(service_ctl stop 不杀子进程,只按端口杀会留下孤儿实例)', fc='#ffffff',
  316. ec='#555555', fs=9)
  317. arrow(ax, (50, 30), (50, 24.6))
  318. ax.text(50, 96, '端口与进程拓扑(依据 configs/serve.json 实读)', ha='center', fontsize=11.5,
  319. fontweight='bold')
  320. save(fig, 'fig-des-02-端口与进程.png')
  321. def fig_des_03():
  322. ensure_evidence()
  323. txt = (SRC_DIR / '_rebuild_plan.txt').read_text(encoding='utf-8', errors='replace')
  324. steps = []
  325. for line in txt.splitlines():
  326. m = re.match(r'^\s{2}(\S.*?)\s{2,}(\S.*)$', line)
  327. if m and not line.strip().startswith('('):
  328. steps.append((m.group(1).strip(), m.group(2).strip()))
  329. if not steps:
  330. WARN.append('rebuild_all --dry-run 未解析出步骤')
  331. return
  332. fig, ax = plt.subplots(figsize=(9.6, 0.42 * len(steps) + 1.8))
  333. ax.axis('off')
  334. ax.set_xlim(0, 10)
  335. ax.set_ylim(-0.5, len(steps) - 0.2)
  336. for i, (name, cmd) in enumerate(steps):
  337. y = len(steps) - 1 - i
  338. ax.add_patch(plt.Rectangle((0.2, y - 0.34), 1.75, 0.68, facecolor='#eef3fb',
  339. edgecolor='#3b6ea5', lw=0.9))
  340. ax.text(1.07, y, name[:22], fontsize=8.6, ha='center', va='center')
  341. ax.text(2.15, y, cmd[:92], fontsize=8.2, va='center', color='#333333', family='monospace')
  342. if i < len(steps) - 1:
  343. ax.annotate('', xy=(1.07, y - 0.42), xytext=(1.07, y - 0.30),
  344. arrowprops=dict(arrowstyle='-|>', color='#8a8a8a', lw=0.9))
  345. ax.set_title('重算链步骤(依据 scripts/rebuild_all.py --dry-run --skip-scada,共 %d 步)' % len(steps),
  346. fontsize=11)
  347. save(fig, 'fig-des-03-重算链.png')
  348. def fig_des_04():
  349. fig, ax = canvas(9.6, 6.4)
  350. ax.text(50, 96, '判级矩阵与四源融合数据流(每条判据都能指到输入列)', ha='center',
  351. fontsize=11.5, fontweight='bold')
  352. srcs = ['SCADA 10min\n(slim10min 48 列)', 'CMS 振动\n(六层链)', '状态·故障·告警\n(台账)', '油样与工单\n(现场记录)']
  353. for i, s in enumerate(srcs):
  354. box(ax, 1.5 + i * 24.6, 78, 22, 12, s, fc='#f6f2e8', ec='#b08a3e', fs=9)
  355. box(ax, 6, 58, 40, 12, '七系统判级矩阵\n变桨·偏航·主轴承·齿轮箱·发电机·变流器·主控与传感网',
  356. fc='#eef3fb', ec='#3b6ea5', fs=9.5)
  357. box(ax, 52, 58, 42, 12, '融合面四源对齐\n振动 × SCADA × 台账 × 人工\n(逐台判级 + 证据链)',
  358. fc='#eef3fb', ec='#3b6ea5', fs=9.5)
  359. for x in (12, 36, 62, 86):
  360. arrow(ax, (x, 78), (26 if x < 50 else 73, 70.6), color='#8a8a8a')
  361. box(ax, 6, 40, 88, 11, '时间窗:判级与曲线按所选时间窗重算(月/年/自定义起止日期,含两端);'
  362. '温度与停机台账按真窗取月', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
  363. arrow(ax, (26, 58), (26, 51.6))
  364. arrow(ax, (73, 58), (73, 51.6))
  365. outs = ['部件问题清单\n(按系统×机组)', '该问题页\n(单问题+完整依据)', '发电性能与可靠性\n(MTBF/MTBO/MDT)',
  366. '控制参数一致性\n(P/ω 封顶·K 聚类)', '报告导出\n(docx/md/页面)', '本体机制链\n与决策台']
  367. for i, o in enumerate(outs):
  368. x = 2 + (i % 3) * 32.3
  369. y = 24 if i < 3 else 8
  370. box(ax, x, y, 30, 12, o, fc='#f4eef6', ec='#7d5ba6', fs=9)
  371. arrow(ax, (50, 40), (50, 36.6), color='#5b8c4a')
  372. box(ax, 2, 21.5, 96, 2.6, '', fc='#ffffff', ec='#ffffff')
  373. ax.text(50, 36, '产物(outputs/<场>/…,逐件登记生成端与输入)', ha='center', fontsize=9, color='#444444')
  374. save(fig, 'fig-des-04-判级与融合.png')
  375. def fig_des_05():
  376. fig, ax = canvas(9.6, 5.6)
  377. ax.text(50, 96, '时间窗按窗重算与 pending 契约(实测:冷窗首查 pending,约 30 s 后落位)',
  378. ha='center', fontsize=11.5, fontweight='bold')
  379. lanes = [('前端 app.js', 82), ('服务 windscada_serve.py', 60), ('按窗缓存 _WIN_CACHE', 38), ('重算线程(_HEAVY_LOCK 串行)', 16)]
  380. for name, y in lanes:
  381. box(ax, 2, y, 24, 11, name, fc='#eef3fb', ec='#3b6ea5', fs=9)
  382. ax.plot([26.5, 97], [y + 5.5, y + 5.5], color='#d0d0d0', lw=0.9, zorder=0)
  383. msgs = [(82, 39, 'GET /api/fleet?win=2026-07'),
  384. (60, 82, 'win 未命中 → 起后台线程,先回上一份口径并标 pending=1'),
  385. (38, 60, '缺 → 入队 sysmx/curves/m9'),
  386. (16, 38, '算(变桨/偏航/蓄能/温度 + 曲线 + M9)'),
  387. (38, 16, '算完写入缓存'),
  388. (60, 38, '5 s 轮询再取 → 命中'),
  389. (82, 60, 'pending=0,页面换成该时间窗口径')]
  390. for y1, y2, text in msgs:
  391. arrow(ax, (36, y1 + 5.5), (36, y2 + 5.5), color='#3b6ea5')
  392. ax.text(38.5, (y1 + y2) / 2 + 5.5, text, fontsize=8.8, va='center')
  393. ax.text(50, 5, '为什么这样:算得慢(每面 5~120 s),不能把页面卡住;宁可先给旧口径并**如实标注**,也不静默等待。',
  394. ha='center', fontsize=9, color='#444444')
  395. save(fig, 'fig-des-05-时间窗与pending.png')
  396. def fig_des_06():
  397. fig, ax = canvas(9.0, 6.0)
  398. cx, cy, r = 50, 50, 30
  399. items = [('改代码/配置', '#eef3fb'), ('重算 rebuild_all 23 步', '#e8f1e4'),
  400. ('审计器 7 件(反向/页面/配置/链路/版本…)', '#f6f2e8'),
  401. ('guanlan.py check 全绿', '#f4eef6'), ('打包 pack_dist + 开箱验证', '#eef3fb'),
  402. ('装到目标机 install.ps1', '#e8f1e4')]
  403. import math
  404. for i, (t, fc) in enumerate(items):
  405. a = math.radians(90 - i * 360 / len(items))
  406. x, y = cx + r * math.cos(a), cy + r * math.sin(a)
  407. box(ax, x - 15, y - 6, 30, 12, t, fc=fc, ec='#3b6ea5', fs=9)
  408. a2 = math.radians(90 - (i + 1) * 360 / len(items))
  409. arrow(ax, (cx + (r - 6) * math.cos(a), cy + (r - 6) * math.sin(a)),
  410. (cx + (r - 6) * math.cos(a2), cy + (r - 6) * math.sin(a2)),
  411. color='#8a8a8a', rad=-0.25)
  412. box(ax, 38, 44, 24, 12, '版本纪律\n单一真源 src/version.py', fc='#ffffff', ec='#555555', fs=9, bold=True)
  413. ax.text(50, 96, '质量门与审计闭环(每环都能独立重跑、逐条给退出码)', ha='center', fontsize=11.5,
  414. fontweight='bold')
  415. save(fig, 'fig-des-06-质量门闭环.png')
  416. GROUPS = {
  417. 'req': [fig_req_01, fig_req_02, fig_req_03, fig_req_04],
  418. 'des': [fig_des_01, fig_des_02, fig_des_03, fig_des_04, fig_des_05, fig_des_06],
  419. }
  420. # ★2026-09-22 用户令: 《数据要求说明》改成纯数据需求规格、不再放插图(原 dat 组四张是样本场现状图,
  421. # 不再体现)—— 组与期望清单同步去掉, 四个 PNG 也从 docs/figures 删除。
  422. EXPECTED = ['fig-req-01-需求演进时间轴.png', 'fig-req-02-角色场景页面.png',
  423. 'fig-req-03-页面归口分布.png', 'fig-req-04-验收门分类.png',
  424. 'fig-des-01-总体架构.png', 'fig-des-02-端口与进程.png', 'fig-des-03-重算链.png',
  425. 'fig-des-04-判级与融合.png', 'fig-des-05-时间窗与pending.png', 'fig-des-06-质量门闭环.png']
  426. def main() -> int:
  427. ap = argparse.ArgumentParser()
  428. ap.add_argument('--only', default=None, choices=sorted(GROUPS))
  429. ap.add_argument('--refresh', action='store_true',
  430. help='重跑取证底稿(guanlan.py check / rebuild_all --dry-run)后再画图')
  431. ap.add_argument('--check', action='store_true', help='只报缺哪些图')
  432. a = ap.parse_args()
  433. if a.refresh:
  434. for p in (SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'):
  435. p.unlink(missing_ok=True)
  436. if a.check:
  437. miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
  438. print('图表体检: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
  439. for f in miss:
  440. print(' ✗', f)
  441. return 0 if not miss else 5
  442. groups = [a.only] if a.only else sorted(GROUPS)
  443. for g in groups:
  444. print('── %s 组 ──' % g)
  445. for fn in GROUPS[g]:
  446. try:
  447. fn()
  448. except Exception as e: # noqa: BLE001
  449. WARN.append('%s 失败: %s' % (fn.__name__, e))
  450. print(' ✗', fn.__name__, e)
  451. miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
  452. if WARN:
  453. print('\n告警/缺件:')
  454. for w in WARN:
  455. print(' -', w)
  456. print('\n图表: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
  457. return 0 if not miss else 5
  458. if __name__ == '__main__':
  459. raise SystemExit(main())