| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692 |
- #!/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')
- # ── 数据要求说明组 ─────────────────────────────────────────────────────────────
- 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',
- '油样报告', '共享技术资料(OEM)']
- 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 项 → 观澜侧到位状态(**兜底表**:正常走「解析《数据要求说明》§8.1 表」这条路;
- # 这里只在源件/表结构异常时顶上,故同样按对外口径写成中性表述,不出现样本场标识)
- REQ_STATUS = [
- ('1', '10分钟SCADA数据', '已在位', 'scada_10min(样本 38 件)+ scada_mdb 归档'),
- ('2', '秒级SCADA高频数据', '部分', 'scada_1min 38 件在位;1 s/5 s 原生高频未见交付'),
- ('3', '测风塔数据', '未到位', '契约 mast: FALSE —— 测风塔目录零交付(须催缴)'),
- ('4', '功率曲线', '部分', '合同担保曲线为图片级(须描点提取)'),
- ('5', '机型参数', '已在位', 'configs/contracts/<机型>_<场>.yaml 的 meta + 理论功率'),
- ('6', '机位分布数据', '已在位', '场站坐标件(依契约记载)'),
- ('7', '状态、事件、故障、告警', '已在位', '故障报警 + 风机故障记录(代码表 2,793 条)'),
- ('8', '机舱CMS振动数据', '已在位', 'windcms 约 15.4 万 MB 级 + 厂商报告'),
- ('9', '机组统计数据/运行数据', '已在位', 'SCADA 统计 + sum 层(DailySummary)'),
- ('10', '保护定值及控制参数', '部分', '整定值表在位;策略文档未交付'),
- ('11', '机组故障录波数据', '未到位', 'fastlog 仅 4 台、10 Hz 事件快照'),
- ('12', '可研报告及微观选址', '未到位', '目录未见交付'),
- ('13', '风电场简介', '部分', '场站元信息散见合同与技术资料'),
- ('14', '风电场坐标、机位布置图', '已在位', '依契约记载;布置图含于技术资料'),
- ('15', '风机技术协议及合同', '已在位', 'OEM 技术资料 + 担保曲线附件'),
- ('16', '并网至今生产报表', '部分', '日粒度发电账 555 天 + 全寿命月账(2016-06 起)'),
- ('17', '周围已建风电场生产报表', '未到位', '未见交付(尾流先验缺外部对照)'),
- ('18', '运维记录', '已在位', '检修记录 2025+2026 / 油样 2025'),
- ('19', '大部件更换记录', '部分', '出保备件台账在位;更换台账不完整'),
- ('20', '图纸、技术文件', '已在位', 'OEM 技术资料(电气/液压/逻辑)'),
- ('21', '设备参数', '部分', '主轴/齿轮箱/发电机型号与齿数部分在位,明细待补'),
- ]
- def fig_dat_03():
- """收资 21 项到位状态 —— **与《数据要求说明》§8.1 表同源**(解析那张表,图文不打架)。
- ★2026-09-22 实逮(我自己的 bug): 原先按**写死的列号**取"状态",而该表是
- `序号|数据类型|观澜是否必须|对应数据族/落位|状态|缺口与催缴要点` —— 第 5 列才是状态,
- 写死取第 4 列 ⇒ 21 行全部落进"未标",图照样画得出来(所以没被"生成成功"发现)。
- 现在一律**按表头名定位列**,并把"未标"当告警打出来。
- """
- from collections import Counter
- head, rows = md_find_table(SRC_DIR / f'数据要求说明_观澜_{VERSION}.md', ('序号', '状态'), first_cell='1')
- items = []
- if rows and len(rows) >= 21:
- ci_no, ci_name = col_of(head, '序号') or 0, col_of(head, '数据类型') or 1
- ci_st = col_of(head, '状态')
- ci_note = col_of(head, '缺口', '催缴')
- for r in rows[:21]:
- status = r[ci_st] if ci_st is not None and ci_st < len(r) else ''
- if '已在位' in status:
- st = '已在位'
- elif '部分' in status:
- st = '部分'
- elif '未到位' in status:
- st = '未到位'
- else:
- st = '未标'
- note = r[ci_note] if ci_note is not None and ci_note < len(r) else ''
- qual = '(依契约记载)' if '记载' in status else ''
- items.append((r[ci_no], r[ci_name], st, qual + note[:34]))
- if any(i[2] == '未标' for i in items):
- WARN.append('§8.1 表有 %d 行状态未识别(表头=%s)'
- % (sum(1 for i in items if i[2] == '未标'), head))
- else:
- WARN.append('数据要求说明 §8.1 的 21 项表未解析到,图 fig-dat-03 用内置判定兜底')
- items = REQ_STATUS
- cnt = Counter(r[2] for r in items)
- print(' §8.1 状态计数:', dict(cnt))
- 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() # 当前场(不硬编码场代号,避免进图题)
- 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/<场>/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())
|