#!/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()` 只是兜底。 ★对外文档口径(用户令 2026-09-22): 真实场站名只用于**取数**,不进图题 —— 图里一律写「样本场」。 """ from src.windscada.config import farm as _farm home = P.RAW_ROOT / str(_farm().get('raw_station') or P.farm()) 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 head = [c.strip() for c in l.strip().strip('|').split('|')] 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 head, rows return [], [] def col_of(head, *names): """按表头名取列号(取第一个命中的;找不到返回 None)。""" for n in names: for i, h in enumerate(head): if n in h: return i return None 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 需求演进时间轴(样本场;依据 src/version.py 的 HISTORY,共 {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, '角色 — 场景 — 页面关系(依据 系统使用说明书 与 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 表同源**(直接解析那张表,避免图文数字打架)。""" head, 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 实跑输出)' c_name, c_n = col_of(head, '分类') or 0, col_of(head, '判定条数') or 1 c_gate = col_of(head, '代表门') for r in rows: if r[c_name] == '合计': break gate = r[c_gate] if c_gate is not None and c_gate < len(r) else '' ks.append('%s(%s)' % (r[c_name], (gate[:22] + '…') if len(gate) > 22 else gate)) vals.append(int(re.sub(r'[^0-9]', '', r[c_n]) or 0)) print(' §9.1 门分类:', dict(zip(ks, vals)) if ks else '—') 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, '共享技术资料(OEM 资料,本体 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') 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], } # ★2026-09-22 用户令: 《数据要求说明》改成纯数据需求规格、不再放插图(原 dat 组四张是样本场现状图, # 不再体现)—— 组与期望清单同步去掉, 四个 PNG 也从 docs/figures 删除。 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'] 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())