# 观澜·如东样板 v2 (v0.2.0) 启动报错修复记录 — 2026-09-11 本目录**不属于原始交付包**, 是现场修完之后留下的记录: 每个脚本都可重复运行 (幂等), 用来复现 "改了什么" 或在别的机器/另一份拷贝上重放同一批修复。 ## 现场现象 ``` powershell -ExecutionPolicy Bypass -File install.ps1 -> 装完最后一步崩: json.decoder.JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig) start.bat -> 同样崩; 随后 [X] Server did not start (reason above). Not opening the browser ``` ## 根因 (5 条, 前 3 条是同一个坑的不同后果) | # | 位置 | 问题 | |---|------|------| | 1 | `install.ps1` 第 4 步 `Set-Content configs\serve.json -Encoding UTF8` | **Windows PowerShell 5.1 的 `-Encoding UTF8` 写出带 BOM 的 UTF-8**; `guanlan.py` 用裸 `utf-8` 读 -> `JSONDecodeError`。启动器在解析配置时就死了, 所以 `check`/`serve` 全跑不起来。 | | 2 | 同一行的 `Get-Content configs\serve.json -Raw` | PS 5.1 对**无 BOM** 的 UTF-8 按 ANSI 代码页 (中文机 = GBK) 解码, 于是把文件里的中文注释读成乱码, 再 `Set-Content` 写回 -> `_note` / `_note_raw` 两个字段永久损坏 (部分字节被替换成 `?`, 不可逆)。 | | 3 | 机器全局 `PYTHONPATH=D:\Program Files\Python\Lib\site-packages;` | ① pip 认为系统那套包 "已满足", **没把传递依赖装进 `.venv`** -> venv 缺 `urllib3`/`polars`/`pyyaml`/`jinja2`/`python-dotenv` 等, 换个没设 PYTHONPATH 的 shell 直接 `ModuleNotFoundError`; ② `PYTHONPATH` 排在 sys.path 前面, 把 pin 住的版本顶掉 (实测 `requests` 2.33.0 顶掉 2.34.2)。 | | 4 | `wheels\win_amd64\` | 缺 `colorama` 轮子 (`tqdm` 在 Windows 上的依赖)。之前被根因 3 掩盖: pip 看到系统已有 colorama 就不下载, 于是离线轮子集不全; 一旦 PYTHONPATH 摘干净, `pip install --no-index` 直接 `ERROR: No matching distribution found for colorama`。 | | 5 | `release/sim_sys_server.py` 第 36 行 `from scrub_rules import scrub, residual` | **`scrub_rules.py` 根本没随包发出** (全盘搜不到, zip 里也没有) -> `/sim/sys/ 仿真·四系统合页` 502, `logs/sim_sys.log` 里是 `ModuleNotFoundError`; 网关因此 `degraded`, `guanlan.py serve` 退出码 1。 | ## 改了什么 | 文件 | 改动 | |------|------| | `guanlan.py` | 新增 `jload()` = `utf-8-sig` 读 JSON (兼容有/无 BOM), 5 处配置读取全部改走它; 配置坏了只打印提示并退回内置默认端口, 不再抛栈。新增 `_hermetic()`: 启动时把 `PYTHONPATH` 从 `sys.path` 与环境里摘掉 (子进程继承干净环境)。 | | `install.ps1` | 第 4 步不再用 PS 的 cmdlet 碰配置, 改由 `.venv` 里的 Python 读写 (读 `utf-8-sig` / 写无 BOM 的 UTF-8), 并加注释说明为什么不能改回去; 顶部加 `Remove-Item Env:PYTHONPATH`, 让 pip 老老实实把包装进 `.venv`。文件仍保持 **UTF-8 带 BOM + CRLF** (PS 5.1 解码中文的前提)。 | | `install.sh` | 同样加 `unset PYTHONPATH` (保持 LF/无 BOM)。 | | `configs/serve.json` | 去掉 BOM; 还原 `_note` / `_note_raw` 两段中文注释 (依据残留可逆部分 + 说明书 §7 的措辞)。 | | `wheels\win_amd64\colorama-0.4.6-py2.py3-none-any.whl` | 补上缺失的轮子, 离线安装才完整。 | | `release/scrub_rules.py` | **恢复缺失文件**: 不新写任何脱敏规则, 只把包内唯一那份规则表 `src/windscada/deid_public.py` 转出 `scrub` / `residual`(= `audit`) / `scrub_or_die`。若拿到交付方原版, 直接覆盖。 | | `start.bat` | `guanlan.py serve` 的退出码 1 有两种含义 (网关没起来 / 网关起来了但有模块降级)。现在先探一次 `/healthz` 区分: 真没起来才报 `[X]` 并停下; 起来了但有降级则打 `[!]` 说明, 浏览器照常打开。 | ## 备份与回滚 原文件都留了副本, 直接改名覆盖即可回到改前状态: ``` guanlan.py.bak-bomfix install.ps1.bak-bomfix configs\serve.json.bak-bomfix guanlan.py.bak-pyfix install.ps1.bak-pyfix install.sh.bak-pyfix start.bat.bak-healthz ``` ## 重放 / 验证 ``` .venv\Scripts\python.exe fix_guanlan_bom.py # 1 2 (BOM + 乱码) .venv\Scripts\python.exe fix_guanlan_pythonpath.py # 3 .venv\Scripts\python.exe fix_guanlan_startbat.py # start.bat 判定 .venv\Scripts\python.exe verify_guanlan_simsys.py # 5 个仿真页 200 + 脱敏回扫干净 ``` 三个 `fix_*` 都是幂等的 (已改过会打印 "已修过")。`wheels\colorama*.whl` 与 `release\scrub_rules.py` 属于新增文件, 没有对应的 `fix_*` 脚本。 ## bug-2 · `#documents` 页「如东液压系统治理清单 · 网页版」报 `{"err":"not found"}` **现象**: 门户 `http://127.0.0.1:28084/#documents` 里那个 iframe 显示 `{"err": "not found", "path": "/如东/如东治理清单_交付_20260901/02_治理清单/如东_液压系统治理清单_v1.2_2026-09-01.html"}`。 **根因**: v0.2.0 发行包里**没有 `release/如东/`** —— 0.2.0 的 zip 里 `release/` 下只有 `viewer/`, 而门户那个 iframe 指的就是 `release/如东/.../_液压系统治理清单_v1.2_2026-09-01.html`; 网关 `guanlan_gateway.py::_static()` 在 `release/` 里找不到文件就回那段 404 JSON (见 `_static()` 里 `return self._json(dict(err="not found", path=path), 404)`)。 顺带核过: 门户里指向 `release/如东/` 的引用**只有这一条**, 所以不是路径写错, 是交付件没随 v0.2.0 发出来。 **修复**: 从本机的 v0.1.0 全量包 `F:\temp\guanlan-rudong-v2_0.1.0_all.zip`(这份交付件在里面是齐的) 把整个 `release/如东/` 恢复到 v0.2.0 安装目录 —— 75 个文件 / 51.0 MB (`如东治理清单_交付_20260901/` 33 件、`如东取数单_2026-08-21/` 18 件、`观澜离线版.app/` 10 件、根下散件 14 件)。 `release/如东/` 按 `.gitignore` 的既有约定不入库(客户交付物只在受保护工作区), 所以 git 里看不到这次恢复。 **验收**: iframe URL 走网关取 → **[200] text/html, 103760 字节**, 标题 `如东液压系统治理清单`, 正文 0 个外部相对引用(自包含); 同页另外两个引用 (`127.0.0.1:18020` CMS、`127.0.0.1:18033/v2`) 均 200; 全门户扫一遍含「如东 / release」的静态引用 → 坏链 0。 脚本: `restore_release_rudong.py` (恢复) · `verify_bug2.py` (验收) · `scan_portal_links.py` (同类坏链全量扫描) ## 路径跨平台化(①+②, 2026-09-11) **用户令**: 后续部署到其它电脑; 兼容 Windows / Linux; **系统内目录路径必须使用相对路径**。 **三条口径**(写进 `docs\数据目录结构与落位约定_v0.2.md` §7, 并由门禁自动检查): ① 只写相对路径(相对**安装根**), 不写机器绝对路径;② 相对基准是安装根、**不是 cwd**, 一律经 `src\paths.py` 解析; ③ 写进产物/清单/页面的用 POSIX 相对形式(`P.rel()`), 显示给人看时才用本机分隔符(`P.disp_dir()`)。 **新增** - `src\paths.py` —— 唯一路径真源(`ROOT/RAW_ROOT` + `store()/ont()/cms()/m5()/sop()/guanlan()/pitch()/tcm_replay()` + `RELEASE/PORTAL/VIEWER/SIM_DIR/LOGS/RUN` + `rel()/disp()/disp_dir()/resolve()/venv_python()`)。 `ROOT` = env `WINDSCADA_ROOT` → 否则按本文件位置回溯 ⇒ **整个安装目录可整拷到别的电脑/别的盘**。 - `scripts\check_portability.py` —— 静态门禁: 机器绝对路径 / cwd 相对字面量 / `os.sep` 拼库存字符串 = ERROR; 例外必须具名登记理由。**当前通过**。 - `scripts\page_fingerprint.py` —— 10 个端点的回归指纹(`status` + 归一化 sha256, 自动剔除 `checked/ms/repo_head` 这类设计上会变的字段、并把安装根前缀归一成 ``)。换机验收也用它: 基准机 `--save`, 新机 `--diff`。 **清理的机器绝对路径**(换机必失配, 且是静默故障): windcms `MAIN_ROOT` 默认 mac 检出、windcms/ontology 写死的 mac venv 解释器、`kb_ingest` 的 mac 技术资料路径、`ingest_ops_2025` 的 `/Volumes/BIG/…`、`place_raw_data` 的 `F:\temp\…`、界面占位符里的 `/Users/…`。**清理的 cwd 相对路径**: 20+ 处 `Path('outputs/rudong/…')`(ontology 14 个 模块、windscada 4 个、windcms、serve/gateway、sop 8 个)全部归到 `paths`; `sop` 里写死的 `rudong` 改为跟场走。 **配置/安装器**: `configs\serve.json` 的 `python` 改为相对(`.venv/Scripts/python.exe`); 启动器 `py()` 支持 "相对→按安装根解析 / 绝对 / 留空自动探测 venv"; `install.ps1|sh` 写配置时写相对路径。 **验证** - 门禁: 运行期 0 处机器路径、0 处 cwd 相对, `os.sep` 只留在显示函数里 ✔ - 回归指纹: 改造前后 **10/10 端点逐字节一致** ⇒ 只换了路径解析, 页面与取数面零变化 ✔ - 换 cwd 实测: 从 `C:\` 且不设 `PYTHONPATH` 跑 `scan_stations.py` / `farm()` → 场站辨识与 `store/src_10min` 均正确 ✔ - 过程中我自己引入并修掉两处: 服务端 `from src import paths` 插在 `sys.path` 引导之前(导致 detail 起不来), 以及 `subsys` 子包导入深度写成两点(应为三点)。这两处都是**靠"重启+指纹比对"逮到**的 —— 印证了那把尺子的价值。 ## 场站扫描辨识 + 两个验收场景 (2026-09-11) **用户令**: 离线数据统一放 `data\raw\`, **下一级目录约定为「场站名称」**, 观澜系统**扫描辨识**。 - `src/windscada/config.py`: 新增 `scan_stations()` / `station_scan()` / `station_report()`; `_expand()` 按扫描结果**派生** `src_*`(外场配置若显式给了 `src_*` 则不覆盖)。辨识规则: ①`raw_station` 全等 → ②与 `src_farm_names` 互为子串 → ③只有一个场站目录(单站部署)→ ④都不中 = 不猜, 报"未识别"。 - `scripts/scan_stations.py`: 打印扫到的场站、辨识依据(`how`)、五个约定子目录的件数与存量。 - 维护页新增一行「场站数据目录 (扫描辨识)」: 扫到 → `覆盖=如东/存在=True`; 扫不到 → `存在=False/覆盖=—`。 - `scripts/windscada_serve.py`: `adhoc_query` 在原始件缺失时不再抛给 HTTP 处理器(前端只看到 500), 改为 **HTTP 200 + 结构化无数据**(`err=no_source` + "本机应在 …\scada_10min\WTG01.csv" 的提示)。 **场景① 清空 `data\raw` + 挪开产物** → 扫描"0 个场站目录 / how=none"; 维护页五项全部 `条数=0 / 存在=False`; 实时接口从 500 变 200+`no_source`。 ✅ **场景② 按约定放回** → 扫描"1 个场站目录 如东(592 件), how=raw_station", 子目录 38/16/134/404; 摄入后 报警 **0→39211**、工单 **0→5876**、油样 **0→404**、loss_monthly 3729(powercurve 31s + availability 88s); 维护页五项覆盖区间全部回来, 实时接口 `months=7`。 ✅ **产物等价**(从 raw 重算 vs 随包基线): `loss_monthly` 3729/3729、`powercurve_dev` 38/38、 `powercurve_bins` 912/912、`alarms` 39211/39211 **逐值完全一致**; `workorders` 5876 行仅 1 条记录 **纳秒尾差**(同一时刻 `.0000015` vs `.0000010`); `oil_samples_index` **506→404** —— 清空重建会丢 **102 行华标 2026-07 批**(源件 `BG-2026-07-YP013 …pdf` 不在现场数据包里, 且一份覆盖多台), 已恢复完整件 并写入文档 §4 作为缺口。 文档: `docs\数据目录结构与落位约定_v0.2.md`(目录结构、落位约定、消费方式、可重建性边界、两个场景实测)。 ## 数据链补齐 · 让系统真正吃 data/raw/如东 (2026-09-11) **问题**: 说明书 §11 写着「不含从原始数据重生成产物的链 (P0)」, 而维护页四条「摄入命令」指向的文件 **在包里一个都不存在**; 于是现场把新台账放进 `data/raw/如东/…` 也不会更新, 工单台账一直停在 2024-11-21。 **新增 4 个脚本** (都在 `scripts/`; 维护页四条命令现在全部「命令可用=True」): | 脚本 | 源 → 产物 | 关键实现 | |---|---|---| | `windscada_alarms_ingest.py` | `故障报警/*.xls` → `alarms.parquet` | `.xls` 实为 SpreadsheetML(XML); 累计快照件自动跳过 (2025年全年 = Q1..Q4 精确并集 20155 行) | | `windscada_workorder_ingest.py` | `风机故障记录/**` → `workorders.parquet` | 按场名过滤 (集团表里混着民勤/来福/宝力格等十来个场); 归并跨表重复 1085 行; 日期按 `src/sop/ledger_dates.py` 同一纪律解析, 解析不了的整行保留 | | `windscada_watch_channels_build.py` | `油样报告/**/*.pdf` → `oil_samples_index.parquet` | 文件名解析 (日期/台号/部件/sample_id); 合并语义: 认不出的老行原样保留 | | `rebuild_from_raw.py` | 总入口 | `--verify` 等价验收; `--scada` 再跑包内 10 个 SCADA 侧构建器 | **等价验收** (`python scripts/rebuild_from_raw.py --verify`, 对 `_pre_rebuild_20260911/` 随包基线): ``` 报警事件 39211 行 / 39211 行 逐值完全一致 ✔ 油液化验 506 行 / 506 行 逐值完全一致 ✔ 检修工单台账 1574 种内容复现 1566 种; 8 种差异逐条定位: · 7 种 = 复位运行时间: 随包 NA、本链有值 — 旧链没认源列名「复位时间时间」这个错别字 · 1 种 = 随包把 Excel 序列号当纳秒解析成 1970-01-01, 本链解析为 2023-05-13 · 随包 78 行副本重复已归并; 另多出 69 行 = 旧链丢弃的日期不可解析行 ``` **效果** (`/detail/v2#tab=system` 数据层, 已重启服务实测): | 行 | 改前 | 改后 | |---|---|---| | 检修工单台账 | 2020-01-03 ~ 2024-11-21, 1652 行 | **2020-01-03 ~ 2026-07-08, 5876 行** | | 四条「摄入命令」命令可用 | False | **True** | **依赖**: `requirements.txt` 加 `xlrd==2.0.2` (2023/2024 年总表是真 BIFF `.xls`, openpyxl 读不了), 轮子已放进 `wheels/win_amd64/` (`wheels/` 按 .gitignore 不入库, 随包分发)。 **SCADA 侧**: `python scripts/rebuild_from_raw.py --scada` 把 10 个构建器按依赖顺序跑一遍 (powercurve → loss_monthly → curves/control/stop_events/温度/偏航/液压/热链/系统辅助)。 抽验过等价性 (WTG01/WTG02 的 `temp_bins` 与随包件 1296/1296 行逐值相同、med 差 0.0), 但**未整体重跑** —— 要跑建议先备份 `outputs/rudong/windscada/` 再逐项比对覆盖区间。 ## 门户拆包 + 闸门退出码 (2026-09-11 收尾) **起因**: 有人问 "`release/portal.html` 20 MB 里到底是什么、能不能不进 git"。查下来 99.3% 是内嵌的 交付文档正文 (28 个 `