delivery_docs_figures.py 27 KB

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