version.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. # -*- coding: utf-8 -*-
  2. """版本与安装信息的**唯一真源**(2026-09-17 用户令 1:安装时检查已装版本、提示差异、问是否重装)。
  3. 为什么单开一个模块:以前版本号散在三处(`scripts/pack_dist.py` 的 `VERSION`、README 抬头、说明书标题),
  4. 改一处忘一处就会出现"包说 0.4.0、安装记录说 0.2.0"这种对不上的事。现在:
  5. src/version.py ← 唯一真源(本文件)
  6. scripts/pack_dist.py ← 读它写进 dist-manifest.json 与默认包名
  7. install.ps1 / install.sh ← 读它写进 <安装目录>/install-info.json,并与已装的比对
  8. guanlan.py / 各文档 ← 读它(文档里的人工版本号以它为准)
  9. `install-info.json` 落在安装根本身(不在 run/ 里):它是**交付物级别的安装记录**,
  10. 卸载/重装/拷机都要跟着走,所以和 `configs/`、`dist-manifest.json` 同级。
  11. """
  12. from __future__ import annotations
  13. import datetime as dt
  14. import json
  15. import pathlib
  16. NAME = '观澜·如东样板 v2'
  17. VERSION = '2.8.1' # ★ 改版本只改这里
  18. EDITION = 'offline-single-package'
  19. PACKAGE_STEM = 'app_guanlang' # 交付包文件名前缀(用户令 2026-09-17)
  20. INSTALL_INFO = 'install-info.json' # 相对安装根
  21. SERVICE_NAME = 'guanlan' # Windows 服务名 / systemd 单元名(用户令 1)
  22. SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
  23. # ── 版本号规则(用户令 2026-09-17)────────────────────────────────────────────────
  24. # 编号: v<大版本号>.<中版本号>.<小版本号> 例: v2.5.0
  25. # 定义: 大版本号 —— 系统解决方案、架构或核心功能改变
  26. # 中版本号 —— 非核心功能新增、减少、修改
  27. # 小版本号 —— 消缺完善
  28. # 包名: 打包文件名 = app_guanlang_v<版本号>.zip 例: app_guanlang_v2.5.0.zip
  29. # 用法: 用户/发布者定"这次算哪一级"→ VERSION 改一行 → 打包器与安装脚本自动跟着变
  30. # (级别判据见 BUMP_RULE;一次发布混了几类就取最高那一级)
  31. RULE = 'v<大版本号>.<中版本号>.<小版本号>'
  32. LEVEL_MEANING = {
  33. 'major': '大版本号 —— 系统解决方案、架构或核心功能改变',
  34. 'minor': '中版本号 —— 非核心功能新增、减少、修改',
  35. 'patch': '小版本号 —— 消缺完善',
  36. }
  37. BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);'
  38. '落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);'
  39. '只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级')
  40. # 版本记录(人读的那份由 scripts/version_log.py 生成到 docs/版本记录.md;这里只有事实,不重复描述)
  41. # level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
  42. # 旧编号体系(0.x,未按本规则),仅作历史对账用。
  43. HISTORY: tuple[dict, ...] = (
  44. dict(version='2.8.1', date='2026-09-20', level='patch',
  45. title='消缺:重算链末步「台账等价验收」在全新机器上必然 rc=5(交付包不含产物 ⇒ 没有随包基线),'
  46. '原先把它判成失败 ⇒ 门户上写"重算失败"(实测服务器 2.8.0 一轮 24/24 步全 OK 却整轮 rc=1)',
  47. note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
  48. '① `rebuild_all.py` 的 ⑧ 台账等价验收由 `tolerate=(4,)` 改成 `tolerate=(4, 5)` —— '
  49. 'rc=5 = "找不到随包基线 ⇒ 没做验收",与 ④ 的口径对齐(④ 一直容忍 5 并写明"跳过等价验收≠通过");'
  50. '步名与步说明都改写清楚"rc=4 = 有需人工看的差异 / rc=5 = 没基线 ⇒ 没验收(不是失败,也不是通过)",'
  51. '并指路 `scripts/set_baseline.py`(用已核实的当前产物快照登记基线,之后这一项才会真比对); '
  52. '② 远端复核(服务器 D:\\产品\\app): 该轮 24 个实质步骤全绿(①b 扫描 53.7s · ② 164s · ③ 1025s · '
  53. '④ 99s · ④b 2118s · ④c 385s · ④d 41.6s · ④e 15.3s · ⑤b/⑤c/⑤a/⑤ · ⑥ · ⑦×6 · ⑦b 78s · ⑧ 7.1s · '
  54. '⑧b 3s · ⑧c 9.5s),只有第 25 步判失败;且同批数据已真进产物:`temp_monthly` 21 个月、末四格 '
  55. '2026-06/07/**08**/09、2026-08 = 1,026 行(服务器 `scada_10min` 也有 76 件含 38 个 `WTG??-B2.csv` '
  56. '同台补充件,说明步骤 B 的同台多件合并已在服务器上生效); '
  57. '③ 已把修好的 `scripts/rebuild_all.py`(+ `src/version.py` / `docs/版本记录.md`)单文件部署到服务器,'
  58. '无需重装整包。交付包 app_guanlang_v2.8.1.zip'),
  59. dict(version='2.8.0', date='2026-09-20', level='minor',
  60. title='输入数据自动扫描识别:<安装目录>/data/raw 逐族指纹 → 发现新增/变化 → 指明该跑哪几步;'
  61. '重算链加 ①b 步 + `rebuild_all.py --auto`(新数据不被 --skip-* 漏掉)+ 服务侧可选看门狗',
  62. note='非核心功能新增 ⇒ 中版本 +1(无架构改动)。要点: '
  63. '① 新增 `scripts/raw_scan.py`(用户令 2026-09-19「对 <安装目录>/data/raw 目录下的接入数据处理,'
  64. '增加自动扫描识别机制,以能发现新增数据,并纳入重算」):逐族指纹 = 件数/体积/最新落盘时间/'
  65. '清单摘要(`--deep` 再叠内容哈希),与快照 `outputs/<场>/_raw_scan.json` 比;'
  66. 'rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; `--write` 记基线, `--json` 给机器读, '
  67. '`--selftest` 对账"族表覆盖反查族表里所有 raw 输入 + 步骤名都能在链上找到"(防两处漂移); '
  68. '② 族表把每个 raw 子目录映射到链上步骤(故障报警/工单/油样→②;scada_10min→③④④c⑤b;'
  69. 'scada_1min→④c;scada_mdb→③④④c;windcms→④b④d④e;m5_cms_tcm→④b;西门子4.0技术资料→⑦),'
  70. '`data/raw/<场>/` 下出现族表没有的目录则如实报"未归类、没有已知消费者"(不猜); '
  71. '③ 进重算链作 **①b 步**(`--check --write`,容忍 rc=4/5,并在步说明里写清"4 = 有新数据不是失败"); '
  72. '④ `rebuild_all.py --auto`:先扫一遍,新数据落在被 `--skip-scada`/`--skip-vib` 跳过的族里就'
  73. '**自动取消跳过**(实测: scada_10min 新增 1 件 → 计划从 22 步变 24 步并打印取消原因); '
  74. '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
  75. '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
  76. '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
  77. '★2026-09-20 按用户令「对 data/raw(**含嵌套子目录**)下文件的增减做到监听;重算要按该目录'
  78. '**最新的变化**算」逐条查证并补了三处: '
  79. '⑥ 扫描改成**递归统计全部文件**(不限扩展名; 白名单命中的另记 matched, 差额单列为"信息,不计变化")'
  80. '+ **子目录清单进指纹**(新建空目录也发现) + 未归类改成**全树逐件**找(不再只看一层); '
  81. '⑦ **产物 = 当前源件的函数**(删/换源件后, 那些行不再产出并大声报出"哪几件不在盘、少多少行、'
  82. '涉及哪些月"): 报警/工单/油样三个摄入器原先是"只替换本次涉及的件、其余原样保留" ⇒ 源件删了行还在; '
  83. '⑧★报警台账改**键集合并集**去重(键=Name/Alarmcode/TimeOn, 归属取最窄的源件)—— 原先按"逐件累加、'
  84. '没新键就跳过"的贪心判快照, 实测**删一个源件反而让总行数从 39,211 涨到 56,593**(被跳过的大快照件'
  85. '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
  86. '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
  87. '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
  88. '要旧行为用 `--reimport-as-new`)。'
  89. '★另按用户令「按你的建议执行 先 C 再 B」补完"同台多件 10min 进不来"这条链: '
  90. '⑩ **步骤 C(让"放了没人读"当场可见)**:`raw_scan.py` 新增 `CONSUME` 表(逐族写清**消费者真实'
  91. '取数口径**),判据从"扩展名白名单没命中"改成"**全部文件里没被消费者读的**"—— 实测 `data/raw/'
  92. '如东/scada_10min/WTG01-B2.csv`(856 列 / 2026-08 数据)扩展名 `*.csv` 命中白名单,旧判据'
  93. '**永远发现不了**它;现在报成**缺口级**(计入变化 rc=4 + ★[缺口] 块写明消费者口径与件例),'
  94. '附件类(.rar/.jpg/厂商软件)仍只列信息;建议行也分流说明"缺口类重算也纳入不了"。'
  95. '`raw_data_check.py` 同步: 同台补充件不再误报"文件名不是本场机组",改为 info 行。'
  96. '⑪ **步骤 B(真正把新月份的 10min 件吃进来)**:`scada_source.load` 取数从"只认 `<台号>.csv`"'
  97. '扩到 **`<台号>.csv` + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`**:按列名对齐(新导出把类型前缀'
  98. '去掉了: `din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`,实测老件 598 列里 597 列能这样对上)、'
  99. '按时间戳排序去重(**主件优先**,重叠条数写进 `attrs.scada_overlap` 并打印)、来路件名与各件行数'
  100. '写进 `attrs.scada_files/scada_rows`。实测 WTG01:主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行'
  101. '(重叠 18 行去重),时间范围由 2026-07-07 延到 **2026-09-01**,2026-08 的 5,819 行关键通道'
  102. '非空率 77%(**空白是源件自带的**:该月 144 行/日齐全但约 23% 行的通道为空,如实保留 NaN)。'
  103. '交付包 app_guanlang_v2.8.0.zip'),
  104. dict(version='2.7.0', date='2026-09-19', level='minor',
  105. title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
  106. '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',
  107. note='一次消缺 + 一项非核心功能新增(对外监听)⇒ 按规则取最高一级(minor)。要点: '
  108. '① ★远端实逮(服务器 D:\\产品\\app):索引 719 s + 谱 894 s 落进 windows\\_staging_ingest 后,'
  109. '`staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问`(360 实时扫描/索引器持着'
  110. '刚写的 1710 件小文件的句柄,Windows 的目录改名要求树里无打开句柄)⇒ ④b 失败 ⇒ 链停在第 4 步,'
  111. '页面表现为「需要关注 0 台 + 融合面缺件」。修法:发布改 `_move_tree()`(整目录 rename 退避重试 '
  112. '→ 逐件搬,rename 不行就 copy+删 → 仍锁住的逐条如实报出);并加 `--publish-only`:已算完的暂存窗'
  113. '可直接发布,不必白等 25 分钟重跑索引/谱; '
  114. '② 用户令「观澜改为监听所有 IP」:`configs/serve.json` 增 `public_host`(空=跟 host=只本机;'
  115. '设 0.0.0.0=所有网卡),**只有门户网关**用它,detail/cms/viewer/sim 仍绑 127.0.0.1 ⇒ 对外只有 '
  116. '28084 一个入口;启动时打印对外地址并提示"页面无鉴权,请在防火墙侧限来源"; '
  117. '③ 消缺: 新生成端在 GBK 控制台打印 m/s² / ⇒ 时抛 UnicodeEncodeError(远端 baseline_38 整件没出)'
  118. '⇒ 四个生成端加 stdout 守卫;trend_ingest 的"台账止 2024-11"过期断言改实话;'
  119. 'CMS 三层基线卡的"健康期窗 N"重复措辞; '
  120. '④ 部署口径: 用 SSH 起的服务在会话断开时会被一起收掉 ⇒ 现场改注册 Windows 服务(`guanlan`)'
  121. '(`scripts/service_ctl.py install`),开机自启、SCM 崩溃重启。交付包 app_guanlang_v2.7.0.zip'),
  122. dict(version='2.6.0', date='2026-09-19', level='minor',
  123. title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步/'
  124. '变桨面/振动在升与三层基线 全部落成观澜自算;SCADA 接入兼容 CSV+MDB;'
  125. '交付包带输入数据目录结构(不含文件)',
  126. note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
  127. '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
  128. '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
  129. '② 融合面 handoff 与总览页 windscada/index.html 由观澜自算生成(原为"包内无生成端",'
  130. '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
  131. '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
  132. '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
  133. '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝;37 个月库按修好的建库脚本'
  134. '重建并逐分片对拍); '
  135. '⑥ 变桨面 pitch/** 落成生成端(10min 开关量/压力锯齿 + 1min 桨距角;零位口径按用户令'
  136. '**停机段/满发段分列**,并据实测反推补上**运行段同工况分档**这一真正的判据轴 —— 它能把'
  137. '现场确诊的 19# 单独拎出来); '
  138. '⑦ 振动 component_history.json(在升/换件闭环)与 baseline_38.json(自/机群/绝对三层基线)'
  139. '落成生成端(窗数不足时如实空表并写明数据边界); '
  140. '⑧ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
  141. '仍不含数据文件; ⑨ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
  142. '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
  143. 'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
  144. dict(version='2.5.0', date='2026-09-17', level='minor',
  145. title='卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录',
  146. note='二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;'
  147. '交付包 app_guanlang_v2.5.0.zip'),
  148. dict(version='0.4.0', date='2026-09-17', level='legacy',
  149. title='打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;'
  150. '页面归口审计;输入数据放置检查;安装时版本检查与服务化',
  151. note='旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip)'),
  152. dict(version='0.2.0', date='2026-09-01', level='legacy',
  153. title='含产物的旧交付基线(历史上用作"包内无生成端"那批产物的补救源;该用途 2026-09-19 起已失效)',
  154. note='旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip —— ★2026-09-19 用户令从工作树'
  155. '清理(943 MB):① 「所有的计算均要形成观澜的源代码」落地后无生成端件 = 0(反向呼应审计 '
  156. '1,800 件全部 raw-derived)⇒ 它作为"补救源"的用途消失;② 该包仍留在 git 历史里'
  157. '(blob 可随时 git 取回),需要时用 git show 恢复即可'),
  158. )
  159. def package_name(version: str | None = None, ext: str = 'zip') -> str:
  160. """交付包文件名(用户令 2026-09-17 的命名规则):`app_guanlang_v<版本号>.zip`。"""
  161. return f'{PACKAGE_STEM}_v{version or VERSION}.{ext}'
  162. def level_of(old: str, new: str = VERSION) -> str:
  163. """两次版本之间是"哪一级"的变化 → major / minor / patch / same / ?(版本管理的机器判据)。"""
  164. a, b = _parts(old), _parts(new)
  165. if a == b:
  166. return 'same'
  167. if len(a) < 3 or len(b) < 3:
  168. return '?'
  169. if b[0] != a[0]:
  170. return 'major'
  171. if b[1] != a[1]:
  172. return 'minor'
  173. return 'patch'
  174. def rule_lines() -> list[str]:
  175. """把版本规则印出来(README / 版本记录 / `guanlan.py version` 共用同一份措辞)。"""
  176. return [f'编号: {RULE} 例: v{VERSION}',
  177. *[f' {LEVEL_MEANING[k]}' for k in ('major', 'minor', 'patch')],
  178. f'包名: 打包文件名 = {package_name()}']
  179. def info_path(root: pathlib.Path) -> pathlib.Path:
  180. return pathlib.Path(root) / INSTALL_INFO
  181. def read_installed(root: pathlib.Path) -> dict | None:
  182. """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。
  183. ★ 用 `utf-8-sig` 读:本器写的是无 BOM 的 UTF-8,但**别的工具/手改**过这份 json 时可能带 BOM
  184. (实测 PowerShell 的 `Set-Content -Encoding UTF8` 就带 BOM)。带 BOM 而按 utf-8 读会抛异常,
  185. 于是"明明装了却报没装过" —— 版本检查直接失灵,所以这里按最宽的方式读。
  186. """
  187. p = info_path(root)
  188. if not p.is_file():
  189. return None
  190. try:
  191. d = json.loads(p.read_text(encoding='utf-8-sig'))
  192. return d if isinstance(d, dict) else None
  193. except Exception:
  194. return None
  195. def write_installed(root: pathlib.Path, python: str = '', mode: str = 'manual', extra: dict | None = None) -> dict:
  196. """写安装记录(安装脚本在装完时调用)。mode: manual | windows-service | systemd | startup-entry。"""
  197. root = pathlib.Path(root)
  198. rec = dict(name=NAME, version=VERSION, edition=EDITION, installed_at=dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  199. python=python or '', mode=mode, host=str(root))
  200. rec.update(extra or {})
  201. info_path(root).write_text(json.dumps(rec, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
  202. return rec
  203. def _parts(v: str) -> tuple:
  204. out = []
  205. for seg in str(v).strip().lstrip('vV').split('.'):
  206. num = ''.join(ch for ch in seg if ch.isdigit())
  207. out.append(int(num) if num else 0)
  208. return tuple(out)
  209. def compare(installed: dict | None, pkg_version: str = VERSION) -> dict:
  210. """安装检查的核心:已装版本 vs 本包版本 → 判断该提醒什么。
  211. → dict(state, installed_version, pkg_version, message, action)
  212. state: none(没装过)· same(同版本)· older(已装更旧,本包是升级)·
  213. newer(已装更新,本包更旧 —— 要特别提醒别降级)· unknown(老安装没写版本记录)
  214. action: install(照常装)· ask(问用户是否重装)· warn(要用户明确确认)
  215. """
  216. cur = (installed or {}).get('version') or ''
  217. if not installed:
  218. return dict(state='none', installed_version='', pkg_version=pkg_version,
  219. message=f'未检测到已安装的{NAME}(将全新安装 v{pkg_version})', action='install')
  220. if not cur:
  221. return dict(state='unknown', installed_version='', pkg_version=pkg_version,
  222. message=f'检测到安装目录已有部署,但没有版本记录(老版本安装)'
  223. f'—— 本包是 v{pkg_version},将按"重装/升级"处理', action='ask')
  224. if _parts(cur) == _parts(pkg_version):
  225. return dict(state='same', installed_version=cur, pkg_version=pkg_version,
  226. message=f'已安装 {NAME} v{cur},与本包 v{pkg_version} **版本相同**', action='ask')
  227. if _parts(cur) < _parts(pkg_version):
  228. return dict(state='older', installed_version=cur, pkg_version=pkg_version,
  229. message=f'已安装 v{cur} → 本包 v{pkg_version}(**升级**)', action='ask')
  230. return dict(state='newer', installed_version=cur, pkg_version=pkg_version,
  231. message=f'已安装 v{cur},而本包是 v{pkg_version}(**本包更旧 / 降级**)'
  232. f'—— 除非确知原因,不要用旧包覆盖新装', action='warn')
  233. def summary_lines(cmp_res: dict, installed: dict | None) -> list[str]:
  234. """给人看的几行(安装脚本与 guanlan.py check 共用同一份措辞)。"""
  235. out = [cmp_res['message']]
  236. if installed:
  237. out.append(f" 现有安装: 装在 {installed.get('installed_at', '?')}"
  238. + (f",运行方式 {installed.get('mode')}" if installed.get('mode') else '')
  239. + (f",解释器 {installed.get('python')}" if installed.get('python') else ''))
  240. return out
  241. if __name__ == '__main__':
  242. import sys
  243. print(f'{NAME} v{VERSION} ({EDITION})')
  244. print(f'服务名: {SERVICE_NAME} · 显示名: {SERVICE_DISPLAY}')
  245. root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[1]
  246. ins = read_installed(root)
  247. c = compare(ins)
  248. print(f'安装记录: {info_path(root)}')
  249. for ln in summary_lines(c, ins):
  250. print(' ' + ln)
  251. print(f" → state={c['state']} action={c['action']}")