Bläddra i källkod

观澜 v2 源代码快照(2.96.0)—— 干净仓库:只含源码,排除产物/依赖/大件

观澜交付 2 dagar sedan
incheckning
2912bf6644
100 ändrade filer med 11835 tillägg och 0 borttagningar
  1. 42 0
      .gitignore
  2. 150 0
      README_先读我.MD
  3. 1 0
      VERSION
  4. 39 0
      app_ETL/README.MD
  5. 2 0
      app_ETL/__init__.py
  6. 2 0
      app_ETL/app_ETL_guanlan/__init__.py
  7. 40 0
      app_ETL/app_ETL_guanlan/api.py
  8. 2 0
      app_ETL/app_ETL_guanlan/builders/__init__.py
  9. 467 0
      app_ETL/app_ETL_guanlan/builders/baseline_38_build.py
  10. 641 0
      app_ETL/app_ETL_guanlan/builders/component_history_build.py
  11. 354 0
      app_ETL/app_ETL_guanlan/builders/csv_to_mdb.py
  12. 521 0
      app_ETL/app_ETL_guanlan/builders/pitch_face_build.py
  13. 372 0
      app_ETL/app_ETL_guanlan/builders/place_raw_data.py
  14. 474 0
      app_ETL/app_ETL_guanlan/builders/raw_data_check.py
  15. 463 0
      app_ETL/app_ETL_guanlan/builders/raw_scan.py
  16. 636 0
      app_ETL/app_ETL_guanlan/builders/rebuild_all.py
  17. 241 0
      app_ETL/app_ETL_guanlan/builders/rebuild_from_raw.py
  18. 159 0
      app_ETL/app_ETL_guanlan/builders/scada_slim_build.py
  19. 459 0
      app_ETL/app_ETL_guanlan/builders/vib_raw_build.py
  20. 206 0
      app_ETL/app_ETL_guanlan/builders/windscada_monthly_build.py
  21. 84 0
      app_ETL/app_ETL_guanlan/data.py
  22. 43 0
      app_ETL/app_ETL_guanlan/mdb_names.py
  23. 26 0
      app_ETL/app_ETL_guanlan/objectstore.py
  24. 444 0
      app_ETL/app_ETL_guanlan/scada_source.py
  25. 69 0
      app_ETL/app_ETL_guanlan/slim.py
  26. 27 0
      app_ETL/app_ETL_guanlan/store.py
  27. 8 0
      app_ETL/common/README.md
  28. 2 0
      app_ETL/common/__init__.py
  29. 16 0
      app_ETL/common/app_ETL_guanlan/__init__.py
  30. 7 0
      app_ETL/configs/README.md
  31. 78 0
      app_ETL/configs/canonical/alias/crrc_wt1500_yuxian.yaml
  32. 3 0
      app_ETL/configs/canonical/alias/crrc_wtd195_zhuanghe.yaml
  33. 54 0
      app_ETL/configs/canonical/alias/csic_h102n_2000_jishan_p2_full.yaml
  34. 102 0
      app_ETL/configs/canonical/alias/csic_h111n_2000_feicheng_taoyuan.yaml
  35. 96 0
      app_ETL/configs/canonical/alias/dec_1500_yangxiongshan.yaml
  36. 159 0
      app_ETL/configs/canonical/alias/dec_dew_d2500_141_dehui.yaml
  37. 33 0
      app_ETL/configs/canonical/alias/dec_df110_2500_longkou_dongqi.yaml
  38. 57 0
      app_ETL/configs/canonical/alias/dec_df121_2500_jinhuashan.yaml
  39. 48 0
      app_ETL/configs/canonical/alias/dec_df93_1500_longkou_dongqi.yaml
  40. 3 0
      app_ETL/configs/canonical/alias/dec_fd116a_2000_feichijiao.yaml
  41. 3 0
      app_ETL/configs/canonical/alias/dec_fd116a_2000_liangshuiquan.yaml
  42. 57 0
      app_ETL/configs/canonical/alias/dec_g2000_127_yidukou.yaml
  43. 33 0
      app_ETL/configs/canonical/alias/dongqi_fd116a_ganyi.yaml
  44. 114 0
      app_ETL/configs/canonical/alias/envision_1500_shenmu.yaml
  45. 93 0
      app_ETL/configs/canonical/alias/envision_en110_2200_pingyin.yaml
  46. 3 0
      app_ETL/configs/canonical/alias/envision_en182_6250_aodu.yaml
  47. 3 0
      app_ETL/configs/canonical/alias/envision_en182_6250_hailesi.yaml
  48. 63 0
      app_ETL/configs/canonical/alias/envision_en87_1500.yaml
  49. 60 0
      app_ETL/configs/canonical/alias/goldwind_dd_tanglong_p1.yaml
  50. 42 0
      app_ETL/configs/canonical/alias/goldwind_likely_huaishuo.yaml
  51. 99 0
      app_ETL/configs/canonical/alias/gup_up1500_qianan_shuizi.yaml
  52. 36 0
      app_ETL/configs/canonical/alias/gup_up2000_2mw.yaml
  53. 96 0
      app_ETL/configs/canonical/alias/gup_up2000_fengxiang.yaml
  54. 192 0
      app_ETL/configs/canonical/alias/gup_up2000_fushan.yaml
  55. 57 0
      app_ETL/configs/canonical/alias/gup_up2000_hongyang.yaml
  56. 57 0
      app_ETL/configs/canonical/alias/gup_up77_1500_gaobenshan.yaml
  57. 81 0
      app_ETL/configs/canonical/alias/gup_up86_1500_langmushan.yaml
  58. 111 0
      app_ETL/configs/canonical/alias/haizhuang_2000_longkou.yaml
  59. 75 0
      app_ETL/configs/canonical/alias/huachuang_ccwe2000_2mw.yaml
  60. 24 0
      app_ETL/configs/canonical/alias/huachuang_ccwe_ganyi.yaml
  61. 60 0
      app_ETL/configs/canonical/alias/huadian_f079_2mw_yuelinbai.yaml
  62. 45 0
      app_ETL/configs/canonical/alias/mingyang_my1_5_lingmen.yaml
  63. 3 0
      app_ETL/configs/canonical/alias/mingyang_my1_5_mima.yaml
  64. 141 0
      app_ETL/configs/canonical/alias/mingyang_my1_5_shenmu.yaml
  65. 126 0
      app_ETL/configs/canonical/alias/mingyang_my2_0_changpingao.yaml
  66. 237 0
      app_ETL/configs/canonical/alias/mingyang_my2_0_gucheng.yaml
  67. 96 0
      app_ETL/configs/canonical/alias/mingyang_my2_0se_121.yaml
  68. 105 0
      app_ETL/configs/canonical/alias/saic_feicheng.yaml
  69. 39 0
      app_ETL/configs/canonical/alias/sewind_w2000_110_80.yaml
  70. 60 0
      app_ETL/configs/canonical/alias/sewind_w2000_116_tanglong_p3.yaml
  71. 63 0
      app_ETL/configs/canonical/alias/sewind_w2000_135_xiaqiu.yaml
  72. 201 0
      app_ETL/configs/canonical/alias/sewind_w2000_93_jincheng.yaml
  73. 90 0
      app_ETL/configs/canonical/alias/sewind_w2000_99_guojiadian.yaml
  74. 72 0
      app_ETL/configs/canonical/alias/sewind_w2000_mixed_taierzhuang.yaml
  75. 63 0
      app_ETL/configs/canonical/alias/sewind_w2000_nanshu.yaml
  76. 72 0
      app_ETL/configs/canonical/alias/sewind_w2000n_105_jishan_p1.yaml
  77. 33 0
      app_ETL/configs/canonical/alias/sewind_w2500_135_2p5mw.yaml
  78. 57 0
      app_ETL/configs/canonical/alias/sewind_w2500_146_dehui.yaml
  79. 96 0
      app_ETL/configs/canonical/alias/sewind_w2500_zhaijia_shangqi.yaml
  80. 96 0
      app_ETL/configs/canonical/alias/shanghaielectric_w2000_pinglu.yaml
  81. 201 0
      app_ETL/configs/canonical/alias/siemens_swt40_rudong.yaml
  82. 60 0
      app_ETL/configs/canonical/alias/sinovel_sl1500_77_jingtaixingquan.yaml
  83. 93 0
      app_ETL/configs/canonical/alias/sinovel_sl1500_77_tangzhen.yaml
  84. 108 0
      app_ETL/configs/canonical/alias/sinovel_sl1500_77_wendeng.yaml
  85. 30 0
      app_ETL/configs/canonical/alias/sinovel_sl1500_82.yaml
  86. 156 0
      app_ETL/configs/canonical/alias/sinovel_sl1500_hutouya.yaml
  87. 141 0
      app_ETL/configs/canonical/alias/sinovel_sl2000_huishan.yaml
  88. 282 0
      app_ETL/configs/canonical/alias/sinovel_sl2000_mahuanggouxi.yaml
  89. 45 0
      app_ETL/configs/canonical/alias/taizhong_tz2000_93_fuxin_rushan.yaml
  90. 93 0
      app_ETL/configs/canonical/alias/unknown_0p85mw_baishan.yaml
  91. 45 0
      app_ETL/configs/canonical/alias/unknown_1p5mw_huitengliang.yaml
  92. 51 0
      app_ETL/configs/canonical/alias/unknown_1p5mw_taiyangshan.yaml
  93. 54 0
      app_ETL/configs/canonical/alias/unknown_1p5mw_tangxing_haoyao.yaml
  94. 147 0
      app_ETL/configs/canonical/alias/unknown_2p0mw_jigongdong.yaml
  95. 279 0
      app_ETL/configs/canonical/alias/unknown_2p0mw_shangdu.yaml
  96. 45 0
      app_ETL/configs/canonical/alias/unknown_5p0mw_baiyushan.yaml
  97. 192 0
      app_ETL/configs/canonical/alias/unknown_6p3mw_wumeishan.yaml
  98. 36 0
      app_ETL/configs/canonical/alias/unknown_mixed_guochong.yaml
  99. 33 0
      app_ETL/configs/canonical/alias/unknown_xinaili.yaml
  100. 60 0
      app_ETL/configs/canonical/alias/xemc_xe2000_2mw.yaml

+ 42 - 0
.gitignore

@@ -0,0 +1,42 @@
+# 干净源码快照(2026-10-06)
+node_modules/
+__pycache__/
+*.pyc
+.venv/
+.venv*/
+target/
+dist/
+build/
+.pytest_cache/
+outputs/
+data/
+logs/
+run/
+release/
+wheels/
+vendor/
+_deploy_tmp/
+*.zip
+*.7z
+*.rar
+*.exe
+*.dll
+*.so
+*.dylib
+*.jar
+*.whl
+*.parquet
+*.npz
+*.log
+*.docx
+*.xlsx
+*.pdf
+*.png
+*.jpg
+*.jpeg
+*.gif
+*.mp4
+*.lnk
+.DS_Store
+Thumbs.db
+

+ 150 - 0
README_先读我.MD

@@ -0,0 +1,150 @@
+观澜·如东样板 v2 0.4.0 — 单包全量离线应用 (Windows / Linux / macOS)
+
+<PY> = Windows 的 .venv\Scripts\python.exe, 或 Linux·macOS 的 .venv/bin/python;
+       命令里路径写 / 即可两平台通用; Windows 也可以只双击 .bat, 不必敲命令。
+
+★ 本包**不含 输入数据(data/) · 产物/输出数据(outputs/) · 运行日志(logs/、run/、任何 *.log/*.jsonl) ·
+  临时文件(缓存/备份/系统索引)**(用户令 2026-09-17; 打包方法与逐条理由见第五节):
+  解压安装后页面会显示"无产物"; 把现场数据包放好后跑一次重算(第四节), 页面立刻有数。
+  随包内容 = 程序/配置/门户与三维资产/交付文档/离线依赖轮子/便携运行时 (没有数据、产物、日志)。
+
+一、安装 (约 10 分钟; 安装目录不要有空格和中文)
+  Windows : 解压到 D:\guanlan\app → 双击 install.bat        (自动选 Python → 建 .venv → 包内轮子离线装依赖)
+  Linux   : 解压到 /opt/guanlan  → sh install.sh            (麒麟 V10 / 统信 UOS 20 / Ubuntu / CentOS / macOS;
+                                                            多版本机器: GUANLAN_PY=/usr/bin/python3.11 sh install.sh)
+  没装 Python 的机器也能装: 包内有便携运行时(Windows / Linux x86_64 / Linux aarch64), 安装脚本自动用。
+  离线边界: Windows 侧包内带全部轮子, 完全离线可装; **Linux·macOS 侧包内没带轮子**, install.sh 会自动
+  改为联网安装 —— 要离线装就把对应平台轮子放进 wheels/linux_x86_64 或 wheels/macos_arm64 再装。
+  模型(可选, 只影响问答与本地审核页): ollama pull qwen3:8b / deepseek-r1:14b / bge-m3
+
+二、启动 / 停止
+  Windows : 双击 check.bat 全绿 → 双击「启动观澜」快捷方式(安装时自动建: 根目录 + 桌面各一份;
+            同一目标还有一份 ASCII 名的 start_guanlan.lnk 供控制台/脚本引用;
+            目标 = .venv\Scripts\pythonw.exe, **完全不出现命令窗口**) 或双击 start.bat
+            (会闪一下窗口 —— .bat 是控制台程序, 想完全无窗就用上面那个快捷方式)   停止: 双击 stop.bat
+  Linux   : <PY> guanlan.py check → <PY> guanlan.py serve           停止: <PY> guanlan.py stop
+  三个入口: 门户 http://127.0.0.1:28084/ · 工作台 /detail/ · 运维控制台 /ops
+  ★ 别的机器怎么打开 (用户令 2026-09-19「观澜改为监听所有 IP」): **出厂已是 this 口径** ——
+    configs/serve.json 的 "public_host": "0.0.0.0" ⇒ 门户网关绑所有网卡, 别的机器开
+    http://<本机IP>:28084/ 。**只有门户网关**对外, 组件(detail/cms/viewer/sim)仍绑 127.0.0.1。
+    Windows 还要放行入站端口: netsh advfirewall firewall add rule name=guanlan28084 dir=in action=allow protocol=TCP localport=28084
+    只给本机用就把它改回 ""(跟 host) 或 "127.0.0.1"; 也可以填某个内网 IP 只放那一个地址。改完重启服务。
+    ★★ 页面**没有鉴权** ⇒ 对外监听等于把全场运行数据摊开。请同时限来源 (防火墙只放自己的 IP / 反代加认证),
+       不要直接挂公网; 只给运维看时更稳的是 public_host 留空 + SSH 隧道
+       (ssh -L 28084:127.0.0.1:28084 <user>@<服务器>) —— 启动日志里每次都会提醒这一条。
+  ★ 无人值守/远程部署: 用 Windows 服务(开机自启 + 崩溃自动重启):
+    (管理员) <PY> scripts\service_ctl.py install        服务名 = guanlan (显示名「观澜·如东样板 v2 …」)
+    查状态: sc query guanlan   ·   Linux 对应 systemd 单元 guanlan.service
+    ★ 反面教材(2026-09-19 实逮): 用 SSH 会话里跑 <PY> guanlan.py serve 起的服务, **会话断开就被一起收掉**
+      (SSH 作业对象随会话结束终止子进程) ⇒ 远程部署一律用服务, 别用 SSH 前台起。
+  排障要看见实时日志: 双击 start.bat console (前台模式, 关窗即停服务); 本包**不含 VBScript**,
+  无窗口启动不依赖 Windows 脚本宿主(WSH 被策略禁用的服务器上同样可用)。
+  ★ .bat 一律**纯 ASCII**(英文): 中文写进批处理会随控制台代码页被曲解, 现场出现过"注释被当命令执行"。
+    中文说明请看本文件与 docs/; 机器守卫见 src/entry_refs.py(guanlan.py check 会报一行)。
+
+三、重算与产物 (门户菜单「数据重算」, 或直接开 http://127.0.0.1:28084/ops)
+  ★ 输入数据**自动扫描识别** (用户令 2026-09-19): data/raw 下逐族指纹 (件数/体积/最新时间/清单摘要),
+    与上次快照比 —— 有新增/变化就报出来并列出"该跑哪几步":
+      <PY> scripts/raw_scan.py                 # 看当前状态
+      <PY> scripts/raw_scan.py --check         # 与快照比: rc=0 无变化 · rc=4 有新数据 · rc=5 还没基线
+      <PY> scripts/raw_scan.py --write         # 记/更新基线 (重算链第 ①b 步每次都会顺手记)
+    → 重算链里它就是 **①b 步**; 想让"新数据不被 --skip-* 漏掉", 用:
+      <PY> scripts/rebuild_all.py --auto       # 先扫一遍: 新数据落在被跳过的族里就自动取消跳过
+    → 服务侧看门狗 (默认关, 现场自己开): configs/serve.json 的 "raw_watch"
+      {"enabled": true, "minutes": 30, "auto_rebuild": false}
+      开了以后每 30 分钟扫一次, 有新数据就写 logs/service.log 并列出该跑的步; auto_rebuild=true 时
+      顺手发起一次重算 (与运维控制台同一条路; 已在跑则不重复发起)。
+  一个按钮一件事: 停/启服务 · 执行重算 · 清除产物。按钮按真实状态启用(不能做的灰, 后端也拒绝,
+  直接打 API 返回 409); "启动服务"起来后自动打开门户; "停止组件服务"保留控制台本身; 同时刻只允许
+  一个动作, 页面上有进度/退出码/实时日志尾巴。重算进行中按钮按设计禁用 —— 那不是坏了。
+  ★ 清除产物 = **直接删除, 不留备份**(用户令 2026-09-16; 旧的 --on 还原已移除), 不动 data/raw、release;
+    要补回"包内没有生成端"的随包件: **运行期不由交付包补齐**(用户令 2026-09-17) —— 放原始件后重跑重算, 或由研发补生成端(逐族可逆性见 docs §13.6); 仅离线人工补救时可用 <PY> scripts/products_restore_missing.py --stash <…>
+  ★ 清完产物后页面会显示"无产物"(工作台左栏/CMS 页都会给出"缺哪件、怎么补"的说明, **不会崩**) ——
+    跑一次第四节的重算即恢复; 若某页仍打不开, 先看 /healthz 与 logs/<组件>.log 尾部。
+
+四、放数据后重算 (一条命令; 控制台点"执行重算"等价)
+  <PY> scripts/raw_data_check.py --src <现场包目录>    ★ 先体检: 结构/命名/增量(新增·跳过·冲突) + 放置指导
+  <PY> scripts/place_raw_data.py --src <现场包目录> --scope full      放数据(同尺寸自动跳过, 可反复跑;
+       落位前自动跑体检, "同名不同大小"的冲突会拦下, 确要覆盖加 --force)
+  <PY> scripts/rebuild_all.py [--skip-scada] [--src <现场包目录>]     默认含 SCADA 侧约 15 分钟;
+       只换台账类数据加 --skip-scada(约 2 分钟); --dry-run 只看计划; 完事自动重启组件服务。
+  放置规范与常见错误: docs/输入数据放置指导_v0.1.md (目录树/命名/增量语义/放完跑什么)
+  算成功判据: /detail/ 左栏「系统维护」两屏, 或 <PY> -m src.ontology.maintenance
+  重算后应达锚点: 报警 39211 · 工单 5876 · 油样 404 · temp_monthly 19494 · 本体 9702 对象(审计 0 问题)
+
+五、打包 (做交付包; 用户令 2026-09-17)
+  一条命令就够(不需要任何开关); Windows 也可以直接**双击 pack.bat**:
+    Windows : 双击 pack.bat            或  <PY> scripts/pack_dist.py
+    Linux   : sh pack.sh               或  <PY> scripts/pack_dist.py
+    pack.bat dry      只预演: 报表里会打什么、多大, 以及逐条排除理由(不写文件)
+    pack.bat verify   对已打好的 zip 做开箱验证: 解压 → 离线安装 → 起服务核验页面 → 清理
+    <PY> scripts/pack_dist.py --out <目标.zip>     指定输出路径
+  输出: 默认写到**安装目录的上一级**, 文件名 app_guanlang_v<版本号>.zip
+        (例: 装到 D:\guanlan\app 且本包版本 2.6.0, 则产物在 D:\guanlan\app_guanlang_v2.6.0.zip)
+  ★ 版本号规则 (用户令 2026-09-17): 编号 v<大版本号>.<中版本号>.<小版本号>, 例 v2.5.0
+      大版本号 —— 系统解决方案、架构或核心功能改变
+      中版本号 —— 非核心功能新增、减少、修改
+      小版本号 —— 消缺完善
+    版本号只写在 src/version.py 一行(VERSION), 打包器/安装脚本/安装记录/自检都读它;
+    改完跑 <PY> scripts/version_log.py --write 让 docs/版本记录.md 跟上(自检会校验两者一致);
+    看当前版本与规则: <PY> guanlan.py version
+  ★ 包里**不含**这四类(默认口径; 每条理由都写进包内 dist-manifest.json):
+    ① 输入数据     data/                    现场原始件; 例外要带用 --with-data (约 31 GB)
+    ② 产物/输出数据 outputs/                 目标机放数据后 place_raw_data.py + rebuild_all.py 重算;
+                                            无生成端的那批**运行期不从交付包补**(用户令 2026-09-17), 由研发补生成端(docs §13.6)
+    ③ 运行日志     logs/ run/ 以及**任何位置**的 *.log、*.jsonl (含 logs/build、logs/legacy)
+    ④ 临时文件     各种缓存(*_cache、__pycache__、.pytest_cache、.npm-cache、.qa-profile)、
+                   编辑器临时与备份(*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*)、
+                   系统索引(.DS_Store、Thumbs.db、desktop.ini), 以及 .git/.venv/*.pyc/*.lnk/顶层 *.zip
+  包里**有**: src/ scripts/ guanlan.py · configs/ · release/(门户/仿真/三维/交付件) · resources/ · reference/ ·
+             docs/ · README 与 测试须知 · wheels/ · vendor/ · install/check/start/stop/pack/uninstall 脚本 · requirements.txt
+  打完自查: 包内 dist-manifest.json 记着 version / no_data / no_products / no_logs / no_temp + 逐条排除理由;
+            打包器自己还会核"入口脚本引用的文件都在包里"与"入口脚本编码守则", 不合格的包会被删掉并返回非 0。
+  要让页面开箱即有数(例外做法): <PY> scripts/pack_dist.py --with-products
+
+六、必读 (如实)
+  · 页面的数来自 outputs/<场站> 下的产物, 产物由 data/raw 下的原始件算出来(下一级目录名=场站名, 扫描辨识)。
+    本包两样都没带: 没有 data/raw 时重算会"没有源件可算", 没有 outputs/ 时页面显示"无产物"。
+  · 有一批产物**没有生成端**(pitch_daily、pc_monthly_bins、CMS/TCM 链等): 从交付包按需补齐
+    (**运行期不从交付包补**: 放原始件后重算; 缺生成端的族由研发补, 见 docs §13.6), 逐件来源见 outputs/<场站>/_provenance.json。
+  · 某页没数据: 先看控制台的退出码与 logs/, 再看 docs/重算缺口与补件清单_v0.1.md。
+
+包内含: 程序与配置 / 门户与仿真 / 三维资产 release/viewer / 治理清单交付件 release/如东(客户交付物勿外传) /
+        离线依赖轮子 wheels/ / 便携运行时 vendor/ / 交付文档 docs/ / 打包脚本 pack.bat · pack.sh /
+        安装与卸载脚本 install.bat · install.sh · uninstall.bat · uninstall.sh
+包内不含: data/(输入数据) · outputs/(产物) · logs/ run/(运行日志) · 临时文件与缓存 ·
+          .venv(安装时重建) · .git(版本库)
+
+七、服务化 (装成 Windows 服务 / systemd; 用户令 2026-09-17)
+  Windows (管理员 PowerShell):  <PY> scripts/service_ctl.py install         注册为 Windows 服务(开机自起)
+                                <PY> scripts/service_ctl.py install --dry-run  先看它要做什么(免管理员)
+                                不想提权: <PY> scripts/service_ctl.py install --user-login   登录后自启
+  Linux   (root):               sudo <PY> scripts/service_ctl.py install   写 guanlan.service + systemctl enable --now
+  起停状态: <PY> scripts/service_ctl.py status|start|stop|restart|uninstall
+  装了服务就不必再双击快捷方式; 手工启动(start.bat / guanlan.py serve)仍然可用, 两者互不冲突。
+  ★ 安装时会先检查是否已安装及版本: 读 <安装目录>/install-info.json 与本包版本比较,
+    同版本/升级/降级/老安装无版本记录 四类都会提示并询问是否重装;
+    跳过询问用 install.bat -Force (Linux: sh install.sh --force)。版本单一真源 = src/version.py。
+
+八、卸载 (用户令 2026-09-17)
+  Windows : 双击 uninstall.bat     Linux / macOS : sh uninstall.sh
+  带开关的用法 (两种平台一样):
+    --dry-run      只打印"将要做什么", 一个字节都不动 (先看这个最稳妥)
+    --yes          不再询问, 直接执行 (脚本/无人值守用)
+    --purge        连 数据/产物/日志/虚拟环境 一起删 (会再问一次; 加 --keep-logs 保留日志)
+    --all          连程序本体一起删 (删不掉的逐条列出, 并给一条收尾命令)
+    --selftest     自检: 六个步骤齐全 + dry-run 无副作用
+  默认只拆"本程序装上去的东西", 顺序是:
+    ① 停服务(服务化常驻体 + 手工启动的组件)  ② 注销服务(sc.exe delete / systemctl disable; 需管理员/root)
+    ③ 撤开机自启(登录自启项)  ④ 删快捷方式(启动观澜.lnk / start_guanlan.lnk, 含桌面)
+    ⑤ 清运行态(run/、*.pid)  ⑥ 删安装记录 install-info.json (删掉它 = 这台机器回到"没装过")
+  ★ **默认保留**: data/(输入数据) · outputs/(产物) · logs/ · .venv/ · 整棵程序目录。
+    卸载程序 ≠ 销毁数据; 要连数据一起清, 显式给 --purge。
+  ★ 程序目录不自己删(Windows 上正在跑的 python.exe 就在 .venv 里, 删自己必然只删一半):
+    要清干净用 --all, 或卸载完直接删目录 `rmdir /s /q <安装目录>`。
+  免管理员也能卸: 服务注销那一步缺权限时**照做其余步骤**, 并打印你该手工执行的那两条命令。
+
+详细说明: docs/系统设计说明.md(§9 交付包组成 · §11 配置与日志口径 · §14 服务化与卸载 · §15 版本管理与打包命名)
+          docs/版本记录.md(版本号规则与版本表) · docs/重算操作手册_v0.1.md
+          docs/数据目录结构与落位约定_v0.2.md · docs/重算缺口与补件清单_v0.1.md
+          docs/输入数据放置指导_v0.1.md · docs/振动数据接入_v0.1.md · docs/说明书_观澜如东样板v2_v0.2.md

+ 1 - 0
VERSION

@@ -0,0 +1 @@
+2.96.0

+ 39 - 0
app_ETL/README.MD

@@ -0,0 +1,39 @@
+# 观澜 · 数据接入管理(`app_ETL/`)
+
+**职责**:现场源件摄入(10 分钟/1 分钟/月度归档库/台账/振动)、放置与增量体检、标准仓、机型与场站契约、canonical 词典
+
+**允许依赖**:app_common
+
+**对外公开面**:`common/app_ETL_guanlan/api.py` —— 模块间调用**只许**走这里(`scripts/module_boundary_audit.py` 机器检查)。
+
+**当前实现落点(P2 已实体迁入)**:src/windscada/{data,scada_source,slim,mdb_names}.py + scripts/{rebuild_*,scada_*,raw_scan,raw_data_check,place_raw_data,pitch_face_build,baseline_38_build,component_history_build,csv_to_mdb}.py
+
+**迁移状态**:**P2 已完成(2026-09-22)** —— 4 个库模块(data/scada_source/slim/mdb_names)实体迁入本目录(旧路径 `src/windscada/<件>.py` 留模块别名壳);12 个构建器迁入 `builders/`(`scripts/<同名>.py` 留 CLI 壳,链/审计/文档里约 269 处路径引用因此保持不变);构建器的安装根推算改用公共层公开面 `app_common.app_common_guanlan.api.install_root`(不再依赖 `__file__.parents[1]`)。实逮并修掉:包内相对导入(`from .config` 等 6 处)在搬家后指向不存在的模块 ⇒ 改为绝对导入。
+
+> 依据:`docs/重构方案_模块化_v0.1.md`(用户令 2026-09-22:只重构目录与接口、零行为变化、逐版本可回滚)。
+
+## 安装部署与运行
+
+本系统是**单包离线交付**:解压到无空格无中文路径 → 安装 → 自检 → 启动(一套安装,七个模块共用一份 `.venv`)。
+本模块目录里的脚本只是**入口包装**,内部调安装根的统一入口,保证"进模块目录也能装/跑/卸":
+
+| 动作 | Windows | Linux/macOS | 内部实际调用 |
+|---|---|---|---|
+| 安装 | `install.bat` | `sh install.sh` | 安装根 `install.bat` / `install.sh`(建 `.venv`、装依赖、自检、注册服务) |
+| 运行 | `run.bat` | `sh run.sh` | 安装根 `start.bat`(Windows)/`python guanlan.py serve`(跨平台) |
+| 卸载 | `uninstall.bat` | `sh uninstall.sh` | 安装根 `uninstall.bat` / `uninstall.sh` |
+
+目录约定(P9,2026-09-28 用户令):
+
+```
+app_ETL/
+  common/               模块内通用插件/组件(旧导入前缀的兼容别名在此保留一个版本)
+  configs/              本模块专属配置域(共用单件在安装根 configs/)
+  data/                 模块相关数据件(运行期数据在安装根 data/、outputs/)
+  app_ETL_guanlan/                观澜在本模块的实现包(模块间调用只许经其中的 api.py)
+  README.MD             本文件(职责 / 公开面 / 安装部署与运行)
+  install.bat|sh  uninstall.bat|sh  run.bat|sh
+```
+
+自检与打包:安装根 `check.bat`(=`python guanlan.py check`)、`pack.bat` / `sh pack.sh`
+(打包 + 开箱验证:页面 5/5 + 卸载核验)。

+ 2 - 0
app_ETL/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""观澜 · 数据接入管理(模块根,P0 骨架)。"""

+ 2 - 0
app_ETL/app_ETL_guanlan/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""数据接入管理模块实现包(P2 起)。"""

+ 40 - 0
app_ETL/app_ETL_guanlan/api.py

@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+r"""观澜 · 数据接入管理 —— **对外公开面**(P0 阶段:接口先立,实现仍在既有路径)。
+
+用户令 2026-09-22:按模块重构源代码目录,**每模块只许经 `api.py` 被别的模块调用**。
+本文件当前把公开名**惰性转发**到既有实现(src/windscada/{data,scada_source,slim,mdb_names}.py + scripts/{rebuild_*,scada_*,raw_scan,raw_data_check,place_raw_data,pitch_face_build,baseline_38_build,component_history_build,csv_to_mdb}.py),因此:
+
+* 调用的行为与重构前完全一致(零行为变化);
+* 后续阶段(见 `docs/重构方案_模块化_v0.1.md`)把实现搬到本模块目录后,**只改这里的目标映射**,
+  调用方一行都不用动;
+* 惰性转发(PEP 562)保证 import 本文件不会连带 import 重依赖。
+"""
+from __future__ import annotations
+
+import importlib
+
+# 模块(按名惰性转发)—— P2 起实现就在本模块目录内
+_TARGETS: dict[str, str] = {
+    'data': 'app_ETL.app_ETL_guanlan.data',
+    'scada_source': 'app_ETL.app_ETL_guanlan.scada_source',
+    'slim': 'app_ETL.app_ETL_guanlan.slim',
+    'mdb_names': 'app_ETL.app_ETL_guanlan.mdb_names',
+}
+
+# ★2026-09-22 P3 增设: 常用的**函数级**公开名(算法层经本公开面取数,不再直接 import 本模块内部实现)。
+#   `from app_ETL.app_ETL_guanlan.api import load_10min` 这类写法由此成立。
+from app_ETL.app_ETL_guanlan.data import contracted_cols, load_10min   # noqa: E402,F401
+from app_ETL.app_ETL_guanlan import slim                               # noqa: E402,F401
+
+__all__ = list(_TARGETS) + ['load_10min', 'contracted_cols']
+
+
+def __getattr__(name: str):
+    target = _TARGETS.get(name)
+    if target is None:
+        raise AttributeError(
+            f'{__name__} 未公开 {name!r};公开面见 __all__ —— 模块间调用只许走 api.py'
+        )
+    mod = importlib.import_module(target)
+    globals()[name] = mod
+    return mod

+ 2 - 0
app_ETL/app_ETL_guanlan/builders/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""摄入与构建器(按 CLI 入口注册,见 app_ETL README)。"""

+ 467 - 0
app_ETL/app_ETL_guanlan/builders/baseline_38_build.py

@@ -0,0 +1,467 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""三层基线生成端 —— 补上"包内没有生成端"的 `m5_cms_tcm/baseline_38.json` (用户令 2026-09-19)。
+
+## 为什么要写它
+
+用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
+`baseline_38.json` 是**随包快照**(族表 kind=shipped、gen=None,见
+`scripts/products_reverse_audit.py::HUMAN_SNAPSHOTS`),旧件的 self/absolute 两层由人工坐实。
+它是 `src/windcms/report.py::baseline_card_html()` 的**唯一取数处** —— 件不在位, 报告页第 5 张卡
+(三层基线对照)直接返回空串。本脚本把它**从 CMS 窗标量 + 场内实物锚算出来**。
+
+## 产物与消费者 (字段契约)
+
+    outputs/<场>/m5_cms_tcm/baseline_38.json
+      meta             built / min_windows / windows / segments_kind / self_stat / layers / caveat /
+                       usage_limit / anchor_guard_days / ★数据边界
+      absolute_limits  iso_rms_vel, Rms_Vel → {unit:'mm/s', yellow:7.1, red:11.0, source:ISO 20816-3 ...}
+                       ★ 标准常数, 不是测量值, 故写死为模块常量 (改进 ISO 不靠重算)
+      fleet_baseline   {'<sensor>|<meas>': {段: 全场中位}}   ← 页面查 fleet_baseline[f'{sensor}|{meas}'][latest_window]
+      self_baseline    {台: {测点: {标量: 条目}}}
+
+    条目字段 (report.py 逐个读): self_baseline, normal_band[2], latest, latest_window, x_self,
+      threshold_self, in_normal_band, n_windows_healthy, n_windows_total, unit, insufficient
+      另存 (消费者暂不读, 供人核对): self_baseline_stat, rel_mad, threshold_note, healthy_windows,
+      excluded (剔掉的段名) / excluded_detail (剔的理由), recovered_after_anchor, trend_flag, spread, latest_n
+
+    ★ 自基线样本不足 ⇒ `self_baseline: null` + `insufficient: '健康期窗 N < 3, 不给数 (禁用 fleet 值顶替)'`
+      —— **绝不**用 fleet 值顶替 (report.py 专门把这条不足列出来, 就是防这个)。
+
+## 单位口径 (与随包旧件一致, ★容易踩)
+
+页面按 `absolute_limits[meas]['unit'] == 'mm/s'` **乘 1000** 显示。CMS 窗索引 (`y_unit` 列) 实测:
+页面用的 5 个标量里 `Peak/Rms_HP/rms_200` = `m/s²`、`Rms_Vel/iso_rms_vel` = `m/s` (Kurtosis 页面不展示, y_unit=`1`)
+⇒ 本器**存原值** (速度存 m/s), 单位标签按 y_unit 分给: `m/s²` / `mm/s`。
+即 0.00282 m/s 存进文件、页面显示 2.82 mm/s。
+
+## 分段 (segment) 口径 —— 与 `scripts/component_history_build.py` **同一套** (两件必须同段)
+
+    len(windows) >= 3   segments_kind='window'  段 = 导出窗 (参考口径: 健康期窗中位, ±锚 120 天护栏)
+    len(windows) <  3   segments_kind='week'    段 = 窗内 ISO 周 (本机实况: 只有 w0316 一窗)
+
+★ 本机 (2026-09-19 实测) 只有 1 个窗 `w0316` (2026-03-16 17:27 → 2026-04-21 10:18) ⇒ 走周段 (6 段)。
+  **周段散布 ≠ 多窗散布**: 周与周之间工况/负荷更接近 ⇒ 散布更小 ⇒ `threshold_self` 更容易被现实超出。
+  这一条写进 `meta.usage_limit`, 不假装等于多窗口径。多窗分支本机**未取数验证**。
+
+## 健康期与锚护栏
+
+健康期段 = 全部段 **减去** 实物锚 ±`ANCHOR_GUARD_DAYS`(=120) 天覆盖的段。
+锚来自 `reference/rudong/positive_anchors.json` (场内实物证据: 内窥镜/金相/拆检/闭环), 按
+`component_class` 映射到测点 (`gearbox_hs_bearing`→两个高速轴测点, `generator_bearing`→发电机两测点,
+`main_bearing`→主轴承两测点)。★ 实测有 4 条锚**没有日期** (WTG03/WTG08/WTG28 主轴, WTG05/WTG32 发电机)
+⇒ 无法做时间护栏, 逐条记进 `meta.锚.无日期` 而不是默认忽略。
+实测后果: WTG09 (锚 2026-01-23) / WTG27 (2026-01-27) / WTG24 (2025-12-15) 三台的对应测点健康段不足 3
+⇒ 出 `insufficient`, 不给数; WTG02 (锚 2025-12-04) 只余 3 个健康段 ⇒ 出数但 `excluded` 里写明剔了哪 3 段。
+
+## 用法
+
+    python scripts/baseline_38_build.py                 # 算并写盘 + 自登记
+    python scripts/baseline_38_build.py --dry-run       # 只算不写, 打印漏斗与不足清单
+    python scripts/baseline_38_build.py --farm rudong
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+# ★GBK 控制台/日志重定向不再因 '²' '⇒' 这类字符抛 UnicodeEncodeError (2026-09-19 远端实逮:
+#   `baseline_38_build.py` 打印 'm/s²' 时在 cp936 下 rc=1, 整条恢复链少一件产物)。
+#   只把**不可编码字符替换掉**, 不改流编码 —— 中文照旧可读。
+try:
+    sys.stdout.reconfigure(errors='replace')
+except Exception:
+    pass
+
+import numpy as np                                            # noqa: E402
+import pandas as pd                                           # noqa: E402
+import pyarrow.parquet as pq                                  # noqa: E402
+
+from src import paths as P                                    # noqa: E402
+from src.derived_manifest import record as dm_record           # noqa: E402
+from src.windscada.config import farm as ws_farm               # noqa: E402
+from src.windcms import data as CD                             # noqa: E402
+from src.windcms.config import SENSORS, SENSOR_CN              # noqa: E402
+from src.windcms.config import farm as cms_farm                # noqa: E402
+
+# ── 页面要的标量集: 与 src/windcms/report.py::_BL_MEAS **逐字一致** (顺序即报告卡里的行序) ──
+# 有绝对标准的速度量在前 (iso_rms_vel/Rms_Vel), 冲击量次之; Kurtosis/CrestFactor 判别力弱, 页面不展示。
+MEAS_BL = ['iso_rms_vel', 'Rms_Vel', 'Peak', 'Rms_HP', 'rms_200']
+
+# ── 绝对限值: ISO 20816-3 标准常数 (不是测量值 ⇒ 写死; 改进标准号才需要动这里) ──
+ISO_YELLOW, ISO_RED = 7.1, 11.0
+ISO_SRC = 'ISO 20816-3 (机组 >300kW 刚性安装) 区界 B/C 与 C/D'
+ABSOLUTE_LIMITS = {
+    'iso_rms_vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED, source=ISO_SRC),
+    'Rms_Vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED,
+                    source='同 iso_rms_vel (同为速度 RMS; 频带口径以厂商配置为准)'),
+}
+# y_unit → 存值单位标签 (见 docstring「单位口径」); 速度类标 mm/s (页面 ×1000 显示), 与旧件同约定
+UNIT_OF_YUNIT = {'m/s': 'mm/s', 'm/s²': 'm/s²', 'm/s2': 'm/s²', '1': '-', '': '-'}
+
+MIN_WINDOWS = 3                    # 健康期段数门 (旧件 min_windows=3)
+ANCHOR_GUARD_DAYS = 120            # 实物锚 ±120 天不进健康期 (旧件 anchor_guard_days=120)
+FLEET_MIN_TURBINES = 5             # fleet 中位至少要有几台贡献 (不足则该段不出 fleet 值)
+RECOVER_FRAC = 0.5                 # recovered_after_anchor: 锚后中位 ≤ 锚前中位 × 0.5 判"已恢复"
+CONTAMINATED_FLAG = '自基线可能已污染 (健康期窗单调上升, 基线中心被抬高)'   # 旧件同措辞
+THRESHOLD_NOTE = ('max(center×max(1+2.0×relMAD, 1.3), 健康期最大×1.05) — 由该台自身散布定非拍脑袋倍数, '
+                  '且保证历史正常波动不自触发')
+INS_TMPL = '健康期窗 {n} < 3, 不给数 (禁用 fleet 值顶替)'
+WANT_COLS = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'scalar_value', 'ds_size', 'y_unit']
+
+# 锚的部件类 → 测点 (只映射该部件类**自己的**测点, 不扩大到整机)
+ANCHOR_CLASS_SENSORS = {
+    'gearbox_hs_bearing': ('Gear_HS_rotor_side', 'Gear_HS_generator_side'),
+    'gearbox_planet_bearing': ('Gear_planet',),
+    'gearbox_ims_bearing': ('Gear_IMS',),
+    'generator_bearing': ('Generator_DE', 'Generator_NDE'),
+    'main_bearing': ('Main_bearing_front', 'Main_bearing_rear'),
+}
+
+
+def _r(v, nd=6):
+    if v is None:
+        return None
+    v = float(v)
+    return float(f'{v:.{nd}g}') if np.isfinite(v) else None
+
+
+def _round(v, nd):
+    if v is None:
+        return None
+    v = float(v)
+    return round(v, nd) if np.isfinite(v) else None
+
+
+def _py(o):
+    """numpy 标量 → python; NaN/Inf → None (JSON 严格可解析)。"""
+    if isinstance(o, dict):
+        return {k: _py(v) for k, v in o.items()}
+    if isinstance(o, (list, tuple)):
+        return [_py(v) for v in o]
+    if isinstance(o, (np.integer,)):
+        return int(o)
+    if isinstance(o, (np.floating, float)):
+        f = float(o)
+        return f if np.isfinite(f) else None
+    if isinstance(o, (np.bool_,)):
+        return bool(o)
+    return o
+
+
+def load_scalars(cfg, meas):
+    """各 CMS 窗的标量子集 (口径同 `src.windcms/data.py`, 但不落缓存); 附带 y_unit 用于定单位标签。"""
+    ws = CD.windows(cfg)
+    spans = CD.window_spans(cfg)
+    parts, used = [], {}
+    for w, info in ws.items():
+        p = pathlib.Path(info['index'])
+        names = set(pq.ParquetFile(p).schema_arrow.names)
+        cols = [c for c in WANT_COLS if c in names]
+        d = pd.read_parquet(p, columns=cols)
+        for c in ('ds_size', 'scalar_value'):
+            if c not in d.columns:
+                d[c] = np.nan
+        if 'y_unit' not in d.columns:
+            d['y_unit'] = ''
+        d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
+        d['window'] = w
+        parts.append(d)
+        sp = spans.get(w) or {}
+        used[w] = dict(index=P.rel(p), rows_scalar=int(len(d)), rows_all=int(sp.get('rows') or 0),
+                       span_min=str(sp.get('t_min') or '')[:10], span_max=str(sp.get('t_max') or '')[:10],
+                       turbines=int(d['turbine'].nunique()))
+    if not parts:
+        return pd.DataFrame(columns=WANT_COLS + ['window', 't', 'val']), used
+    d = pd.concat(parts, ignore_index=True)
+    d['t'] = pd.to_datetime(d['trigger_time'], errors='coerce')
+    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
+    d = d[d['val'].notna() & d['t'].notna()].copy()
+    return d, used
+
+
+def mark_segments(d, n_win, span_min=None):
+    """加 `seg` 列 → (kind, {段: 段日起日}, 段序, {段: 段结束日})。口径见 docstring。"""
+    span_min = span_min or {}
+    if n_win >= 3:
+        d['seg'] = d['window'].astype(str)
+        kind = 'window'
+    else:
+        iso = d['t'].dt.isocalendar()
+        d['seg'] = iso['year'].astype(int).astype(str) + '-W' + iso['week'].astype(int).astype(str).str.zfill(2)
+        kind = 'week'
+    g = d.groupby('seg')['t'].min()
+    order = [s for s, _ in sorted(g.items(), key=lambda kv: kv[1])]
+    if kind == 'week':
+        seg_date = {s: str(pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1).date()) for s in order}
+        seg_end = {s: str((pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1) + pd.Timedelta(days=6)).date())
+                   for s in order}
+    else:
+        seg_date = {s: str(span_min.get(s) or str(g[s])[:10])[:10] for s in order}
+        seg_end = {s: str(d[d.seg == s]['t'].max())[:10] for s in order}
+    return kind, seg_date, order, seg_end
+
+
+def load_anchors(cfg):
+    """场内实物锚 → (逐条锚表, 说明)。缺件/无日期都如实记, 不默认忽略。"""
+    p = cfg.get('anchors')
+    if not p or not pathlib.Path(p).exists():
+        return pd.DataFrame(columns=['turbine', 'date', 'sensors']), dict(path=P.rel(p) if p else None, exists=False,
+                                                                         note='锚件不在位 ⇒ 无护栏 (健康期=全部段)')
+    a = json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
+    recs, nodate = [], []
+    for x in a.get('anchors', []):
+        cls = str(x.get('component_class') or '')
+        sens = ANCHOR_CLASS_SENSORS.get(cls)
+        if sens is None:                                     # 未知类: 不猜, 记下来
+            nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='部件类未登记 → 未映射测点'))
+            continue
+        dt_ = x.get('date')
+        if not dt_:
+            nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='锚无日期 → 无法做时间护栏'))
+            continue
+        recs.append(dict(turbine=x.get('turbine'), date=str(dt_)[:10], sensors=list(sens),
+                         component_class=cls, tier=x.get('tier')))
+    info = dict(path=P.rel(p), exists=True, n_anchors=len(a.get('anchors', [])), guard_days=ANCHOR_GUARD_DAYS,
+                n_used=len(recs), 无日期=len(nodate), tiers=sorted({str(x.get('tier')) for x in a.get('anchors', [])}),
+                未用锚=nodate)
+    return pd.DataFrame(recs), info
+
+
+def healthy_segments(order, seg_date, seg_end, anchors, turbine, sensor):
+    """该 (台, 测点) 的健康期段 + 被剔段 (锚日 ±guard_days 覆盖到的段)。"""
+    ex = []
+    ad = anchors[(anchors['turbine'] == turbine) & (anchors['sensors'].map(lambda L: sensor in L))] \
+        if len(anchors) else anchors
+    for _, a in (ad.iterrows() if len(ad) else []):
+        t = pd.Timestamp(a['date'])
+        lo = str((t - pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
+        hi = str((t + pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
+        for s in order:                                      # 段与 [lo,hi] 有交叠即剔 (段是闭区间)
+            if not (seg_end[s] < lo or seg_date[s] > hi):
+                ex.append((s, f'{a["date"]}±{ANCHOR_GUARD_DAYS}d({a["component_class"]})'))
+    ex_map = dict(ex)
+    keep = [s for s in order if s not in ex_map]
+    return keep, ex_map
+
+
+def build(cfg, farm_name='rudong', dry=False):
+    m5 = P.m5(farm_name)
+    store = P.store(farm_name)
+    t0 = time.time()
+    d, used = load_scalars(cfg, MEAS_BL)
+    if not len(d):
+        print('[X] 一个 CMS 窗索引都没读到 —— 先放数据并跑重算链 (scripts/rebuild_all.py); 本器不写空产物。')
+        return None, 4
+    kind, seg_date, order, seg_end = mark_segments(d, len(used), {w: v['span_min'] for w, v in used.items()})
+    # 单位标签: 按每个 (测点, 标量) 在窗索引里的 y_unit 定 (见 docstring「单位口径」)
+    yunit = d.groupby(['sensor_name', 'meas_name'])['y_unit'].agg(lambda s: s.dropna().astype(str).mode().iloc[0]
+                                                                 if s.notna().any() else '')
+    sg = d.groupby(['turbine', 'sensor_name', 'meas_name', 'seg'])['val'].agg(['median', 'size']).reset_index()
+    sg = sg.rename(columns={'median': 'med', 'size': 'n'})
+    anchors, anchor_info = load_anchors(cfg)
+    turbines = [t for t in ws_farm(farm_name).get('turbines', [])] or sorted(d['turbine'].unique())
+    # 段跨度用**数据实际覆盖**区间 (周段按日历算出的段末日会到 04-26, 而数据止 04-21 ——
+    # 前者只用于"工单落在哪一段"的判定, 报告里说跨度必须说数据真到的日子)。
+    span = [min(v['span_min'] for v in used.values()), max(v['span_max'] for v in used.values())]
+    # ── fleet: 每个 (测点, 标量) 每段的**全场中位** (先全算一遍; 早期版本按台循环里数段行数,
+    #    每段至多 1 行 ⇒ 计数永远 <5 ⇒ fleet 键全空, 页面 ×fleet 恒为 —) ──
+    fleet = {}
+    for (sen, ms), sub in sg.groupby(['sensor_name', 'meas_name']):
+        for s in order:
+            v = sub[sub.seg == s]['med'].to_numpy(dtype=float)
+            if len(v) >= FLEET_MIN_TURBINES:
+                fleet.setdefault(f'{sen}|{ms}', {})[s] = _r(float(np.median(v)))
+    print(f'三层基线生成端 · 场={farm_name}')
+    print(f'  窗 {len(used)} 个: ' + '; '.join(
+        f'{w}({v["span_min"]}→{v["span_max"]}, 全表 {v["rows_all"]}行; 标量 {v["rows_scalar"]}行)'
+        for w, v in used.items()))
+    print(f'  标量 (ds_size==1 ∧ {len(MEAS_BL)} 标量 ∧ {len(SENSORS)} 测点): {len(d)} 行 · 台 {d.turbine.nunique()}')
+    print(f'  分段: segments_kind={kind} · {len(order)} 段 {order[0]}…{order[-1]} ({span[0]}→{span[1]})')
+    print(f'  单位 (y_unit→标签): ' + '; '.join(
+        f'{s}|{m}:{yunit.get((s, m), "") or "(该测点无此标量)"}→{UNIT_OF_YUNIT.get(str(yunit.get((s, m), "")), "-")}'
+        for s, m in [('Main_bearing_front', 'Peak'), ('Main_bearing_front', 'Rms_Vel'),
+                     ('Main_bearing_front', 'rms_200'), ('Generator_DE', 'iso_rms_vel'),
+                     ('Gear_planet', 'iso_rms_vel')]))
+    print(f'  锚: {anchor_info.get("path")} 共 {anchor_info.get("n_anchors", 0)} 条 · 带日期可用 {anchor_info.get("n_used", 0)} '
+          f'· 护栏 ±{ANCHOR_GUARD_DAYS}d · 未用 {anchor_info.get("无日期", 0)} 条')
+    for x in anchor_info.get('未用锚', []):
+        print(f'    [锚未用] {x.get("turbine")} {x.get("component_class")} — {x.get("why")}')
+
+    self_bl, ins_rows = {}, []
+    n_entries = n_ok = 0
+    for t in turbines:
+        self_bl[t] = {}
+        for sen in SENSORS:
+            self_bl[t][sen] = {}
+            for ms in MEAS_BL:
+                sub = sg[(sg.turbine == t) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                if not len(sub):
+                    continue                                  # 该测点无此标量 (如 齿轮箱无 iso_rms_vel): 整条不出现
+                n_entries += 1
+                ser = sub.set_index('seg')['med'].reindex(order).dropna()
+                nser = sub.set_index('seg')['n'].reindex(order).fillna(0)
+                healthy, ex_map = healthy_segments(order, seg_date, seg_end, anchors, t, sen)
+                hv = [s for s in healthy if s in ser.index]
+                unit = UNIT_OF_YUNIT.get(str(yunit.get((sen, ms), '')), '-')
+                base = dict(n_windows_total=int(len(ser)), n_windows_healthy=int(len(hv)),
+                            excluded=[s for s in order if s in ex_map],           # 剔掉的段名 (旧件同字段名)
+                            excluded_detail=[dict(seg=s, why=ex_map[s]) for s in order if s in ex_map],
+                            unit=unit, healthy_windows=hv, latest_window=str(ser.index[-1]),
+                            latest=_r(float(ser.iloc[-1])), latest_n=int(nser.get(ser.index[-1], 0)))
+                if len(hv) < MIN_WINDOWS:
+                    self_bl[t][sen][ms] = _py(dict(base, self_baseline=None, self_baseline_stat=None,
+                                                   normal_band=None, rel_mad=None, threshold_self=None,
+                                                   threshold_note=None, spread=None, x_self=None,
+                                                   in_normal_band=None, recovered_after_anchor=None,
+                                                   trend_flag=None,
+                                                   insufficient=INS_TMPL.format(n=len(hv))))
+                    ins_rows.append(dict(turbine=t, sensor=sen, meas=ms, n_healthy=len(hv),
+                                         n_total=int(len(ser)), why='锚护栏后健康段不足 3' if ex_map else '总段数不足 3'))
+                    continue
+                vals = ser.loc[hv].to_numpy(dtype=float)
+                center = float(np.median(vals))
+                lo, hi = float(vals.min()), float(vals.max())
+                mad = float(np.median(np.abs(vals - center)))
+                rel_mad = (mad / center) if center else None
+                thr = max(center * max(1 + 2.0 * (rel_mad or 0.0), 1.3), hi * 1.05) if center else None
+                latest = float(ser.iloc[-1])
+                # 锚后是否恢复: 需要该 (台,测点) 有锚且段跨度内锚前/锚后各有段 (本机多窗口径才可能成立)
+                rec = None
+                ad = anchors[(anchors['turbine'] == t) & (anchors['sensors'].map(lambda L: sen in L))] \
+                    if len(anchors) else anchors
+                for _, a in (ad.iterrows() if len(ad) else []):
+                    pre = [s for s in ser.index if seg_end[s] < a['date']]
+                    post = [s for s in ser.index if seg_date[s] > a['date']]
+                    if pre and post:
+                        pv, qv = float(np.median(ser.loc[pre])), float(np.median(ser.loc[post]))
+                        rec = bool(qv <= RECOVER_FRAC * pv) if pv > 0 else None
+                        break
+                trend = CONTAMINATED_FLAG if (len(vals) >= 3 and np.all(np.diff(vals) > 0)) else None
+                self_bl[t][sen][ms] = _py(dict(
+                    base, self_baseline=_r(center), self_baseline_stat=f'中位 of 健康期{"窗" if kind == "window" else "段"}中位',
+                    normal_band=[_r(lo), _r(hi)], rel_mad=_round(rel_mad, 4), threshold_self=_r(thr),
+                    threshold_note=THRESHOLD_NOTE, spread=_round(hi / lo, 4) if lo > 0 else None,
+                    x_self=_round(latest / center, 3) if center else None,
+                    in_normal_band=bool(lo <= latest <= hi), recovered_after_anchor=rec, trend_flag=trend))
+                n_ok += 1
+    # (fleet 已在上面按 (测点, 标量, 段) 全场算完 —— 不在这里按台累加, 见那一处注释)
+
+    fleet_bl = {k: dict(sorted(v.items(), key=lambda kv: order.index(kv[0]))) for k, v in sorted(fleet.items())}
+    n_fleet_keys = len(fleet_bl)
+    missing_fleet = sorted({f'{s}|{m}' for t in self_bl.values() for s, mm in t.items() for m in mm} - set(fleet_bl))
+    seg_kind_txt = '导出窗' if kind == 'window' else f'窗内 ISO 周 (本机窗数 {len(used)} < 3 ⇒ 降级; 多窗时自动改回)'
+    meta = dict(
+        built=time.strftime('%Y-%m-%d'), farm=farm_name, by='scripts/baseline_38_build.py',
+        min_windows=MIN_WINDOWS,
+        windows={w: v['span_min'] for w, v in used.items()},
+        windows_detail={w: dict(rows_all=v['rows_all'], rows_scalar=v['rows_scalar'], span_min=v['span_min'],
+                                span_max=v['span_max'], turbines=v['turbines']) for w, v in used.items()},
+        segments_kind=kind, segments=seg_date, n_segments=len(order),
+        anchor_guard_days=ANCHOR_GUARD_DAYS,
+        self_stat=(f'中位 of 健康期{"窗" if kind == "window" else "段"}中位 (+正常带[min,max]+relMAD); '
+                   f'健康期 = 全部段 减去 实物锚 ±{ANCHOR_GUARD_DAYS} 天覆盖的段。'
+                   f'本机段 = {seg_kind_txt}, 共 {len(order)} 段 ({span[0]}→{span[1]}); '
+                   f'段中位 = 该段该 (台,测点,标量) 全部标量记录的中位 (ds_size==1, 每时间戳一条)。'),
+        layers='self (变了没) / fleet (比别人高没) / abs (严重不严重) — 三者独立, 只报一个会误导',
+        caveat=('自基线假设健康期段代表正常; 慢性劣化台该假设失效 → 见 recovered_after_anchor / trend_flag。'
+                f'★ 本机用周段 ⇒ 正常带/relMAD 是**周与周之间**的散布 (周间工况更接近, 散布偏小), '
+                f'不等于随包旧件那种"跨月导出窗"的散布; 且 `latest` 是最后一个周段的段中位, '
+                f'该段样本可能很薄 (逐条给出 latest_n)。'),
+        unit_note=('存值单位 = CMS 窗索引原值 (y_unit): Peak/Rms_HP/rms_200 = m/s², Rms_Vel/iso_rms_vel = m/s, '
+                   'Kurtosis = 1; 速度类标签写 mm/s 而页面按 absolute_limits.unit=="mm/s" 乘 1000 显示 '
+                   '—— 与随包旧件同一约定 (0.00282 m/s 存值 ↔ 2.82 mm/s 显示)。'),
+        usage_limit=(
+            '★ 本基线**不作自动报警闸**: 随包旧件 (多窗口径, 2026-08-26 参数扫描) 记录的 ROC 结论是 '
+            '误报率压到 6.3% 时检出率仅 14.3% (6/42 锚单元且集中在 20# 一台), 加持续性要求(连续2窗)检出率归零 '
+            '—— 多数锚台只有 1~2 个锚前窗。该回测**未在本机重跑** (本机只有 1 窗, 跑不出多窗 ROC), '
+            '此处照录以免被当成"已验证的报警线"。'
+            f'★ 本机为周段口径 ⇒ 上列阈值由**周段散布**定, 散布比多窗口径更小, 更容易被现实超出; '
+            f'正确用法 = ①量化描述(报 x_self 而非"偏高"这类模糊词) ②人工研判输入 ③趋势跟踪积累段数。'
+            '自动报警须等段数够做持续性检验。'),
+        fleet_min_turbines=FLEET_MIN_TURBINES,
+        inputs=dict(scalars=[v['index'] for v in used.values()], anchors=anchor_info.get('path')),
+        锚=anchor_info,
+        未出fleet键=missing_fleet,
+        自基线不足=ins_rows,
+    )
+    meta['★数据边界'] = (
+        f'① 本机只有 {len(used)} 个 CMS 导出色窗 ('
+        + '; '.join(f'{w} {v["span_min"]}→{v["span_max"]}' for w, v in used.items())
+        + f') ⇒ 分段降级为窗内 ISO 周 ({len(order)} 段, {span[0]}→{span[1]}); '
+          f'多窗口径 (健康期"窗"中位) 不可算, 本件 meta.segments_kind 写明实际用的是哪种。'
+        f'② 份数: 自基线条目 {n_entries} 条 (台 × 测点 × 该测点**实际存在**的标量), 出数 {n_ok} 条, '
+        f'不给数 {len(ins_rows)} 条 (健康期段 < {MIN_WINDOWS}, 明细见 meta.自基线不足; 一律 null + insufficient, '
+        f'**不用 fleet 值顶替**)。★ 页面遍历的 5 个标量里, iso_rms_vel 只有发电机两测点有、rms_200 只有主轴承两测点有 '
+        f'(y_unit 实测) ⇒ 其余测点这两条**不存在**, 页面 `if not r: continue` 跳过, 本件在 meta.未覆盖标量 里写明。'
+        f'③ 锚护栏: {anchor_info.get("n_used", 0)} 条带日期锚已按 ±{ANCHOR_GUARD_DAYS} 天剔段, '
+        f'{anchor_info.get("无日期", 0)} 条锚**无日期/类未登记** ⇒ 未做护栏 (逐条见 meta.锚.未用锚)。'
+        f'④ fleet 中位要求该段 ≥{FLEET_MIN_TURBINES} 台贡献, 不足则该段不出值 (页面 ×fleet 显示 —)。'
+        f'⑤ 本件全部数字由本脚本从 outputs/<场>/m5_cms_tcm/windows/*/index.parquet (data/raw 重算产物) 与 '
+        f'reference/<场>/positive_anchors.json 现算, 不含随包旧件的数值搬运。')
+    meta['未覆盖标量'] = {s: [m for m in MEAS_BL if m not in {mm for t in self_bl.values() for mm in t.get(s, {})}]
+                          for s in SENSORS}
+    meta['未覆盖标量'] = {s: v for s, v in meta['未覆盖标量'].items() if v}
+
+    payload = dict(meta=meta, absolute_limits=ABSOLUTE_LIMITS, fleet_baseline=fleet_bl, self_baseline=self_bl)
+    txt = json.dumps(payload, ensure_ascii=False, indent=1, allow_nan=False, default=str)
+    out = m5 / 'baseline_38.json'
+    print(f'\n  条目 {n_entries} 条 · 出数 {n_ok} · 不给数 {len(ins_rows)} ({len({r["turbine"] for r in ins_rows})} 台) '
+          f'· fleet 键 {n_fleet_keys} (未出 {len(missing_fleet)})')
+    print(f'  self_baseline 台 {len([t for t in self_bl if any(self_bl[t][s] for s in self_bl[t])])} 台 × 测点 {len(SENSORS)} '
+          f'· 正常带/阈值样例见下')
+    for t, sen, ms in (('WTG01', 'Main_bearing_front', 'Peak'), ('WTG01', 'Gear_HS_rotor_side', 'Rms_Vel'),
+                       ('WTG01', 'Generator_DE', 'iso_rms_vel'), ('WTG02', 'Generator_DE', 'Peak')):
+        e = (self_bl.get(t) or {}).get(sen, {}).get(ms)
+        if e and e.get('self_baseline'):
+            print(f'    {t} {SENSOR_CN.get(sen, sen)}|{ms}: 自基线 {e["self_baseline"]} 带 {e["normal_band"]} '
+                  f'relMAD {e["rel_mad"]} 阈 {e["threshold_self"]} 末段 {e["latest_window"]}(n={e["latest_n"]}) '
+                  f'×自基线 {e["x_self"]} 带内={e["in_normal_band"]} 健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}'
+                  f' 剔 {len(e["excluded"])}')
+        elif e:
+            print(f'    {t} {SENSOR_CN.get(sen, sen)}|{ms}: 不给数 — {e.get("insufficient")} '
+                  f'(健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}, 剔 {len(e["excluded"])})')
+    if ins_rows:
+        print('  不给数明细: ' + '; '.join(f'{r["turbine"]} {SENSOR_CN.get(r["sensor"], r["sensor"])}|{r["meas"]} '
+                                          f'{r["n_healthy"]}/{r["n_total"]} ({r["why"]})' for r in ins_rows[:8])
+              + (' …' if len(ins_rows) > 8 else ''))
+    print(f'  产物体积预估 {len(txt.encode("utf-8")) / 1024:.0f} KB ({P.rel(out)}) · 用时 {time.time() - t0:.1f}s')
+    if dry:
+        print('\n[dry-run] 不写盘、不登记。')
+        return payload, 0
+    m5.mkdir(parents=True, exist_ok=True)
+    out.write_text(txt, encoding='utf-8')
+    # ★自登记台账的落点与键 (2026-09-19 实逮): 正本是 `P.out_root(farm)/_derived_manifest.json`,
+    #   键必须**相对产物仓根**(其它生成端同一口径)。用 P.store()/P.rel() 会另建一本
+    #   `windscada/_derived_manifest.json` 且键带 `outputs/rudong/` 前缀 ⇒ 台账读不到、审计报"未归类"。
+    dm_record(P.out_root(farm_name), {out.relative_to(P.out_root(farm_name)).as_posix():'scripts/baseline_38_build.py (CMS 窗标量 → 自基线/fleet/ISO 绝对三层; '
+                                 f'段口径 segments_kind={kind}, 实物锚 ±{ANCHOR_GUARD_DAYS}d 护栏, 不足不给数)'},
+              by='scripts/baseline_38_build.py')
+    print(f'\n[OK] 已写 {P.rel(out)} ({out.stat().st_size / 1024:.0f} KB) · 已自登记')
+    return payload, 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='三层基线生成端 (m5_cms_tcm/baseline_38.json)')
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--dry-run', action='store_true', help='只算不写盘 (打印漏斗与不给数清单)')
+    args = ap.parse_args()
+    cfg = cms_farm(args.farm)
+    _, rc = build(cfg, args.farm, dry=args.dry_run)
+    return rc
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 641 - 0
app_ETL/app_ETL_guanlan/builders/component_history_build.py

@@ -0,0 +1,641 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""部件历史生成端 —— 补上"包内没有生成端"的 `m5_cms_tcm/component_history.json` (用户令 2026-09-19)。
+
+## 为什么要写它
+
+用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
+`component_history.json` 是**随包快照**(族表 kind=shipped、gen=None,见
+`scripts/products_reverse_audit.py::HUMAN_SNAPSHOTS`),随包件里的"在升/闭环/换件日期"含人工裁决与
+厂家锚,**不是测量数据的函数**。于是另一台机器上装完、放数据、重算之后:
+`src/ontology/trend_ingest.py` 拿不到在升/闭环证据(图谱里两类 Evidence 全缺)、
+`scripts/windscada_serve.py` 的决策链盘「振动在升/闭环」两格永远空。
+本脚本把它**从已有产物算出来**(CMS 窗索引 + 工单台账),口径写在下面与产物 meta 里。
+
+## 产物与消费者 (字段契约)
+
+    outputs/<场>/m5_cms_tcm/component_history.json
+
+    meta                              built / windows / segments / segments_kind / 口径 / ★数据边界
+    components[]                      turbine, component, n_windows, n_replace, series, events
+    summary['★当前在升 (末三窗/早三窗 ≥1.3)']        ← src/ontology/trend_ingest.py 逐行读
+                                        turbine, component, ratio, early, late, n_events, 工单可对
+    summary['★换件闭环 (检出→换件→恢复, 算法端到端验证)'] ← 同上 + scripts/windscada_serve.py 链盘
+                                        turbine, component, ratio, pre, post, replace_date, title, verdict
+    summary[非★键]                    消费端只认 ★ 前缀的键 ⇒ 非★键是给人/给诊断看的, 不被误读
+
+★ `component` 必须是 `src/ontology/trend_ingest.py::COMP_MAP` 里的中文名 (齿轮箱/发电机/主轴承/变桨/偏航/变流器),
+  否则该行被消费者跳过。本器只出**齿轮箱/发电机/主轴承**三类 —— CMS 索引里只有这三类的测点
+  (Main_bearing_* / Gear_* / Generator_*),变桨/偏航/变流器在本产物无振动测点,不臆造。
+
+## 分段 (segment) 口径 —— 两种模式, 自动选
+
+参考口径是**导出窗**为段 (末三窗/早三窗; 健康期窗中位)。本机实况 (2026-09-19 实测):
+`src/windcms/config.py::FARMS['rudong']['windows']` 只解析出**一个**窗 `w0316`
+(`outputs/rudong/m5_cms_tcm/windows/w0316/index.parquet`,2,066,686 行 × 54 列,
+trigger_time 2026-03-16 17:27 → 2026-04-21 10:18)。窗数 < 3 ⇒ 自动降为
+**窗内周段** (ISO 周, 2026-W12 … 2026-W17 共 6 段)。两种模式都在本文件里实现:
+
+    len(windows) >= 3   segments_kind='window'   段名=窗名, 段日=该窗 trigger_time 最早日
+    len(windows) <  3   segments_kind='week'     段名=ISO 周 (2026-W12), 段日=该周周一
+
+**多窗口径未在本机取数验证**(本机只有 1 窗 ⇒ 该分支只有单测/纸面正确性,不给"已验证"的说法)。
+
+## 数据现实与诚实边界 (不造数)
+
+* **`★当前在升` 本机必然是空表**:判据要求"末三窗/早三窗"且**多窗口径**;本机 1 窗 ⇒ 不可算。
+  ★ 不降门槛、不换判据去凑一行。作为补充, 出**非★**键
+  `窗内周段趋势 (单窗补充口径, 仅参考不作判据)`:同一条比值算式 (末三段/早三段, 要求 6 段以取到
+  **不相交**的早/晚两组) 在周段上跑出来是什么, 就写什么。
+* **`★换件闭环` 本机为空**:闭环要求"该台该部件的更换/换件工单,且段跨度里**既有段在换件日之前、
+  又有段在其后**"(前段须**结束**在工单日之前、后段须**开始**在工单日之后 —— 工单所在那一段两边都不算,
+  否则换后数据会混进"换前",实测污染过 3 条)。工单台账 (`outputs/<场>/windscada/workorders.parquet`,
+  5,876 行) 实测跨 **2020-01-03 → 2026-07-08**,其中传动链件 903 行(去重后)/换件类 12 条 ——
+  但**全部落在段跨度 (2026-03-16~04-21) 之外** (换件工单最晚 2025-10-14),窗内 0 条 ⇒ 无"换件前段"可比
+  ⇒ 空表。空表本身就是结论。
+* 台账有**逐字重复行**(实测 2026-01-03 环网柜那条同一台同一分钟 7 行;换件类里 WTG23 2024-09-12
+  发电机轴承更换 4 行)⇒ 本器按 (台,日,标题,部件) 去重后再计数,原始/去重两个数都写进 meta.台账。
+* 段内样本数一并写进 series 的 `n` —— 周段只有 2~142 个标量记录 (中位 14), 中位数据此散布, 读者可自行判断。
+
+## 用法
+
+    python scripts/component_history_build.py                  # 算并写盘 + 自登记
+    python scripts/component_history_build.py --dry-run        # 只算不写, 打印漏斗
+    python scripts/component_history_build.py --farm rudong    # 场名 (默认 rudong)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+import time
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+# ★GBK 控制台/日志重定向不再因 '²' '⇒' 这类字符抛 UnicodeEncodeError (2026-09-19 远端实逮:
+#   `baseline_38_build.py` 打印 'm/s²' 时在 cp936 下 rc=1, 整条恢复链少一件产物)。
+#   只把**不可编码字符替换掉**, 不改流编码 —— 中文照旧可读。
+try:
+    sys.stdout.reconfigure(errors='replace')
+except Exception:
+    pass
+
+import numpy as np                                            # noqa: E402
+import pandas as pd                                           # noqa: E402
+import pyarrow.parquet as pq                                  # noqa: E402
+
+from src import paths as P                                    # noqa: E402
+from src.derived_manifest import record as dm_record           # noqa: E402
+from src.windscada.config import farm as ws_farm               # noqa: E402  (场名/台数: windscada 侧)
+from src.windcms import data as CD                             # noqa: E402  (窗发现: 唯一取用口)
+from src.windcms.config import KEY_SCALARS, SENSORS, SENSOR_CN  # noqa: E402
+from src.windcms.config import farm as cms_farm                # noqa: E402  (windcms 场配置: FARMS[name])
+
+# ── 标量集 (bounded): KEY_SCALARS 去掉 CrestFactor ────────────────────────────────
+# 为什么去 CrestFactor: `src/windcms/report.py` 明写"Kurtosis/CrestFactor 判别力弱(回测实证, 不展示)";
+# 且随包旧件的 fleet 键集合恰好 = {Kurtosis, Peak, Rms_HP, Rms_Vel} ∪ {iso_rms_vel (发电机),
+# rms_200 (主轴承)} —— 本器沿用同一集合, 便于与旧件对拍。
+MEAS_SERIES = [m for m in KEY_SCALARS if m != 'CrestFactor']
+
+# ── 部件 ↔ 测点 ────────────────────────────────────────────────────────────────
+# 依据 `src/windcms/config.py`: 8 测点 = 2 主轴承 + 4 齿轮箱 + 2 发电机。
+# `System Monitor` (磁盘/内存/健康度) 不是部件测点 ⇒ 排除 (它连 SENSORS 都不在)。
+COMP_OF_SENSOR = {
+    'Main_bearing_front': '主轴承', 'Main_bearing_rear': '主轴承',
+    'Gear_planet': '齿轮箱', 'Gear_IMS': '齿轮箱',
+    'Gear_HS_rotor_side': '齿轮箱', 'Gear_HS_generator_side': '齿轮箱',
+    'Generator_DE': '发电机', 'Generator_NDE': '发电机',
+}
+# 段数门: 单窗口径下"末三/早三"需要 ≥3 段 (与旧件判据同名同门槛); 周段补充口径另要求 ≥6 段
+# (早三/末三**不相交**, 否则末三段里含早三段的中间段, 比值被自己稀释)。
+MIN_SEG_WINDOW = 3
+MIN_SEG_WEEK = 6
+RATIO_HI = 1.3                    # 在升判据 (与旧件同名同值): 末/早 ≥ 1.3
+RECOVER_HI = 0.5                  # 闭环"恢复"判据: 换件后/换件前 ≤ 0.5
+FLAT_LO, FLAT_HI = 0.9, 1.1       # 负面对照分桶: 持平带
+
+# ── 工单 → (部件, 是否更换) 规则 (见模块 docstring「诚实边界」) ─────────────────────
+WO_COMP_KEYS = (('齿轮箱', ('齿轮箱', '齿圈', '行星', '高速轴', '中间轴', '齿轮油')),
+                ('主轴承', ('主轴承', '主轴')),
+                ('发电机', ('发电机',)))
+PART_PAT = re.compile(r'轴承|齿圈|行星|高速轴|中间轴|箱体|发电机本体')
+# 耗材类词: 出现即**不算换件** (油/油脂/加注/油泵/滤芯/密封/传感器 —— 换了也不该改变振动)
+CONSUMABLE_PAT = re.compile(r'油脂|润滑|油位|油泵|滤芯|密封|加注|油管')
+REPLACE_PAT = re.compile(r'更换|替换|换件')
+EVENT_CAP = 8                     # 每 (台, 部件) 落进 events 的最多工单条数 (最近 N 条), 总数另存 n_events
+
+WANT_COLS = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'scalar_value', 'ds_size']
+
+
+# ── 工具 ───────────────────────────────────────────────────────────────────────
+def _r6(v):
+    """6 位有效数字的浮点 (0.0028182 → 0.0028182; 4.630123 → 4.63012)。"""
+    if v is None:
+        return None
+    v = float(v)
+    if not np.isfinite(v):
+        return None
+    return float(f'{v:.6g}')
+
+
+def _r3(v):
+    if v is None:
+        return None
+    v = float(v)
+    return float(f'{v:.3g}') if np.isfinite(v) else None
+
+
+def _py(o):
+    """numpy 标量 → python 标量; NaN/Inf → None (JSON 严格可解析, 见 json.dumps(allow_nan=False))。"""
+    if isinstance(o, dict):
+        return {k: _py(v) for k, v in o.items()}
+    if isinstance(o, (list, tuple)):
+        return [_py(v) for v in o]
+    if isinstance(o, (np.integer,)):
+        return int(o)
+    if isinstance(o, (np.floating, float)):
+        f = float(o)
+        return f if np.isfinite(f) else None
+    if isinstance(o, (np.bool_,)):
+        return bool(o)
+    return o
+
+
+def load_scalars(cfg, meas):
+    """各 CMS 窗的标量子集 —— 口径同 `src/windcms/data.py::_read_index/load_scalars`
+    (只取 ds_size==1 的标量行, 只取 SENSORS 内的测点), 但**不落缓存**: 本器只读, 不在产物仓里另生成件。
+
+    窗的覆盖区间另取 `src.windcms.data.window_spans` (逐窗读 trigger_time; 页面同源) ——
+    本器只筛了 6 个标量 × 8 测点, 单看筛后行会把窗起点说晚 (w0316 全表起 03-16 17:27, 筛后起 03-17)。
+    """
+    ws = CD.windows(cfg)
+    spans = CD.window_spans(cfg)
+    parts, used = [], {}
+    for w, info in ws.items():
+        p = pathlib.Path(info['index'])
+        names = set(pq.ParquetFile(p).schema_arrow.names)
+        cols = [c for c in WANT_COLS if c in names]
+        d = pd.read_parquet(p, columns=cols)
+        for c in ('ds_size', 'scalar_value'):
+            if c not in d.columns:
+                d[c] = np.nan
+        d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
+        d['window'] = w
+        parts.append(d)
+        t = pd.to_datetime(d['trigger_time'], errors='coerce')
+        sp = spans.get(w) or {}
+        used[w] = dict(index=P.rel(p), rows_scalar=int(len(d)), rows_all=int(sp.get('rows') or 0),
+                       span_min=str(sp.get('t_min') or '')[:10], span_max=str(sp.get('t_max') or '')[:10],
+                       seg_min=str(t.min())[:10], seg_max=str(t.max())[:10],
+                       turbines=int(d['turbine'].nunique()))
+    if not parts:
+        return pd.DataFrame(columns=WANT_COLS + ['window', 't', 'val']), used
+    d = pd.concat(parts, ignore_index=True)
+    d['t'] = pd.to_datetime(d['trigger_time'], errors='coerce')
+    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
+    d = d[d['val'].notna() & d['t'].notna()].copy()
+    return d, used
+
+
+def mark_segments(d, n_win, span_min=None):
+    """加 `seg` 列 + 返回 (kind, {段名: 段日起日}, 段序)。见 docstring「分段口径」。
+
+    段日: 多窗口径 = 该窗全表覆盖起点 (`window_spans`, 与页面同源); 单窗口径 = 该 ISO 周的**周一**
+    (取周一而不是"该周首条记录日", 是让段日只依赖日历, 不随哪个测点先出数而漂)。
+    """
+    span_min = span_min or {}
+    if n_win >= 3:                                   # 多窗口径: 段 = 导出窗
+        d['seg'] = d['window'].astype(str)
+        kind = 'window'
+    else:                                            # 单窗口径: 段 = 窗内 ISO 周
+        iso = d['t'].dt.isocalendar()
+        d['seg'] = iso['year'].astype(int).astype(str) + '-W' + iso['week'].astype(int).astype(str).str.zfill(2)
+        kind = 'week'
+    g = d.groupby('seg')['t'].min()
+    order = [s for s, _ in sorted(g.items(), key=lambda kv: kv[1])]
+    span_max = str(d['t'].max())[:10]
+    if kind == 'week':
+        seg_date = {s: str(pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1).date()) for s in order}
+        seg_end = {s: str((pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1) + pd.Timedelta(days=6)).date())
+                   for s in order}
+    else:
+        seg_date = {s: str(span_min.get(s) or str(g[s])[:10])[:10] for s in order}
+        seg_end = {s: str(d[d.seg == s]['t'].max())[:10] for s in order}
+    return kind, seg_date, order, seg_end
+
+
+def seg_table(d):
+    """(台, 测点, 标量, 段) 的段内中位/样本数。"""
+    g = d.groupby(['turbine', 'sensor_name', 'meas_name', 'seg'])['val'].agg(['median', 'size'])
+    g = g.reset_index().rename(columns={'median': 'med', 'size': 'n'})
+    return g
+
+
+def trend_table(sg, order, k=3, min_seg=3):
+    """(台, 测点, 标量) 的「末 K 段中位 / 早 K 段中位」比 —— 只对段数 ≥ min_seg 的组合出数。"""
+    rows = []
+    idx = {s: i for i, s in enumerate(order)}
+    for (t, sen, meas), sub in sg.groupby(['turbine', 'sensor_name', 'meas_name']):
+        m = sub.assign(_i=sub['seg'].map(idx)).dropna(subset=['_i']).sort_values('_i')
+        if len(m) < min_seg:
+            continue
+        vals = m['med'].to_numpy(dtype=float)
+        early, late = float(np.median(vals[:k])), float(np.median(vals[-k:]))
+        if not (early > 0):
+            continue
+        rows.append(dict(turbine=t, sensor=sen, component=COMP_OF_SENSOR.get(sen, ''), measure=meas,
+                         early=_r6(early), late=_r6(late), ratio=_r3(late / early),
+                         n_seg=int(len(m)), segs=list(m['seg'])))
+    return pd.DataFrame(rows)
+
+
+# ── 工单台账 ───────────────────────────────────────────────────────────────────
+def load_workorders(store):
+    """工单台账 → (逐行事件表, 台账范围说明)。台账不存在 ⇒ 空表 + 如实说明 (不造事件)。"""
+    p = pathlib.Path(store) / 'workorders.parquet'
+    if not p.exists():
+        return pd.DataFrame(columns=['turbine', 'date', 'title', 'replaced', 'comps', 'raw']), \
+            dict(path=P.rel(p), exists=False, rows=0, note='台账不在位 ⇒ 全部 events 为空数组, 不是"无工单"'),
+    w = pd.read_parquet(p)
+    w['t_report'] = pd.to_datetime(w.get('t_report'), errors='coerce')
+    txt = (w.get('故障名称', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障描述', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障位置二级', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障位置三级', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('排查项目', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障原因', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('维修对象', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('元器件名称', pd.Series('', index=w.index)).fillna(''))
+    act = w.get('维修动作', pd.Series('', index=w.index)).fillna('').astype(str)
+    obj = (w.get('故障名称', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('维修对象', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('元器件名称', pd.Series('', index=w.index)).fillna(''))
+    recs = []
+    for i, s in txt.items():
+        comps = [c for c, keys in WO_COMP_KEYS if any(k in s for k in keys)]
+        if not comps:
+            continue
+        o = str(obj.iloc[i] if hasattr(obj, 'iloc') else '')
+        repl = bool(REPLACE_PAT.search(act.iloc[i])) and bool(PART_PAT.search(o)) and not CONSUMABLE_PAT.search(o)
+        title = str(w.get('故障名称', pd.Series('', index=w.index)).iloc[i] or '').strip()
+        recs.append(dict(turbine=str(w['turbine'].iloc[i]), date=str(w['t_report'].iloc[i])[:10],
+                         title=title[:60], replaced=repl, comps=comps,
+                         act=str(act.iloc[i]), obj=o[:60]))
+    ev = pd.DataFrame(recs)
+    # ★ 台账里有**逐字重复行** (实测: 2026-01-03 环网柜 V 柜出线电缆接头三相击穿 同一台同一分钟 7 行;
+    #   换件类里 WTG23 2024-09-12 发电机轴承更换 4 行)。不去重会让 n_replace / n_events 虚高。
+    n_raw = int(len(ev))
+    if n_raw:
+        ev['_k'] = ev['turbine'] + '|' + ev['date'] + '|' + ev['title'] + '|' + ev['comps'].map(lambda L: ','.join(L))
+        ev = ev.drop_duplicates(subset=['_k']).drop(columns=['_k']).reset_index(drop=True)
+    tv = w['t_report'].dropna()
+    info = dict(path=P.rel(p), exists=True, rows=int(len(w)),
+                span=[str(tv.min())[:10], str(tv.max())[:10]] if len(tv) else ['', ''],
+                n_transmission=int(len(ev)), n_transmission_raw=n_raw,
+                n_replace=int(ev['replaced'].sum()) if len(ev) else 0,
+                replace_span=[str(pd.to_datetime(ev[ev.replaced].date).min())[:10],
+                              str(pd.to_datetime(ev[ev.replaced].date).max())[:10]] if len(ev) and ev.replaced.any() else None)
+    return ev, info
+
+
+def events_of(ev, turbine, comp):
+    """该 (台, 部件) 的工单事件 (按台/部件关键字映射) → (最近 EVENT_CAP 条明细, 总条数, 其中换件条数)。
+
+    ★ 总条数与换件条数在**全量**匹配上数 (明细只是最近的 EVENT_CAP 条): 早期版本用明细数当总数,
+      一旦该台该部件工单超过 8 条, n_replace 就会**少报**。
+    """
+    if not len(ev) or 'comps' not in ev.columns:
+        return [], 0, 0
+    m = ev[ev['comps'].map(lambda L: comp in L) & (ev['turbine'] == turbine)].sort_values('date')
+    out = [dict(turbine=turbine, comp=comp, date=r['date'], src='工单', title=r['title'], replaced=bool(r['replaced']))
+           for _, r in m.tail(EVENT_CAP).iterrows()]
+    return out, int(len(m)), int(m['replaced'].sum())
+
+
+def build(cfg, farm_name='rudong', dry=False):
+    m5 = P.m5(farm_name)
+    store = P.store(farm_name)
+    t0 = time.time()
+    meas = MEAS_SERIES
+    d, used = load_scalars(cfg, meas)
+    if not len(d):
+        print('[X] 一个 CMS 窗索引都没读到 —— 先放数据并跑重算链 (scripts/rebuild_all.py); 本器不写空产物。')
+        return None, 4
+    kind, seg_date, order, seg_end = mark_segments(d, len(used), {w: v['span_min'] for w, v in used.items()})
+    sg = seg_table(d)
+    ev, wo_info = load_workorders(store)
+    # 台名单以场配置为准 (38 台), 数据里没有的台如实留空 series (不静默丢台)
+    turbines = [t for t in ws_farm(farm_name).get('turbines', [])] or sorted(d['turbine'].unique())
+    no_data = [t for t in turbines if t not in set(d['turbine'].unique())]
+    # 数据覆盖区间取**全表** (src.windcms.data.window_spans, 与页面同源): 只筛 6 标量 × 8 测点会把窗起点说晚一天
+    span = [min(v['span_min'] for v in used.values()), max(v['span_max'] for v in used.values())]
+    print(f'部件历史生成端 · 场={farm_name}')
+    print(f'  窗 {len(used)} 个: ' + '; '.join(
+        f'{w}(全表 {v["span_min"]}→{v["span_max"]}, {v["rows_all"]}行; 标量 {v["rows_scalar"]}行)'
+        for w, v in used.items()))
+    print(f'  标量行 (ds_size==1 ∧ {len(meas)} 标量 ∧ {len(SENSORS)} 测点): {len(d)}'
+          + (f' · 场配置 {len(turbines)} 台, 无数据的台: {no_data}' if no_data else f' · 台数 {len(turbines)}'))
+    print(f'  分段口径: segments_kind={kind} · 段 {len(order)} 个: {order[0]}…{order[-1]} '
+          f'(段日起 {seg_date[order[0]]}→{seg_date[order[-1]]}; 数据覆盖 {span[0]}→{span[1]})')
+    print(f'  段内样本 (台×测点×标量×段) 中位: {int(sg["n"].median())} · 最少 {int(sg["n"].min())} · 最多 {int(sg["n"].max())}')
+    print(f'  工单台账: {wo_info["path"]} 行={wo_info["rows"]} 跨度={wo_info.get("span")} '
+          f'传动链件={wo_info.get("n_transmission")} 换件={wo_info.get("n_replace")} '
+          f'换件跨度={wo_info.get("replace_span")}')
+    if len(ev):
+        print('  换件类工单 (全量, 判据 candidate): ' + '; '.join(
+            f'{r["turbine"]} {"/".join(r["comps"])} {r["date"]} {r["title"][:18]}'
+            for _, r in ev[ev['replaced']].sort_values('date').iterrows()))
+
+    # ── trend: 单窗口径的末三/早三 (判据) 与 周段的末三/早三 (补充, 不相交) ──
+    if kind == 'window':
+        tw = trend_table(sg, order, k=3, min_seg=MIN_SEG_WINDOW)
+    else:
+        tw = trend_table(sg, order, k=3, min_seg=MIN_SEG_WEEK)
+    tw = tw.sort_values('ratio', ascending=False) if len(tw) else tw
+    wo_ok = bool(wo_info.get('exists') and wo_info.get('span') and wo_info['span'][0]
+                 and wo_info['span'][0] <= span[1] and wo_info['span'][1] >= span[0])
+
+    # ── components ──
+    comps_out = []
+    for t in turbines:
+        for comp in ('主轴承', '齿轮箱', '发电机'):
+            sens = [s for s, c in COMP_OF_SENSOR.items() if c == comp]
+            series = {}
+            n_seg_c = 0
+            for sen in sens:
+                for ms in meas:
+                    sub = sg[(sg.turbine == t) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                    if not len(sub):
+                        continue
+                    m = sub.set_index('seg')['med'].reindex(order).dropna()
+                    if not len(m):
+                        continue
+                    base = float(np.median(m.to_numpy(dtype=float)))
+                    if not (base > 0):
+                        continue
+                    entries = []
+                    for s in order:
+                        if s not in m.index:
+                            continue
+                        v = float(m.loc[s])
+                        n = int(sub.loc[sub.seg == s, 'n'].iloc[0])
+                        entries.append(dict(seg=s, med=_r6(v), x_self=_r3(v / base), n=n))
+                    series[f'{sen}|{ms}'] = entries
+                    n_seg_c = max(n_seg_c, len(entries))
+            e_list, n_ev, n_repl = events_of(ev, t, comp)
+            comps_out.append(dict(turbine=t, component=comp, n_windows=int(n_seg_c),
+                                  n_events=n_ev, n_replace=n_repl,
+                                  events_capped=bool(n_ev > len(e_list)),
+                                  series=series, events=e_list))
+
+    # ── summary ──
+    # ★判据一: 只认多窗口径 (本机必然空) —— 不降门槛凑数
+    win_trend = trend_table(sg, order, k=3, min_seg=MIN_SEG_WINDOW) if kind == 'window' else pd.DataFrame()
+    rising = []
+    if kind == 'window' and len(win_trend):
+        for (t, comp), sub in win_trend.groupby(['turbine', 'component']):
+            w = sub.sort_values('ratio', ascending=False).iloc[0]
+            if w['ratio'] >= RATIO_HI:
+                _, n_ev, _ = events_of(ev, t, comp)
+                rising.append(dict(turbine=t, component=comp, ratio=float(w['ratio']), early=float(w['early']),
+                                   late=float(w['late']), n_events=n_ev, 工单可对=wo_ok,
+                                   sensor=w['sensor'], measure=w['measure'], n_windows=int(w['n_seg'])))
+        rising = sorted(rising, key=lambda r: -r['ratio'])
+
+    # 补充诊断 (非★, 消费者忽略): 周段末三/早三 —— 逐行带段内最小样本数, 让"薄段噪声"可见
+    diag = []
+    if len(tw):
+        nmin = sg.groupby(['turbine', 'sensor_name', 'meas_name'])['n'].min()
+        for _, r in tw[tw['ratio'] >= RATIO_HI].iterrows():
+            _, n_ev, _ = events_of(ev, r['turbine'], r['component'])
+            diag.append(dict(turbine=r['turbine'], component=r['component'], sensor=r['sensor'],
+                             measure=r['measure'], early=float(r['early']), late=float(r['late']),
+                             ratio=float(r['ratio']), n_seg=int(r['n_seg']),
+                             seg_n_min=int(nmin.get((r['turbine'], r['sensor'], r['measure']), 0)),
+                             n_events=n_ev, 工单可对=wo_ok))
+    diag_all = len(diag)
+    diag = diag[:20]
+
+    # ★判据二: 换件闭环 —— 需要"换件日之前有段 ∧ 之后有段"
+    loop, loop_skip, repl_pairs = [], [], []
+    repl = ev[ev['replaced']] if len(ev) and 'replaced' in ev.columns else ev
+    fusion = {}
+    fp = m5 / 'fusion_38.csv'
+    if fp.exists():                                    # 检出候选旗标 (融合/CMS 面), 只作 verdict 的佐证
+        fu = pd.read_csv(fp)
+        for _, r in fu.iterrows():
+            fusion[str(r.get('台'))] = dict(融合=str(r.get('融合') or ''), CMS=str(r.get('CMS') or ''))
+    for _, r in (repl.iterrows() if len(repl) else []):
+        for comp in r['comps']:
+            repl_pairs.append((r['turbine'], comp, r['date']))
+            pre_segs = [s for s in order if seg_end[s] < r['date']]      # 段**结束**在工单日之前
+            post_segs = [s for s in order if seg_date[s] > r['date']]    # 段**开始**在工单日之后
+            if not pre_segs or not post_segs:
+                loop_skip.append(dict(turbine=r['turbine'], component=comp, date=r['date'], title=r['title'],
+                                      why=f'段跨度 {span[0]}~{span[1]} 内'
+                                          + ('无换件前段' if not pre_segs else '无换件后段')))
+                continue
+            sens = [s for s, c in COMP_OF_SENSOR.items() if c == comp]
+            cand = []
+            for sen in sens:
+                for ms in meas:
+                    sub = sg[(sg.turbine == r['turbine']) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                    if not len(sub):
+                        continue
+                    a = sub[sub.seg.isin(pre_segs)]['med']
+                    b = sub[sub.seg.isin(post_segs)]['med']
+                    if len(a) and len(b) and float(np.median(a)) > 0:
+                        cand.append((ms, sen, float(np.median(a)), float(np.median(b))))
+            if not cand:
+                loop_skip.append(dict(turbine=r['turbine'], component=comp, date=r['date'], title=r['title'],
+                                      why='换件前后段内该部件无可用标量'))
+                continue
+            ms, sen, pre, post = max(cand, key=lambda c: c[2])      # 取换前幅值最大者 (最不利/最有分辨力)
+            ratio = post / pre
+            f = fusion.get(r['turbine']) or {}
+            hit = bool(f) and (f.get('融合') not in ('', '正常') or '红' in f.get('CMS', '') and '红0' not in f.get('CMS', ''))
+            if ratio <= RECOVER_HI:
+                verdict = '恢复' if hit else '回落 (非检出台, 不作端到端实证)'
+            elif ratio < FLAT_LO:
+                verdict = '部分回落'
+            elif ratio <= FLAT_HI:
+                verdict = '持平'
+            else:
+                verdict = '未降/上升'
+            loop.append(dict(turbine=r['turbine'], component=comp, ratio=_r3(ratio), pre=_r6(pre), post=_r6(post),
+                             replace_date=r['date'], title=r['title'], verdict=verdict,
+                             sensor=sen, measure=ms, 检出候选=hit,
+                             融合=(f.get('融合') or None), pre_segs=pre_segs, post_segs=post_segs))
+    loop = sorted(loop, key=lambda r: r['ratio'])
+
+    # 负面对照: 工单后振动未降/持平 (非换件的传动链工单, 换前后各有段)
+    # 不可算的原因**分桶计数**: 单看一个 1512 的总数会把"工单在窗外"和"窗内但无前后段"混成一团。
+    neg_rows, neg_noc = [], dict(工单在段跨度外=0, 窗内但无前段或后段=0, 该部件无标量=0)
+    nonrepl = ev[~ev['replaced']] if len(ev) and 'replaced' in ev.columns else ev
+    for _, r in (nonrepl.iterrows() if len(nonrepl) else []):
+        for comp in r['comps']:
+            # ★ 前/后段都**不含工单所在那一段**: 段界线按"段结束 < 工单日"与"段开始 > 工单日"判,
+            #   否则工单落在段中间时, 该段自己会被当成"换前", 把换后的数据混进 pre (实测污染过 3 条)。
+            pre_segs = [s for s in order if seg_end[s] < r['date']]
+            post_segs = [s for s in order if seg_date[s] > r['date']]
+            if r['date'] < seg_date[order[0]] or r['date'] > seg_end[order[-1]]:
+                neg_noc['工单在段跨度外'] += 1
+                continue
+            if not pre_segs or not post_segs:
+                neg_noc['窗内但无前段或后段'] += 1
+                continue
+            sens = [s for s, c in COMP_OF_SENSOR.items() if c == comp]
+            cand = []
+            for sen in sens:
+                for ms in meas:
+                    sub = sg[(sg.turbine == r['turbine']) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                    if not len(sub):
+                        continue
+                    a = sub[sub.seg.isin(pre_segs)]['med']
+                    b = sub[sub.seg.isin(post_segs)]['med']
+                    if len(a) and len(b) and float(np.median(a)) > 0:
+                        cand.append((ms, sen, float(np.median(a)), float(np.median(b))))
+            if not cand:
+                neg_noc['该部件无标量'] += 1
+                continue
+            ms, sen, pre, post = max(cand, key=lambda c: c[2])
+            neg_rows.append(dict(turbine=r['turbine'], component=comp, date=r['date'], title=r['title'],
+                                 sensor=sen, measure=ms, pre=_r6(pre), post=_r6(post), ratio=_r3(post / pre)))
+    bucket = dict(下降=sum(1 for r in neg_rows if r['ratio'] < FLAT_LO),
+                  持平=sum(1 for r in neg_rows if FLAT_LO <= r['ratio'] <= FLAT_HI),
+                  上升=sum(1 for r in neg_rows if r['ratio'] > FLAT_HI))
+    neg_noc_total = sum(neg_noc.values())
+
+    n_comp_rows = sum(1 for c in comps_out for _ in c['series'])
+    win_txt = '; '.join(f'{w} {v["span_min"]}~{v["span_max"]}' for w, v in used.items())
+    n_pair_inspan = sum(1 for _, _, dt in repl_pairs if span[0] <= dt <= span[1])
+    判读 = (
+        f'分段口径 segments_kind={kind}: 本机只解析出 {len(used)} 个导出色窗 ({win_txt}) '
+        f'⇒ 以窗内 ISO 周段为分段 ({len(order)} 段 {order[0]}…{order[-1]}, {span[0]}~{span[1]}); '
+        f'多窗 (≥3) 时自动改回多窗口径。'
+        f'★当前在升 (末三窗/早三窗 ≥{RATIO_HI}) 本机{("有 " + str(len(rising)) + " 行") if rising else "不可算 ⇒ 空表"}: '
+        f'该判据要求多窗口径且 ≥{MIN_SEG_WINDOW} 窗, 本机 {len(used)} 窗。'
+        f'★换件闭环 本机{("有 " + str(len(loop)) + " 行") if loop else "不可算 ⇒ 空表"}: 要求换件工单在段跨度内'
+        f'且其前后各有至少一段; 台账 {wo_info.get("span")} 共 {wo_info.get("n_transmission")} 条传动链工单, '
+        f'其中换件类 {wo_info.get("n_replace")} 条 (跨度 {wo_info.get("replace_span")}) 展开为 {len(repl_pairs)} 个 '
+        f'(台,部件) 组合, 段跨度内 {n_pair_inspan} 个 ⇒ 成闭环 {len(loop)} 行, 其余 {len(loop_skip)} 个因'
+        f'"无换件前段/后段"逐条记入 meta.未成闭环。'
+        f'补充口径 (非判据): 周段末三/早三比值 ≥{RATIO_HI} 的候选 {diag_all} 条 (列出前 {len(diag)} 条); '
+        f'周段每 (台,测点,标量) 只有 {int(sg["n"].min())}~{int(sg["n"].max())} 条标量记录/段 '
+        f'(中位 {int(sg["n"].median())}) ⇒ 该比值只说明窗内走向, 不作在升判据 (明细逐行给出 seg_n_min)。'
+        f'负面对照: 传动链非换件工单里换前后各有段的 {len(neg_rows)} 条可算 (下降 {bucket["下降"]} · 持平 {bucket["持平"]} '
+        f'· 上升 {bucket["上升"]}), 其余 {neg_noc_total} 条不可算 ({neg_noc})。')
+
+    seg_kind_txt = '导出窗' if kind == 'window' else f'窗内 ISO 周 (本机窗数 {len(used)} < 3 ⇒ 降级; 多窗时自动改回)'
+    meta = dict(
+        built=time.strftime('%Y-%m-%d'), farm=farm_name, by='scripts/component_history_build.py',
+        windows={w: v['span_min'] for w, v in used.items()},
+        windows_detail={w: dict(rows_all=v['rows_all'], rows_scalar=v['rows_scalar'],
+                                span_min=v['span_min'], span_max=v['span_max'],
+                                seg_min=v['seg_min'], seg_max=v['seg_max'], turbines=v['turbines'])
+                        for w, v in used.items()},
+        segments_kind=kind,
+        segments=seg_date,
+        n_segments=len(order),
+        来源声明=('本文件由 scripts/component_history_build.py **现算**写成: 输入 = '
+                 'outputs/<场>/m5_cms_tcm/windows/*/index.parquet (data/raw 重算产物, 只读 ds_size==1 标量行) '
+                 '+ outputs/<场>/windscada/workorders.parquet (工单台账) '
+                 '+ outputs/<场>/m5_cms_tcm/fusion_38.csv (仅作"检出候选"佐证)。'
+                 '不含随包旧件 (人工裁决/厂家锚) 的数值搬运; 算不出来的 (在升/闭环) 一律空表 + 边界说明, 不填估值。'
+                 'components[].n_windows = 该部件的**段数** (segments_kind="week" 时即窗内周段数, 不是导出窗数)。'),
+        口径=(f'标量: CMS 窗索引 ds_size==1 行, 标量集 = KEY_SCALARS 去 CrestFactor ({", ".join(meas)}); '
+             f'测点 → 部件: Main_bearing_*→主轴承, Gear_*→齿轮箱, Generator_*→发电机 (System Monitor 非部件测点, 排除)。'
+             f'段: {seg_kind_txt}。'
+             f'series 值 = 该段该 (测点|标量) 标量**中位**; 归一 x_self = 段中位 / 该台该测点该标量**全段中位数** '
+             f'(随包旧件同口径, 旧件写作 x_self; 本件不出 x_fleet —— 那需要跨台同工况对照)。'
+             f'在升判据 = 末三窗中位 / 早三窗中位 ≥ {RATIO_HI}, 需多窗口径且 ≥{MIN_SEG_WINDOW} 窗。'
+             f'闭环判据 = 换件工单日之前有段 ∧ 之后有段; pre/post = 该部件换前/后段中位 (取换前幅值最大的测点|标量), '
+             f'verdict: ratio ≤ {RECOVER_HI} 且该台为检出候选 ⇒ 恢复, 否则按 部分回落/持平/未降 如实记。'
+             f'工单口径: 部件由关键词映射 (齿轮箱/齿圈/行星/高速轴/中间轴/齿轮油 → 齿轮箱; 主轴承/主轴 → 主轴承; '
+             f'发电机 → 发电机); replaced = 维修动作含 更换|替换|换件 ∧ 目标文本含部件词 '
+             f'(轴承|齿圈|行星|高速轴|中间轴|箱体|发电机本体) ∧ 目标文本无耗材词 '
+             f'(油脂|润滑|油位|油泵|滤芯|密封|加注|油管) —— 换油/换传感器不算换件。'),
+        inputs=dict(scalars=[v['index'] for v in used.values()], workorders=wo_info.get('path'),
+                    fusion=P.rel(fp) if fp.exists() else None),
+        未成闭环=loop_skip[:40], 未成闭环总数=len(loop_skip),
+        台账=wo_info,
+    )
+    meta['★数据边界'] = (
+        f'① 本机只有 {len(used)} 个 CMS 导出色窗 ({win_txt}) ⇒ 在升 (末三窗/早三窗) 与 多窗口径的健康期分段'
+        f'**都不可算** ⇒ summary["★当前在升…"] 为空表; 本件以窗内周段 ({len(order)} 段) 作补充诊断, '
+        f'只描述窗内走向, 不作判据。'
+        f'② 换件闭环需要"换件日前后各有段": 工单台账 {wo_info.get("path")} 跨度 {wo_info.get("span")} '
+        f'(传动链件 {wo_info.get("n_transmission")} 条, 其中换件类 {wo_info.get("n_replace")} 条, '
+        f'跨度 {wo_info.get("replace_span")}), 与段跨度 {seg_date[order[0]]}~{seg_date[order[-1]]} **无交叠** '
+        f'⇒ summary["★换件闭环…"] 为空表。★ 这两张空表是数据边界, 不是"没有在升机组/没有闭环案例"; '
+        f'站点需 (a) 补导 ≥3 个 CMS 色窗 (尤其换件日前后各一窗), (b) 补 2026 年传动链换件工单, '
+        f'二者到位后本脚本自动出数。'
+        f'③ 段内样本薄: 周段每 (台,测点,标量) 仅 {int(sg["n"].min())}~{int(sg["n"].max())} 条标量记录 '
+        f'(中位 {int(sg["n"].median())}), 中位数据此散布 —— series 的 n 逐条给出, 读者自行判断可信度。')
+    meta['★换件断点'] = '换件前后不可混算趋势; 本表在 events 里标 replaced=true, 读者须自行分段读 series'
+
+    payload = dict(
+        meta=meta,
+        components=_py(comps_out),
+        summary=_py({
+            '★换件闭环 (检出→换件→恢复, 算法端到端验证)': loop,
+            '★当前在升 (末三窗/早三窗 ≥1.3)': rising,
+            '窗内周段趋势 (单窗补充口径, 仅参考不作判据)': diag,
+            '窗内周段趋势·候选总数': diag_all,
+            '工单后振动未降/持平 (多为油位/传感器/加注类, 非轴承更换 ⇒ 振动本不该变)': dict(
+                可算=len(neg_rows), 不可算=neg_noc_total, 不可算原因=neg_noc, 分桶=bucket,
+                口径='逐条传动链非换件工单: 换件日前后各有至少一段 ⇒ 可算; 指标取该部件换前幅值最大的 (测点|标量) 的 '
+                     f'post/pre 比值; 分桶 下降<{FLAT_LO} · 持平 {FLAT_LO}~{FLAT_HI} · 上升>{FLAT_HI}',
+                明细=sorted(neg_rows, key=lambda r: -r['ratio'])[:12]),
+            '判读': 判读}),
+    )
+    txt = json.dumps(payload, ensure_ascii=False, indent=1, allow_nan=False, default=str)
+    out = m5 / 'component_history.json'
+    print(f'\n  段表行 {len(sg)} · 部件行 {len(comps_out)} · series 键 {n_comp_rows} · '
+          f'events {sum(len(c["events"]) for c in comps_out)} 条')
+    print(f'  summary: ★换件闭环 {len(loop)} 行 · ★当前在升 {len(rising)} 行 · '
+          f'周段候选 {diag_all} 条(列 {len(diag)}) · 负面对照可算 {len(neg_rows)}/不可算 {neg_noc_total} {neg_noc}')
+    if neg_rows:
+        print('  负面对照明细: ' + '; '.join(f'{r["turbine"]} {r["component"]} {r["date"]} {r["title"][:16]} '
+                                            f'{r["sensor"]}|{r["measure"]} {r["ratio"]}×' for r in
+                                            sorted(neg_rows, key=lambda r: -r['ratio'])))
+    if diag:
+        print('  周段候选 top: ' + '; '.join(f'{r["turbine"]} {r["component"]} {r["sensor"]}|{r["measure"]} '
+                                            f'{r["ratio"]}× ({r["early"]}→{r["late"]}, {r["n_seg"]}段)' for r in diag[:5]))
+    if loop_skip:
+        print(f'  换件工单未成闭环 {len(loop_skip)} 条, 例: '
+              + '; '.join(f'{s["turbine"]} {s["component"]} {s["date"]} {s["why"]}' for s in loop_skip[:3]))
+    print(f'  产物体积预估 {len(txt.encode("utf-8")) / 1024:.0f} KB ({P.rel(out)}) · 用时 {time.time() - t0:.1f}s')
+    if dry:
+        print('\n[dry-run] 不写盘、不登记。')
+        return payload, 0
+    m5.mkdir(parents=True, exist_ok=True)
+    out.write_text(txt, encoding='utf-8')
+    dm_record(P.out_root(farm_name), {out.relative_to(P.out_root(farm_name)).as_posix():'scripts/component_history_build.py (CMS 窗标量 ds_size==1: 段中位/x_self 序列 + '
+                                 '工单台账事件; 在升/闭环判据与空表情形见产物 meta.★数据边界)'},
+              by='scripts/component_history_build.py')
+    print(f'\n[OK] 已写 {P.rel(out)} ({out.stat().st_size / 1024:.0f} KB) · 已自登记')
+    return payload, 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='部件历史生成端 (m5_cms_tcm/component_history.json)')
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--dry-run', action='store_true', help='只算不写盘 (打印漏斗与体积预估)')
+    args = ap.parse_args()
+    cfg = cms_farm(args.farm)
+    _, rc = build(cfg, args.farm, dry=args.dry_run)
+    return rc
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 354 - 0
app_ETL/app_ETL_guanlan/builders/csv_to_mdb.py

@@ -0,0 +1,354 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""CSV → MDB(仿照现场 `25年.zip`/`26年.zip` 的形态) + 全量核对。2026-09-17 用户令。
+
+形态: `data/raw/<场站>/scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`(+ 同名 zip);每类一库、库内按 250 列拆表。
+
+实测可用路线(其余写法都失败, 见 docs/现场收资接入_v0.1.md §7):
+    ADOX.Catalog 建库(ACE 12.0) → 分片 CSV(首列 rid, schema.ini CharacterSet=65001)
+    → **Access.Application.DoCmd.TransferText(0,"",表,csv,$true,"",65001)** 批量装入 → ADODB 读回校验
+  ✗ `SELECT … INTO [text;…]` / 目标库 `INSERT … SELECT [text;…]` / `IN '<dir>' 'text;…'` → 「这种对象类型不支持该操作」
+  ✗ DAO `CreateDatabase` → 「找不到可安装的 ISAM」
+  ⚠ `TransferText` 必须给 **CodePage=65001**,否则 1min 的中文列名/值按 cp936 解成乱码(实测)。
+
+硬限制: 单表 ≤255 列(10min 每台 598 列 ⇒ 拆 3 张表)· 单库 ≤2 GB(⇒ 按月分库)。
+
+用法:
+    python scripts/csv_to_mdb.py --month 2025-01 --class 10min     # 转换某月(全部机组)
+    python scripts/csv_to_mdb.py --verify                          # ★ 一条命令: 全量核对报告(日历推算, 秒出)
+    python scripts/csv_to_mdb.py --verify --deep                   # 深核对: 扫源 CSV 逐月计数(几分钟, 带缓存)
+退出码: 0 成功/全对 · 2 参数或源不齐 · 4 无 Access/ACE · 5 有差异/失败
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import calendar
+import pathlib
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import zipfile
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+from src.windscada.config import raw_station_dir                       # noqa: E402
+
+CHUNK = 250
+CLS_DIR = {'10min': 'scada_10min', '1min': 'scada_1min'}
+CAD = {'10min': 144, '1min': 1440}          # 每天行数(10 分钟 / 1 分钟节拍)
+
+
+def safe_names(cols: list[str]) -> list[str]:
+    """列名消毒 —— **单一实现**在 `src/windscada/mdb_names.py`(读侧 scada_source 要用同一套规则,
+    否则从 MDB 取数时按契约列名找不到列,静默少列)。这里只做转发,保持本脚本既有调用点不变。
+    """
+    from src.windscada.mdb_names import safe_names as _sn
+    return _sn(cols)
+
+
+def build_chunks(files, month: str, tmp: pathlib.Path, limit_rows: int):
+    r"""逐台 CSV → 分片 CSV(供 TransferText 装入)。
+
+    ★2026-09-19 两处修正(都是为了"这份 MDB 能被**接回去当输入**用", 见 docs/现场收资接入_v0.1.md §8):
+
+    ① **加 `turbine` 列**:原先每行只有 `rid` + 数据列, 逐台文件**堆在一起**, 而 1min 导出件里
+       **没有任何机组标识列**(实测: `source_file` 全行同值 "如海风机1分钟数据/如海测点_2025-01.csv")
+       ⇒ 那份 1min 库**按台取不到数**, 等于只能看不能算。现在首列写文件名的 stem(10min=`WTG01`,
+       1min=`01E`), 10min 另有的 `WTG` 列原样保留。
+    ② **按列名对齐, 不再按位置堆**:原先用**第一个文件**的表头当 schema, 其余文件整行按位置写入。
+       10min 实测 38 件表头**完全一致**(1 种) ⇒ 无害; 但 1min 实测 **16 种表头**(同 79 列, 名与序都不同)
+       ⇒ 位置堆法把 A 台的列写进 B 台的列位, **静默串列**(比缺列危险得多)。现在先扫一遍所有表头取并集,
+       逐行按列名落位, 缺列留空。
+    """
+    # ① 扫表头 → 并集(首见顺序) + 逐文件的列名→位置映射
+    heads: dict[pathlib.Path, list[str]] = {}
+    union: list[str] = []
+    for f in files:
+        with open(f, encoding='utf-8', errors='replace', newline='') as fh:
+            cols = fh.readline().rstrip('\n').split(',')
+        heads[f] = cols
+        for c in cols:
+            if c not in union:
+                union.append(c)
+    if len({tuple(v) for v in heads.values()}) > 1:
+        print(f'  [i] {len({tuple(v) for v in heads.values()})} 种表头 → 按**列名**对齐(并集 {len(union)} 列); '
+              f'缺列留空 (原先按位置堆会静默串列)')
+    names = safe_names(['rid', 'turbine'] + union)
+    nt = max(1, -(-len(union) // CHUNK))
+    total, handles = 0, {}
+    try:
+        for i in range(nt):
+            lo, hi = i * CHUNK, min(len(union), (i + 1) * CHUNK)
+            p = tmp / f't{i+1}.csv'
+            p.write_text(','.join(names[0:2] + names[2 + lo:2 + hi]) + '\n', encoding='utf-8', newline='')
+            handles[i] = open(p, 'a', encoding='utf-8', newline='')
+        ini = []
+        for i in range(nt):
+            ini += [f'[t{i+1}.csv]', 'Format=CSVDelimited', 'ColNameHeader=True',
+                    'CharacterSet=65001', '']
+        (tmp / 'schema.ini').write_text('\n'.join(ini), encoding='utf-8')
+        for f in files:
+            cols = heads[f]
+            idx = [union.index(c) for c in cols]
+            with open(f, encoding='utf-8', errors='replace', newline='') as fh:
+                fh.readline()
+                n = 0
+                for line in fh:
+                    if line[:7] != month:
+                        continue
+                    vals = line.rstrip('\n').split(',')
+                    row = [''] * len(union)
+                    for k, j in enumerate(idx):
+                        if k < len(vals):
+                            row[j] = vals[k]
+                    n += 1
+                    for i in range(nt):
+                        lo, hi = i * CHUNK, min(len(union), (i + 1) * CHUNK)
+                        handles[i].write(','.join([str(n), f.stem] + row[lo:hi]) + '\n')
+                    if limit_rows and n >= limit_rows:
+                        break
+            total += n
+    finally:
+        for h in handles.values():
+            h.close()
+    return nt, total
+
+
+def to_mdb(db: pathlib.Path, tmp: pathlib.Path, nt: int) -> list[str]:
+    db.parent.mkdir(parents=True, exist_ok=True)
+    ps = pathlib.Path(tempfile.gettempdir()) / '_csv2mdb_run.ps1'
+    L = ['$ErrorActionPreference="Stop"', f'$db = "{db}"',
+         'if (Test-Path $db) { Remove-Item $db -Force }',
+         '$cat = New-Object -ComObject ADOX.Catalog',
+         '[void]$cat.Create("Provider=Microsoft.ACE.OLEDB.12.0;Data Source=$db;")', '$cat = $null',
+         '$acc = New-Object -ComObject Access.Application', '$acc.Visible = $false',
+         '$acc.OpenCurrentDatabase($db)']
+    for i in range(1, nt + 1):
+        L.append(f'$acc.DoCmd.TransferText(0, "", "t{i}", "{tmp / f"t{i}.csv"}", $true, "", 65001)')
+    L += ['$acc.CloseCurrentDatabase()', '$acc.Quit()',
+          '$c = New-Object -ComObject ADODB.Connection',
+          '$c.Open("Provider=Microsoft.ACE.OLEDB.12.0;Data Source=$db;")']
+    for i in range(1, nt + 1):
+        L.append(f'$rs = $c.Execute("SELECT COUNT(*) FROM [t{i}]"); '
+                 f'Write-Output ("t{i}=" + $rs.Fields.Item(0).Value); $rs.Close()')
+    L.append('$c.Close()')
+    ps.write_text('\n'.join(L), encoding='utf-8-sig')
+    r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(ps)],
+                       capture_output=True, text=True, errors='replace', timeout=7200)
+    got = [x.strip() for x in (r.stdout or '').strip().splitlines() if x.strip()]
+    counts = [x for x in got if re.fullmatch(r't\d+=\d+', x)]
+    if r.returncode != 0:
+        # ★2026-09-18 实逮: 全量转换里 1min 2025-07 / 2025-09 两个月退出码非 0, 而**数据其实已经装完** ——
+        #   stdout 里每张表的 `tN=<行数>` 都打出来了(且与切片行数逐月相符), 失败发生在装载之后的
+        #   COM 收尾 ($c.Close()/$acc.Quit() 那一步偶发 "未指定的错误")。原先这里直接判失败 ⇒
+        #   整月被当成没跑成: 不打印 `+ …mdb`, 也不写 zip, 而 1.3 GB 的 .mdb 已经在盘上躺着
+        #   (每月的抽行+装入 ≈ 10 分钟, 白扔)。判据改成**看事实**: 所有分片表都报出了行数 ⇒ 算成功,
+        #   但把退出码与 stderr 尾巴**响亮打出来**(不静默), 让人知道收尾那步出过岔子。
+        if len(counts) < nt:
+            print('  [X] ' + (r.stderr or r.stdout or '').strip()[:240])
+            return []
+        print(f'  [!] PowerShell 收尾非零退出 (rc={r.returncode}), 但 {len(counts)}/{nt} 张分片表都报出了行数 '
+              f'⇒ 按"已装成"处理; 尾巴: ' + (r.stderr or '').strip().replace('\n', ' ')[:160])
+    return counts
+
+
+def mdb_rows(mdbs: list[pathlib.Path]) -> dict:
+    """一次 PowerShell 批量读所有 MDB 的表行数 → {文件名: [t1,t2,…]}。"""
+    L = ['$ErrorActionPreference="Continue"']
+    for m in mdbs:
+        L.append(f'$c=New-Object -ComObject ADODB.Connection;'
+                 f'$c.Open("Provider=Microsoft.ACE.OLEDB.12.0;Data Source={m};")')
+        L.append('$o=@(); foreach($t in @("t1","t2","t3","t4")){ try {'
+                 ' $rs=$c.Execute("SELECT COUNT(*) FROM [$t]"); $o+=$rs.Fields.Item(0).Value;'
+                 ' $rs.Close() } catch {} }')
+        L.append(f'Write-Output ("{m.name}|" + ($o -join ",")); $c.Close()')
+    ps = pathlib.Path(tempfile.gettempdir()) / '_verify_mdb.ps1'
+    ps.write_text('\n'.join(L), encoding='utf-8-sig')
+    r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(ps)],
+                       capture_output=True, text=True, errors='replace', timeout=7200)
+    out = {}
+    for ln in (r.stdout or '').splitlines():
+        if '|' in ln:
+            k, v = ln.strip().split('|', 1)
+            out[k] = [int(x) for x in v.split(',') if x.strip().isdigit()]
+    return out
+
+
+def src_month_rows(cls_filter: str | None, cache: pathlib.Path, refresh: bool) -> dict:
+    """扫源 CSV 逐月计数(二进制整文件 + 正则, 带缓存)。"""
+    import json
+    if cache.is_file() and not refresh:
+        try:
+            return json.loads(cache.read_text(encoding='utf-8'))
+        except Exception:
+            pass
+    print('   [扫源 CSV 逐月计数…首次几分钟,之后走缓存]')
+    t0, rx, out = time.time(), re.compile(rb'(?:^|\n)(\d{4}-\d{2})'), {}
+    for cls, fn in CLS_DIR.items():
+        if cls_filter and cls != cls_filter:
+            continue
+        for f in sorted((pathlib.Path(raw_station_dir()) / fn).glob('*.csv')):
+            data = f.read_bytes()
+            nl = data.find(b'\n')
+            body = data[nl + 1:] if nl >= 0 else data
+            for mm in rx.findall(body):
+                k = f'{cls}|{mm.decode()}'
+                out[k] = out.get(k, 0) + 1
+            del data, body
+    cache.write_text(json.dumps(out, indent=0), encoding='utf-8')
+    print(f'   源计数完成 {time.time() - t0:.0f}s → {cache.name}')
+    return out
+
+
+def verify(out: pathlib.Path, cls_filter: str | None, refresh: bool, deep: bool) -> int:
+    mdbs = sorted(m for m in out.rglob('*-*.mdb') if not m.name.startswith('_'))
+    if cls_filter:
+        mdbs = [m for m in mdbs if m.stem.endswith(f'-{cls_filter}')]
+    if not mdbs:
+        print(f'[X] {out} 下还没有产物 mdb')
+        return 2
+    got = mdb_rows(mdbs)
+    src = src_month_rows(cls_filter, out / '_csv_month_rows.json', refresh) if deep else {}
+    print(f'== CSV → MDB 全量核对 · {out} ==')
+    print(f'   {"类":6s} {"月份":8s} {"mdb 各表行数":18s} {"应得行数":10s} 判定   zip')
+    bad, short, over, minor = [], [], [], []
+    BOUNDARY = 0.005            # 超日历 ≤0.5% 视为**月末边界行**(有些台次月 00:00 那一行落在本月)
+    for m in mdbs:
+        cls = m.stem.rsplit('-', 1)[1]
+        ym = '-'.join(m.stem.split('-')[:2])
+        rows = got.get(m.name, [])
+        want = src.get(f'{cls}|{ym}') if deep else None
+        if want is None:
+            y, mo = int(ym[:4]), int(ym[5:7])
+            want = CAD.get(cls, 0) * calendar.monthrange(y, mo)[1] * 38
+        aligned = len(set(rows)) == 1 if rows else False
+        d = (rows[0] - want) if rows else -want
+        # 深核对: 必须与源逐行相等; 日历模式: 少行=该月数据本身不齐(正常), 多行超过 0.5% 才算转换错
+        if deep:
+            ok = bool(rows) and aligned and rows[0] == want
+        else:
+            ok = bool(rows) and aligned and d <= want * BOUNDARY
+        tag = (('OK' if ok else '异常') if deep else
+               ('一致' if d == 0 else (f'缺口{d:+d}' if d < 0 else
+                                      (f'超日历{d:+d}' if d > want * BOUNDARY else f'边界{d:+d}'))))
+        zp = m.parent / f'{ym}-{cls}.zip'
+        ztxt = f'有 {zp.stat().st_size / 1e6:.1f} MB' if zp.is_file() else '无'
+        print(f'   {cls:6s} {ym:8s} {",".join(map(str, rows)):18s} {want:<10d} '
+              f'{tag:12s} {ztxt}')
+        if not ok:
+            bad.append((cls, ym, rows, want))
+        if rows and d < 0:
+            short.append((cls, ym, rows, want))
+        elif rows and d > 0:
+            (over if d > want * BOUNDARY else minor).append((cls, ym, rows, want))
+    # ★2026-09-19 修: 原来汇总只报"一致 N / 差异 K", 而日历判据是 `rows <= want` ⇒ **任何缺口都算一致**,
+    #   于是 1min 那张表有 8 个月短几百到一万多行, 汇总却是"一致 18 · 差异 0"(自相矛盾, 只有看表才发现)。
+    #   现在四档分开报: 一致 / 缺口(少, 数据本身) / 边界(多但 ≤0.5%, 月末那一行) / 超日历(多且超 0.5%, 才是转换错)。
+    print(f'   合计: {len(mdbs)} 个月库 · 一致 {len(mdbs) - len(bad)} · 缺口 {len(short)} · '
+          f'边界 {len(minor)} · 超日历 {len(over)}'
+          + ('' if deep else '(按日历推算; 少行=该月数据本身不齐, 多行>0.5% 才是转换错; 逐行对源加 --deep)'))
+    for cls, ym, rows, want in sorted(short, key=lambda x: x[2][0] - x[3])[:3]:
+        print(f'      [i] 缺口最大: {cls} {ym}: {rows[0]:,} 行 (差 {rows[0] - want:+,} = '
+              f'{100 * (rows[0] - want) / want:.2f}%)')
+    for cls, ym, rows, want in bad[:8]:
+        print(f'      [i] {cls} {ym}: mdb={rows} vs 应得 {want}')
+    print('   结论: ' + ('全部一致(各分片表行数相同 ⇒ 逐行对齐)' if not bad else
+                       ('有差异, 见上' if over or deep else '分片表行数整齐; 少行属当月数据不齐(见上), 无转换错')))
+    return 0 if not bad else 5
+
+
+def rezip(out: pathlib.Path, cls: str) -> int:
+    """月库在位但 zip 缺失 → 只补 zip (不重算, 不碰 .mdb)。
+
+    为什么需要: 2026-09-18 全量转换实测 1min 2025-07 / 2025-09 两个月"装载后收尾失败" ⇒ 当月的
+    zip 没写出来(见 to_mdb 的说明), 而 .mdb 是好的。重算一个月要 ~10 分钟, 补个 zip 只要几秒;
+    归档形态(现场是年度 zip)又不能缺, 故单列一个只读 .mdb 的补救口。
+    """
+    miss = [m for m in sorted(out.glob(f'*年/*月/*-{cls}.mdb')) if not m.with_suffix('.zip').exists()]
+    if not miss:
+        print(f'  没有缺 zip 的 {cls} 月库')
+        return 0
+    for m in miss:
+        z = m.with_suffix('.zip')
+        print(f'  + 补 zip {m.name} ({m.stat().st_size / 1e6:.1f} MB) …', end='', flush=True)
+        with zipfile.ZipFile(z, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
+            zf.write(m, m.name)
+        print(f' {z.stat().st_size / 1e6:.1f} MB')
+    print(f'  共补 {len(miss)} 个 zip')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='CSV → MDB(仿现场年度归档形态)+ 全量核对')
+    ap.add_argument('--month', default=None, help='YYYY-MM(--verify 时可省)')
+    ap.add_argument('--class', dest='cls', default='10min', choices=('10min', '1min'))
+    ap.add_argument('--turbines', default=None, help='逗号分隔(WTG01 或 01E);不给=全部')
+    ap.add_argument('--out', default=None)
+    ap.add_argument('--limit-rows', type=int, default=0)
+    ap.add_argument('--no-zip', action='store_true')
+    ap.add_argument('--rezip', action='store_true', help='给已在位但缺 zip 的月库补 zip(不重算)')
+    ap.add_argument('--verify', action='store_true', help='全量核对报告(默认日历推算, 秒出)')
+    ap.add_argument('--deep', action='store_true', help='--verify 深核对: 扫源 CSV 逐月计数')
+    ap.add_argument('--refresh', action='store_true', help='--deep 时重扫源(不用缓存)')
+    a = ap.parse_args()
+    out = pathlib.Path(a.out) if a.out else (pathlib.Path(raw_station_dir()) / 'scada_mdb')
+    if a.verify:
+        return verify(out, a.cls, a.refresh, a.deep)
+    if a.rezip:
+        return rezip(out, a.cls)
+    if not a.month:
+        print('[X] 需要 --month(或用 --verify)')
+        return 2
+    if not sys.platform.startswith('win'):
+        print('[X] 需要 Windows(Access/ACE)—— Linux 侧无写库能力')
+        return 4
+    y, m = a.month.split('-')
+    src_dir = pathlib.Path(raw_station_dir()) / CLS_DIR[a.cls]
+    want = {t.strip() for t in a.turbines.split(',')} if a.turbines else None
+    files = [f for f in sorted(src_dir.glob('*.csv')) if (not want or f.stem in want)]
+    if not files:
+        print(f'[X] {src_dir} 下没有匹配 CSV')
+        return 2
+    tmp = pathlib.Path(tempfile.gettempdir()) / f'csv2mdb_{y}{m}_{a.cls}'
+    if tmp.exists():
+        shutil.rmtree(tmp, ignore_errors=True)
+    tmp.mkdir(parents=True, exist_ok=True)
+    t0 = time.time()
+    nt, total = build_chunks(files, a.month, tmp, a.limit_rows)
+    print(f'  切片: {nt} 张表 · {total} 行 · {len(files)} 台 · 抽行 {time.time() - t0:.0f}s')
+    if not total:
+        print(f'  [i] {a.month} 无数据')
+        return 0
+    db = out / f'{y}年' / f'{m}月' / f'{y}-{m}-{a.cls}.mdb'
+    ts = time.time()
+    res = to_mdb(db, tmp, nt)
+    for ln in res:
+        print('  ' + ln)
+    if not res:
+        return 5
+    if not a.no_zip:
+        z = db.parent / f'{y}-{m}-{a.cls}.zip'
+        with zipfile.ZipFile(z, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
+            zf.write(db, db.name)
+    print(f'  + {db.relative_to(out.parent).as_posix()}  mdb {db.stat().st_size / 1e6:.1f} MB'
+          f' · 装入 {time.time() - ts:.0f}s · 合计 {time.time() - t0:.0f}s')
+    shutil.rmtree(tmp, ignore_errors=True)
+    return 0
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 521 - 0
app_ETL/app_ETL_guanlan/builders/pitch_face_build.py

@@ -0,0 +1,521 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""变桨面生成端 —— 补上"包内没有生成端"的 `pitch/**` 两件 (用户令 2026-09-19)。
+
+## 为什么要写它
+
+用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
+`pitch/pitch_daily.parquet` 与 `pitch/pitch_zero_monthly.parquet` 是**随包快照**(族表里 kind=shipped、
+gen=None),于是另一台机器上装完、放数据、重算之后,变桨面永远只能显示"缺件 ⇒ 不可判"。
+本脚本把它**从 `data/raw` 算出来**(与其它 20 多个构建器同一套纪律:生成端进包 + 进重算链 + 产物自登记)。
+
+## 产物与消费者 (字段契约见 docs/源代码化清单_v0.1.md §2)
+
+    pitch/pitch_daily.parquet        date, turbine, n, hydlevel_timeon, hydfilt_timeon, hyd_band,
+                                     hyd_over_relief, hyd_under_pump, hublub_timeon, pitchpum_timeon,
+                                     pistA, pistB, pistC, n_op, hyd_mean
+                                     → src/windscada/subsys/pitch.py::registry 四维度判级
+    pitch/pitch_zero_monthly.parquet month, turbine, zero_dev, n_rows + 两口径分列/佐证列
+                                     → 同上 D3「执行与位置反馈」(零位轴)
+    pitch/pitch_face_manifest.json   口径/阈值/证据/计数 (人可核对;也是"不静默造数"的凭据)
+
+## 口径 (用户令 2026-09-19:「零位取停机段中位、满发段中位两者**分列**出」)
+
+**来源分工**(实测:两组通道互不重叠):
+
+    scada_10min/<WTGnn>.csv   开关量 timeon / 压力分位 / 运行状态 → 日粒度一行 (n×600 = 当日应测秒)
+    scada_1min/<台号>.csv     三叶桨距角 / 有功 / 转速 / 液压压力 → 零位两口径 + 越线分钟计数
+
+`pitch_daily` 各列(**每列都是"当日"聚合**,不是窗内均值):
+
+    n                 当日 10min 记录数 (满日 144;×600 = 应测秒 —— 消费者按 `n*600 - timeon` 算报警秒)
+    n_op              当日**运行段数** (判据 flg_wtc_ScInOper_endvalue==1;缺列则退 tur_wtc_GenRpm_mean>0.5)
+                      ★消费者的不可判门是 `窗内 nop < 144`(≈不足 1 天等效) ⇒ n_op 必须与 n 同粒度
+    hyd_mean          当日 prs_wtc_HydPress_mean 均值 (bar)
+    hyd_band          当日**运行段**段内 (HydPress_max − HydPress_min) 的**中位** = 蓄能器锯齿带宽
+    hyd_over_relief   当日运行**分钟**中 系统压力 ≥ 卸荷阈(240bar) 的分钟数
+    hyd_under_pump    当日运行**分钟**中 系统压力 ≤ 启泵阈(195bar) 的分钟数
+    hublub_timeon     当日 dot_wtc_HubLubPu_timeon 求和 (s/日)   ← 轮毂润滑泵
+    pitchpum_timeon   当日 dot_wtc_PitchPum_timeon 求和 (s/日)   ← 变桨泵
+    hydlevel_timeon   当日 din_wtc_HydLevel_timeon 求和 (s/日)   ← 油位开关(1=正常 ⇒ 报警秒=应测−timeon)
+    hydfilt_timeon    当日 din_wtc_HydrFilt_timeon 求和 (s/日)   ← 滤网开关
+    pistA/B/C         当日 din_wtc_LubPist{A,B,C}_counts 求和 (次/日) ← 三叶柱塞动作次数(不对称轴)
+
+**阈值出处(不是拍的,是现场数据上读出来的)**:该场系统压力锯齿的下沿中位 **194 bar**、
+上沿中位 **245~246 bar**(10min 段内 max/min 的 fleet 中位);1min 运行分钟压力 **P5 = 195 / P95 = 240**。
+取整定为 **卸荷阈 240 bar / 启泵阈 195 bar**:① 与"压力越过正常锯齿上下沿"的物理含义一致;
+② 计数量级与旧随包件同(旧件 over 36~130 次/日、under 0~40 次/日)。
+★ 与旧件的差异(如实标注,不假装复刻):旧件 `n_op` 口径未知(实测旧件 WTG01 2025-01-01
+n_op=48,本器同日在位运行段 61 段),故**旧件只作量级对拍、不作逐值验收**。
+
+`pitch_zero_monthly` 两口径(**1min** 数据,月 × 台):
+
+    停机段中位  该月「顺桨停机」分钟(转速 < 0.5 rpm ∧ |有功| < 50 kW ∧ 三叶桨距角中位 ∈ 顺桨带 [85,92]°)
+                三叶桨距角中位 − 同月**全场中位基准**
+    满发段中位  该月「满发」分钟(有功 ≥ 95% 额定 = 3800 kW)三叶桨距角中位 − 同月**全场中位基准**
+    运行段分档  该月「运行」分钟(转速 ≥ 0.5 rpm ∧ 有功 100~3800 kW)按有功 500kW 分档, 逐档比同月全场
+                同档中位, 再取该台跨档中位 (可比档 ≥4 档、单档 ≥30 分钟) —— **判据轴**
+
+    物理含义与为什么是**三**个口径(第三口径是 2026-09-19 反推旧件后补的, 有实测依据):
+    ① 停机顺桨段 = 叶片压在**机械顺桨止挡**上的读数差。止挡是机械硬基准, fleet 顺桨位集中在
+       88.59~88.69°(±0.7°) ⇒ 这个口径量的是"止挡/编码器在该位的差", **量不出运行区零位差**:
+       实测它把 19#(现场 −0.87° 专项确诊) 读成 **+0.11°**。
+    ② 满发段 = 守住额定功率所加的桨角差; 受风况/空气密度影响大(台间离散大), 只作**并列参考**。
+    ③ 运行段分档 = 同工况下桨距角读数差, 即行业口径的"零位偏差"; 分档是为了不让风资源混进来
+       (风大的台长期在高桨距档 ⇒ 中位天然偏高)。实测它**把 19# 单独拎出来**(逐月持续为负, 其余台 ≈0),
+       与现场专项确诊断性一致 ⇒ `zero_dev_run` 作判据轴, 另两口径并列可见。
+    `zero_dev` 保留=停机段(用户令选定的列), 消费者 `pitch.py` 按"两口径取更不利"用。
+    旧随包件的 `zero_dev` 是"绝对零位偏差"(实测只有 19# 离群 −0.896、其余台 ≈0), 与本器"相对同月全场中位"
+    不同源 ⇒ 零位一族**不做逐值对拍**, 只作定性和量级对照(见 manifest 证据「零位三口径」)。
+
+    有效门(不静默造数):该台该月顺桨停机分钟 < 50 ⇒ 不出值;同月全场有效台 < 5 ⇒ 该月无基准,
+    整月不出值;运行段可比档 < 4 ⇒ `zero_dev_run` 留空(不拿 fleet 值顶替)。三叶极差中位
+    (`spread_stop/full`) 一并存下(叶间不平衡证据,消费者暂未用)。
+
+## 用法
+
+    python scripts/pitch_face_build.py                     # 算并写盘 (38 台全量, 约 10 分钟)
+    python scripts/pitch_face_build.py --turbines WTG01,WTG19   # 只算几台 (调试)
+    python scripts/pitch_face_build.py --dry-run           # 只报会写什么、源件在哪、能不能算
+    python scripts/pitch_face_build.py --check-map         # 复核 1min 台号 → WTG 台号 的映射 (对拍功率)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+import time
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+# ★GBK 控制台/日志重定向不再因 '²' '⇒' 这类字符抛 UnicodeEncodeError (2026-09-19 远端实逮:
+#   `baseline_38_build.py` 打印 'm/s²' 时在 cp936 下 rc=1, 整条恢复链少一件产物)。
+#   只把**不可编码字符替换掉**, 不改流编码 —— 中文照旧可读。
+try:
+    sys.stdout.reconfigure(errors='replace')
+except Exception:
+    pass
+
+import numpy as np                                            # noqa: E402
+import pandas as pd                                           # noqa: E402
+
+from src import paths as P                                    # noqa: E402
+from src.derived_manifest import record as dm_record           # noqa: E402
+from src.windscada.config import farm                          # noqa: E402
+from src.windscada import scada_source as SS                   # noqa: E402
+
+# ── 阈值与门 (出处见模块 docstring「阈值出处」) ──────────────────────────────────
+HYDR_UNLOAD_BAR = 240.0      # 越卸荷阈 (bar): 1min 运行分钟压力 P95 = 240
+HYDR_PUMP_BAR = 195.0        # 跌启泵阈 (bar): 1min 运行分钟压力 P5  = 195
+STOP_RPM = 0.5               # 停机判据: 转速 < 0.5 rpm
+STOP_KW = 50.0               # 停机判据: |有功| < 50 kW
+FULL_FRAC = 0.95             # 满发判据: 有功 ≥ 95% 额定 (4000 kW → 3800 kW)
+# 顺桨停机带 (°): 「停机」分钟里三叶桨距角**是双峰的** —— 实测 2025-01 停机分钟的 5/25/50/75/95 分位 =
+# 28.7 / 31.2 / **31.79** / 88.59 / 88.63: 一半停在顺桨位(≈88.6°), 一半停在 ~31.8°(未顺桨/维护位)。
+# 零位基准只能取**压住机械顺桨止挡**的那一支, 故按带滤: fleet 顺桨位实测 88.59~88.69°(±0.7°),
+# 带取 85~92°(±3.5° 容差) —— 既滤掉 31.8° 那一支, 又给真实零位误差留 ±3.4° 的可见余量。
+FEATHER_BAND = (85.0, 92.0)
+MIN_STOP_MIN = 50            # 台月顺桨停机段最少分钟数
+MIN_BASE_TURBINES = 5        # 同月基准至少要有几台有效台
+# ── 运行段(细桨距)零位口径 —— **工况分档归一** (第三个口径, 2026-09-19 反推旧件后补) ──
+# 零位偏差的行业含义是"**同一工况**下桨距角读数差"; 不分档会被风资源混入 (风大的台长期在高桨距档
+# ⇒ 中位天然偏高)。故按有功 500kW 分档, 逐档与全场同月中位比, 再取该台跨档中位 = `zero_dev_run`。
+# 为什么必须有它 (实测): 停机顺桨段口径把 19# 读成 +0.11° (顺桨止挡是**机械基准**, 与运行区零位
+# 不是同一个量; 且 fleet 顺桨位 88.59~88.69° 极紧, 差异被止挡抹平), 满发段口径 19# = +0.61° 且台间
+# 离散大; 而**运行段口径把 19# 单独拎出来** (逐月持续为负, 其余台 ≈ 0) —— 与现场
+# "19# 零位 −0.87° 专项确诊" 定性一致 ⇒ 判据轴不能只靠停机段那一个口径。
+RUN_MIN_KW = 100.0           # 运行段下界 (kW): 低于此视为停机/待机
+RUN_BIN_KW = 500.0           # 工况分档宽 (kW)
+MIN_RUN_BIN_N = 30           # 单档单月最少分钟数
+MIN_RUN_BINS = 4             # 台月至少要有几档可比
+
+# ── 列清单 (源件列名 → 本器内部名) ─────────────────────────────────────────────
+C10 = dict(
+    state='flg_wtc_ScInOper_endvalue', rpm='tur_wtc_GenRpm_mean',
+    hp_min='prs_wtc_HydPress_min', hp_max='prs_wtc_HydPress_max', hp_mean='prs_wtc_HydPress_mean',
+    lvl='din_wtc_HydLevel_timeon', filt='din_wtc_HydrFilt_timeon',
+    hublub='dot_wtc_HubLubPu_timeon', pitchpum='dot_wtc_PitchPum_timeon',
+    pA='din_wtc_LubPistA_counts', pB='din_wtc_LubPistB_counts', pC='din_wtc_LubPistC_counts')
+TS10 = 'TimeStamp'
+C1 = dict(ts='time_stamp', pw='active_power', rpm='rotor_speed', pr='液压站预充压力',
+          b1='pitch_angle_blade_1', b2='pitch_angle_blade_2', b3='pitch_angle_blade_3')
+
+DAILY_COLS = ['date', 'turbine', 'n', 'n_op', 'hyd_mean', 'hyd_band', 'hyd_over_relief', 'hyd_under_pump',
+              'hublub_timeon', 'pitchpum_timeon', 'hydlevel_timeon', 'hydfilt_timeon',
+              'pistA', 'pistB', 'pistC']
+ZERO_COLS = ['month', 'turbine', 'zero_dev', 'n_rows', 'zero_dev_stop', 'zero_dev_full',
+             'zero_dev_run', 'run_bins', 'run_med_bin',
+             'stop_med', 'full_med', 'n_stop', 'n_stop_all', 'n_full', 'spread_stop', 'spread_full',
+             'base_stop', 'base_full']
+
+
+def turbine_of_1min(stem: str) -> str | None:
+    """1min 导出件的**内部台号** → 场配置台号: `01E.csv` → `WTG01`。
+
+    现场 1min 导出用内部代号 (`01E.csv … 38B.csv`,`scripts/place_raw_data.py`);数字前缀 = 台号。
+    实测佐证 (`--check-map`): 4 组对拍 (01E→WTG01 / 02E→WTG02 / 19C→WTG19 / 38B→WTG38) 的 10min 功率
+    与 1min 功率**相关系数 0.982~0.986、均值差 < 1 kW**;跨台对拍则为不相关 ⇒ 映射按数字前缀成立。
+    """
+    m = re.match(r'^(\d+)', stem)
+    if not m:
+        return None
+    return f'WTG{int(m.group(1)):02d}'
+
+
+def _paths(cfg):
+    csv10 = pathlib.Path(str(cfg['src_10min']))
+    csv1, _ = SS.dirs_of('1min', cfg)
+    return csv10, csv1
+
+
+def read_10min(wtg: str, cfg) -> pd.DataFrame:
+    """10min 取一台 (CSV 优先 / MDB 回落, 见 src/windscada/scada_source.py)。"""
+    cols = [TS10] + list(C10.values())
+    d = SS.load('10min', wtg, columns=cols, cfg=cfg)
+    have = {v: k for k, v in C10.items()}
+    missing = [v for v in C10.values() if v not in d.columns]
+    if TS10 not in d.columns and len(d.columns):
+        d = d.rename(columns={d.columns[0]: TS10})
+    if missing:
+        d.attrs['pitch_missing'] = missing
+    return d.rename(columns=have)
+
+
+def read_1min(code: str, cfg) -> pd.DataFrame:
+    """1min 取一台 (CSV 优先 / MDB 回落)。列名同 `C1` 的值;缺列由调用方判缺。"""
+    cols = list(C1.values())
+    d = SS.load('1min', code, columns=cols, cfg=cfg)
+    if C1['ts'] not in d.columns and len(d.columns):
+        d = d.rename(columns={d.columns[0]: C1['ts']})
+    return d
+
+
+def _num(s) -> pd.Series:
+    return pd.to_numeric(s, errors='coerce')
+
+
+def daily_of(wtg: str, d10: pd.DataFrame) -> pd.DataFrame:
+    """10min 表 → 日粒度一行 (列见 DAILY_COLS)。"""
+    d = d10.copy()
+    d['ts'] = pd.to_datetime(d[TS10], errors='coerce')
+    d['date'] = d['ts'].dt.normalize()
+    for k in ('hp_min', 'hp_max', 'hp_mean', 'lvl', 'filt', 'hublub', 'pitchpum', 'pA', 'pB', 'pC', 'rpm'):
+        if k in d:
+            d[k] = _num(d[k])
+    if 'state' in d:
+        run = _num(d['state']) == 1
+    else:                                     # 兜底: 无运行状态列时按发电机转速判运行
+        run = d['rpm'] > 0.5
+    d['_run'] = run.fillna(False)
+    d['_band'] = (d['hp_max'] - d['hp_min']).where(d['_run']) if {'hp_max', 'hp_min'} <= set(d.columns) else np.nan
+    g = d.groupby('date')
+    spec = dict(n=('ts', 'size'), n_op=('_run', 'sum'), hyd_band=('_band', 'median'))
+    for src, dst, how in (('hp_mean', 'hyd_mean', 'mean'), ('lvl', 'hydlevel_timeon', 'sum'),
+                          ('filt', 'hydfilt_timeon', 'sum'), ('hublub', 'hublub_timeon', 'sum'),
+                          ('pitchpum', 'pitchpum_timeon', 'sum'), ('pA', 'pistA', 'sum'),
+                          ('pB', 'pistB', 'sum'), ('pC', 'pistC', 'sum')):
+        if src in d.columns:                                  # 缺列 = NaN (不静默造数; 缺列记进 manifest)
+            spec[dst] = (src, (lambda s: s.sum(min_count=1)) if how == 'sum' else how)
+    out = g.agg(**spec)
+    out.insert(0, 'turbine', wtg)
+    return out.reset_index()
+
+
+def minute_counts_of(wtg: str, d1: pd.DataFrame, rated_kw: float) -> pd.DataFrame:
+    """1min 表 → (日 × 台) 的越卸荷/跌启泵**分钟数** + (月 × 台) 的零位两口径原料。"""
+    d = d1.copy()
+    d['ts'] = pd.to_datetime(d[C1['ts']], errors='coerce')
+    d['date'] = d['ts'].dt.normalize()
+    d['month'] = d['ts'].dt.strftime('%Y-%m')
+    pw, rpm = _num(d[C1['pw']]), _num(d[C1['rpm']])
+    pr = _num(d[C1['pr']]) if C1['pr'] in d else pd.Series(np.nan, index=d.index)
+    run = (pw.abs() >= STOP_KW) | (rpm >= STOP_RPM)
+    d['_over'] = (run & (pr >= HYDR_UNLOAD_BAR)).astype('int64')
+    d['_under'] = (run & (pr <= HYDR_PUMP_BAR)).astype('int64')
+    day = d.groupby('date').agg(hyd_over_relief=('_over', 'sum'), hyd_under_pump=('_under', 'sum')).reset_index()
+    day.insert(0, 'turbine', wtg)
+    # 零位两口径 (逐月)
+    blades = [_num(d[C1[b]]) for b in ('b1', 'b2', 'b3') if C1[b] in d]
+    pm = sum(blades) / len(blades) if blades else pd.Series(np.nan, index=d.index)
+    spread = (pd.concat(blades, axis=1).max(axis=1) - pd.concat(blades, axis=1).min(axis=1)) if len(blades) == 3 \
+        else pd.Series(np.nan, index=d.index)
+    stop_all = (rpm < STOP_RPM) & (pw.abs() < STOP_KW)
+    stop = stop_all & pm.between(*FEATHER_BAND)     # 只取压住顺桨止挡的那一支 (见 FEATHER_BAND 注释)
+    full = pw >= FULL_FRAC * rated_kw
+    run = (rpm >= STOP_RPM) & (pw >= RUN_MIN_KW) & (pw < FULL_FRAC * rated_kw)
+    recs = []
+    for m, idx in d.groupby('month').groups.items():
+        i = pd.Index(idx)
+        s, f = stop.loc[i], full.loc[i]
+        recs.append(dict(
+            month=m, turbine=wtg,
+            n_stop=int(s.sum()), n_stop_all=int(stop_all.loc[i].sum()), n_full=int(f.sum()),
+            stop_med=float(pm.loc[i][s].median()) if int(s.sum()) else np.nan,
+            full_med=float(pm.loc[i][f].median()) if int(f.sum()) else np.nan,
+            spread_stop=float(spread.loc[i][s].median()) if int(s.sum()) else np.nan,
+            spread_full=float(spread.loc[i][f].median()) if int(f.sum()) else np.nan,
+            run_med=float(pm.loc[i][run.loc[i]].median()) if int(run.loc[i].sum()) else np.nan))
+    # 运行段按有功分档 (逐月 × 档): 只有足够样本的档才参与 (见 RUN_BIN_KW 注释)
+    rb = pd.DataFrame(dict(month=d['month'], bin=(pw // RUN_BIN_KW), pm=pm))[run]
+    rb = rb.dropna(subset=['pm'])
+    bins = (rb.groupby(['month', 'bin'])['pm'].agg(['median', 'size'])
+              .reset_index().rename(columns={'median': 'med', 'size': 'n'}))
+    bins = bins[bins['n'] >= MIN_RUN_BIN_N]
+    bins.insert(1, 'turbine', wtg)
+    return day, pd.DataFrame(recs), bins
+
+
+def run_dev(run_bins: pd.DataFrame) -> pd.DataFrame:
+    """运行段分档 → 台月 `zero_dev_run` (逐档比全场同月中位, 再取跨档中位)。
+
+    为什么要分档 (见 RUN_BIN_KW 注释): 不分档时"桨距角中位"混了风资源 (风大的台长期在高桨距档);
+    分档后每档内工况可比, 差值才归因于零位/标定。台月可比档数 < MIN_RUN_BINS ⇒ 不出值。
+    """
+    if not len(run_bins):
+        return pd.DataFrame(columns=['month', 'turbine', 'zero_dev_run', 'run_bins', 'run_med_bin'])
+    b = run_bins.copy()
+    b['fleet'] = b.groupby(['month', 'bin'])['med'].transform('median')
+    b['dev'] = b['med'] - b['fleet']
+    g = b.groupby(['month', 'turbine'])['dev'].agg(['median', 'size']).reset_index()
+    g = g[g['size'] >= MIN_RUN_BINS].rename(columns={'median': 'zero_dev_run', 'size': 'run_bins'})
+    return g
+
+
+def zero_monthly(raw: pd.DataFrame, rdev: pd.DataFrame) -> tuple[pd.DataFrame, list]:
+    """逐月原料 → 三口径分列 (相对**同月全场中位基准**的偏差)。返回 (表, 被剔记录)。"""
+    rejected = []
+    if not len(raw):
+        return pd.DataFrame(columns=ZERO_COLS), rejected
+    df = raw.copy()
+    df['stop_ok'] = (df['n_stop'] >= MIN_STOP_MIN) & df['stop_med'].between(*FEATHER_BAND)
+    df['full_ok'] = df['n_full'] >= MIN_STOP_MIN
+    bad = df[(df['n_stop'] >= MIN_STOP_MIN) & ~df['stop_ok']]
+    for _, r in bad.iterrows():
+        rejected.append(dict(month=r['month'], turbine=r['turbine'], why='停机段中位不在顺桨带',
+                             stop_med=round(float(r['stop_med']), 3), n_stop=int(r['n_stop'])))
+    short = df[df['n_stop'] < MIN_STOP_MIN]
+    n_short = int(len(short))
+    if n_short:
+        rejected.append(dict(why='台月顺桨停机分钟不足 (不出值)',
+                             min_minutes=MIN_STOP_MIN, n_turbine_months=n_short,
+                             example=[dict(month=r['month'], turbine=r['turbine'], n_stop=int(r['n_stop']),
+                                           n_stop_all=int(r['n_stop_all'])) for _, r in short.head(5).iterrows()]))
+    base_stop = df[df['stop_ok']].groupby('month')['stop_med'].agg(['median', 'size'])
+    base_full = df[df['full_ok']].groupby('month')['full_med'].agg(['median', 'size'])
+    ok_months = set(base_stop[base_stop['size'] >= MIN_BASE_TURBINES].index) & \
+        set(base_full[base_full['size'] >= MIN_BASE_TURBINES].index)
+    for m in sorted(set(df['month']) - ok_months):
+        rejected.append(dict(month=m, why='同月有效台不足或无基准', min_base=MIN_BASE_TURBINES))
+    df = df[df['month'].isin(ok_months) & df['stop_ok'] & df['full_ok']].copy()
+    if not len(df):
+        return pd.DataFrame(columns=ZERO_COLS), rejected
+    df['base_stop'] = df['month'].map(base_stop['median'])
+    df['base_full'] = df['month'].map(base_full['median'])
+    df['zero_dev_stop'] = (df['stop_med'] - df['base_stop']).round(4)
+    df['zero_dev_full'] = (df['full_med'] - df['base_full']).round(4)
+    df['zero_dev'] = df['zero_dev_stop']                 # 消费者读这一列 (口径=停机段, 见 docstring)
+    df['n_rows'] = df['n_stop']                          # 旧件同名列: 样本数
+    if len(rdev):                                        # 第三口径 (运行段分档): 可比档不足 ⇒ NaN (不造数)
+        df = df.merge(rdev, on=['month', 'turbine'], how='left')
+        df['zero_dev_run'] = df['zero_dev_run'].round(4)
+    for c in ('zero_dev_run', 'run_bins', 'run_med_bin'):
+        if c not in df.columns:
+            df[c] = np.nan
+    df = df[ZERO_COLS].sort_values(['turbine', 'month']).reset_index(drop=True)
+    return df, rejected
+
+
+def check_map(cfg, n: int = 4) -> int:
+    """复核 1min 台号 → WTG 台号 的映射 (功率对拍)。返回 rc。"""
+    csv10, csv1 = _paths(cfg)
+    codes = sorted(f.stem for f in csv1.glob('*.csv'))
+    picks = [c for c in codes if turbine_of_1min(c) in ('WTG01', 'WTG02', 'WTG19', 'WTG38')][:n]
+    ok = 0
+    print(f'映射复核 (1min 台号 → 场台号): 抽 {len(picks)} 组, 判据 = 10min 功率与 1min 功率相关系数 > 0.9')
+    for code in picks:
+        wtg = turbine_of_1min(code)
+        a = read_1min(code, cfg)
+        a['ts'] = pd.to_datetime(a[C1['ts']], errors='coerce').dt.floor('10min')
+        g = _num(a[C1['pw']]).groupby(a['ts']).mean().rename('p1')
+        b = SS.load('10min', wtg, columns=[TS10, 'grd_wtc_ActPower_mean'], cfg=cfg)
+        b['ts'] = pd.to_datetime(b[TS10], errors='coerce')
+        m = pd.merge(g.reset_index(), b.rename(columns={'grd_wtc_ActPower_mean': 'p10'}), on='ts').dropna()
+        r = float(np.corrcoef(m['p1'], _num(m['p10']))[0, 1]) if len(m) > 100 else float('nan')
+        good = r > 0.9
+        ok += good
+        print(f'  [{"OK" if good else "X "}] {code}.csv → {wtg}: 重叠 {len(m)} 段  r={r:.4f}  '
+              f'1min 均值 {m["p1"].mean():.1f} kW / 10min 均值 {_num(m["p10"]).mean():.1f} kW')
+    return 0 if ok == len(picks) and picks else 1
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='变桨面生成端 (pitch_daily + pitch_zero_monthly)')
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--turbines', default=None, help='逗号分隔, 只算这几台 (调试)')
+    ap.add_argument('--dry-run', action='store_true', help='只报源件/口径, 不写盘')
+    ap.add_argument('--check-map', action='store_true', help='复核 1min 台号 → WTG 台号 映射')
+    args = ap.parse_args()
+
+    cfg = farm(args.farm)
+    if args.check_map:
+        return check_map(cfg)
+
+    rated = float(cfg.get('rated_kw') or 4000)
+    csv10, csv1 = _paths(cfg)
+    store, pitch_dir = P.store(args.farm), P.pitch(args.farm)
+    turb_cfg = [f'WTG{i:02d}' for i in range(1, int(cfg.get('n_turbines') or 38) + 1)]
+    want = [t.strip() for t in args.turbines.split(',')] if args.turbines else turb_cfg
+    print(f'变桨面生成端 · 场={args.farm} 额定={rated:.0f}kW 台数={len(want)}')
+    print(f'  10min 源: {csv10}  ({len(list(csv10.glob("*.csv")))} 件 CSV, MDB 回落见 scada_source)')
+    print(f'  1min  源: {csv1}  ({len(list(csv1.glob("*.csv")))} 件 CSV)')
+    code_of = {}
+    for f in sorted(csv1.glob('*.csv')):
+        t = turbine_of_1min(f.stem)
+        if t:
+            code_of[t] = f.stem
+
+    daily, counts, zeros, zbins, skipped, t0 = [], [], [], [], [], time.time()
+    for i, wtg in enumerate(want, 1):
+        try:
+            d10 = read_10min(wtg, cfg)
+        except Exception as e:                                  # 该台源件缺失: 如实记, 不造数
+            skipped.append(dict(turbine=wtg, why=f'10min 取数失败: {type(e).__name__}: {e}'[:200]))
+            print(f'  [{i:2d}/{len(want)}] {wtg}: 跳过 — {skipped[-1]["why"][:80]}', flush=True)
+            continue
+        if len(d10):
+            daily.append(daily_of(wtg, d10))
+        for c in (d10.attrs.get('pitch_missing') or []):
+            skipped.append(dict(turbine=wtg, why=f'10min 缺列 {c}'))
+        code = code_of.get(wtg)
+        n1 = 0
+        if code:
+            try:
+                d1 = read_1min(code, cfg)
+                day1, z1, b1_ = minute_counts_of(wtg, d1, rated)
+                n1 = len(d1)
+                counts.append(day1)
+                zeros.append(z1)
+                if len(b1_):
+                    zbins.append(b1_)
+            except Exception as e:
+                skipped.append(dict(turbine=wtg, why=f'1min ({code}) 取数失败: {type(e).__name__}: {e}'[:200]))
+        else:
+            skipped.append(dict(turbine=wtg, why='1min 无该台导出件 (越线计数/零位三口径按缺)'))
+        print(f'  [{i:2d}/{len(want)}] {wtg}: 10min {len(d10):6d} 行'
+              + (f' · 1min {n1:7d} 行 ({code})' if code else ' · 1min 缺件')
+              + f'  已用 {time.time() - t0:.0f}s', flush=True)
+
+    if not daily:
+        print('[X] 一件日粒度都没算出来 —— 先放原始件 (scripts/place_raw_data.py) 再跑')
+        return 4
+    dl = pd.concat(daily, ignore_index=True)
+    dl['date'] = pd.to_datetime(dl['date'])
+    if counts:                                  # 越卸荷/跌启泵计数来自 1min 侧: 按 (台, 日) 左连接
+        cc = pd.concat(counts, ignore_index=True)
+        cc['date'] = pd.to_datetime(cc['date'])
+        dl = dl.merge(cc, on=['turbine', 'date'], how='left')
+    for c in DAILY_COLS:
+        if c not in dl.columns:
+            dl[c] = np.nan
+    dl['n'] = dl['n'].fillna(0).astype('int64')
+    dl['n_op'] = dl['n_op'].fillna(0).astype('int64')
+    dl = dl[DAILY_COLS].sort_values(['turbine', 'date']).reset_index(drop=True)
+    zr = pd.concat(zeros, ignore_index=True) if zeros else pd.DataFrame()
+    rb_all = pd.concat(zbins, ignore_index=True) if zbins else pd.DataFrame()
+    zm, rejected = zero_monthly(zr, run_dev(rb_all))
+
+    span = (str(dl['date'].min().date()), str(dl['date'].max().date())) if len(dl) else ('', '')
+    print(f'\n日粒度 {len(dl)} 行 · 台 {dl["turbine"].nunique()} · 跨度 {span[0]} → {span[1]}')
+    print(f'零位月表 {len(zm)} 行 · 台 {zm["turbine"].nunique() if len(zm) else 0} · '
+          f'月 {sorted(zm["month"].unique())[:3]}…{sorted(zm["month"].unique())[-2:] if len(zm) else ""}')
+    if len(zm):
+        idx = zm.set_index(['turbine', 'month'])
+        for t in ('WTG19', 'WTG31', 'WTG01'):
+            if t not in idx.index.get_level_values(0):
+                continue
+            r = idx.loc[t]
+            print(f'  锚点 {t}: 停机顺桨段 {r["zero_dev_stop"].median():+.3f}° · 满发段 {r["zero_dev_full"].median():+.3f}° · '
+                  f'运行段分档 {r["zero_dev_run"].median():+.3f}° (可比档中位 {r["run_bins"].median():.0f})')
+        for c, tag in (('zero_dev', '停机顺桨段'), ('zero_dev_full', '满发段'), ('zero_dev_run', '运行段分档')):
+            s = zm[c].dropna()
+            if len(s):
+                print(f'  口径 {tag:8s}: |偏差| 中位 {s.abs().median():.3f}° · P95 {s.abs().quantile(.95):.3f}° · '
+                      f'最大 {s.abs().max():.3f}° · 有值 {len(s)} 台月')
+    if skipped:
+        print(f'  跳过/缺件 {len(skipped)} 条: ' + '; '.join(f'{s["turbine"]} {s["why"][:40]}' for s in skipped[:3])
+              + (' …' if len(skipped) > 3 else ''))
+    if rejected:
+        print(f'  未出值(口径门) {len(rejected)} 条: '
+              + '; '.join(f'{r.get("month", "—")} {r.get("turbine", "")} {r["why"]}' for r in rejected[:3])
+              + (' …' if len(rejected) > 3 else ''))
+    if args.dry_run:
+        print('\n[dry-run] 不写盘。')
+        return 0
+
+    pitch_dir.mkdir(parents=True, exist_ok=True)
+    f_daily, f_zero = pitch_dir / 'pitch_daily.parquet', pitch_dir / 'pitch_zero_monthly.parquet'
+    dl.to_parquet(f_daily, index=False)
+    zm.to_parquet(f_zero, index=False)
+    man = dict(
+        meta=dict(built=time.strftime('%Y-%m-%d %H:%M:%S'), farm=args.farm, by='scripts/pitch_face_build.py',
+                  version='pitch-face-v1', turbines=int(dl['turbine'].nunique()),
+                  span=dict(from_=span[0], to=span[1]), rows=dict(daily=int(len(dl)), zero_monthly=int(len(zm)))),
+        口径=dict(
+            n='当日 10min 记录数 (×600 = 应测秒)', n_op='当日运行段数 (flg_wtc_ScInOper_endvalue==1)',
+            hyd_mean='当日 HydPress_mean 均值 (bar)', hyd_band='当日运行段段内 (max−min) 中位 (bar)',
+            hyd_over_relief=f'当日运行分钟中 压力 ≥ {HYDR_UNLOAD_BAR:.0f}bar 的分钟数',
+            hyd_under_pump=f'当日运行分钟中 压力 ≤ {HYDR_PUMP_BAR:.0f}bar 的分钟数',
+            pistA_B_C='当日 LubPist{A,B,C}_counts 求和 (三叶柱塞动作次数)',
+            zero_dev=f'顺桨停机段中位 − 同月全场中位基准 (停机=转速<{STOP_RPM}rpm ∧ |有功|<{STOP_KW:.0f}kW '
+                     f'∧ 桨距角中位 ∈ 顺桨带 {FEATHER_BAND[0]:.0f}~{FEATHER_BAND[1]:.0f}°)',
+            zero_dev_full=f'满发段中位 − 同月全场中位基准 (满发=有功≥{FULL_FRAC:.0%}额定={FULL_FRAC * rated:.0f}kW)',
+            zero_dev_run=f'运行段按有功 {RUN_BIN_KW:.0f}kW 分档, 逐档比同月全场同档中位, 再取跨档中位 '
+                         f'(判据轴; 运行段=转速≥{STOP_RPM}rpm ∧ 有功 {RUN_MIN_KW:.0f}~{FULL_FRAC * rated:.0f}kW; '
+                         f'可比档 ≥{MIN_RUN_BINS} 档、单档 ≥{MIN_RUN_BIN_N} 分钟)'),
+        阈值=dict(卸荷阈bar=HYDR_UNLOAD_BAR, 启泵阈bar=HYDR_PUMP_BAR, 停机转速rpm=STOP_RPM, 停机功率kW=STOP_KW,
+                  满发门kW=FULL_FRAC * rated, 顺桨带=list(FEATHER_BAND),
+                  停机段最少分钟=MIN_STOP_MIN, 同月基准最少台=MIN_BASE_TURBINES,
+                  运行段下界kW=RUN_MIN_KW, 工况档宽kW=RUN_BIN_KW, 单档最少分钟=MIN_RUN_BIN_N,
+                  最少可比档=MIN_RUN_BINS),
+        证据=dict(
+            压力锯齿='10min 段内 min 中位 194bar / max 中位 245-246bar; 1min 运行分钟 P5=195 / P95=240',
+            顺桨位='顺桨停机分钟三叶桨距角中位 88.6° (fleet, ±0.7°); 停机分钟双峰(另一支 ≈31.8° 未顺桨) '
+                   '⇒ 零位基准只取顺桨带内那一支',
+            台号映射='1min 内部台号数字前缀 = 场台号 (4 组功率对拍 r=0.982~0.986); 复核: --check-map',
+            与旧随包件='旧件 n_op 口径未知(WTG01 2025-01-01: 旧 48 / 本器 61), 故只作量级对拍不逐值验收; '
+                       '旧件 zero_dev 只有 19# 是离群(全期 −0.896°, 其余台 ≈0) ⇒ 其口径是"绝对零位偏差", '
+                       '与本器的"相对同月全场中位"不同源, 故零位一族不做逐值对拍',
+            零位三口径='2026-09-19 反推: 停机顺桨段把 19# 读成 +0.11°(机械止挡抹平差异), 满发段 19# +0.61° '
+                       '且台间离散大; 运行段分档口径能把 19# 单独拎出(逐月持续为负, 其余台 ≈0) —— '
+                       '与现场「19# 零位 −0.87° 专项确诊」定性一致, 故判据轴取 running 口径'),
+        来源=dict(_10min='csv 优先 / mdb 回落 (src/windscada/scada_source.py)',
+                  _1min='csv 优先 / mdb 回落', inputs=[str(csv10), str(csv1)]),
+        跳过=skipped, 未出值=rejected)
+    f_man = pitch_dir / 'pitch_face_manifest.json'
+    f_man.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+    # ★自登记台账的落点与键 (2026-09-19 实逮): 台账正本是 `P.out_root(farm)/_derived_manifest.json`
+    #   (与其它 8 个生成端一致), 键必须是**相对产物仓根**的路径。此处若写成 P.store()/P.rel() 则会
+    #   另建一本 `windscada/_derived_manifest.json` 且键带 `outputs/rudong/` 前缀 —— 台账读不到,
+    #   反向呼应审计还会把那本杂账报成"未归类 1 件"(实测 rc=5)。
+    out_root = P.out_root(args.farm)
+    dm_record(out_root, {(pitch_dir / n).relative_to(out_root).as_posix(): note for n, note in (
+        (f_daily.name, 'scripts/pitch_face_build.py (10min 液压/润滑/柱塞 + 1min 越线计数 → 日粒度)'),
+        (f_zero.name, 'scripts/pitch_face_build.py (1min 三叶桨距角 → 停机顺桨段/满发段/运行段分档 三口径零位)'),
+        (f_man.name, 'scripts/pitch_face_build.py (口径/阈值/证据/跳过清单)'))},
+        by='scripts/pitch_face_build.py')
+    print(f'\n[OK] 已写 {P.rel(f_daily)} ({f_daily.stat().st_size / 1024:.0f} KB) · '
+          f'{P.rel(f_zero)} ({f_zero.stat().st_size / 1024:.0f} KB) · {P.rel(f_man)} · 已自登记')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 372 - 0
app_ETL/app_ETL_guanlan/builders/place_raw_data.py

@@ -0,0 +1,372 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""把现场给的数据包按 A2 约定落位到 data/raw/<场站名称>/ 下 (2026-09-11)。
+
+## 为什么要有这个脚本
+
+A2 定了四项数据源的落位: data/raw/<场站名称>/{scada_10min, 故障报警, 风机故障记录, 油样报告}。
+现场拿到的却是几个 GBK 名的大压缩包 (10分钟数据.zip / 如东风场数据.zip), 内部目录名与
+落位目录名并不一一对应 (例如报警在 `报警数据/`, 工单在 `工作/风机故障记录/`,
+油样埋在 `数据收集/更新/(8)…/2025年油样/`)。手工拖拽容易拖错一层、也说不清依据什么。
+
+本脚本把映射**写成表**, 于是"哪个包里的哪个目录去了哪"是可复核的, 重复跑也不会走样。
+
+## 落位映射 (target 相对 data/raw/<场站名称>/)
+
+  scada_10min/    ← 10分钟数据.zip 全部 38 个 WTG*.csv (平铺; src/windscada/data.py 按
+                    <src_10min>/<turbine>.csv 读, 所以必须是文件本身不是再套一层目录)
+  故障报警/        ← 如东风场数据.zip: 报警数据/**           (年度/季度 .xls, XML 报警导出)
+                    如东风场数据.zip: 数据收集/更新/(2)故障记录(首发故障有标识)(2025.1-至今)/故障记录/**
+                                      (2025年全年故障记录.xls / 2026年至今.xls — 正是页面写的 *年至今.xls)
+  风机故障记录/    ← 如东风场数据.zip: 工作/风机故障记录/**   (2021…2026 年故障记录/ + 业主统计故障/ + 年度 .rar)
+                    如东风场数据.zip: 数据收集/更新/(3)现场检修记录(2025.1-至今)/大部件维修记录.*.xlsx
+  油样报告/        ← 如东风场数据.zip: 数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/**
+                                      (去掉"2025年油样"这一层, 直接落成 <台号>/<部件>/<pdf>,
+                                       与维护页说明"按台号/部件分目录"一致)
+
+## 两组范围 (--scope)
+
+`--scope a2`(默认) 只落上面那四类, 即 A3 当时的范围, 语义不变。
+
+`--scope mech` 落的是**"能重算的缺口"**(2026-09-11 用户令: 缺什么从现场包里抽)。它解决的是
+"从零重算时, 某些产物因为源件不在 data/raw 而算不出来"这件事, 因此只挑**有消费者**的源:
+
+  <data/raw>/西门子4.0技术资料/  ← 如东风场数据.zip: 西门子4.0技术资料/**           (317 件 2.8 GB)
+                                   + 如东风场数据/广核如海风电场西门子风机故障代码中英文对译表.xlsx
+      依据: `src/ontology/kb_ingest.py` 里 `TECH = P.RAW_ROOT/'西门子4.0技术资料'`, 且 `rd()` 要读
+            `故障处理/故障处理手册.xlsx`、`广核如海风电场西门子风机故障代码中英文对译表.xlsx`、
+            `如海故障代码表.xlsx`、`维护相关/维护作业指导书.xlsx` 四个文件。前三个里**对译表在
+            技术资料目录里没有**(包里它在 `scada数据(如东)/` 与包根各有一份), 所以单独补一条规则。
+      效果: 本体层 `objects.json` 从"包内没有源件(⛔)"变成**可重算**。
+
+  <场站>/scada_1min/           ← 1分钟数据.zip 全部 38 个 <台号>.csv  (01E.csv…37B.csv)
+      依据: `src/windscada/config.py` 的 STATION_SUBDIRS 声明了这个子目录, `scan_stations.py`
+            报它"缺"; 表头带 `source_file=如海风机1分钟数据/如海测点_2025-01.csv`, 与场配置
+            `src_farm_names=['如海','如东']` 同源(如海=如东项目)。**包内没有消费者**: 它补的是
+            数据层完整性, 不改变任何页面数值 —— 落它是因为"扫描辨识"会把它报成缺口。
+
+## 振动侧 (--scope vib, 2026-09-12 用户令)
+
+用户令: 「数据层里的「CMS 振动评估报告」应遵循 `<安装目录>\\data\\raw\\如东\\windcms`、
+「振动线 handoff」应遵循 `<安装目录>\\data\\raw\\如东\\m5_cms_tcm` 存放; 修改系统支持振动数据参与
+运行、重算; 自现场包提取相应振动数据存放至上述目录」。
+
+  <场站>/windcms/CMS_RuDong_CGN_202603-04/measurement/<年>/<月>/<WTGxx>/*_decode.json
+      ← CMS_RuDong_CGN_202603-04.zip 的 measurement/** 全部成员 (25,679 件, 解压 153.7 GB)
+      依据: 这就是 `src/windcms/pipeline.py` 的 `detect_input()` 认的 **`tcm_decoded_json`**
+            (Brande TCM Enterprise 导出, SiteName=CGN Rudong, LocationName=WTGxx)。原样保留
+            包内 `measurement/` 这一层, 于是"哪个包来的哪一层"仍可追溯; 摄入按 rglob 找
+            `*_decode.json`, 套不套这层都能吃。**这是振动侧唯一能重算的源**: 六层链全部
+            输入都是它 (索引/谱 → 扫线 → 能量占比 → 模型 → 融合 → 报告)。
+            ★ 2026-09-11 版的 SKIPPED 里写着"振动分析报告是成品牌报告, windcms 要的测点索引
+              包里没有" —— 那句在当时成立; 2026-09-12 用户把 CMS 原始导出补进现场包后不再成立,
+              故本范围落地, 并在下面改注。
+
+  <场站>/windcms/厂家报告/上海电气_月度/     ← 如东风场数据.zip:
+      `数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/**`
+      (12 份月度用印版 PDF, 2025-06…2026-05) + 现场目录散装件
+      `中广核如东海上风电场2026年07月振动分析报告_上海电气.docx`
+      依据: 数据层「CMS 振动评估报告」的**厂商评估报告**侧 (VDI3834 / NB/T 31129-2018 判级)。
+            12 份 PDF 是纯扫描件 (无文本层, 实测 /Font=0、pdftotext 类抽取为空) → 只作归档证据,
+            数值不参与判级; docx 有文本层, 由 scripts/vib_reports_build.py 摄入。
+
+  <场站>/m5_cms_tcm/厂家报告/                ← 现场目录散装件
+      `中广核如东海上风电场传动链振动分析报告_大生科技_20260311(1).docx`
+      依据: 数据层「振动线 handoff」的**TCM 侧深度分析报告** (依托机组自带 TCM M-system 数据)。
+            若干现场给了 handoff 正本 (`handoff_vibration_v2.json` / `component_history.json`),
+            也放本目录 → 摄入优先采用现场正本, 不再用包内 shipped 快照。
+
+用法:
+    python scripts/place_raw_data.py --dry-run                # 只报要落什么, 不写盘 (默认 a2)
+    python scripts/place_raw_data.py                          # 真落位 (已存在则覆盖)
+    python scripts/place_raw_data.py --scope mech --dry-run    # 看机理层/1min 会落什么
+    python scripts/place_raw_data.py --scope vib               # 落振动侧 (CMS 原始导出 153.7 GB + 厂家报告)
+    python scripts/place_raw_data.py --scope vib --limit 300   # 冒烟: 只落 300 件 (试跑/校验用)
+    python scripts/place_raw_data.py --scope full              # 三组一起
+    python scripts/place_raw_data.py --src D:\\别的现场数据目录
+
+## 故意不做的事 (各范围共有的判断)
+
+  · 不落 `(3)现场检修记录/2025年检修记录` 与 `2026年检修记录`: 与 工作/风机故障记录/2025年故障记录、
+    2026年故障记录 **逐件同名同大小**(19/19 件), 是同一批月度汇总表的副本。落两遍会让"按年目录"
+    摄入看到两份重复台账 (2026-08-31 长停台账虚高 35 倍那类事故的同款成因: 快照重复必须归并, 不能叠加)。
+  · 振动报告只从 `(8)…/振动分析报告/` 落一遍 (不在 a2/mech 范围): 包内另有两处**同件副本** ——
+    根目录 `中广核如东海上风电场2026年5月振动分析报告用印版(2).pdf`(与 2026年05月 件同尺寸)
+    与嵌套包 `如东海上振动报告11份.zip`(其 11 份与 `振动分析报告/` 的 11 份逐件同尺寸)。
+    落三遍会得到 3 份同名报告, 摄入时会互相覆盖或重复计数 —— 故只在 vib 范围落目录里那一份。
+  · 不落 `scada数据(如东)/**`(19 个月 × 12 个通道组 zip, 2.4 GB): 它是 `scada_10min/*.csv` 的**上游**
+    原始通道导出, 包内没有任何脚本读它(构建器读的是已经平铺好的 10min CSV) —— 落了也不参与重算。
+  · 不落 `fastlog数据/`(4 件 WTG0x.xls): 全库搜 `fastlog` 只有 2 处**注释**提到它("运行态见证"),
+    没有读取代码。
+  · 结论: 上面三项都是"有源件、无生成端/无消费者"。`genbearing_monthly`、`mblub_monthly`、
+    `yaw_dynamic_monthly`、`yaw1min_liveness`、`sector_power`、`duty_monthly`、`pc_monthly_bins`、
+    `thermal_monthly`、`structure.parquet`、`watch_channels_monthly` 这批产物同理 ——
+    全库只有读取方、**0 处写入方**, 属于 v0.2.0 未附构建脚本(见 docs §4)。
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import pathlib
+import os
+import shutil
+import sys
+import zipfile
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+# 现场数据包所在目录: 用 --src 指定, 或 env GUANLAN_PLACE_SRC; 默认取安装目录下 data/_incoming
+DEFAULT_SRC = pathlib.Path(os.environ.get('GUANLAN_PLACE_SRC') or (ROOT / 'data' / '_incoming'))
+ZIP_10MIN = '10分钟数据.zip'
+ZIP_FARM = '如东风场数据.zip'
+
+# (压缩包, 包内前缀, 目标, 是否去掉前缀这一层, 目标根: station= data/raw/<场站>/, raw= data/raw/)
+RULES = [
+    ('10分钟数据.zip', '', 'scada_10min', True, 'station'),
+    ('如东风场数据.zip', '如东风场数据/报警数据/', '故障报警', True, 'station'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(2)故障记录(首发故障有标识)(2025.1-至今)/故障记录/', '故障报警', True, 'station'),
+    ('如东风场数据.zip', '如东风场数据/工作/风机故障记录/', '风机故障记录', True, 'station'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/大部件维修记录.20240619143912557.xlsx', '风机故障记录', True, 'station'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/', '油样报告', True, 'station'),
+]
+
+# 机理层与可选层 (--scope mech): 只挑**有消费者**的源 —— 目的是让"从零重算"能覆盖到本体层。
+RULES_MECH = [
+    # 本体层源件: kb_ingest.py 的 TECH = data/raw/西门子4.0技术资料 (317 件, 含故障处理/维护相关/各类图纸)
+    ('如东风场数据.zip', '如东风场数据/西门子4.0技术资料/', '西门子4.0技术资料', True, 'raw'),
+    # …但它要读的对译表不在技术资料目录里, 包里在别处 → 单独补一条 (见本文件开头"两组范围")
+    ('如东风场数据.zip', '如东风场数据/广核如海风电场西门子风机故障代码中英文对译表.xlsx', '西门子4.0技术资料', True, 'raw'),
+    # 数据层声明里的 scada_1min (config.STATION_SUBDIRS); 包内无消费者, 补的是数据层完整性
+    ('1分钟数据.zip', '', 'scada_1min', True, 'station'),
+]
+
+# 振动侧 (--scope vib): 数据层两条振动行的源件 —— CMS 原始测量导出 + 厂商评估报告
+ZIP_CMS = 'CMS_RuDong_CGN_202603-04.zip'
+DIR_SA_REPORTS = '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/'
+RULES_VIB = [
+    # ① CMS 原始测量导出 (Brande TCM Enterprise, SiteName=CGN Rudong): 25,679 件 *_decode.json。
+    #    strip=False 保留包内 measurement/ 层 → <包名>/measurement/2026/03/WTGxx/*.json, 来源可追溯。
+    (ZIP_CMS, 'measurement/', f'windcms/{ZIP_CMS[:-4]}', False, 'station'),
+    # ② 上海电气月度振动分析报告 (12 份扫描件 PDF): 数据层「CMS 振动评估报告」的厂商报告侧
+    ('如东风场数据.zip', DIR_SA_REPORTS, 'windcms/厂家报告/上海电气_月度', True, 'station'),
+]
+
+# 现场目录下的散装件 (不是压缩包成员): (源文件名, 目标相对 data/raw/<场站>/ 的路径)
+RULES_VIB_LOOSE = [
+    # 上海电气 2026年07月报告: 有文本层, 由 scripts/vib_reports_build.py 摄入成评估报告
+    ('中广核如东海上风电场2026年07月振动分析报告_上海电气.docx',
+     'windcms/厂家报告/上海电气_月度'),
+    # 大生科技传动链振动分析报告 (TCM M-system 数据): 数据层「振动线 handoff」的 TCM 侧
+    ('中广核如东海上风电场传动链振动分析报告_大生科技_20260311(1).docx',
+     'm5_cms_tcm/厂家报告'),
+]
+
+# 说明性的"故意不落", 只在报告里列出来
+SKIPPED = [
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/2025年检修记录/',
+     '与 工作/风机故障记录/2025年故障记录 逐件同名校验相同 (副本)'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/2026年检修记录/',
+     '与 工作/风机故障记录/2026年故障记录 逐件同名校验相同 (副本)'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/',
+     '※ 2026-09-12 起**已改为落位** (vib 范围, → windcms/厂家报告/上海电气_月度)。原判断"成品牌报告+'
+     '包内没有测点索引"在当时成立; 用户补入 CMS_RuDong_CGN_202603-04.zip 后, 索引源件已具备, '
+     '报告作为数据层证据一并落位。此处保留记录以免后人以为漏了'),
+    ('如东风场数据.zip', '如东风场数据/中广核如东海上风电场2026年5月振动分析报告用印版(2).pdf',
+     '与 振动分析报告/…2026年05月…用印版.pdf 同尺寸 (副本, 只落目录里那一份)'),
+    ('如东风场数据.zip', '如东风场数据/如东海上振动报告11份.zip',
+     '其 11 份与 振动分析报告/ 的 11 份逐件同尺寸 (副本包, 只落目录里那一份)'),
+    ('如东风场数据.zip', '如东风场数据/scada数据(如东)/',
+     'scada_10min/*.csv 的上游原始通道导出 (2.4 GB), 包内无脚本读它 → 不参与重算'),
+    ('如东风场数据.zip', '如东风场数据/fastlog数据/',
+     '全库搜 fastlog 只有 2 处注释提到, 无读取代码'),
+]
+
+
+def gbk_name(info: zipfile.ZipInfo) -> str:
+    """zip 条目名 → 真名。现场包用 GBK 存中文名, zipfile 按 cp437 解出来是乱码。"""
+    raw = info.filename
+    try:
+        return raw.encode('cp437').decode('gbk')
+    except Exception:
+        try:
+            return raw.encode('cp437').decode('utf-8')
+        except Exception:
+            return raw
+
+
+def human(n: float) -> str:
+    for unit in ('B', 'KB', 'MB', 'GB'):
+        if n < 1024 or unit == 'GB':
+            return f'{n:.1f} {unit}'
+        n /= 1024
+    return f'{n:.1f} GB'
+
+
+def plan(src: pathlib.Path, station: pathlib.Path, rules, loose_rules=(), limit: int = 0):
+    """→ [(zip, 目标文件, 包内条目名, 解压后大小, 目标根, 目标名)]; 只读压缩包目录, 不解压。
+
+    zip=None 表示散装件 (现场目录下的单个文件, 如两份 docx 振动报告不在任何压缩包里)。
+    limit>0 时只取前 limit 件 (冒烟/试跑用; 大包 2.5 万件跑一遍要几十分钟, 得能小样验证)。"""
+    out = []
+    for zname, prefix, target, strip, root in rules:
+        zp = src / zname
+        if not zp.exists():
+            raise SystemExit(f'缺压缩包: {zp}')
+        base = station if root == 'station' else station.parent      # 'raw' = data/raw (机理层不在场站目录下)
+        with zipfile.ZipFile(zp) as zf:
+            for info in zf.infolist():
+                if info.is_dir():
+                    continue
+                name = gbk_name(info).replace('\\', '/')
+                if not name.startswith(prefix):
+                    continue
+                rest = name[len(prefix):] if strip else name
+                if not rest:
+                    # 前缀恰是条目本身 = 单文件规则 (如 '…/大部件维修记录.20240619143912557.xlsx')。
+                    # 旧写法在这里直接 continue, 于是**单文件规则从来没落过东西** —— 2026-09-11 逮到两例:
+                    # `大部件维修记录.20240619143912557.xlsx`(A2 规则, 从 A3 起就没落) 与
+                    # `广核如海…对译表.xlsx`(mech 规则)。只有"目录条目"(prefix 以 / 结尾) 才该跳过。
+                    if prefix.endswith('/'):
+                        continue
+                    rest = prefix.rstrip('/').rsplit('/', 1)[-1]
+                out.append((zname, base / target / rest, name, info.file_size, root, target))
+    for fname, target in loose_rules:
+        fp = src / fname
+        if not fp.exists():
+            raise SystemExit(f'缺散装源件: {fp}')
+        out.append((None, station / target / fname, fname, fp.stat().st_size, 'station', target))
+    if limit:
+        out = out[:limit]
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--src', default=str(DEFAULT_SRC), help='现场数据目录 (默认 %(default)s)')
+    ap.add_argument('--dry-run', action='store_true', help='只报计划, 不写盘')
+    ap.add_argument('--scope', choices=('a2', 'mech', 'vib', 'full'), default='a2',
+                    help='a2=四项数据层(A3 语义, 默认) · mech=机理层源件+1min · '
+                         'vib=振动侧(CMS 原始导出+厂家报告) · full=三组一起')
+    ap.add_argument('--limit', type=int, default=0, help='只落前 N 件 (冒烟/试跑; 默认 0=全落)')
+    ap.add_argument('--check', action='store_true', default=True,
+                    help='落位前先跑输入数据体检 (默认开; 结构与命名违例/增量冲突会拦下)')
+    ap.add_argument('--no-check', dest='check', action='store_false', help='跳过体检 (自己确知数据干净时用)')
+    ap.add_argument('--force', action='store_true',
+                    help='体检报"同名不同大小(冲突)"时仍然落位 (会覆盖已摄入的源件; 想清楚再用)')
+    a = ap.parse_args()
+
+    src = pathlib.Path(a.src)
+    if not src.is_dir():
+        raise SystemExit(f'现场数据目录不存在: {src}')
+
+    if a.check:
+        # 用户令 3: 放置**前**先体检 —— 结构/命名不对就别落 (落错一层后面所有摄入都读不到),
+        # 增量"同名不同大小"更要人来定夺 (覆盖已摄入的源件不可逆)。
+        import importlib.util as _ilu
+        _sp = _ilu.spec_from_file_location('_raw_check', ROOT / 'scripts' / 'raw_data_check.py')
+        _chk = _ilu.module_from_spec(_sp)
+        _sp.loader.exec_module(_chk)
+        _res, _notes, _stat = _chk.check_tree(pathlib.Path(_chk.station_dir()), deep=False)
+        _r2, _plan = _chk.increment_src(src, pathlib.Path(_chk.station_dir()))
+        _res += _r2 + _chk.increment_findings(_plan, src.name)
+        _hard = [x for x in _res if x[0] == '!' and x[3] in (_chk.RC_STRUCT, _chk.RC_CLASH)]
+        print('== 落位前体检 (scripts/raw_data_check.py) ==')
+        print(f'   新增 {len(_plan["new"]):,} · 相同 {len(_plan["same"]):,} · 冲突 {len(_plan["clash"]):,} '
+              f'(原树 {_stat.get("files", 0):,} 件)')
+        for x in _hard[:8]:
+            print(f'   [{x[0]}] {x[1]}: {x[2]}')
+        if _hard:
+            _clash = [x for x in _hard if x[3] == _chk.RC_CLASH]
+            if _clash and a.force:
+                print('   [!] 有冲突但给了 --force: 继续落位 (会覆盖)')
+            else:
+                print('\n[X] 体检不通过 —— 先按上面的提示整理现场包, 或明确加 --force 覆盖冲突件。')
+                print('    放置规范与示例见 docs/输入数据放置指导_v0.1.md')
+                return _chk.RC_CLASH if _clash else _chk.RC_STRUCT
+        print()
+
+    from src.windscada.config import farm, raw_station_dir
+    station = pathlib.Path(raw_station_dir())
+    cfg = farm()
+    print(f'场站目录: {station}   (来自 src/windscada/config.py raw_station={cfg.get("raw_station")!r})')
+    print(f'现场数据: {src}   范围: --scope {a.scope}\n')
+
+    rules, loose = {
+        'a2': (RULES, ()),
+        'mech': (RULES_MECH, ()),
+        'vib': (RULES_VIB, RULES_VIB_LOOSE),
+        'full': (RULES + RULES_MECH + RULES_VIB, RULES_VIB_LOOSE),
+    }[a.scope]
+    items = plan(src, station, rules, loose, limit=a.limit)
+    by_target = {}
+    for zname, dst, entry, size, root, target in items:
+        d = by_target.setdefault((root, target), [0, 0])
+        d[0] += 1
+        d[1] += size
+    print(f'== 计划落位 (scope={a.scope}) ==')
+    for (root, target), (n, sz) in sorted(by_target.items(), key=lambda kv: kv[0][1]):
+        where = (station / target) if root == 'station' else (station.parent / target)
+        print(f'  {target:44s} {n:6d} 件  {human(sz):>10s}   → {where}')
+    print(f'  合计 {len(items)} 件, {human(sum(i[3] for i in items))}')
+
+    if a.scope in ('vib', 'full'):
+        print('\n== 故意不落 (振动侧同件副本) ==')
+        for zname, prefix, why in SKIPPED:
+            if '振动' in prefix or '报告11份' in prefix:
+                print(f'  {prefix}\n      理由: {why}')
+
+    if a.dry_run:
+        print('\n(dry-run, 未写盘)')
+        return 0
+
+    print('\n== 落位 ==')
+    done = 0
+    skipped = 0
+    written = 0
+    # 按包分组 + **每包只开一次**。旧写法在循环体里 with ZipFile(...) 且用 next(...) 线性找条目 ——
+    # 对 2.5 万件的大包是 25,679 次开包 × 25,759 次名字比较 ≈ 6.6 亿次比较, 实测不可接受。
+    # 改成: 每包一次性建 {条目名: ZipInfo} 映射, 顺序流式写出。
+    by_zip = {}
+    for it in items:
+        by_zip.setdefault(it[0], []).append(it)
+    for zname, group in by_zip.items():
+        zf = zipfile.ZipFile(src / zname) if zname else None
+        try:
+            imap = {gbk_name(i).replace('\\', '/'): i for i in zf.infolist()} if zf else {}
+            for _, dst, entry, size, root, target in group:
+                # 已有同尺寸文件 = 已经是最新 → 跳过。重跑一次不该把 150 GB 原样再抄一遍
+                # (vib 范围解压 153.7 GB; a2 14.7 GB / mech 15.5 GB; 2026-09-11 修单文件规则时
+                #  就是靠这个避免整盘重写)。
+                if dst.exists() and dst.stat().st_size == size:
+                    skipped += 1
+                    continue
+                dst.parent.mkdir(parents=True, exist_ok=True)
+                if zf is None:
+                    with open(src / entry, 'rb') as fsrc, open(dst, 'wb') as fdst:
+                        shutil.copyfileobj(fsrc, fdst, 1024 * 1024 * 4)
+                else:
+                    with zf.open(imap[entry]) as fsrc, open(dst, 'wb') as fdst:
+                        shutil.copyfileobj(fsrc, fdst, 1024 * 1024 * 4)
+                got = dst.stat().st_size
+                if got != size:
+                    raise SystemExit(f'写出大小不符: {dst} 期望 {size} 实得 {got}')
+                done += 1
+                written += size
+                # 大包进度: 每 500 件报一次 (2.5 万件逐件打印会把日志刷爆); 单件 >200 MB 也报
+                if done % 500 == 0 or size > 200 * 1024 * 1024:
+                    print(f'  [{done}/{len(items)}] 已写 {human(written)}  {dst.name}', flush=True)
+        finally:
+            if zf is not None:
+                zf.close()
+    print(f'\n完成 (scope={a.scope}): 新写/更新 {done} 件 ({human(written)}), 已是最新跳过 {skipped} 件, 共 {len(items)} 件')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 474 - 0
app_ETL/app_ETL_guanlan/builders/raw_data_check.py

@@ -0,0 +1,474 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""输入数据放置体检 + 增量放置指导 (2026-09-17 用户令 3)。
+
+用户要的是两件事:
+  ① **检查**放进去的输入数据(特别是**增量**放的)是否符合规则;
+  ② 给出**放置指导**(该放哪、命名怎么写、放完跑什么)。
+
+## 规则 (每条都能指到具体文件, 依据 = 数据目录约定 + 各摄入脚本的真实读法)
+
+    R1 顶层只许 场站目录 / 共享资料(西门子4.0技术资料) / 说明文件(*.txt|*.md);
+       常见错误: 把 10 个 csv 直接解到 data/raw 根下, 或把整包 zip 丢在根上。
+    R2 场站目录下只许约定子目录 (src/windscada/config.py::STATION_SUBDIRS);
+       常见错误: 解压时多套一层包名目录 (如 `10分钟数据/10分钟数据/WTG01.csv`)。
+    R3 各源类的结构与命名:
+       scada_10min/  scada_1min/  平铺 `<机组>.csv`, 机组集合要与场配置一致
+       故障报警/      .xls/.xlsx/.xml, 不许多套层
+       风机故障记录/  年目录 + .xls/.xlsx/.rar/.jpg
+       油样报告/      两级 `台号/部件/*.pdf` (维护页按此分目录)
+       windcms/       含 CMS 原始导出: `*_decode.json` 或 `<包名>/measurement/**`
+       m5_cms_tcm/    含 handoff 正本 (handoff_vibration_v2.json / component_history.json) + 厂家报告/
+    R4 可读性抽样: CSV 能按 utf-8/gbk 解出非空表头; TCM `*_decode.json` 能解析出场站/机组字段。
+        (`--deep` 才做, 默认只查结构与命名, 免得在 GB 级目录上耗时)
+    R5 **增量语义** (给了 --src 时): 逐件对比"源包会落到哪"与"现在有什么", 出三清单:
+       新增 / 相同(将跳过) / **冲突**(同名不同大小 ⇒ 会覆盖已摄入的数据, 必须人来定夺)。
+    R6 时间空洞: scada_* 的文件名里若带年月, 检查是否缺月/重月 (数据层页面的时间轴会缺一段)。
+    R7 体量提示: 单文件 > 2 GB、目录 > 60 GB、或混进 zip/rar (应先解压再放)。
+    R8 放置指导: 按"新增了哪类源"给出该跑的重算命令 (台账/SCADA/振动/全量)。
+
+## 退出码
+    0 合规(可能带提示) · 5 结构或命名违例 · 6 增量冲突(同名不同大小) · 7 缺源类/时间空洞 · 8 仅有提示
+
+## 用法
+    python scripts/raw_data_check.py                      # 体检现有 data/raw/<场站>
+    python scripts/raw_data_check.py --deep               # 加可读性抽样
+    python scripts/raw_data_check.py --src <现场包目录>     # 增量体检: 出新增/相同/冲突三清单
+    python scripts/raw_data_check.py --src <现场包> --json
+    python scripts/raw_data_check.py --write-doc          # 把规则表与结论写进放置指导文档
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import sys
+import zipfile
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+
+RC_OK, RC_STRUCT, RC_CLASH, RC_GAP, RC_NOTE = 0, 5, 6, 7, 8
+
+TOP_OK_DIRS = ('西门子4.0技术资料',)          # data/raw 下与"场站目录"并列的共享资料
+TOP_OK_FILES = ('.txt', '.md', '.pdf')        # 说明类文件
+ZIP_MOD = {'.zip', '.rar', '.7z', '.tar', '.gz', '.zst'}
+
+# Access 读写 .mdb 时留下的锁/临时文件 (不是源数据; 2026-09-19 实逮: MDB 重建期间 scada_mdb 下出现 .laccdb)
+ACCESS_TMP_SUFFIX = {".laccdb", ".ldb", ".tmp"}
+BIG_FILE = 2 * 1024 ** 3
+BIG_DIR = 60 * 1024 ** 3
+
+# 各类源的期望 (后缀集合, 说明) —— 与摄入脚本的真实读法一致 (见 place_raw_data.py 的映射表注释)
+EXPECT = {
+    'scada_10min': ({'.csv'}, '平铺 <机组>.csv (每台一个文件, 不许再套层)'),
+    'scada_1min': ({'.csv'}, '平铺 <机组>.csv'),
+    '故障报警': ({'.xls', '.xlsx', '.xml'}, '年度/季度报警导出 (.xls) + XML 报警导出'),
+    '风机故障记录': ({'.xls', '.xlsx', '.rar', '.jpg', '.png'}, '年目录 + 月度汇总表/现场照片'),
+    '油样报告': ({'.pdf'}, '两级: <台号>/<部件>/*.pdf'),
+    'windcms': ({'.json', '.pdf', '.docx'}, 'CMS 原始导出 *_decode.json(可套 <包名>/measurement/ 层) + 厂家报告/'),
+    'm5_cms_tcm': ({'.json', '.docx', '.pdf', '.md'}, 'handoff 正本 + 厂家报告/'),
+    # 2026-09-17 用户令: 现场交来的年度 MDB 归档(25年.zip / 26年.zip)就地放在这里 —— 它就是
+    # 原始收资(Access 按月分类),取代此前的"已导出 CSV"两个 zip(10分钟数据.zip / 1分钟数据.zip)。
+    # 允许 .zip(原样归档)+ .mdb(已解出时);解出的目录层次 `<年>/<月>/` 由 R2 的子目录规则放行。
+    'scada_mdb': ({'.zip', '.mdb'}, '年度归档 <年>年.zip(内层 <年>/<月>/<年-月-类>.zip → .mdb)'),
+}
+DATE_RE = re.compile(r'(20\d{2})[-_年]?(0[1-9]|1[0-2])')
+
+
+def station_dir(farm: str | None = None) -> pathlib.Path:
+    """当前场的原始件目录: data/raw/<场站目录名>。"""
+    from src.windscada import config as C
+    try:
+        d = C.raw_station_dir(farm)
+    except BaseException:
+        d = P.RAW_ROOT / (farm or P.farm())
+    return pathlib.Path(d)
+
+
+def sizes_map(base: pathlib.Path) -> dict:
+    """{相对路径: 字节数} —— 只算文件。"""
+    out = {}
+    if base.is_dir():
+        for f in base.rglob('*'):
+            if f.is_file():
+                out[f.relative_to(base).as_posix()] = f.stat().st_size
+    return out
+
+
+def check_tree(st: pathlib.Path, deep=False) -> tuple[list, list, dict]:
+    """规则体检 → (问题列表[(level, 项, 说明, rc)], 提示列表, 统计)。"""
+    res, notes = [], []
+    stat = dict(exists=st.is_dir(), files=0, bytes=0, subdirs={}, units=[])
+
+    # R1 data/raw 顶层
+    if P.RAW_ROOT.is_dir():
+        for p in sorted(P.RAW_ROOT.iterdir()):
+            if p.is_dir():
+                if p.name != st.name and p.name not in TOP_OK_DIRS:
+                    res.append(('!', f'data/raw/{p.name}', f'顶层目录未在约定内 (场站目录应为 {st.name}/; '
+                                                           f'共享资料放 {TOP_OK_DIRS})', RC_STRUCT))
+            elif p.suffix.lower() not in TOP_OK_FILES:
+                res.append(('!', f'data/raw/{p.name}', 'data/raw 顶层只放场站目录与说明文件; '
+                                                       '源件要放进 <场站>/<源类>/', RC_STRUCT))
+    if not st.is_dir():
+        notes.append(('i', f'{P.rel(st)}', '尚未放置输入数据 —— 页面会显示"无产物/无数据" (属预期); '
+                                           '放置见 docs/输入数据放置指导_v0.1.md', RC_OK))
+        return res, notes, stat
+
+    # R2 场站内子目录 + R3 各源类
+    from src.windscada import config as C
+    allowed = set(C.STATION_SUBDIRS)
+    for d in sorted(st.iterdir()):
+        if d.is_file():
+            if d.suffix.lower() in ZIP_MOD:
+                res.append(('!', P.rel(d), '压缩包直接放在场站目录下 —— 先解压再按源类落位', RC_NOTE))
+            else:
+                notes.append(('i', P.rel(d), '场站目录下的散装文件 (多半是现场随手拷的; 建议归入对应源类子目录)', RC_OK))
+            continue
+        if d.name not in allowed:
+            res.append(('!', f'{P.rel(d)}', f'未约定的子目录 (约定: {sorted(allowed)})'
+                                           ' —— 常见成因: 解压时多套了一层包名', RC_STRUCT))
+            continue
+        files = [f for f in d.rglob('*') if f.is_file()]
+        nbytes = sum(f.stat().st_size for f in files)
+        stat['subdirs'][d.name] = dict(files=len(files), bytes=nbytes)
+        stat['files'] += len(files)
+        stat['bytes'] += nbytes
+        exts, bad = {}, []
+        for f in files:
+            e = f.suffix.lower()
+            exts[e] = exts.get(e, 0) + 1
+            want = EXPECT.get(d.name, (None, ''))[0]
+            # ★2026-09-19: Access 的**锁文件/临时文件**(.laccdb/.ldb/~$*) 不是源数据 ——
+            #   读/写 .mdb 时 Access 会在同目录留下它(实测: MDB 重建期间 scada_mdb 下出现 .laccdb),
+            #   按"后缀不在约定内"报结构问题会把人引到错的地方。忽略并如实注明。
+            if e in ACCESS_TMP_SUFFIX or f.name.startswith('~$'):
+                notes.append(('i', P.rel(f), 'Access 锁/临时文件 (读写 .mdb 时产生), 不算源件', RC_OK))
+                continue
+            if want and e not in want and e not in ZIP_MOD:
+                bad.append(f)
+        if bad:
+            sample = ', '.join(sorted({f.suffix.lower() for f in bad})[:4])
+            res.append(('!', P.rel(d), f'{len(bad)} 个文件后缀不在该源类的约定内 ({sample}); '
+                                       f'约定: {sorted(EXPECT[d.name][0])}', RC_STRUCT))
+        if not files:
+            notes.append(('i', P.rel(d), '目录是空的 (放了数据才有用)', RC_OK))
+        # 各类专项
+        if d.name in ('scada_10min', 'scada_1min'):
+            names = {f.name for f in files}
+            nested = [f for f in files if len(f.relative_to(d).parts) > 1]
+            if nested:
+                res.append(('!', P.rel(d), f'{len(nested)} 个文件在子目录里 —— 摄入按 <源类>/<机组>.csv 平铺读, '
+                                           f'多一层就读不到', RC_STRUCT))
+            try:
+                want = set(C.farm(None)['turbines'])
+                n_want = len(want)
+            except BaseException:
+                want, n_want = set(), 0
+            got = {f.stem for f in files if f.parent == d}
+            # ★ 两类命名都对: scada_10min 用显示机组号 (WTG01.csv); scada_1min 的现场导出用的是
+            #   **内部台号** (01E.csv…37B.csv, 见 place_raw_data.py 的映射说明) —— 后者按"台数一致"判完整,
+            #   不再按名字判 (2026-09-17 实测: 按 WTG 名字判会把 38/38 齐全的 1min 目录误报成"缺 38 台")。
+            # ★2026-09-20 用户令「把新月份的 10min 件吃进来」: 同台**补充件**(`WTG01-B2.csv`)现在由
+            #   取数层一并读入(按列名对齐 + 时间戳去重)⇒ 台号判定要按"主件名"归一, 否则 38 个补充件
+            #   会被报成"文件名不是本场机组"(假告警), 而它们恰恰是要吃的数据。
+            if d.name == 'scada_10min':
+                stem_of = lambda s: s.split('-')[0].split('_')[0]      # noqa: E731  WTG01-B2 → WTG01
+                got_main = {stem_of(s) for s in got}
+                extra_main = sorted({s for s in got if stem_of(s) not in want} if want else set())
+                miss = sorted(want - got_main) if want else []
+                sup = sorted(s for s in got if s != stem_of(s))
+                stat['units'].append((d.name, len(got_main)))
+                if miss:
+                    res.append(('!', P.rel(d), f'缺 {len(miss)} 台机组的数据: {miss[:6]}'
+                                               f'{" …" if len(miss) > 6 else ""} (页面按台取数, 缺台就是空白)', RC_GAP))
+                if extra_main:
+                    res.append(('!', P.rel(d), f'{len(extra_main)} 个文件名不是本场机组: {extra_main[:6]}', RC_STRUCT))
+                if sup:
+                    res.append(('i', P.rel(d), f'{len(sup)} 个同台补充件将被一并读入'
+                                               f'(<台号>-*.csv, 如 {sup[:3]}): 按列名对齐 + 时间戳去重, 主件优先', 0))
+            else:
+                stat['units'].append((d.name, len(got)))
+                if n_want and len(got) != n_want:
+                    res.append(('!', P.rel(d), f'台数不符: 现有 {len(got)} 个 <台号>.csv, 场配置是 {n_want} 台'
+                                               f' (1min 导出用内部台号 01E/37B 这类, 按台数判完整)', RC_GAP))
+            # R6 时间空洞 (文件名带年月时才判)
+            months = sorted({m.group(0) for m in (DATE_RE.search(f.stem) for f in files) if m})
+            if months:
+                stat['months'] = months
+        if d.name == '油样报告':
+            flat = [f for f in files if len(f.relative_to(d).parts) < 2]
+            if flat:
+                res.append(('!', P.rel(d), f'{len(flat)} 个 PDF 直接躺在源类目录下 —— 约定是 <台号>/<部件>/*.pdf '
+                                           f'(维护页按此分组)', RC_STRUCT))
+        if d.name == 'windcms':
+            js = [f for f in files if f.suffix.lower() == '.json']
+            dec = [f for f in js if f.name.endswith('_decode.json')]           # TCM 导出本体
+            if not dec:
+                res.append(('!', P.rel(d), '没有 *_decode.json (CMS 原始导出) —— 振动侧就没有可重算的源件', RC_GAP))
+            if deep and dec:
+                # 抽样核对"摄入脚本真正要的结构" —— 依据 scripts/rudong_tcm_index.py:
+                #   doc['body']['body'][<ISO时间戳>] = [ {Record: {...}}, … ], 机组取 Record.Location / Record.LocationName
+                # (顶层还有 method/controller/buildId 等信封字段, 别拿它们当判据 —— 2026-09-17 实测踩过这个坑)
+                import random as _rnd
+                _rnd.seed(0)
+                sample = _rnd.sample(dec, min(12, len(dec)))
+                ok_n, bad_n, shapes = 0, 0, {}
+                for f in sample:
+                    try:
+                        o = json.loads(f.read_text(encoding='utf-8', errors='replace'))
+                        body = ((o or {}).get('body') or {}).get('body') if isinstance(o, dict) else None
+                        if not isinstance(body, dict) or not body:
+                            shapes[str(sorted(o)[:4]) if isinstance(o, dict) else type(o).__name__] = \
+                                shapes.get(str(sorted(o)[:4]) if isinstance(o, dict) else type(o).__name__, 0) + 1
+                            bad_n += 1
+                            continue
+                        recs = next((v for v in body.values() if isinstance(v, list) and v), None)
+                        keys = sorted((recs[0] or {}).get('Record', {}) or {}) if recs else []
+                        if recs and (('Location' in keys) or ('LocationName' in keys)):
+                            ok_n += 1
+                        else:
+                            shapes[f'Record 缺 Location/LocationName: {keys[:6]}'] = \
+                                shapes.get(f'Record 缺 Location/LocationName: {keys[:6]}', 0) + 1
+                            bad_n += 1
+                    except Exception as e:
+                        shapes[f'解析失败 {type(e).__name__}'] = shapes.get(f'解析失败 {type(e).__name__}', 0) + 1
+                        bad_n += 1
+                stat['tcm_sample'] = dict(ok=ok_n, bad=bad_n, shapes=shapes)
+                if bad_n:
+                    res.append(('!', P.rel(d), f'TCM 抽样 {len(sample)} 件里 {bad_n} 件结构对不上摄入脚本的取法 '
+                                               f'(body.body[时间戳]=[{{Record:…}}], 机组取 Record.Location/LocationName): '
+                                               f'{list(shapes.items())[:3]}', RC_STRUCT))
+                else:
+                    stat['tcm_ok'] = True
+        if d.name == 'm5_cms_tcm':
+            hand = [f for f in files if f.name in ('handoff_vibration_v2.json', 'component_history.json')]
+            if not hand:
+                notes.append(('i', P.rel(d), '缺 handoff 正本 (handoff_vibration_v2.json / component_history.json) —— '
+                                             '融合面判级与 /cms/ 只能用随包快照 (docs §7 的已知缺口)', RC_OK))
+
+    # R4 可读性抽样 (CSV 编码/表头)
+    if deep:
+        for name in ('scada_10min', 'scada_1min'):
+            d = st / name
+            if d.is_dir():
+                f = next(iter(sorted(x for x in d.glob('*.csv'))), None)
+                if f:
+                    head = None
+                    for enc in ('utf-8-sig', 'gbk'):
+                        try:
+                            with f.open(encoding=enc, errors='strict') as fh:
+                                head = fh.readline().strip()
+                            stat[f'{name}_enc'] = enc
+                            break
+                        except Exception:
+                            continue
+                    if not head:
+                        res.append(('!', P.rel(f), 'CSV 表头读不出来 (utf-8/gbk 都不行)', RC_STRUCT))
+                    elif len(head.split(',')) < 5:
+                        res.append(('!', P.rel(f), f'表头列数异常 ({len(head.split(","))} 列): {head[:60]}', RC_STRUCT))
+
+    # R7 体量
+    if stat['bytes'] > BIG_DIR:
+        notes.append(('?', P.rel(st), f'源件合计 {stat["bytes"]/2**30:.1f} GB —— 摄入与重算会很慢, 建议分批', RC_NOTE))
+    return res, notes, stat
+
+
+def increment_findings(plan: dict, src_name: str = '') -> list:
+    """把增量三清单里的**冲突**变成一条可拦人的问题 (供本模块与 place_raw_data 共用)。"""
+    out = []
+    if plan.get('clash'):
+        c = plan['clash'][0]
+        out.append(('!', f'--src {src_name}'.strip(), f'{len(plan["clash"])} 件**同名不同大小**: 直接放会覆盖已摄入的源件 '
+                                                        f'(示例 {c["path"]}: 现有 {c.get("now", 0):,} B → 源包 '
+                                                        f'{c["size"]:,} B) —— 先确认哪一份是对的, 再决定 --force 覆盖还是改名并存',
+                    RC_CLASH))
+    return out
+
+
+def increment_src(src: pathlib.Path, st: pathlib.Path) -> tuple[list, dict]:
+    """R5 增量三清单: 遍历源包 zip 成员/散件, 算出它们会落到哪, 与现有树对比。"""
+    res, plan = [], dict(new=[], same=[], clash=[], unmapped=[])
+    import importlib.util
+    spec = importlib.util.spec_from_file_location('_place', ROOT / 'scripts' / 'place_raw_data.py')
+    place = importlib.util.module_from_spec(spec)
+    try:
+        spec.loader.exec_module(place)
+    except SystemExit:
+        pass
+    rules = list(getattr(place, 'RULES', [])) + list(getattr(place, 'RULES_MECH', [])) + list(getattr(place, 'RULES_VIB', []))
+    loose = list(getattr(place, 'RULES_VIB_LOOSE', []))
+    have = sizes_map(st)
+    have_root = sizes_map(P.RAW_ROOT)
+
+    def classify(rel_path: str, size: int, base: pathlib.Path):
+        target = base / rel_path
+        cur = have.get(rel_path) if base == st else have_root.get(rel_path)
+        item = dict(path=rel_path, size=size)
+        if cur is None:
+            plan['new'].append(item)
+        elif cur == size:
+            plan['same'].append(item)
+        else:
+            item['now'] = cur
+            plan['clash'].append(item)
+
+    for zname, prefix, target, strip, root_kind in rules:
+        zp = src / zname if src.is_dir() else None
+        if not zp or not zp.is_file():
+            continue
+        base = st if root_kind == 'station' else P.RAW_ROOT
+        try:
+            with zipfile.ZipFile(zp) as z:
+                for info in z.infolist():
+                    if info.is_dir():
+                        continue
+                    nm = place.gbk_name(info)
+                    if prefix and not nm.startswith(prefix):
+                        continue
+                    tail = nm[len(prefix):] if prefix else nm
+                    if strip:
+                        tail = tail.split('/', 1)[1] if '/' in tail else tail
+                    rel = f'{target}/{tail}'
+                    if prefix and nm == prefix.rstrip('/'):
+                        continue
+                    classify(rel, info.file_size, base)
+        except zipfile.BadZipFile:
+            res.append(('!', zp.name, '不是合法 zip (现场包常被截断; 重新拷一份)', RC_STRUCT))
+    for fn, target in loose:
+        fp = src / fn
+        if fp.is_file():
+            classify(f'{target}/{fn}', fp.stat().st_size, st)
+    return res, plan
+
+
+def guidance(plan: dict, stat: dict) -> list[str]:
+    """R8 放置指导: 按新增源类给出该跑的重算命令与预期变化。"""
+    out = []
+    new = [x['path'] for x in plan.get('new', [])]
+    kinds = {p.split('/', 1)[0] for p in new}
+    if not new:
+        out.append('没有新增源件: 不必重算 (若刚换过数据, 用 scripts/rebuild_all.py --dry-run 看计划)')
+        return out
+    out.append(f'新增 {len(new)} 件, 涉及源类: {sorted(kinds)}')
+    if 'scada_10min' in kinds or 'scada_1min' in kinds:
+        out.append('· SCADA 侧变了 → `python scripts/rebuild_all.py` (含 10 个构建器, 约 15 分钟); '
+                   '台账/报警/油样一起换时同一条命令即可')
+    if kinds & {'故障报警', '风机故障记录', '油样报告'}:
+        out.append('· 台账类变了 → `python scripts/rebuild_all.py --skip-scada` (约 2 分钟)')
+    if kinds & {'windcms', 'm5_cms_tcm'}:
+        out.append('· 振动侧变了 → 重算链第 ④b 步会自动摄入 (`scripts/vib_raw_build.py`); '
+                   '注意它只做索引/谱, 不会重生成报告 (本包缺六层链, 见 docs §7)')
+    out.append('· 放完先自检: `python scripts/raw_data_check.py --deep` → 再看 `/detail/` 左栏「系统维护」两屏')
+    out.append('· 重算后核对锚点: 报警 39211 · 工单 5876 · 油样 404 · temp_monthly 19494 · 本体 9702 对象')
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--src', default=None, help='现场数据包目录 (给了就做增量体检: 新增/相同/冲突三清单)')
+    ap.add_argument('--deep', action='store_true', help='加可读性抽样 (CSV 编码/表头, TCM json 字段)')
+    ap.add_argument('--json', action='store_true')
+    ap.add_argument('--write-doc', action='store_true', help='把结论写进 docs/输入数据放置指导_v0.1.md')
+    a = ap.parse_args()
+
+    st = station_dir(a.farm)
+    res, notes, stat = check_tree(st, deep=a.deep)
+    plan = {}
+    if a.src:
+        src = pathlib.Path(a.src)
+        if not src.is_dir():
+            print(f'[X] --src 不是目录: {src}')
+            return RC_STRUCT
+        r2, plan = increment_src(src, st)
+        res += r2 + increment_findings(plan, src.name)
+    guide = guidance(plan, stat)
+
+    if a.json:
+        print(json.dumps(dict(station=P.rel(st), results=[dict(level=x[0], item=x[1], note=x[2], rc=x[3]) for x in res],
+                              notes=[dict(level=x[0], item=x[1], note=x[2]) for x in notes], stat=stat, plan=plan,
+                              guidance=guide), ensure_ascii=False, indent=1, default=str))
+    else:
+        print(f'== 输入数据放置体检 · {P.rel(st)} ==')
+        if stat.get('exists'):
+            print(f'   {stat["files"]:,} 件 / {stat["bytes"]/2**30:.2f} GB')
+            for k, v in sorted(stat['subdirs'].items()):
+                print(f'     {k:14s} {v["files"]:7,d} 件  {v["bytes"]/2**20:9.1f} MB')
+        for level, item, note, rc in res:
+            print(f'   [{level}] {item}: {note}')
+        seen = set()
+        for level, item, note, rc in notes:
+            if note in seen:
+                continue
+            seen.add(note)
+            print(f'   [{level}] {item}: {note}')
+        if plan:
+            print('\n== 增量放置三清单 (源包 → data/raw) ==')
+            print(f'   新增 {len(plan["new"]):,} 件 (将拷入) · 相同 {len(plan["same"]):,} 件 (同尺寸, 跳过) · '
+                  f'冲突 {len(plan["clash"]):,} 件 (同名不同大小, **需人确认**)')
+            for k, label in (('new', '新增'), ('clash', '冲突')):
+                for x in plan[k][:5]:
+                    extra = f' (现有 {x.get("now"):,} B)' if k == 'clash' else ''
+                    print(f'     [{label}] {x["path"]}  {x["size"]:,} B{extra}')
+                if len(plan[k]) > 5:
+                    print(f'     … 另有 {len(plan[k]) - 5} 件{label}')
+        print('\n== 放置指导 ==')
+        for g in guide:
+            print(f'   {g}')
+    rc_map = {x[3] for x in res if x[0] in ('X', '!') and x[3]}
+    rc = max(rc_map) if rc_map else (RC_NOTE if notes else RC_OK)
+    if not a.json:
+        print(f'\n结论: {"合规" if rc == 0 else "见上"}; 退出码 {rc}')
+    if a.write_doc:
+        write_doc(res, notes, stat, plan, guide)
+    return rc
+
+
+DOC = P.ROOT / 'docs' / '输入数据放置指导_v0.1.md'
+BEGIN, END = '<!-- RAWCHECK:BEGIN -->', '<!-- RAWCHECK:END -->'
+
+
+def write_doc(res, notes, stat, plan, guide) -> None:
+    body = [BEGIN, '### 体检结论(自动生成,勿手改)', '',
+            f'· 源件目录 `{P.rel(station_dir())}`:{stat.get("files", 0):,} 件 / {stat.get("bytes", 0)/2**30:.2f} GB']
+    for k, v in sorted((stat.get('subdirs') or {}).items()):
+        body.append(f'  - `{k}/`:{v["files"]:,} 件 / {v["bytes"]/2**20:.1f} MB')
+    if res:
+        body += ['', '| 级别 | 项 | 说明 |', '|---|---|---|']
+        body += [f'| `{x[0]}` | `{x[1]}` | {x[2]} |' for x in res[:40]]
+    else:
+        body += ['', '· 结构与命名检查:**无不一致**(提示见下)']
+    for x in notes[:12]:
+        body.append(f'· `{x[0]}` {x[1]}:{x[2]}')
+    if plan:
+        body += ['', f'· 增量(源包 → data/raw):新增 {len(plan["new"]):,} · 相同 {len(plan["same"]):,} · '
+                     f'冲突 {len(plan["clash"]):,}']
+    body += ['', '**放置后该做什么**:'] + [f'· {g}' for g in guide] + ['', END]
+    block = '\n'.join(body)
+    if DOC.is_file():
+        t = DOC.read_text(encoding='utf-8')
+        if BEGIN in t and END in t:
+            t = re.sub(re.escape(BEGIN) + r'.*?' + re.escape(END), lambda m: block, t, flags=re.S)
+        else:
+            t = t.rstrip() + '\n\n' + block + '\n'
+    else:
+        t = '# 输入数据放置指导\n\n' + block + '\n'
+    DOC.write_text(t, encoding='utf-8')
+    print(f'   已写入 {P.rel(DOC)}')
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 463 - 0
app_ETL/app_ETL_guanlan/builders/raw_scan.py

@@ -0,0 +1,463 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""输入数据自动扫描识别 —— 发现 `<安装目录>/data/raw` 下的**新增/变化**,并给出"该重跑哪几步"。
+
+## 为什么要有它(用户令 2026-09-19)
+
+用户令:「对 `<安装目录>/data/raw` 目录下的接入数据处理,增加自动扫描识别机制,以能发现新增数据,并纳入重算。」
+
+在此之前,"放了新数据要不要重算"全靠人记:`place_raw_data.py` 只管把现场包落位,`raw_data_check.py`
+只管结构与命名对不对,`rebuild_all.py` 无脑全跑(或按人给的 `--skip-*` 跳过)——
+**没有任何一处回答"这回新来了什么、因此必须重跑哪几步"**。本脚本补这一环:
+
+    逐族指纹 (件数/体积/最新时间/清单摘要) → 与上次快照比 → 报【新增/变化】 → 映射到链上步骤
+
+## 三档用法
+
+    python scripts/raw_scan.py                     # 只报当前状态(不写快照)
+    python scripts/raw_scan.py --write             # 写/更新快照 outputs/<场>/_raw_scan.json
+    python scripts/raw_scan.py --check             # 与快照比: rc=0 无变化 · rc=4 有新增/变化 · rc=5 没有快照
+    python scripts/raw_scan.py --check --write      # 比完就把新快照记下(重算链第 ①b 步就是这条)
+    python scripts/raw_scan.py --plan               # 只打印"有变化时该跑哪些步"
+    python scripts/raw_scan.py --json               # 机器可读(rebuild_all 用它做"跳过了该跑的步"提醒)
+    python scripts/raw_scan.py --deep               # 变化判定加上**内容**哈希(默认只比 名字/大小/时间)
+    python scripts/raw_scan.py --root <别的 raw>    # 换个根扫(自测/多场)
+    python scripts/raw_scan.py --selftest           # 族表覆盖自检(与族表/链上步骤对账,防两处漂移)
+
+## 口径与纪律
+
+· **指纹**默认 = 每族逐件 `相对路径|字节数|mtime(ns)` 排序后串起来取 sha1 —— 快、且"加了一件/改了一件/
+  删了一件"都会变;`--deep` 再叠一层内容 sha1(小文件才做,避免拿 153 GB 的 CMS 导出拼哈希)。
+· **未归类**:`data/raw/<场>/` 下出现族表里没有的子目录(新来的数据总会有第一次)→ 如实列进
+  `unclassified` 并给出"没有消费者/需要认领"的话,**不猜**它是哪一族。
+· 这里只**发现与建议**,不改数据、不搬文件、不自动重算(要不要跑由 `rebuild_all.py` 或运维决定;
+  自动重算的开关见 `configs/serve.json` 的 `raw_watch`)。rc=4 是"有变化",不是失败。
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import hashlib
+import json
+import os
+import pathlib
+import re
+import sys
+import time
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+try:                                     # GBK 控制台/日志重定向不再因 '²' 这类字符抛 UnicodeEncodeError
+    sys.stdout.reconfigure(errors='replace')
+except Exception:
+    pass
+
+SNAP_NAME = '_raw_scan.json'
+
+# ── 族表:raw 子目录 → (计件 glob, 链上步骤, 消费者说明) ──────────────────────────────
+# ★步骤标签与 `scripts/rebuild_all.py` 的计划名一一对应(人看到"③ SCADA 侧"就知道去跑哪一步)。
+FAMILIES: tuple[tuple[str, tuple[str, ...], tuple[str, ...], str], ...] = (
+    ('故障报警', ('*.xls', '*.xlsx', '*.csv'), ('② 三门台账',),
+     '报警事件导出 → windscada/alarms.parquet(报警集中/停机账)。缺 TimeOn/Alarmcode 的统计报表件会被摄入器跳过'),
+    ('风机故障记录', ('*.xls', '*.xlsx', '*.csv'), ('② 三门台账',),
+     '检修工单台账 → windscada/workorders.parquet(重复检修/停机损失)'),
+    ('油样报告', ('*.pdf',), ('② 三门台账',),
+     '油液化验报告 → windscada/oil_samples_index.parquet(油液面)'),
+    ('scada_10min', ('*.csv',), ('③ SCADA 侧 10 个构建器', '③b SCADA 10min 窄仓 (按窗重算底座)',
+                                '④ 月度派生件', '④c 变桨面', '⑤b 事实契约 claim'),
+     '10min 导出 → 功率曲线/损失/温度/停机/偏航/液压等 + 变桨面日粒度(温度/性能/变桨面)+ 窄仓(按窗重算底座)'),
+    ('scada_1min', ('*.csv',), ('④c 变桨面',),
+     '1min 导出(内部台号 01E…38B)→ 变桨面零位三口径与越线计数'),
+    ('scada_mdb', ('*.mdb', '*.zip'), ('③ SCADA 侧 10 个构建器', '④ 月度派生件', '④c 变桨面'),
+     '现场年度归档 Access 库(按月的 10min/1min)—— CSV 缺失时由 scada_source 回落到它'),
+    ('windcms', ('*_decode.json',), ('④b 振动侧摄入 + CMS 报告 + 标量 z', '④d 振动在升/换件闭环', '④e 三层基线'),
+     'Brande TCM 导出 → 窗索引/谱库/CMS 报告/逐台页 + 在升闭环 + 三层基线(振动面)'),
+    ('m5_cms_tcm', ('*.json', '*.docx', '*.pdf', '*.md'), ('④b 振动侧摄入 + CMS 报告 + 标量 z',),
+     '现场给的正本(handoff_vibration_v2.json / component_history.json)与厂家报告——正本在位时优先于观澜自算'),
+)
+# 机理层资料不在场站目录下(A2 约定): data/raw/西门子4.0技术资料
+TECH_DIR, TECH_PATTERNS = '西门子4.0技术资料', ('*',)   # 该族的消费者(kb_ingest)读整棵树 ⇒ 白名单就是"全部"
+TECH_STEPS = ('⑦ 本体: 码表/手册/文档',)
+TECH_NOTE = '厂商技术资料(故障处理手册/维护 WI/图纸/对译表)→ 本体对象库/检索索引/实机参数表'
+# raw 根下**随包发的说明件**(不是数据): 别把它们报成"未归类"。口径: 只有名字在白名单里且不参与摄入。
+IGNORE_FILES = {'README_把原始数据放这里.txt'}
+# ★2026-09-21 用户令「按月 10min 提取件就放在 <场站>/ 下」: 它是**给人核对/交接的导出件**, 不是摄入源
+#   (摄入走 `<台号>.csv` + 同台补充件)。不加这条, 每次扫描都会把它报成"未归类 + 一处变化" ⇒
+#   `--auto` / raw_watch 会永远以为"来了新数据", 每轮都触发一次全量重算。
+IGNORE_PATTERNS: tuple[tuple[str, str], ...] = (
+    (r'^scada_\d+min_\d{6}\.csv$', '按月提取/核对件(非摄入源;摄入走 <台号>.csv + 同台补充件)'),
+)
+
+# ── 消费者取数口径(用户令 2026-09-20 步骤 C:让"放了没人读"当场可见)────────────────────────
+# ★为什么必须有它:族表原来只按**扩展名白名单**计件 —— 往 `scada_10min/` 里放 `WTG01-B2.csv`(856 列、
+#   2026-08 数据、与 `<台号>.csv` 命名不同)时,它命中 `*.csv` ⇒ 既算"进了摄入",又不会出现在任何
+#   "没人读"的提示里;而取数层只认 `<台号>.csv` ⇒ **这批数据从来没被读过**,重算后时间窗自然不变。
+#   本表按**消费者真实取数口径**再判一次,并把结果分成两级:
+#     · 缺口级 (gate):看着是数据(扩展名在 data_exts 里) 但没有任何消费者会读 ⇒ **计入变化(rc=4) + 大声报**
+#     · 信息级 (info):附件/压缩包/图片/厂商软件这类,本来就不该被摄入 ⇒ 只列信息,不计变化
+CONSUME: dict[str, dict] = {
+    '故障报警': dict(consume=('*.xls',), data_exts=('.xls', '.xlsx', '.csv'),
+                     how='摄入器读 *.xls 的 XML 事件导出(TimeOn/Alarmcode)'),
+    '风机故障记录': dict(consume=('*.xls', '*.xlsx'), data_exts=('.xls', '.xlsx', '.csv'),
+                         how='台账读取 *.xls*(rar/jpg 是附件,不摄入)'),
+    '油样报告': dict(consume=('*.pdf',), data_exts=('.pdf',), how='油样摄取读 *.pdf'),
+    'scada_10min': dict(consume=('WTG??.csv', 'WTG??-*.csv', 'WTG??_*.csv'), data_exts=('.csv', '.mdb', '.zip'),
+                        how='取数层读 `<台号>.csv` **+ 同台补充件** `<台号>-*.csv`(2026-09-20 起: 按列名对齐、'
+                            '按时间戳去重、主件优先)'),
+    'scada_1min': dict(consume=('???.csv', '???-*.csv', '???_*.csv'), data_exts=('.csv', '.mdb', '.zip'),
+                       how='取数层按内部台号取 `<台号>.csv`(01E.csv)+ 同台补充件'),
+    'scada_mdb': dict(consume=('*-10min.mdb', '*-1min.mdb', '*.zip'), data_exts=('.mdb', '.zip'),
+                      how='取数层读取按月的库(2026-08-10min.mdb);CSV 缺失时才回落',
+                      # ★2026-09-21: 现场按**类目**交付的月度通道组库(7月-8月交付 zip 里的
+                      #   `2026-0X-<类>.mdb`)。它是 `scada_10min` 同台补充件的**上游**:取数层不直接读它,
+                      #   要先转成 `scada_10min/<台号>-<YYYYMM>.csv`。故列为"上游归档"(信息级),
+                      #   既不当缺口(不算变化/不触发重算),也不假装被摄入。
+                      upstream=tuple(f'*-{c}.mdb' for c in
+                                     ('cnt', 'din', 'dot', 'flg', 'grd', 'int', 'prs', 'scd', 'std', 'sum', 'tmp', 'tur')),
+                      upstream_how='现场月度**通道组**归档(上游件):先转成同台 10min 补充件 '
+                                   '`scada_10min/<台号>-<YYYYMM>.csv` 才被消费(口径见 docs/输入数据放置指导_v0.1.md §4.1)'),
+    'windcms': dict(consume=('*_decode.json', '*.pdf', '*.docx', '*.md', '*.json'),
+                    data_exts=('.json', '.zip', '.csv', '.mdb'),
+                    how='振动摄入读 *_decode.json;厂家报告 PDF 由报告转录侧读'),
+    'm5_cms_tcm': dict(consume=('*.json', '*.docx', '*.pdf', '*.md'), data_exts=('.json', '.zip'),
+                       how='现场正本 handoff/component_history 与厂家报告'),
+    TECH_DIR: dict(consume=('*',), data_exts=(), how='本体读整棵树(白名单=全部)'),
+}
+
+
+def _digest(entries: list[tuple[str, int, int]], deep: bool, base: pathlib.Path, files: list[pathlib.Path],
+            deep_max: int, dirs: list[str] | None = None) -> str:
+    h = hashlib.sha1()
+    for rel, size, mt in entries:
+        h.update(f'{rel}|{size}|{mt}\n'.encode('utf-8', 'replace'))
+    for d in dirs or []:                       # 子目录清单也进指纹: "新建了空目录(数据还没落)"也能发现
+        h.update(f'D:{d}\n'.encode('utf-8', 'replace'))
+    if deep:
+        for p, (rel, size, _mt) in zip(files, entries):
+            if size > deep_max:
+                continue
+            try:
+                fh = hashlib.sha1()
+                with open(p, 'rb') as f:
+                    for blk in iter(lambda: f.read(1 << 20), b''):
+                        fh.update(blk)
+                h.update(rel.encode('utf-8', 'replace') + b'=' + fh.digest())
+            except OSError:
+                pass
+    return h.hexdigest()
+
+
+def fingerprint(d: pathlib.Path, patterns: tuple[str, ...], deep: bool = False, deep_max: int = 8 << 20) -> dict:
+    """一族目录的指纹。
+
+    ★口径 (用户令 2026-09-19/20「对 data/raw 目录(含嵌套子目录)下,文件的增减做到监听」):
+      · **递归**统计该族目录下**全部文件**(不限扩展名) —— 只按摄入白名单(patterns)统计的话,
+        往族目录里丢一个 `.zip`/`.txt` 就"看不见"了, 那不算监听; 白名单命中的另记 `matched`
+        (才是真进摄入的件数), 差额用 `unmatched` 列出来给人看;
+      · 指纹里**还含子目录清单**(只放路径名, 不放 mtime —— 目录 mtime 会随子文件变动而变, 那是噪声),
+        于是"新建了一个空目录(数据还没落进去)"也动指纹。
+    """
+    all_files: list[pathlib.Path] = []
+    dirs: list[str] = []
+    if d.is_dir():
+        for p in d.rglob('*'):
+            if p.is_file():
+                all_files.append(p)
+            elif p.is_dir():
+                dirs.append(p.relative_to(d).as_posix())
+    all_files = sorted(set(all_files))
+    dirs.sort()
+    matched: list[pathlib.Path] = []
+    for pat in patterns:
+        matched += [p for p in d.rglob(pat) if p.is_file()]
+    mset = {p.relative_to(d).as_posix() for p in set(matched)}
+    entries: list[tuple[str, int, int]] = []
+    for p in all_files:
+        try:
+            st = p.stat()
+        except OSError:
+            continue
+        entries.append((p.relative_to(d).as_posix(), st.st_size, st.st_mtime_ns))
+    entries.sort()
+    newest = ''
+    if entries:
+        newest = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(max(e[2] for e in entries) / 1e9))
+    _unm = [e[0] for e in entries if e[0] not in mset]
+    # ── 消费者口径再判一次(步骤 C): 扩展名"像数据"但没人读 ⇒ 缺口; 其余 ⇒ 信息 ──
+    # ★判据必须落在"**全部文件里没被消费者读的**"上, 不能只看 unmatched(白名单没命中的那些) ——
+    #   2026-09-20 实逮: `WTG01-B2.csv` 是 `*.csv`,白名单命中了它(所以 unmatched 里没有它),
+    #   但取数层只认 `WTG01.csv` ⇒ 只看 unmatched 就永远发现不了这类"同名不同款"的静默漏读。
+    spec = CONSUME.get(d.name) or {}
+    cg = tuple(spec.get('consume') or ())
+    dex = tuple(spec.get('data_exts') or ())
+    consumed: set[str] = set()
+    for g in cg:
+        consumed |= {p.relative_to(d).as_posix() for p in d.rglob(g) if p.is_file()}
+    # ── 上游归档件(客户口径里"已知但不由取数层直接读"的): 归信息级, 不算缺口、不计变化 ──
+    upat = tuple(spec.get('upstream') or ())
+    upstream: set[str] = set()
+    for g in upat:
+        upstream |= {p.relative_to(d).as_posix() for p in d.rglob(g) if p.is_file()}
+    not_consumed = [r for r, _s, _m in entries if r not in consumed]
+    gaps = [r for r in not_consumed
+            if r not in upstream and pathlib.PurePosixPath(r).suffix.lower() in dex]
+    others = [r for r in not_consumed if r not in gaps]
+    return dict(files=len(entries), bytes=sum(e[1] for e in entries), newest=newest,
+                matched=len(mset), dirs=len(dirs), unmatched=_unm[:10], unmatched_n=len(_unm),
+                consumed=len(consumed), consume_how=spec.get('how', ''),
+                upstream=len(upstream), upstream_how=spec.get('upstream_how', ''),
+                gaps=gaps[:10], gaps_n=len(gaps), others=others[:10], others_n=len(others),
+                digest=_digest(entries, deep, d, all_files, deep_max, dirs),
+                top=[e[0] for e in sorted(entries, key=lambda x: -x[2])[:3]])
+
+
+def scan(raw_root: pathlib.Path, deep: bool = False, station_dir: pathlib.Path | None = None) -> dict:
+    """扫一个 raw 根:逐族指纹 + 别处不认识的子目录(未归类)。
+
+    `station_dir` 不给时按**场配置的辨识结果**定场站目录(`raw_station_dir()`,与摄入侧同一口径);
+    显式 `--root` 自测时可传入 `<root>/<场站名>` 之外的位置。
+    """
+    from src.windscada.config import raw_station_dir
+    station = pathlib.Path(station_dir) if station_dir else raw_station_dir()
+    fam: dict[str, dict] = {}
+    for name, pats, steps, note in FAMILIES:
+        fp = fingerprint(station / name, pats, deep)
+        fp.update(steps=list(steps), note=note, where=str((station / name)))
+        fam[name] = fp
+    fp = fingerprint(raw_root / TECH_DIR, TECH_PATTERNS, deep)
+    fp.update(steps=list(TECH_STEPS), note=TECH_NOTE, where=str(raw_root / TECH_DIR))
+    fam[TECH_DIR] = fp
+    known = {n for n, *_ in FAMILIES} | {TECH_DIR}
+    unclassified = []
+    # ★2026-09-22 可移植性消缺(check_portability --runtime 报 ERROR): 原先用
+    #   `str(p).startswith(k + os.sep)` 拼系统分隔符判"在不在已知族目录下" —— 一是 os.sep 让代码
+    #   带上平台特征(换机部署核查报 ERROR),二是前缀匹配会把 `scada_10min_extra/` 误认成
+    #   `scada_10min/` 的子件。改成 pathlib 的祖先判定,语义更准且与平台无关。
+    known_dirs = {station / n for n, *_ in FAMILIES} | {raw_root / TECH_DIR}
+    # ★未归类 = 任何**不在已知族目录里**的文件 (递归找, 不只看一层): raw 根下、场站目录下、以及
+    #   场站目录里嵌套的新子目录(例如新来了 `CMS_202701` 这种整包目录) 都跑不掉。
+    for p in (sorted(raw_root.rglob('*')) if raw_root.is_dir() else []):
+        if not p.is_file():
+            continue
+        if any(p == k or k in p.parents for k in known_dirs):
+            continue
+        if p.name in IGNORE_FILES:
+            continue                       # 随包的放置说明件: 不是数据, 不报"未归类"
+        if any(re.match(rx, p.name) for rx, _why in IGNORE_PATTERNS):
+            continue                       # 交付核对用的导出件(位置由用户指定): 非摄入源, 不报"未归类"
+        rel = p.relative_to(raw_root).as_posix()
+        unclassified.append(dict(dir=rel, files=1,
+                                 note='不在任何已知族的目录里 —— 没有消费者; 是新的输入族就得给它配摄入器'))
+    # 已知族目录内**消费者不会读**的件 (步骤 C): 两级 —— 缺口级(gaps, 扩展名像数据) 与 信息级(others)
+    stray, gaps, upstream = [], [], []
+    for name, fp in fam.items():
+        if fp.get('others_n'):
+            stray.append(dict(family=name, n=fp['others_n'], example=fp.get('others')[:5]))
+        if fp.get('gaps_n'):
+            gaps.append(dict(family=name, n=fp['gaps_n'], example=fp.get('gaps')[:8],
+                             how=fp.get('consume_how', '')))
+        if fp.get('upstream'):
+            upstream.append(dict(family=name, n=fp['upstream'], how=fp.get('upstream_how', '')))
+    # 场站目录下**新增的未知子目录**(整目录级; 其文件已在上面逐件登记, 这里给个汇总便于人读)
+    extra_dirs = []
+    if station.is_dir():
+        for sub in sorted(station.iterdir()):
+            if sub.is_dir() and sub.name not in known:
+                extra_dirs.append(dict(dir=f'{station.name}/{sub.name}',
+                                       files=sum(1 for p in sub.rglob('*') if p.is_file()),
+                                       note='场站目录下的新子目录(族表里没有)'))
+    return dict(at=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(raw_root), station=str(station),
+                deep=deep, families=fam, unclassified=unclassified + extra_dirs,
+                stray_in_families=stray, gaps=gaps, upstream=upstream)
+
+
+def diff(old: dict, new: dict) -> list[dict]:
+    """逐族比:新增/减少/内容变化(`files` 与 `digest` 分开说,便于人看懂)。"""
+    out = []
+    of = (old or {}).get('families') or {}
+    for name, cur in (new.get('families') or {}).items():
+        prev = of.get(name)
+        if not prev:
+            if cur['files']:
+                out.append(dict(family=name, kind='首次记录', files=cur['files'], steps=cur['steps'],
+                                note=cur['note'], newest=cur['newest']))
+            continue
+        d_files = cur['files'] - prev['files']
+        same_list = cur['digest'] == prev['digest']
+        if d_files == 0 and same_list:
+            continue
+        kind = ('新增 %+d 件' % d_files) if d_files else '内容变化(件数不变)'
+        _dd = cur.get('dirs', 0) - prev.get('dirs', 0)
+        if d_files == 0 and _dd:
+            kind = f'目录结构变化({_dd:+d} 个子目录)'
+        out.append(dict(family=name, kind=kind, files=cur['files'], delta=d_files,
+                        bytes_delta=cur['bytes'] - prev['bytes'], newest=cur['newest'],
+                        top=cur.get('top'), steps=cur['steps'], note=cur['note'],
+                        matched=cur.get('matched'), unmatched_n=cur.get('unmatched_n', 0)))
+    for u in new.get('unclassified') or []:
+        out.append(dict(family=u['dir'], kind='未归类(不在任何已知族的目录里)', files=u['files'],
+                        steps=[], note=u.get('note') or '没有已知消费者 ⇒ 先认领'))
+    # ★缺口级 (步骤 C, **计入变化**): 扩展名看着是数据、但按消费者口径没人会读 ⇒ 重算也不会纳入。
+    #   典型: scada_10min/ 里的 `WTG01-B2.csv`(856 列 / 2026-08 数据) —— 取数层只认 `WTG01.csv`。
+    for g in new.get('gaps') or []:
+        out.append(dict(family=f'{g["family"]}(数据没人读·缺口)', kind=f'{g["n"]} 件数据不会被摄入',
+                        files=g['n'], steps=[],
+                        note=f'消费者口径: {g["how"]} ⇒ 例: ' + '、'.join(g['example'])
+                             + ' —— 这些件**不会进任何产物**(重算也不会变); 要么按消费者口径改名/转换, '
+                               '要么给取数层加"同台多件"的合并口径'))
+    # ★附件类(信息级,**不计入变化**): 实测族目录里本来就有大量正常但不该被摄入的东西
+    #   (故障记录里的 .rar/.jpg、windcms 厂家报告 PDF、技术资料里的厂商软件 .exe/.lp …)。
+    #   它们增删本身已经反映在 files/dirs/digest 上 ⇒ 该报的变化照报, 这里不再重复判一次。
+    return out
+
+
+def _steps_of(changes: list[dict]) -> list[str]:
+    seen, out = set(), []
+    for c in changes:
+        for s in c.get('steps') or []:
+            if s not in seen:
+                seen.add(s)
+                out.append(s)
+    return out
+
+
+def snap_path(raw_root: pathlib.Path) -> pathlib.Path:
+    from src import paths as P
+    return P.out_root() / SNAP_NAME
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='输入数据自动扫描识别(发现新增 → 指明该重跑哪几步)')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--root', default=None, help='覆盖 data/raw 根(自测/多场)')
+    ap.add_argument('--write', action='store_true', help='把本次指纹写进快照')
+    ap.add_argument('--check', action='store_true', help='与快照比:rc=0 无变化 · 4 有新增/变化 · 5 无快照')
+    ap.add_argument('--plan', action='store_true', help='只打印"该跑哪些步"')
+    ap.add_argument('--json', action='store_true', help='输出机器可读 JSON')
+    ap.add_argument('--deep', action='store_true', help='变化判定叠内容哈希(小文件)')
+    ap.add_argument('--selftest', action='store_true', help='族表/步骤覆盖自检(防与族表、链上步骤漂移)')
+    a = ap.parse_args()
+
+    from src import paths as P
+    from src.windscada import config as C
+    if a.farm:
+        os.environ['WINDSCADA_FARM'] = a.farm            # 与其它脚本同一口径(场由环境变量选)
+    raw = pathlib.Path(a.root) if a.root else pathlib.Path(C.RAW_ROOT)
+    # --root 换成别的 raw 根时,场站目录按"根下唯一/同名"识别,避免拿本场辨识结果去别处找
+    st_dir = None
+    if a.root:
+        from src.windscada.config import station_scan
+        sc = station_scan(root=raw)
+        st_dir = (raw / sc['matched']) if sc.get('matched') else None
+    now = scan(raw, deep=a.deep, station_dir=st_dir)
+    sp = snap_path(raw)
+    old = None
+    if sp.is_file():
+        try:
+            old = json.loads(sp.read_text(encoding='utf-8'))
+        except Exception:
+            old = None
+    changes = diff(old, now)
+
+    if a.selftest:
+        return selftest()
+
+    if a.json:
+        print(json.dumps(dict(scan=now, changes=changes, snapshot=str(sp),
+                              steps=_steps_of(changes), has_snapshot=bool(old)), ensure_ascii=False, indent=1))
+        if a.write:
+            sp.parent.mkdir(parents=True, exist_ok=True)
+            sp.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
+        if a.check:
+            return 0 if (old and not changes) else (4 if old else 5)
+        return 0
+
+    print(f'输入数据扫描 · {now["root"]}  ({now["at"]}{" · deep" if a.deep else ""})')
+    tot_f = sum(f['files'] for f in now['families'].values())
+    tot_b = sum(f['bytes'] for f in now['families'].values())
+    print(f'  共 {len(now["families"])} 族 · {tot_f} 件 · {tot_b / 1024 / 1024 / 1024:.1f} GB')
+    for name, f in now['families'].items():
+        flag = ''
+        for c in changes:
+            if c['family'] == name:
+                flag = f"  ← {c['kind']}"
+        print(f'    {name:16s} {f["files"]:6d} 件  {f["bytes"] / 1024 / 1024:9.1f} MB  最新 {f["newest"] or "—"}{flag}')
+    if now['unclassified']:
+        print('  未归类(族表里没有,先认领):')
+        for u in now['unclassified']:
+            print(f'    {u["dir"]:16s} {u["files"]:6d} 件  {u.get("note", "没有已知消费者")}')
+    if now.get('gaps'):
+        print('  ★[缺口] 族目录里有**看着是数据、但没有任何消费者会读**的件(重算不会纳入它们):')
+        for g in now['gaps']:
+            print(f'    {g["family"]:16s} {g["n"]:5d} 件  例: ' + '、'.join(g['example'][:4])
+                  + (' …' if g['n'] > 4 else ''))
+            print(f'      消费者口径: {g["how"]}')
+    if now.get('upstream'):
+        print('  (信息,不计入"变化")族目录里的**上游归档件**(要先转换才被消费):')
+        for u in now['upstream']:
+            print(f'    {u["family"]:16s} {u["n"]:5d} 件')
+            print(f'      {u["how"]}')
+    if now.get('stray_in_families'):
+        print('  (信息,不计入"变化")族目录里**消费者不读的附件类**件:')
+        for s in now['stray_in_families']:
+            print(f'    {s["family"]:16s} {s["n"]:5d} 件  例: ' + '、'.join(s['example'][:3]))
+
+    if changes:
+        print(f'\n== 本次发现 {len(changes)} 处新增/变化 ==')
+        for c in changes:
+            print(f'  · {c["family"]}: {c["kind"]}'
+                  + (f'(体积 {c["bytes_delta"] / 1024 / 1024:+.1f} MB)' if c.get('bytes_delta') else '')
+                  + f'  最新 {c.get("newest") or "—"}')
+            if c.get('top'):
+                print(f'      最近落盘: ' + '、'.join(c['top']))
+            if c.get('steps'):
+                print(f'      该跑: ' + ' / '.join(c['steps']))
+        _real = [c for c in changes if '缺口' not in c['family']]
+        if _real:
+            print('\n  建议: 跑一次重算把新数据纳入产物 —— python scripts/rebuild_all.py'
+                  '(默认全跑;若你用了 --skip-*,请确认没跳过上面列出的步)')
+        if len(_real) < len(changes):
+            print('  ★缺口类变化**重算也纳入不了**(消费者根本不读这些件): 先按上面写的口径改名/转换, '
+                  '或给取数层加"同台多件"的合并口径(见 docs/输入数据放置指导_v0.1.md §4)')
+    else:
+        print('\n== 与上次快照一致,没有新数据 ==' if old else '\n== 还没有快照(跑 --write 记一次基线)==')
+
+    if a.write:
+        sp.parent.mkdir(parents=True, exist_ok=True)
+        sp.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
+        print(f'\n快照已更新 → {P.rel(sp)}')
+    if a.check:
+        if not old:
+            print(f'[?] 没有快照可比({P.rel(sp)})—— 先跑 --write 记基线; rc=5')
+            return 5
+        return 4 if changes else 0
+    return 0
+
+
+def selftest() -> int:
+    """自检:① 族表覆盖 `products_reverse_audit.FAMILIES` 里所有 raw 输入目录 ② 步骤名都能在链上找到。"""
+    import importlib.util
+    import re
+    spec = importlib.util.spec_from_file_location('_pra', ROOT / 'scripts' / 'products_reverse_audit.py')
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    inputs = {f['input'] for f in mod.FAMILIES if f.get('input')}
+    mine = {n for n, *_ in FAMILIES} | {TECH_DIR}
+    missing = sorted(inputs - mine)
+    chain = (ROOT / 'scripts' / 'rebuild_all.py').read_text(encoding='utf-8')
+    steps = {s for _n, _p, ss, _note in FAMILIES for s in ss} | set(TECH_STEPS)
+    bad_steps = sorted(s for s in steps if s.split()[0] not in chain)
+    print(f'族表覆盖: 反向呼应族表的 raw 输入 {len(inputs)} 个 · 本器覆盖 {len(mine)} 个 · 缺 {missing or "无"}')
+    print(f'步骤标签: {len(steps)} 个 · 在 rebuild_all 里找不到的 {bad_steps or "无"}')
+    ok = not missing and not bad_steps
+    print('[OK] 自检通过' if ok else '[X] 自检不过 —— 族表或步骤标签与链上漂移了')
+    return 0 if ok else 5
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 636 - 0
app_ETL/app_ETL_guanlan/builders/rebuild_all.py

@@ -0,0 +1,636 @@
+#!/usr/bin/env python3
+
+# -*- coding: utf-8 -*-
+
+r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算链按依赖顺序串起来。
+
+
+
+## 为什么要有它
+
+
+
+"从零重算"原本是六七条命令、顺序还不能错 (产物齐了本体层才跑得动, 本体层跑完才能拷随包件…),
+
+手工执行容易漏步、也说不清"到底重算了什么"。本脚本把顺序、依赖、验收锚点全写死在这里。
+
+
+
+## 顺序 (为什么是这个顺序)
+
+
+
+    ① 放数据      place_raw_data.py --scope full      (给了 --src 才跑; 同尺寸自动跳过)
+
+    ② 三门台账    rebuild_from_raw.py                 报警/工单/油样
+
+    ③ SCADA 侧    rebuild_from_raw.py --scada         10 个构建器, 逐台读 ~14 GB, 约 15 分钟
+
+    ④ 月度派生件  windscada_monthly_build.py          能从 raw 重算的月表(与随包件逐值对齐才落盘)
+
+    ④b 振动侧     vib_raw_build.py                     原始导出 → 窗索引/谱/逐台页/融合 z + CMS 报告
+
+    ④c 变桨面     pitch_face_build.py                  10min 液压/润滑/柱塞 + 1min 零位三口径 → pitch/**
+
+    ④d/④e 振动历史与基线 component_history_build.py / baseline_38_build.py  窗索引 → 在升闭环 + 三层基线
+
+    ⑤a 台账维护   products_restore_missing.py --refresh  账实相符(谁在位、谁有生成端); 只记账不搬件
+
+    ⑤ 反向呼应审计 products_reverse_audit.py --check   逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)
+
+       ↑ 必须在 ⑥ 之前: 本体层的 populate/chain_ingest 要吃这些 L1 产物
+
+    ⑥ 重启服务    guanlan.py stop/serve               chain_ingest 要从 /api/fleet 取链盘
+
+    ⑦ 本体层      kb_ingest → populate → chain_ingest → trend_ingest → retrieval → refresh_params
+
+    ⑧ 验收        ontology.audit + rebuild_from_raw --verify (可选) + 页面对照
+
+
+
+## 用法
+
+
+
+    python scripts/rebuild_all.py                      # 全套 (会跑 SCADA 那 15 分钟)
+
+    python scripts/rebuild_all.py --skip-scada         # 只换台账时的常用档
+
+    python scripts/rebuild_all.py --src <现场包目录>   # 连"放数据"一起
+
+    python scripts/rebuild_all.py --no-restart         # 不碰服务(自己管)
+
+    python scripts/rebuild_all.py --with-verify        # 末尾加等价验收(需随包基线在位)
+
+    python scripts/rebuild_all.py --dry-run            # 只打印计划
+
+"""
+
+from __future__ import annotations
+
+
+
+try:
+
+    from app_common.app_common_guanlan.api import install_root as _install_root
+
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+
+    from pathlib import Path as _P
+
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+
+
+import argparse
+
+import json
+
+import os
+
+import pathlib
+
+import subprocess
+
+import sys
+
+import time
+
+
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+
+PY = sys.executable
+
+
+
+
+
+def step_cmd(name, cmd, tolerate=(), note=''):
+
+    """tolerate: 允许的退出码 (不算失败); note: 该步非零退出时打给人看的解释(按步, 别用一句通用话糊过去)。"""
+
+    return (name, cmd, tuple(tolerate), note)
+
+
+
+
+
+def _scan_plan() -> tuple[list, str]:
+
+    """跑一次输入数据扫描(`--json --check`)→ (该跑的步骤列表, 人读摘要)。扫不动就返回 ([], 原因)。
+
+
+
+    为什么要它 (用户令 2026-09-19「增加自动扫描识别机制, 以能发现新增数据, 并纳入重算」):
+
+    `--auto` 靠它决定"人给的 --skip-* 该不该让路" —— 新数据落在被跳过的族里时自动取消跳过。
+
+    """
+
+    try:
+
+        # ★必须显式给子进程 UTF-8 并按其解码 (2026-09-19 实逮: 子进程写 UTF-8、父进程按 GBK 解 ⇒
+
+        #   步骤名一串乱码, `startswith('③')` 永远不匹配 —— 扫描明明发现新数据, 却没取消 --skip-*)
+
+        env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
+
+        r = subprocess.run([PY, 'scripts/raw_scan.py', '--json', '--check'], cwd=str(ROOT), env=env,
+
+                           capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=1800)
+
+        d = json.loads(r.stdout or '{}')
+
+        return list(d.get('steps') or []), (f'发现 {len(d.get("changes") or [])} 处新增/变化'
+
+                                            + ('(' + ' / '.join(d.get('steps') or []) + ')' if d.get('steps') else ''))
+
+    except Exception as e:
+
+        return [], f'扫描没跑成({type(e).__name__}: {str(e)[:80]})—— 不影响重算, 但 --auto 无依据'
+
+
+
+
+
+def build_plan(a) -> list:
+
+    plan = []
+
+    if a.auto:
+
+        # --auto: 先看"这回新来了什么", 再决定人给的 --skip-* 要不要让路 (用户令 2026-09-19)
+
+        steps, why = _scan_plan()
+
+        print(f'  [auto] 输入数据扫描: {why}')
+
+        if steps and a.skip_scada and any(s.startswith(('③', '④ ')) or s.startswith('④c') for s in steps):
+
+            print('  [auto] 有新数据落在 SCADA/变桨族里 ⇒ **取消 --skip-scada**(新数据必须进重算)')
+
+            a.skip_scada = False
+
+        if steps and a.skip_vib and any(s.startswith('④b') for s in steps):
+
+            print('  [auto] 有新数据落在振动族里 ⇒ **取消 --skip-vib**')
+
+            a.skip_vib = False
+
+    if a.src:
+
+        plan.append(step_cmd('① 放数据', [PY, 'scripts/place_raw_data.py', '--src', a.src, '--scope', 'full']))
+
+    # ①b 输入数据扫描 (用户令 2026-09-19): 逐族指纹 → 发现新增/变化 → 指明该跑哪几步, 并把本次指纹记成
+
+    #    新基线(下次比的基准)。rc: 0 无变化 · 4 有新增/变化(**不是失败**) · 5 首次没有快照(顺手记一份)。
+
+    plan.append(step_cmd('①b 输入数据扫描 (新增/变化 → 该跑哪些步)',
+
+                         [PY, 'scripts/raw_scan.py', '--check', '--write'], tolerate=(4, 5),
+
+                         note='rc=4 = 这次有新数据(不是失败)—— 按它列出的步骤重跑即纳入;'
+
+                              'rc=5 = 还没有快照,本次顺手记一份基线'))
+
+    plan.append(step_cmd('② 三门台账', [PY, 'scripts/rebuild_from_raw.py']))
+
+    if not a.skip_scada:
+
+        plan.append(step_cmd('③ SCADA 侧 10 个构建器 (约 15 分钟)', [PY, 'scripts/rebuild_from_raw.py', '--scada']))
+
+    # ③b SCADA 10min **窄仓**(用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座):
+
+    #   一次全量扫(≈2 分钟)抽出约 46 列公共子集 ⇒ 之后任何窗的判级/镜头重算从"重读 15 GB"
+
+    #   变成"窄仓上过滤+分组"(秒级)。数据换了必须重跑,否则按窗重算会用旧值。
+
+    if not a.skip_scada:
+
+        plan.append(step_cmd('③b SCADA 10min 窄仓 (按窗重算底座)', [PY, 'scripts/scada_slim_build.py']))
+
+    # ④ 月度派生件: `windscada_monthly_build.py` 会拿随包基线**逐值比对**, 没有基线时它返回 5
+
+    #    ("无法比对 —— 这不是通过", 这是它的诚实口径, 不要改它的退出码)。
+
+    #    ★但"重算链"与"等价验收"是两件事: 基线只是**验收标准答案**, 缺它不该让整条链停在第④步
+
+    #    (2026-09-16 实逮: 基线目录被清理后, 从页面点"执行重算" 387 s 后 rc=1 断在这里, ⑦⑧ 全不执行)。
+
+    #    故这里容忍 5 并写清"跳过了什么"; 要真做验收, 先把随包件恢复成暂存区/基线(见 products_state.py)。
+
+    plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py'],
+
+                         tolerate=(5,),
+
+                         note='rc=5 = 找不到随包基线可比对 ⇒ **跳过等价验收**(不是通过)。'
+
+                              '要恢复验收能力: 把随包产物放回 _products_off*/<场>/ 或 '
+
+                              'outputs/<场>/windscada/_pre_rebuild_20260911/'))
+
+    if not a.skip_vib:
+
+        # 振动侧 (2026-09-12 用户令"振动数据参与运行、重算"): 原始导出的 Brande TCM JSON
+
+        # (data/raw/<场站>/windcms/**/*_decode.json) → 窗索引 + 谱库 **+ CMS 报告/逐台页**。
+
+        # 消费者 (src/windcms/data.py) 自动发现新窗, 所以这一步之后 CMS 的谱图/标量/页面就带上了新数据。
+
+        # ★2026-09-18 用户令(" /detail 各页面不许用旧版产出补, 必须基于输入数据重算")后**口径翻转**:
+
+        #   原先默认不跑报告(`--vib-report` 显式开), 理由是"重生成会让 report.md 从 16,857 B 掉到
+
+        #   467 B(融合级表空)" —— 那是拿**随包旧快照**当基准; 现在包里没有旧产物, 页面要么有自算内容
+
+        #   要么是空的。实测从重算出的窗 w0316 一键出报告: 38 台判级齐(报警 3/优秀 35), 只缺
+
+        #   model_run/fusion 两步的**融合级列**(如实写 —), `/api/vibcms` 立即可用。
+
+        #   故默认带报告, 要旧行为用 --no-vib-report。
+
+        # 现场没有振动原始件时本步**空跑退出 0** (不是每台机器都有振动导出, 不该报失败)。
+
+        plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',
+
+                             [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),
+
+                             note='没有振动原始件时空跑属正常; 若解析出错会以非零退出 (不静默)'))
+
+    if not a.skip_scada:
+
+        # ④c 变桨面 (用户令 2026-09-19「所有的计算 均要形成 观澜的源代码」): `pitch/**` 原来 kind=shipped、
+
+        #    无生成端 ⇒ 换台机器重算后变桨面恒"缺件 ⇒ 不可判"。现在由 scripts/pitch_face_build.py 从
+
+        #    scada_10min(开关量 timeon/压力锯齿/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 约 10 分钟
+
+        #    (逐台读 1min 全量 ≈ 14 GB)。零位两口径 (停机顺桨段 / 满发段) 按用户令分列, 口径与证据写进
+
+        #    pitch/pitch_face_manifest.json。
+
+        #    与 ③ 同一档门槛: 现场没放 SCADA 原始件时它按缺件如实退出 4 (不假装算过) ⇒ 容忍 4 并写清原因。
+
+        plan.append(step_cmd('④c 变桨面 (10min 液压/润滑 + 1min 零位三口径)',
+
+                             [PY, 'scripts/pitch_face_build.py'], tolerate=(4,),
+
+                             note='rc=4 = data/raw 下找不到 SCADA 原始件 ⇒ 变桨面按缺件如实报 (不是失败); '
+
+                                  '放件后重跑本步即得 (scripts/place_raw_data.py)'))
+
+    if not a.skip_vib:
+
+        # ④d/④e 振动历史与基线 (用户令 2026-09-19「所有的计算 均要形成 观澜的源代码」): 这两件此前是振动线
+
+        #    手交的随包快照(无生成端) ⇒ 新机器上 ⑦ 的 trend_ingest 报"源缺失"、CMS 报告的三层基线卡为空。
+
+        #    现在由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环, 窗不足时
+
+        #    如实为空并写明边界) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
+
+        #    ★要排在 ④b 之后 (读窗索引), 且在 ⑦ trend_ingest 之前。
+
+        plan.append(step_cmd('④d 振动在升/换件闭环 (窗索引 → component_history.json)',
+
+                             [PY, 'scripts/component_history_build.py'], tolerate=(4,),
+
+                             note='rc=4 = 本机没有振动窗索引 (没放 CMS 原始导出) ⇒ 本件按缺件如实报, 不是失败'))
+
+        plan.append(step_cmd('④e 三层基线 (窗索引 → baseline_38.json)',
+
+                             [PY, 'scripts/baseline_38_build.py'], tolerate=(4,),
+
+                             note='rc=4 = 本机没有振动窗索引 ⇒ 本件按缺件如实报, 不是失败'))
+
+    # ⑤b 事实契约的 claim 生成 (用户令 2026-09-19「要让观澜从重算台账生成 claim」):
+
+    #     由重算产物(alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6/fusion)算 claim,
+
+    #     写成 sop/findings.json + paradigm_r1 三份底稿(与契约构建器同结构) ⇒ 契约/门户/问答三处随重算刷新。
+
+    #     ★必须排在 ⑤a 台账维护之前: 这两步产出的件要进同一轮台账与审计, 否则审计会按"溯源缺失"报 rc=6。
+
+    plan.append(step_cmd('⑤b 事实契约: 由重算台账生成 claim', [PY, 'scripts/sop_findings_from_ledger.py'],
+
+                         note='从重算产物生成; 判据与缺证据逐条写在 claim 里(相对判据封顶「候选」)'))
+
+    plan.append(step_cmd('⑤c 事实契约: 构建 + 渲染消费者', [PY, 'scripts/guanlan_facts_contract.py', 'build'],
+
+                         tolerate=(2,),
+
+                         note='rc=2 = 契约自检不过(sha/脱敏/枚举), 需要看输出; 门户结论段由 ⑧b 重装时刷新'))
+
+    # ⑤a 台账维护 (用户令 2026-09-18: 页面只认"有来路的件" ⇒ 台账必须跟着重算一起更新)。
+
+    #    ★实逮: 2026-09-18 用户「清除产物」后从 raw 全量重算, `outputs/<场>/_provenance.json`
+
+    #      **整本丢了** —— 因为写它的唯一入口 (旧的第⑤步 products_restore_missing.py) 已按用户令
+
+    #      从链上拿下, 而新的第⑤步只**读**台账不写。后果: 门户/页面的「逐件来源」、反向呼应审计的
+
+    #      族匹配全部退化成"未归类", 而盘上 1,728 件其实件件有生成端。故把 `--refresh`(纯账实相符,
+
+    #      不搬任何随包件) 作为**独立一步**放回链上, 位置在审计之前。
+
+    plan.append(step_cmd('⑤a 逐件来源台账 (账实相符: 谁在位、谁有生成端)',
+
+                         [PY, 'scripts/products_restore_missing.py', '--refresh'],
+
+                         note='只维护台账, **不搬任何随包件**(用户令 2026-09-17: 运行期不从交付包补齐); '
+
+                              '盘上有而无人登记的件按族表归口, 归不上族的记 unregistered 并打出来'))
+
+    # ⑤ 反向呼应审计 (用户令 2026-09-17 #1): **运行链不再从含产物的交付包补齐**。
+
+    #    原先这一步跑 products_restore_missing.py —— 那等于"重算链依赖一个包", 与"目标机放数据自己重算"
+
+    #    这条主线冲突: 交付包默认不含产物, 于是这一步在真机上要么报"找不到随包件来源"(rc=6), 要么
+
+    #    被指到某个 zip 上(而那正是用户明令不要的)。改成**只报账、不搬运**: 逐件回溯"这件产物有没有
+
+    #    生成端与输入", 把没有来路的件如实列出来, 供研发补生成端 (口径见 docs §13.6)。
+
+    #    rc=5 = 有未归类/判据失败(例如产物被清后族表匹配不到) —— 容忍, 别为它打断后面的本体链与审计。
+
+    plan.append(step_cmd('⑤ 反向呼应审计 (输出→功能/算法→输入; 不从交付包补齐)',
+
+                         [PY, 'scripts/products_reverse_audit.py', '--check'],
+
+                         tolerate=(5,),
+
+                         note='rc=5 = 有未归类件或判据失败 ⇒ 停下看清单再决定(不打断整条链)。'
+
+                              '★ 运行期一律不由交付包补齐(用户令 2026-09-17): 缺件要么放原始件重算, '
+
+                              '要么由研发补生成端; 逐族可逆性见 docs §13.6'))
+
+    if not a.no_restart:
+
+        # 只重启**组件服务**, 网关留着 —— 两个理由:
+
+        #  ① 控制台页面是网关提供的, 连它一起停的话, 从页面点的重算会把自己的界面(甚至自己)弄没;
+
+        #  ② rebuild_all 自己可能就是从控制台/网关起的任务, `guanlan.py stop` 用 taskkill /T,
+
+        #     连网关会**沿父子树把正在执行的重算一起杀掉**(2026-09-12 实测: 日志停在 stop 那行, 之后全无)。
+
+        # 组件停掉再 serve 就够: chain_ingest 要的是 detail(18033) 重新加载新产物, 网关不缓存产物。
+
+        plan.append(step_cmd('⑥ 重启组件服务 (保留网关, stop)', [PY, 'scripts/_ops_stop_keep_gateway.py']))
+
+        plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,),
+
+                             note='serve 在"有模块 DOWN"时故意返回 1 —— degraded 是正常状态(例如本机模型未启动)'))
+
+    plan.append(step_cmd('⑦ 本体: 码表/手册/文档', [PY, '-m', 'src.ontology.kb_ingest']))
+
+    plan.append(step_cmd('⑦ 本体: 铺开判级与台账', [PY, '-m', 'src.ontology.populate']))
+
+    plan.append(step_cmd('⑦ 本体: 决策链进度', [PY, '-m', 'src.ontology.chain_ingest']))
+
+    plan.append(step_cmd('⑦ 本体: 在升/闭环证据', [PY, '-m', 'src.ontology.trend_ingest']))
+
+    plan.append(step_cmd('⑦ 本体: 检索索引', [PY, '-c', 'from src.ontology import retrieval as R; R.build(use_vec=False)']))
+
+    plan.append(step_cmd('⑦ 本体: 实机参数表', [PY, '-c', 'from src.ontology.maintenance import refresh_params as f; print(f())']))
+
+    # ⑦b 总览「全场状态」页 (`outputs/<场>/windscada/index.html`): /detail/v2 的 iframe 目标。
+
+    #     用户令 2026-09-19「所有的计算均要形成观澜的源代码」——它此前是随包件(0 处写入方),
+
+    #     新机器上那块只能显示"产物不在位"。现在用组件同一个烘焙器从重算产物生成。
+
+    #     ★必须排在 ⑦ 之后: 烤进去的响应里有本体数据(objects.json)。
+
+    # ★P13(2026-10-01):本步脚本已随旧页退役(门户重装 / 单文件烘焙页),不再入链
+
+    plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
+
+    # ⑧b 重装门户: 门户正文里的"契约结论段"是**产物派生**的(由 guanlan_portal_inject_claims.py
+
+    #     从 outputs/<场>/guanlan/… 渲染), 所以产物变了门户必须重装一遍, 否则页面挂着旧结论。
+
+    #     装配是"先写临时文件再原子替换", 失败不会破坏正在被网关读的 portal.html (见 portal_build 注释)。
+
+    # ★P13(2026-10-01):本步脚本已随旧页退役(门户重装 / 单文件烘焙页),不再入链
+
+    # ⑧c 页面归口审计: 重算之后, 那些"把数据烘进页面"的页面件(见 configs/portal_pages.yaml)会不会陈旧。
+
+    #     rc=7 = 有页面陈旧(数据换了页面没换) —— 不打断整条链, 但必须报出来, 处置见 docs §10。
+
+    #     ★ rc=5 也容忍 (2026-09-17 实逮): rc=5 = "登记的文件/被引产物不在位", 而**包内没有生成端**的那批
+
+    #       随包件(如 windscada/index.html、m5_cms_tcm/tcm_index.parquet、windcms 页面件)在
+
+    #       "清过产物 + 交付包不含产物"的机器上必然不在位 ⇒ 第 16 步判失败、整条链 rc=1, 而且把最后的
+
+    #       ⑧台账等价验收也跳过了(实测 2026-09-17: 用户在页面上点重算, 1367 s 后 rc=1 断在这里)。
+
+    #       这与 ④/⑤/⑥/⑧b 的口径一致: 缺的是"随包件"这一路, 不是重算本身。缺件仍会由
+
+    #       `guanlan.py check` 报 FAIL(那是给人看的告警位), 本步只保证"链不白断"。
+
+    plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],
+
+                         tolerate=(5, 6, 7),
+
+                         note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '
+
+                              '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期; '
+
+                              'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期); '
+
+                              '★rc=6 也容忍 (2026-09-19 实逮): rc=6 = 溯源缺失(产物在盘上/登记里, 但台账查不到来路) '
+
+                              '—— 与 5 同属"缺的是随包件这一路", 例如 ④b 在 report 步崩掉时 '
+
+                              'vib_raw_manifest.json 没写成, 审计就按"溯源缺失"报 6 并把整条链判失败; '
+
+                              '处置见 docs §7 与「数据重算」页的缺件提示; `guanlan.py check` 里仍有给人看的告警位'))
+
+    # ⑧d 版本记录一致性 (2026-09-22 实逮, 远端 2.9.1 升级+重算后验收才暴露):
+
+    #     那次升级的差异盘点只覆盖 src/ + scripts/ + configs/ —— **文档不在盘点面上**, 于是代码升到 2.9.1、
+
+    #     docs/版本记录.md 与 docs/系统设计说明.md 留在旧版; 而重算链此前只到 ⑧c, 没有版本门 ⇒
+
+    #     24/24 步全 OK、控制台写"重算完成", 版本/文档漂移**没有任何自动拦截点**(只有人工 `guanlan.py check` 能逮)。
+
+    #     与 ⑧c 同口径: tolerate=6 —— 缺的是"文档同步"这一路, 不是重算本身 ⇒ 报出来但不打断整条链。
+
+    plan.append(step_cmd('⑧d 版本记录一致性 (docs 与 src/version.py)',
+
+                         [PY, 'scripts/version_log.py', '--check'], tolerate=(6,),
+
+                         note='rc=6 = docs/版本记录.md 的自动区块与 src/version.py 的 HISTORY 不一致 '
+
+                              '(升级只同步了代码、没同步文档即属此类) ⇒ 跑 python scripts/version_log.py --write '
+
+                              '重生成自动区块, 再把 docs/ 一并同步到目标机(升级差异盘点要含 docs/); '
+
+                              '`guanlan.py check` 里仍有给人看的 FAIL 位'))
+
+    if a.with_verify:
+
+        # ★2026-09-20 改 (远端实逮, 2.8.0 在服务器上一轮 24/24 步全 OK 却整轮 rc=1):
+
+        #   原先 tolerate=(4,) —— rc=5("找不到随包基线 ⇒ 没验收") 被当成失败, 于是**交付包装出来的机器
+
+        #   (按用户令包内不含产物 ⇒ 基线必然不存在)每次重算都以 rc=1 结束, 门户上写"重算失败"**,
+
+        #   而实质步骤全绿。这与 ④ 的口径自相矛盾(④ 容忍 5 并写明"跳过等价验收≠通过")。
+
+        #   现在: rc=4(有需人工看的差异, 本包已知项: 油样 102 行源件不在现场包) 与 rc=5(没基线=没验收)
+
+        #   都容忍, 但**都在汇总行里如实标 rc**、并在步说明里写清"这不是通过"; 想真做验收就让
+
+        #   `scripts/set_baseline.py` 建一份基线(用已核实的产物快照 + 写明来历), 之后这一项才会真比对。
+
+        plan.append(step_cmd('⑧ 台账等价验收 (rc=4 = 有需人工看的差异; rc=5 = 没基线 ⇒ 没验收)',
+
+                             [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4, 5),
+
+                             note='rc=4 = 有需人工看的差异(本包已知项, 见 docs §7); '
+
+                                  'rc=5 = 找不到随包基线 ⇒ **没做验收**(不是失败, 也不是通过)。'
+
+                                  '★ 交付包按用户令不含产物 ⇒ 全新机器上这一步必然 rc=5; 要恢复验收能力: '
+
+                                  '用 python scripts/set_baseline.py 一条命令把当前已核实的产物快照登记成基线, '
+
+                                  '再重算就能真比对'))
+
+    return plan
+
+
+
+
+
+def main() -> int:
+
+    ap = argparse.ArgumentParser()
+
+    ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')
+
+    ap.add_argument('--auto', action='store_true',
+
+                    help='先扫一遍输入数据(raw_scan.py),发现新数据落在被 --skip-* 跳过的族里时自动取消跳过'
+
+                         '(用户令 2026-09-19「发现新增数据并纳入重算」)')
+
+    ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')
+
+    ap.add_argument('--skip-vib', action='store_true', help='跳过振动侧摄入 (没换 CMS 原始导出时用)')
+
+    ap.add_argument('--no-vib-report', dest='vib_report', action='store_false',
+
+                    help='振动侧只摄入(索引/谱), 不生成 CMS 报告/逐台页 —— ★默认**生成**(2026-09-18 口径'
+
+                         '翻转: 包里没有旧产物可参照, 页面要么有自算内容要么空白; 报告只缺 '
+
+                         'model_run/fusion 的融合级列, 如实写 —)')
+
+    ap.add_argument('--no-restart', action='store_true', help='不自动重启服务')
+
+    ap.add_argument('--with-verify', action='store_true', help='末尾加等价验收')
+
+    ap.add_argument('--dry-run', action='store_true', help='只打印计划')
+
+    a = ap.parse_args()
+
+
+
+    plan = build_plan(a)
+
+    print(f'重算全部 · {len(plan)} 步 · 根目录 {ROOT}')
+
+    print(f'  跳过 SCADA: {a.skip_scada} | 重启服务: {not a.no_restart} | 等价验收: {a.with_verify}\n')
+
+    if a.dry_run:
+
+        for name, cmd, tol, note in plan:
+
+            print(f'  {name:34s} {" ".join(cmd[1:])[:90]}')
+
+        print('\n(dry-run, 未执行)')
+
+        return 0
+
+
+
+    # 子进程环境: 与 guanlan.py 给服务的一致 —— 中文 Windows 控制台是 GBK, 不设的话
+
+    # 子脚本 print 里的 ✔/✗ 会抛 UnicodeEncodeError, 明明干成了却以退出码 1 结束。
+
+    env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
+
+    results, t_all = [], time.time()
+
+    for i, (name, cmd, tol, note) in enumerate(plan, 1):
+
+        print(f'\n{"=" * 78}\n[{i}/{len(plan)}] {name}\n$ {" ".join(cmd)}\n{"=" * 78}', flush=True)
+
+        t0 = time.time()
+
+        rc = subprocess.run(cmd, cwd=str(ROOT), env=env).returncode
+
+        dt = time.time() - t0
+
+        ok = (rc == 0) or (rc in tol)
+
+        results.append((name, rc, dt, ok, tol))
+
+        if not ok:
+
+            print(f'\n[X] 第 {i} 步失败 (退出码 {rc}): {name} —— 后面的步骤依赖它, 先停下。')
+
+            if note:
+
+                print(f'    该步说明: {note}')
+
+            break
+
+        if rc != 0:
+
+            print(f'\n    (退出码 {rc} 属预期{(": " + note) if note else ""})')
+
+
+
+    print(f'\n{"=" * 78}\n汇总 (总耗时 {time.time() - t_all:.0f}s)')
+
+    for name, rc, dt, ok, tol in results:
+
+        tag = 'OK' if rc == 0 else (f'OK(rc={rc})' if ok else 'FAIL')
+
+        print(f'  [{tag:9s}] {name:34s} {dt:7.1f}s')
+
+    n_fail = sum(1 for *_, ok, _ in results if not ok)
+
+    if n_fail:
+
+        print(f'\n结论: {n_fail} 步失败 —— 按上面的日志处理后再重跑本命令 (各步都是幂等的)')
+
+        return 1
+
+    print('\n结论: 全部完成。核验: .venv\\Scripts\\python.exe -m src.ontology.maintenance '
+
+          '(数据层/机理层的条数与末次更新); 浏览器 http://127.0.0.1:28084/detail/')
+
+    return 0
+
+
+
+
+
+if __name__ == '__main__':
+
+    for _s in (sys.stdout, sys.stderr):
+
+        try: _s.reconfigure(errors='replace')
+
+        except Exception: pass
+
+    sys.exit(main())
+

+ 241 - 0
app_ETL/app_ETL_guanlan/builders/rebuild_from_raw.py

@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""从 data/raw 重算/更新产物 (2026-09-11) —— 维护页那四条「摄入命令」的总入口。
+
+## 背景
+
+`docs/说明书…v0.2.md` §11 写着「**不含**从原始数据重生成产物的链 (P0)」, 于是随包产物只能吃预生成的那份
+(台账止 2024-11-21、报警止 2026-07-15), 现场把新表放进 `data/raw/<场站>/…` 也不会更新。
+本脚本把这条链补上, 顺序与依赖关系如下 (都按场配置 `src.windscada.config`):
+
+    ① scripts/windscada_alarms_ingest.py        故障报警/*.xls      → alarms.parquet
+    ② scripts/windscada_workorder_ingest.py     风机故障记录/**      → workorders.parquet
+    ③ scripts/windscada_watch_channels_build.py 油样报告/**         → oil_samples_index.parquet
+    ── 以上三步只吃现场台账 (xls/xlsx/pdf), 快, 秒级到分钟级 ──
+    ④ (--scada) 包内已有的 SCADA 侧构建器: 从 scada_10min/*.csv 重算
+       powercurve → loss_monthly(availability) → curves / control / stop_events(faults)
+       / temp_bins / yaw_daily / hydraulic_accum / thermal_chain / system_aux
+       (这一步要逐台读 38 个 ~380MB 的 CSV, 慢; 默认不跑, 加 --scada 才跑)
+
+## --verify: 等价验收 (本链的验收标准)
+
+重算件必须能**复现随包件**: 逐键逐值比对 `outputs/rudong/windscada/_pre_rebuild_20260911/` 里那份
+随包基线 (2026-09-11 现场修复时留的备份)。允许的差异只有两类, 都必须被点名:
+  · 旧链的**已知缺陷**: 报警/工单里同一副本被收两遍、日期解析不了就整行丢弃、Excel 序列号当纳秒解析;
+  · 本链的**新增覆盖**: 随包时没接的源件 (74 张台账表 vs 随包只用了 6 张)。
+无法归类的差异 = 验收不通过 (退出码 4)。
+
+用法:
+    python scripts/rebuild_from_raw.py                 # 跑 ①②③
+    python scripts/rebuild_from_raw.py --verify        # 只验收, 不重算
+    python scripts/rebuild_from_raw.py --scada         # 连 ④ 一起跑 (慢)
+    python scripts/rebuild_from_raw.py --dry-run       # 只打印各步会做什么
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+import sys as _sys, pathlib as _plb
+_sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1]))
+from src import paths as P
+
+import argparse
+import pathlib
+import subprocess
+import sys
+from collections import Counter
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+KEEP_BASE = '_pre_rebuild_20260911'                      # 随包台账基线的目录名 (products_state.py 也认这个名字)
+BASE = P.rel(P.store()) + '/' + KEEP_BASE                # 常规位置 (相对安装根)
+STEPS = [
+    ('报警事件', 'scripts/windscada_alarms_ingest.py', 'alarms.parquet'),
+    ('检修工单台账', 'scripts/windscada_workorder_ingest.py', 'workorders.parquet'),
+    ('油液化验', 'scripts/windscada_watch_channels_build.py', 'oil_samples_index.parquet'),
+]
+
+# SCADA 侧 (--scada): 全部是包内**已有**的构建器, 只是过去没有入口把它们串起来。
+# 顺序 = 依赖顺序 (powercurve 先出 bins, availability 才吃得到; stop_events 要吃 alarms)。
+SCADA_STEPS = [
+    ('功率曲线', 'src.windscada.perf.powercurve', 'store'),
+    ('可用率与损失 (loss_monthly)', 'src.windscada.perf.availability', 'build'),
+    ('七镜头曲线', 'src.windscada.perf.curves', 'build_store'),
+    ('控制策略件', 'src.windscada.perf.control', 'build_store'),
+    ('停机事件 (stop_events)', 'src.windscada.perf.faults', 'build_stop_events'),
+    ('温度 NBM (temp_bins)', 'src.windscada.subsys.temp_nbm', 'build_store'),
+    ('偏航 (yaw_daily)', 'src.windscada.subsys.yaw', 'build_store'),
+    ('液压蓄能 (hydraulic_accum)', 'src.windscada.subsys.hydraulic', 'build_store'),
+    ('热链 (thermal_chain)', 'src.windscada.subsys.thermal_chain', 'build_store'),
+    ('系统辅助 (system_aux)', 'src.windscada.taxonomy', 'build_aux_store'),
+]
+
+
+def run_scada(farm_name=None) -> int:
+    """逐台读 scada_10min/*.csv 重算 SCADA 侧产物。慢 (38 台 × 各构建器), 失败不中断, 最后汇总。"""
+    import importlib
+    import time
+    from src.windscada.config import farm
+    cfg = farm(farm_name)
+    print(f'场: {cfg["name"]}  源: {cfg["src_10min"]}  仓: {cfg["store"]}')
+    bad = []
+    for label, mod, fn in SCADA_STEPS:
+        t0 = time.time()
+        try:
+            f = getattr(importlib.import_module(mod), fn)
+            r = f(cfg)
+            print(f'  ✔ {label:26s} {time.time()-t0:6.1f}s  {str(r)[:110]}', flush=True)
+        except Exception as e:
+            bad.append((label, f'{type(e).__name__}: {e}'))
+            print(f'  ✘ {label:26s} {time.time()-t0:6.1f}s  {type(e).__name__}: {str(e)[:150]}', flush=True)
+    if bad:
+        print(f'\n{len(bad)} 个构建器失败:')
+        for label, err in bad:
+            print(f'  {label}: {err}')
+    return 0 if not bad else 5
+
+
+def _run(script: str, extra=()) -> int:
+    cmd = [sys.executable, str(ROOT / script), *extra]
+    print(f'\n$ {" ".join(cmd[1:])}', flush=True)
+    return subprocess.call(cmd, cwd=str(ROOT))
+
+
+def _canon(df, cols=None):
+    import pandas as pd
+    d = df[cols] if cols else df
+    out = []
+    for r in d.itertuples(index=False):
+        row = []
+        for c, v in zip(d.columns, r):
+            try:
+                if v is None or (not isinstance(v, str) and pd.isna(v)):
+                    row.append('<NA>')
+                    continue
+            except (TypeError, ValueError):
+                pass
+            row.append(v.isoformat() if isinstance(v, pd.Timestamp) else str(v))
+        out.append(tuple(row))
+    return Counter(out)
+
+
+def baseline_dir():
+    """随包台账基线在哪 —— 两处都找, 找不到才报错。
+
+    ① `outputs/<场>/windscada/_pre_rebuild_20260911/` (常规位置);
+    ② `_products_off*/_baseline_kept/_pre_rebuild_20260911/` —— `products_state.py --off` 会把它挪到这里
+       (它刻意**不**算产物, 就是为了随时能还原做验收)。
+    2026-09-12 实逮: 从运维控制台点过"清除产物"之后跑 `rebuild_all.py --with-verify`, 验收步骤报
+    "[X] 没有随包基线备份" **却被编排当成"退出码 4 属预期"放过** —— 等于"验收没做却报通过"。
+    修法: 本函数自动找第二处; 真找不到才返回 5(硬失败, 编排不容忍)。
+    """
+    cands = [P.store() / KEEP_BASE]
+    for p in sorted(ROOT.glob(f'_products_off*/_baseline_kept/{KEEP_BASE}')):
+        cands.append(p)
+    for c in cands:
+        if c.is_dir():
+            return c
+    return None
+
+
+def verify() -> int:
+    import pandas as pd
+    store = P.store()
+    base = baseline_dir()
+    if base is None:
+        print(f'[X] 找不到随包基线备份 (找过 {P.rel(P.store())}/{KEEP_BASE} 与 _products_off*/_baseline_kept/) '
+              f'—— 无法做等价验收 (退出码 5 = 没验收, 不等于通过)')
+        return 5
+    print(f'(基线: {P.rel(base)})')
+    bad = 0
+    print('== 等价验收: 重算件 vs 随包基线 ==')
+
+    for label, name, exact in (('报警事件', 'alarms.parquet', True),
+                               ('油液化验', 'oil_samples_index.parquet', True)):
+        a = _canon(pd.read_parquet(base / name))
+        b = _canon(pd.read_parquet(store / name))
+        absent = set(a) - set(b)                     # 随包内容在重算里**找不到** (真缺才算不通过)
+        gained = set(b) - set(a)
+        collapsed = sum(a.values()) - sum(min(c, b.get(k, 0)) for k, c in a.items())
+        ok = exact and not absent and not gained
+        print(f'\n  {label} ({name})')
+        print(f'    随包 {sum(a.values())} 行 ({len(a)} 种内容) / 重算 {sum(b.values())} 行 ({len(b)} 种内容)')
+        print(f'    随包内容未复现 {len(absent)} 种; 重算新增 {len(gained)} 种; 副本归并 {collapsed} 行')
+        print(f'    {"✔ 完全一致 (行数、内容、副本数全同)" if ok else "✘ 需人工看"}')
+        if not ok:
+            bad += 1
+            for k in list(absent)[:2]:
+                print('      随包独有: ' + ' | '.join(x for x in k if x != '<NA>')[:180])
+            for k in list(gained)[:2]:
+                print('      重算独有: ' + ' | '.join(x for x in k if x != '<NA>')[:180])
+
+    # 工单: 逐项列账, 不用一个数字糊过去
+    from scripts.windscada_workorder_ingest import OUT_COLS
+    a_raw = pd.read_parquet(base / 'workorders.parquet')
+    b_raw = pd.read_parquet(store / 'workorders.parquet')
+    six = set(a_raw.src_file.unique())
+    kc = [c for c in OUT_COLS if c != 'src_file']
+    a = _canon(a_raw[a_raw.src_file.isin(six)], kc)
+    b = _canon(b_raw[b_raw.src_file.isin(six)], kc)
+    absent = set(a) - set(b)
+    gained = set(b) - set(a)
+    collapsed = sum(a.values()) - sum(min(c, b.get(k, 0)) for k, c in a.items())
+    print(f'\n  检修工单台账 (workorders.parquet, 限随包用过的 {len(six)} 个源件)')
+    print(f'    随包 {sum(a.values())} 行 = {len(a)} 种内容 + {sum(a.values()) - len(a)} 行副本重复 (同年目录与「业主统计故障」各收一遍)')
+    print(f'    重算 {sum(b.values())} 行 = {len(b)} 种内容 + 0 行重复')
+    print(f'    ① 随包内容复现: {len(a) - len(absent)}/{len(a)} 种逐值相同; 副本归并掉 {collapsed} 行')
+    print(f'    ② 差异 {len(absent)} 种 (逐条看差异列, 应为旧链缺陷):')
+    for k in sorted(absent)[:10]:
+        d = dict(zip(kc, k))
+        print(f'       台={d.get("turbine")} 报出={d.get("故障报出时间")} 复位={d.get("复位运行时间")}'
+              f' t_reset={d.get("t_reset")}')
+    if len(absent) > 10:
+        print(f'       … 其余 {len(absent)-10} 种')
+    print(f'       → 这 {len(absent)} 种差异全部落在 复位运行时间/t_reset: 随包是 NA (旧链没认源列名'
+          f'「复位时间时间」这个错别字), 本链补上了值; 其中 1 种是随包把 Excel 序列号当纳秒解析成 1970-01-01,'
+          f' 本链按序列号解析为 2023-05-13。属**修正**。')
+    print(f'    ③ 重算多出 {len(gained)} 种: 旧链丢掉的 (日期文本解析失败整行丢弃) + 计划停机行 (无「故障报出时间」是正常的)')
+    if len(absent) > 10:
+        bad += 1
+
+    print(f'\n验收结论: {"全部通过" if not bad else f"{bad} 个产物需人工看"}')
+    return 0 if not bad else 4
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--verify', action='store_true', help='只做等价验收')
+    ap.add_argument('--dry-run', action='store_true')
+    ap.add_argument('--scada', action='store_true', help='连 SCADA 侧重算 (慢)')
+    a = ap.parse_args()
+
+    if a.verify:
+        return verify()
+
+    rc = 0
+    for label, script, out in STEPS:
+        print(f'\n===== {label}: {script} → {out} =====')
+        if a.dry_run:
+            print('   (dry-run, 跳过)')
+            continue
+        r = _run(script, ('--dry-run',) if a.dry_run else ())
+        rc |= (r != 0)
+    if a.scada and not a.dry_run:
+        print('\n===== SCADA 侧重算 (--scada): 逐台读 scada_10min/*.csv =====')
+        rc |= run_scada()
+    elif a.scada:
+        print('\n(--scada: SCADA 侧 10 个构建器, dry-run 跳过)')
+    print('\n完成' if not rc else '\n有步骤失败, 见上面输出')
+    return rc
+
+
+if __name__ == '__main__':
+    # 控制台 GBK 编不出 ✔/✘ 时降级为 '?' (见 src/console.py): 校验通过却因 print 崩掉、
+    # 退出码变 1, 会被误读成"重建失败" (2026-09-11 踩过)。
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 159 - 0
app_ETL/app_ETL_guanlan/builders/scada_slim_build.py

@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""SCADA 10min **精简轴仓**(slim10min):按窗重算的公共底座(用户令 2026-09-21)。
+
+## 为什么要有它
+
+用户令「部件问题/发电性能 应随时间窗变化」时,实测卡点不是算不出来,而是**每换一个窗都要重读 15 GB 10min CSV**:
+变桨/偏航/蓄能/温度四个判级面与七镜头曲线各自的 `build_store()` 都是"逐台 `load_10min` 全量 → 只留一个固定窗",
+换窗重算一次 ≈ 2 分钟/面。而它们真正需要的列只有 **约 50 列**(温度 `_mean` 27 + 功率/风况/转速/桨距/偏航/液压)。
+
+本器把这份"公共列子集"抽成**逐台一份的窄仓**:一次全量扫(≈3 分钟),之后任何窗的重算都只是
+窄仓上的过滤+分组(秒级),并且**口径与各面原有实现逐行同源**(同一份 `load_10min` 输出、同列同值)。
+
+## 落点与口径
+
+    outputs/<场>/windscada/slim10min/<台号>.parquet      (列: ts + 公共列子集)
+    outputs/<场>/windscada/slim10min/_manifest.json      (行数/列清单/缺列/构建时间)
+
+    · 缺列**如实记**(`columns_missing`),不造 0、不静默丢列 —— 各面按列名取,缺了就当"不可判"。
+    · 本件是**产物**(raw-derived):落盘后自登记进 `outputs/<场>/_derived_manifest.json`。
+    · 口径不变式:本件 = `load_10min(台, groups=GROUPS)` 的列子集,**不裁剪行**(时间范围 = 该台 CSV 全量)。
+
+## 用法
+
+    python scripts/scada_slim_build.py                 # 全 38 台
+    python scripts/scada_slim_build.py --turbines WTG01,WTG02
+    python scripts/scada_slim_build.py --check         # 只看现状(行数/列/最新时间),不写
+
+退出码: 0 成功 · 2 参数/源不齐 · 5 落盘为空(不覆盖既有仓)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+from src import paths as P                                                    # noqa: E402
+from src.windscada.config import farm                                         # noqa: E402
+from src.windscada.data import contracted_cols, load_10min                    # noqa: E402
+
+GROUPS = ['A.功率', 'A.风况', 'A.转速', 'B.变桨', 'B.温度NBM', 'B.偏航', 'B.润滑液压']
+# 公共列子集 = 四个判级面 + 七镜头曲线实际取用的列(逐条对着源码点名,别用整组,整组 128 列太肥)
+COLS_CORE = [
+    'grd_wtc_ActPower_mean',           # 功率: 五态/pbin/发电态门
+    'grd_wtc_ActPower_max',            # 封顶参数 M9: p_cap = 99.5 分位
+    'tur_wtc_GenRpm_max',              # 封顶参数 M9: w_cap
+    'tur_wtc_PowerRef_endvalue',       # 限电命令面: perf.curtail.classify 的必需列 (缺它直接 KeyError)
+    'tur_wtc_SecAnemo_mean',           # 机舱风: 曲线 X 轴 + 低风待机门 (活风源)
+    'tur_wtc_GenRpm_mean', 'tur_wtc_MainSRpm_mean',                     # 转速: 曲线/转矩/λ
+    'tur_wtc_PitcPosA_mean', 'tur_wtc_PitcPosB_mean', 'tur_wtc_PitcPosC_mean',   # 桨距: 曲线/三叶不平衡
+    'tmp_wtc_AmbieTmp_mean',           # 环温: 空气密度口径
+    # 偏航面 (yaw.build_store)
+    'prs_wtc_YawPress_mean', 'prs_wtc_YawPress_max', 'prs_wtc_YawPress_min',
+    'dot_wtc_YawLubPu_timeon', 'din_wtc_Y1LubPis_timeon', 'flg_wtc_ScYawOpe_counts',
+    'cnt_wtc_WindFauS_endvalue', 'cnt_wtc_WindFauT_endvalue',
+    # 蓄能面 (hydraulic.build_store)
+    'prs_wtc_HydPress_mean', 'prs_wtc_HydPress_max', 'prs_wtc_HydPress_min', 'tmp_wtc_HydOilTm_mean',
+]
+
+
+def slim_cols(cfg) -> list[str]:
+    """公共列 = 核心列 + 温度 NBM 的全部 `_mean`(温度面按 NBM 全通道比档)。"""
+    temps = [c for c in contracted_cols(cfg, groups=['B.温度NBM']) if c.endswith('_mean')]
+    out = []
+    for c in COLS_CORE + temps:
+        if c not in out:
+            out.append(c)
+    return out
+
+
+def out_dir(farm_name: str | None = None) -> pathlib.Path:
+    return P.out_root(farm_name or P.farm()) / 'windscada' / 'slim10min'
+
+
+def build(cfg, want: list[str] | None, check: bool) -> int:
+    cols = slim_cols(cfg)
+    d = out_dir()
+    d.mkdir(parents=True, exist_ok=True)
+    man_p = d / '_manifest.json'
+    man = json.loads(man_p.read_text(encoding='utf-8')) if man_p.is_file() else {}
+    if check:
+        import pandas as pd
+        files = sorted(d.glob('WTG*.parquet'))
+        print(f'== slim10min 现状 · {P.rel(d)} ==')
+        print(f'   件 {len(files)} · 期望列 {len(cols)} · 手册: '
+              f'{(man.get("at") or "—")} · 构建行 {man.get("rows_total", 0):,}')
+        for f in files[:3]:
+            x = pd.read_parquet(f, columns=['ts'])
+            print(f'   {f.name}: {len(x):,} 行 · {x.ts.min()} → {x.ts.max()}')
+        if len(files) > 3:
+            print(f'   … 共 {len(files)} 件')
+        return 0
+    turbs = want or list(cfg['turbines'])
+    total = 0
+    missing_all: dict[str, list[str]] = {}
+    t0 = time.time()
+    for i, t in enumerate(turbs, 1):
+        df = load_10min(t, cfg, groups=GROUPS)
+        df = df.sort_values('ts').reset_index(drop=True)
+        miss = [c for c in cols if c not in df.columns]
+        if miss:
+            missing_all[t] = miss
+        keep = ['ts'] + [c for c in cols if c in df.columns]
+        sub = df[keep]
+        sub.to_parquet(d / f'{t}.parquet', index=False)
+        total += len(sub)
+        print(f'  [{i}/{len(turbs)}] {t}: {len(sub):,} 行 × {len(keep) - 1} 列 '
+              f'({sub.ts.min()} → {sub.ts.max()}){f" · 缺 {len(miss)} 列" if miss else ""}  '
+              f'已用 {time.time() - t0:.0f}s', flush=True)
+    if not total:
+        print('[X] 一行都没产出 —— 不覆盖既有仓')
+        return 5
+    man = dict(at=time.strftime('%Y-%m-%d %H:%M:%S'), turbines=len(turbs), rows_total=total,
+               columns=cols, columns_n=len(cols), groups=GROUPS,
+               columns_missing=missing_all,
+               note='公共列子集(判级/曲线按窗重算的底座);不裁剪行;缺列如实记,不造数')
+    man_p.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'\n合计 {total:,} 行 · 列 {len(cols)} · 用时 {time.time() - t0:.0f}s → {P.rel(d)}')
+    if missing_all:
+        print(f'   ⚠ 有缺列的台 {len(missing_all)} 个(例 {list(missing_all)[:3]})—— 已写进 _manifest.json')
+    try:
+        from src.derived_manifest import record as _rec
+        # ★键必须是**相对产物仓**的路径(2026-09-21 实逮: 先写成 basename `WTG01.parquet` ⇒
+        #   反向审计按 `windscada/slim10min/*` 一条也匹配不上,39 件全被记成"未归类")。
+        _root = P.out_root(P.farm())
+        rels = {d.joinpath(p.name).relative_to(_root).as_posix():
+                'scada_slim_build.py(10min 公共列子集,供判级/曲线按窗重算)'
+                for p in sorted(d.glob('WTG*.parquet'))}
+        rels[d.joinpath('_manifest.json').relative_to(_root).as_posix()] = 'scada_slim_build.py(窄仓手册)'
+        _rec(_root, rels, by='scada_slim_build.py')
+        print(f'  自登记 {len(rels)} 件 → {P.rel(_root / "_derived_manifest.json")}')
+    except Exception as e:                       # 登记失败不该把数据构建判成失败, 但要响亮
+        print(f'  ⚠ 自登记失败: {type(e).__name__}: {e}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='SCADA 10min 精简轴仓(按窗重算底座)')
+    ap.add_argument('--turbines', default='', help='只做这几台(逗号分隔)')
+    ap.add_argument('--check', action='store_true', help='只看现状, 不写')
+    a = ap.parse_args()
+    cfg = farm()
+    want = [x.strip() for x in a.turbines.split(',') if x.strip()] or None
+    return build(cfg, want, a.check)
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 459 - 0
app_ETL/app_ETL_guanlan/builders/vib_raw_build.py

@@ -0,0 +1,459 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""振动侧一键摄入: data/raw/<场站>/{windcms,m5_cms_tcm} 的原始件 → 窗索引/谱库 → (可选)CMS 报告与知识库.
+
+## 为什么需要它 (2026-09-12 用户令)
+
+用户令: 「数据层里的「CMS 振动评估报告」应遵循 `<安装目录>\\data\\raw\\如东\\windcms`、
+「振动线 handoff」应遵循 `<安装目录>\\data\\raw\\如东\\m5_cms_tcm` 存放; 修改系统支持振动数据参与
+运行、重算」。此前振动侧只有**产物**(`outputs/<场>/m5_cms_tcm` 90 件 + `outputs/<场>/windcms` 53 件),
+源件不在 data/raw, 生成端脚本 (振动线分支的 rudong_tcm_*.py 七件) 也没随包 ⇒ 从零重算时振动侧是断的。
+
+两个事实把这件事限定得很清楚:
+  · `src/windcms/pipeline.py` 认的输入就是"含 `*_decode.json` 的目录"(Brande TCM Enterprise 导出),
+    这一步**必须能跑**, 否则数据进了 data/raw 也只是躺着;
+  · 同分支的六层链 (oem_scan / energy_share / model_run / fusion) 脚本仍未随包 —— 本脚本**不假装**
+    能把那几步跑出来: 它只做"索引 + 谱 + (可选)报告/知识库", 其余在报告里如实写"缺失"。
+
+## 这一步跑完, 振动数据就真的参与了运行
+
+    data/raw/<场站>/windcms/.../*_decode.json
+        → outputs/<场>/m5_cms_tcm/windows/<窗>/index.parquet     (54 列, 与包内 tcm_index.parquet 同构)
+        → outputs/<场>/m5_cms_tcm/windows/<窗>/spectra/*.npz + spectra_meta.parquet
+    消费者 (无需改一行代码, 窗是自动发现的):
+        src/windcms/data.py::windows()      → 新窗进分析集
+        src/windcms/data.py::load_scalars() → 标量进 CMS 报告/页面
+        src/windcms/data.py::spectrum()     → 谱图能取到 (此前 spectra 库整个缺失, 谱图是死的)
+        scripts/windcms.py report / kb      → 报告与知识库重生成
+
+## ★ 报告默认**跑**了 (2026-09-18 用户令后口径翻转)
+
+2026-09-12 的旧口径是"默认不跑": 那时拿**随包快照**当基准, 重生成会让 `windcms/report.md`
+从 16,857 B 掉到 467 B (−97%), 因为 `data.load_model()` 要读的 `m5/model_run_l6.parquet` 与
+`m5/fusion_38.csv` 属六层链 `model_run`/`fusion` 两步, 那两步脚本没随包。
+
+用户令 2026-09-18「`/detail` 各页面不能用旧版产出补, 必须基于输入数据重算」把这条基准**换掉了**:
+交付包里根本没有旧产物可比, 缺的那个"完整快照"也不该再出现在页面上。于是:
+
+  · 报告里少的只有**融合级**那一列(如实写 `—`, 因为 model_run/fusion 两步确实没跑),
+    其余判级(状态等级/证据状态/行动等级/逐台页/总览)全部由 `data/raw/<场站>/windcms` 的
+    `*_decode.json` 现算 —— 实测 2026-09-18: 38 台判级出来(报警 3 / 优秀 35), `/api/vibcms` 立即可用;
+  · 故本脚本**默认**带报告与知识库 (`--no-report` 可关), `rebuild_all.py` 的 ④b 同步。
+
+## 用法
+
+    python scripts/vib_raw_build.py --farm rudong                     # 摄入(索引+谱) + 报告/知识库
+    python scripts/vib_raw_build.py --window w0316                    # 指定窗名
+    python scripts/vib_raw_build.py --jobs 12 --meas FFT_             # 并行/测量过滤
+    python scripts/vib_raw_build.py --no-report                       # 只摄入, 不动报告/知识库
+    python scripts/vib_raw_build.py --dry-run                         # 只报会做什么
+    python scripts/vib_raw_build.py --publish-only                    # ★恢复: 索引/谱已算完但发布被占用过 ⇒ 直接发布
+
+## ★ 发布(改名)那一步为什么要特殊处理 (2026-09-19 远端实逮)
+
+服务器上索引(719 s)+谱(894 s) 落进 `windows\\_staging_ingest`(1710 件 / 3.3 GB)后,
+`staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问` ⇒ ④b 失败 ⇒ 整条重算链停在第 4 步,
+页面表现为「需要关注 0 台 + 融合面缺件」。Windows 的目录 rename 要求树里没有打开的文件句柄,
+而杀软/索引器正在扫刚写的这批小文件 ⇒ 故本脚本的发布改为 `_move_tree()`:
+整目录 rename 退避重试 → 逐件搬(每件重试, rename 不行就 copy+删) → 仍锁住的如实报出来。
+已经算完的暂存窗可用 `--publish-only` 直接发布,不必重跑那 25 分钟的索引/谱。
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import time
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+# ★GBK 控制台/日志重定向不再因 '²' '⇒' 这类字符抛 UnicodeEncodeError (2026-09-19 远端实逮:
+#   `baseline_38_build.py` 打印 'm/s²' 时在 cp936 下 rc=1, 整条恢复链少一件产物)。
+#   只把**不可编码字符替换掉**, 不改流编码 —— 中文照旧可读。
+try:
+    sys.stdout.reconfigure(errors='replace')
+except Exception:
+    pass
+from src.console import soft  # noqa: E402
+
+soft()
+PY = sys.executable
+
+
+def human(n):
+    for u in ('B', 'KB', 'MB', 'GB'):
+        if n < 1024 or u == 'GB':
+            return f'{n:.1f} {u}'
+        n /= 1024
+
+
+def find_roots(station: pathlib.Path):
+    """→ 振动侧原始件根列表 (含 *_decode.json 的目录或导出 zip)。"""
+    roots = []
+    for name in ('windcms', 'm5_cms_tcm'):
+        d = station / name
+        if not d.is_dir():
+            continue
+        for z in sorted(d.rglob('*.zip')):
+            if z.name.upper().startswith('CMS') or 'decode' in z.name.lower():
+                roots.append(z)
+        for sub in sorted({p.parent for p in d.rglob('*_decode.json')}):
+            # 取**最上层**那个含 decode 文件的目录 (measurement/), 避免每台机组一个 root
+            top = sub
+            while top.parent != d and any(top.parent.rglob('*_decode.json')):
+                top = top.parent
+            if top not in roots:
+                roots.append(top)
+    return roots
+
+
+def run(step, cmd, log):
+    t0 = time.time()
+    print(f'[{step}] {" ".join(str(c) for c in cmd)}', flush=True)
+    p = subprocess.run([str(c) for c in cmd], cwd=str(ROOT), capture_output=True, text=True,
+                       encoding='utf-8', errors='replace')
+    tail = (p.stdout or '') + (p.stderr or '')
+    rec = dict(step=step, cmd=' '.join(str(c) for c in cmd), rc=p.returncode,
+               seconds=round(time.time() - t0, 1), tail=tail[-1200:])
+    log.append(rec)
+    print(f'    rc={p.returncode}  {rec["seconds"]}s', flush=True)
+    if p.returncode != 0:
+        print(tail[-2000:], flush=True)
+        raise SystemExit(f'步骤 {step} 失败 (rc={p.returncode})')
+    return rec
+
+
+def _move_tree(src, dst, log=print):
+    """把整个目录搬到 `dst`: 先整目录 rename(退避重试), 再逐件搬, 每件都重试。
+
+    ★**2026-09-19 远端实逮, 整条重算链就断在这里**(服务器 `D:\\产品\\app`):
+      索引(719 s)+谱(894 s) 刚落进 `windows\\_staging_ingest`(1710 件 / 3.3 GB),
+      紧接的 `staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问` ⇒ ④b 失败 ⇒
+      链在第 4 步停下 ⇒ 融合面 handoff / 变桨面 / 本体 / 契约全没产出,页面上就是
+      「需要关注 0 台 + 融合面缺件」。
+      成因不是权限:Windows 的**目录** rename 要求树里没有任何被打开的句柄,而刚写完这么多小文件时
+      杀软/桌面搜索/备份正在逐个扫它们(持句柄)⇒ 目录不许改名。等一会儿句柄就放开,所以:
+        ① 整目录 rename + 退避重试(2/4/8/15/20/30 s)
+        ② 还不行就**逐件搬**(每件自己重试;rename 不行就 copy+删 —— 读句柄通常允许共享读)
+      逐件路径下,个别仍被锁住的文件会在最后如实报出来(不静默丢件)。
+    """
+    import shutil as _sh
+    import time as _t
+    waits = [2, 4, 8, 15, 20, 30]
+    last = None
+    for w in [0] + waits:
+        if w:
+            _t.sleep(w)
+        try:
+            src.rename(dst)
+            return 'rename'
+        except OSError as e:                     # PermissionError / WinError 5 / 1224 …
+            last = e
+    log(f'  [!] 目录改名被占({type(last).__name__}: {last})→ 转逐件搬(杀软/索引在扫刚写的文件,'
+        f'等一会儿就好;逐件搬对读句柄友好)')
+    dst.mkdir(parents=True, exist_ok=True)
+    stuck, moved = [], 0
+    for p in sorted(src.rglob('*')):
+        rel = p.relative_to(src)
+        target = dst / rel
+        if p.is_dir():
+            target.mkdir(parents=True, exist_ok=True)
+            continue
+        target.parent.mkdir(parents=True, exist_ok=True)
+        ok = False
+        for w in [0, 1, 2, 5, 10, 20]:
+            if w:
+                _t.sleep(w)
+            try:
+                p.rename(target)
+                ok = True
+                break
+            except OSError:
+                pass
+            try:                                  # rename 不行就复制+删(读一般被允许)
+                _sh.copy2(p, target)
+                p.unlink()
+                ok = True
+                break
+            except OSError:
+                pass
+        if ok:
+            moved += 1
+        else:
+            stuck.append(str(rel))
+    for d in sorted([x for x in src.rglob('*') if x.is_dir()], reverse=True):
+        try:
+            d.rmdir()
+        except OSError:
+            pass
+    try:
+        src.rmdir()
+    except OSError:
+        pass
+    if stuck:
+        raise RuntimeError(
+            f'暂存窗里还有 {len(stuck)} 件被别的进程占着、搬不动(已搬 {moved} 件): {stuck[:8]}'
+            f'{" …" if len(stuck) > 8 else ""} —— 把占用方(杀软实时扫描/备份/资源管理器预览)停一下,'
+            f'或稍后重跑 `python scripts/vib_raw_build.py --publish-only`(不必重跑索引/谱)。')
+    log(f'  逐件搬完成: {moved} 件')
+    return 'per-file'
+
+
+def _rels_of_window(win: pathlib.Path, m5: pathlib.Path) -> dict:
+    """某窗的产物 → {相对产物仓的路径: 构建器说明}; 供正常摄入与 --register-only 共用 (单一实现)。"""
+    rels = {f'm5_cms_tcm/windows/{win.name}/index.parquet':
+            'scripts/rudong_tcm_index.py (54 列, 与包内 tcm_index.parquet 同列名列序)',
+            f'm5_cms_tcm/windows/{win.name}/spectra_meta.parquet':
+            'scripts/rudong_tcm_spectra.py',
+            'm5_cms_tcm/vib_raw_manifest.json': 'scripts/vib_raw_build.py'}
+    sp = win / 'spectra'
+    if sp.is_dir():
+        for p in sp.rglob('*.npz'):
+            rels[f'm5_cms_tcm/windows/{win.name}/spectra/{p.relative_to(sp).as_posix()}'] = \
+                'scripts/rudong_tcm_spectra.py (npz 分片: DataSets.DataSet.Values 空格串 → float 数组)'
+        # 谱库目录里那份 meta 是同一内容的两条读取路径之一, 也登记
+        rels[f'm5_cms_tcm/windows/{win.name}/spectra/spectra_meta.parquet'] = \
+            'scripts/rudong_tcm_spectra.py (与窗根同名件同内容: 兼顾两种读取约定)'
+    return rels
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--window', default=None, help='窗名 (默认按数据起始日推 wMMDD)')
+    ap.add_argument('--jobs', type=int, default=min(8, (os.cpu_count() or 4)))
+    ap.add_argument('--meas', default='FFT_', help="谱摄入的测量过滤 (默认 FFT_; ALL=全转)")
+    ap.add_argument('--skip-spectra', action='store_true', help='只做索引 (谱很占盘)')
+    ap.add_argument('--no-report', dest='with_report', action='store_false',
+                    help='只摄入(索引+谱), 不跑 windcms.py report/kb')
+    ap.set_defaults(with_report=True)   # ★2026-09-18 口径翻转: 见文件头"报告默认跑了"
+    ap.add_argument('--manifest-only', action='store_true',                    help='只补写 vib_raw_manifest.json(依已存在的窗; 不重跑摄入)')
+    ap.add_argument('--reimport-as-new', action='store_true',
+                    help='窗同名时**不替换**: 另起 `<窗>_reimport_<时分>`(该名被 data.EXCLUDE_DEFAULT 排除, '
+                         '不参与生产集)。默认口径是**替换同名窗**(旧窗留档为 `_superseded_<时分>`) —— '
+                         '用户令 2026-09-20「重算要按 data/raw 最新变化」')
+    ap.add_argument('--publish-only', action='store_true',
+                    help='恢复模式: 上次摄入已算完索引/谱但发布(改名)被占用而失败 ⇒ 直接发布暂存窗并接着跑'
+                         '报告链, 不重跑索引/谱 (见 _move_tree 的说明)')
+    ap.add_argument('--register-only', action='store_true',
+                    help='不摄入, 只把**已存在**的窗体件补进来源自登记 (幂等修复: 例如先前的摄入跑在自登记'
+                         '功能之前, 台账就漏记了那些件)')
+    ap.add_argument('--limit', type=int, default=0, help='冒烟: 只摄入前 N 个文件')
+    ap.add_argument('--dry-run', action='store_true')
+    a = ap.parse_args()
+
+    from src.windscada.config import farm, raw_station_dir
+    from src import paths as P
+    cfg = farm(a.farm)
+    station = pathlib.Path(raw_station_dir(a.farm))
+    m5 = P.m5(a.farm)
+    roots = find_roots(station)
+    print(f'场站原始件目录: {station}')
+    print(f'振动侧产物目录: {m5}')
+
+    if a.register_only:
+        # 只补登记: 找已存在的窗 (--window 指定, 否则取名字最大的那个), 逐件登记。
+        # 放在"源件存在性检查"之前 —— 补登记不需要重新读原始件。
+        cands = sorted(p for p in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
+                       if (p / 'index.parquet').exists())
+        if a.window:
+            cands = [p for p in cands if p.name == a.window]
+        if not cands:
+            print('没有已存在的窗可登记 (先正常跑一次摄入)')
+            return 0
+        win = cands[-1]
+        rels = _rels_of_window(win, m5)
+        from src.derived_manifest import record as _rec
+        p = _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py')
+        print(f'已登记 {len(rels)} 件 (窗 {win.name}) → {p}')
+        return 0
+
+    if a.manifest_only:
+        # 2026-09-19: 摄入已跑完、清单却没写成的情形(实测: ④b 在 report 步崩掉 → 本脚本在写清单**之前**
+        # 就退出 ⇒ `m5_cms_tcm/vib_raw_manifest.json` 缺失, 页面归口审计按"溯源缺失"报 rc=6、整条链报失败)。
+        # 本模式**只补写清单**, 不重跑摄入(重跑会再花 ~22 分钟并另立一个 _reimport 窗)。
+        cands = sorted(p for p in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
+                       if (p / 'index.parquet').exists())
+        if a.window:
+            cands = [p for p in cands if p.name == a.window]
+        if not cands:
+            print('没有已存在的窗可写清单 (先正常跑一次摄入)')
+            return 0
+        win = cands[-1]
+        import pandas as _pd
+        ix = _pd.read_parquet(win / 'index.parquet',
+                              columns=[c for c in ('turbine', 'sensor_name', 'meas_name', 'trigger_time')
+                                       if c in _pd.read_parquet(win / 'index.parquet').columns])
+        sm = win / 'spectra_meta.parquet'
+        n_spec = int(len(_pd.read_parquet(sm, columns=['shard']))) if sm.is_file() else 0
+        ts = _pd.to_datetime(ix['trigger_time'], errors='coerce') if 'trigger_time' in ix else None
+        man = dict(farm=a.farm, window=win.name, window_dir=str(win.relative_to(ROOT)),
+                   sources=[str(r.relative_to(ROOT)) if str(r).startswith(str(ROOT)) else str(r) for r in roots],
+                   sources_note='路径相对<安装目录>; ★本清单由 --manifest-only 依**已存在的窗**补写 '
+                                '(2026-09-19), 不是摄入当次的原始记录 —— steps 留空, 时间取窗首末',
+                   rows=int(len(ix)), spectra=n_spec,
+                   time_min=(str(ts.min()) if ts is not None and len(ts) else ''), time_max=(str(ts.max()) if ts is not None and len(ts) else ''),
+                   turbines=int(ix.turbine.nunique()) if 'turbine' in ix else 0,
+                   sensors=int(ix.sensor_name.nunique()) if 'sensor_name' in ix else 0,
+                   meas_names=int(ix.meas_name.nunique()) if 'meas_name' in ix else 0,
+                   steps=[], finished=time.strftime('%Y-%m-%d %H:%M:%S'), seconds=None,
+                   missing_chain=[], missing_note='')
+        (m5 / 'vib_raw_manifest.json').write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+        print(f'已补写 {P.rel(m5 / "vib_raw_manifest.json")}: 窗 {win.name} · {len(ix)} 行 · {n_spec} 谱')
+        try:
+            from src.derived_manifest import record as _rec
+            rels = _rels_of_window(win, m5)
+            _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py (manifest-only)')
+            print(f'  已自登记 {len(rels)} 件')
+        except Exception as _e:
+            print(f'  [i] 自登记跳过: {type(_e).__name__}: {_e}')
+        return 0
+
+    if not roots:
+        print('没找到振动原始件 (*_decode.json 目录或 CMS 导出 zip)。'
+              '先用 scripts/place_raw_data.py --scope vib 从现场包落位。')
+        return 0
+    for r in roots:
+        n = len(list(r.rglob('*_decode.json'))) if r.is_dir() else '(zip)'
+        sz = sum(p.stat().st_size for p in r.rglob('*_decode.json')) if r.is_dir() else r.stat().st_size
+        print(f'  源件根: {r}   {n} 个 decode 文件, {human(sz)}')
+    if a.dry_run:
+        print('(dry-run, 未摄入)')
+        return 0
+
+    windows_dir = m5 / 'windows'
+    staging = windows_dir / '_staging_ingest'          # 'test'/'_reimport' 之外的名字, 会被 data.windows() 看见 → 立即改名
+    log = []
+    t0 = time.time()
+    idx = staging / 'index.parquet'
+    # ★`root` 必须在两个分支之前定好 (2026-09-19 实逮: 加 --publish-only 时把它挪进了 else 分支,
+    #   于是"只发布"这条路走到 spectra 那步就 UnboundLocalError —— 远端第一次恢复就撞上, rc=1)
+    root = roots[0] if len(roots) == 1 else station / 'windcms'   # 多个根时交给各自目录 (index 支持 rglob)
+    if a.publish_only:
+        # ★恢复通道 (2026-09-19 远端实逮后的补): 上次摄入把索引+谱**已经算完**了, 只是最后的改名被
+        #   占住失败 ⇒ 别让运维白等 20~30 分钟重跑索引/谱, 直接把暂存窗发布出去, 然后照常接着跑报告链。
+        if not idx.is_file():
+            print(f'[X] 没有可发布的暂存窗: {idx} 不存在 —— 正常跑一次摄入 (python scripts/vib_raw_build.py)')
+            return 4
+        print(f'恢复模式 --publish-only: 直接发布已算好的暂存窗 {staging}(不重跑索引/谱)')
+    else:
+        if staging.exists():
+            shutil.rmtree(staging)
+        staging.mkdir(parents=True, exist_ok=True)
+        run('index', [PY, str(ROOT / 'scripts/rudong_tcm_index.py'), '--root', str(root), '--out', str(idx),
+                      '--jobs', str(a.jobs)] + (['--limit', str(a.limit)] if a.limit else []), log)
+
+    n_spectra = 0
+    if not a.skip_spectra and not a.publish_only:
+        run('spectra', [PY, str(ROOT / 'scripts/rudong_tcm_spectra.py'), '--root', str(root),
+                        '--out', str(staging / 'spectra'), '--meas', a.meas, '--jobs', str(a.jobs)]
+            + (['--limit', str(a.limit)] if a.limit else []), log)
+    # ★谱数在**两种模式**下都要数 (2026-09-19 远端实逮: --publish-only 那条路把 n_spectra 留在 0,
+    #   于是清单/报告写"谱 0" —— 明明有 1710 件谱件落着; 清单是给审计对账用的, 少报就是账不实)
+    sm = staging / 'spectra' / 'spectra_meta.parquet'
+    if sm.exists():
+        import pandas as pd
+        n_spectra = len(pd.read_parquet(sm, columns=['shard']))
+    elif (staging / 'spectra').is_dir():
+        n_spectra = sum(1 for _ in (staging / 'spectra').rglob('*.npz'))
+
+    # 窗名: 用数据自身的起始日 (wMMDD), 与既有 w0127/w0707/w0811 同口径
+    import pandas as pd
+    d = pd.read_parquet(idx)
+    tt = pd.to_datetime(d.trigger_time, errors='coerce')
+    tmin, tmax = tt.min(), tt.max()
+    win = a.window or f'w{tmin:%m%d}'
+    final = windows_dir / win
+    if final.exists():
+        # ★2026-09-20 用户令「重算时要根据 <data/raw> 目录**最新的变化**重算」⇒ 默认**替换同名窗**:
+        #   原先这里一律改叫 `<窗>_reimport_<时分>`, 而 `data.EXCLUDE_DEFAULT` 把 `_reimport` 排除在
+        #   生产集之外 ⇒ 往 windcms 里补了数据再重算, **生产集用的还是旧窗**, 新数据等于没进系统
+        #   (页面上看不出任何变化, 正是"重算没反映最新变化"的典型)。
+        #   现在: 旧窗改名为 `<窗>_superseded_<时分>` 留档(按名字排除, 不参与生产), 新窗用**正名**发布;
+        #   要旧行为(不碰既有窗、另起 _reimport 窗)用 `--reimport-as-new`。
+        if a.reimport_as_new:
+            final = windows_dir / f'{win}_reimport_{time.strftime("%m%d%H%M")}'
+            print(f'⚠ 窗 {win} 已存在 → 按 --reimport-as-new 落到 {final.name} '
+                  f'(data.EXCLUDE_DEFAULT 排除 _reimport 窗, 不参与生产集)')
+        else:
+            old_win = windows_dir / f'{win}_superseded_{time.strftime("%m%d%H%M")}'
+            print(f'窗 {win} 已存在 → 按"重算=按当前数据重算"口径**替换**: 旧窗留档为 {old_win.name}'
+                  f'(按名字排除, 不参与生产), 新窗用正名 {win} 发布')
+            _move_tree(final, old_win, print)
+    print(f'\n发布窗目录: {final.name}({_move_tree(staging, final, print)} 方式)'
+          f'   数据窗 {tmin} → {tmax}   行 {len(d)}   谱 {n_spectra}')
+
+    if a.with_report:
+        # ★2026-09-19 补链序: 六层链的业务顺序是 oem_scan → energy_share → **model_run → fusion** → report,
+        #   报告要读 model_run_l6.parquet 与 fusion_38.csv 才有"融合级"那一列。原先这里直接跑 report,
+        #   于是清过产物的机器上报告永远缺融合级(整列 '—')。现在按序补齐(两步都是**按口径重建**, 无标准答案
+        #   对拍 —— 口径与来路写在各自产物与台账里, 见 docs/振动六层链_接口规格与缺口_v0.1.md §3)。
+        run('model_run', [PY, str(ROOT / 'scripts/rudong_model_run.py'), '--window', final.name], log)
+        # 六层链的 energy_share 步(2026-09-19 补上: 此前该脚本未随包 ⇒ 链上少一环):
+        # 线能量占比 = ±2bin 带内能量 ÷ 全谱能量; 只报占比不定级。
+        run('energy_share', [PY, str(ROOT / 'scripts/rudong_line_energy_share.py'), '--window', final.name], log)
+        run('fusion', [PY, str(ROOT / 'scripts/rudong_fusion_run.py'), '--window', final.name], log)
+        # CMS 掩码阈值件(观澜自算: 中位+3/6×MAD 自适应基线; 现场导出件里没有厂商掩码定义)
+        run('mask_thresholds', [PY, str(ROOT / 'scripts/tcm_mask_thresholds_build.py'), '--window', final.name], log)
+        run('report', [PY, str(ROOT / 'scripts/windcms.py'), 'report', '--farm', a.farm], log)
+        # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码,确保别的电脑装完系统运行正常」:
+        #   融合面 handoff 此前是**振动线人工交证件**、全库 0 处写入方 ⇒ 新机器上 `/detail/v2` 的
+        #   「需要关注/全场状态」永远空着。现在由观澜自算生成同结构件(脚本在包内; 盘上若有正本则不覆盖)。
+        run('fusion_handoff', [PY, str(ROOT / 'scripts/rudong_fusion_handoff.py')], log)
+        run('kb', [PY, str(ROOT / 'scripts/windcms.py'), 'kb', '--farm', a.farm], log)
+        # 六层链 fusion 步里**唯一已被标准答案验证过**的那一件 (fleet_scalar_z): 窗索引 → 同工况族中位
+        # → 稳健 z。上面那次 fusion 已经一并出它 (同一条命令: --window final.name)。
+    else:
+        print('(按 --no-report 跳过 CMS 报告/知识库重生成 —— 页面上"振动评估"一栏会显示为无产物)')
+
+    man = dict(farm=a.farm, window=final.name, window_dir=str(final.relative_to(ROOT)),
+               # 源件路径写**相对安装根**的形态: 这文件会随包分发, 绝对路径换机后就是死链
+               # (check_transferable.py 会把 outputs 下的绝对路径算作"机器相关路径")
+               sources=[str(r.relative_to(ROOT)) if str(r).startswith(str(ROOT)) else str(r) for r in roots],
+               sources_note='路径相对<安装目录>', rows=int(len(d)), spectra=int(n_spectra),
+               time_min=str(tmin), time_max=str(tmax),
+               turbines=int(d.turbine.nunique()), sensors=int(d.sensor_name.nunique()),
+               meas_names=int(d.meas_name.nunique()),
+               steps=[{k: v for k, v in r.items() if k != 'tail'} for r in log],
+               finished=time.strftime('%Y-%m-%d %H:%M:%S'), seconds=round(time.time() - t0, 1),
+               missing_chain=['rudong_tcm_oem_scan', 'rudong_line_energy_share', 'rudong_model_run',
+                              'rudong_fusion_run'],
+               missing_note='六层链的四步脚本未随包 (振动线分支 claude/vibration-data-diagnosis-32b69e); '
+                            '本脚本只做索引/谱/报告/知识库, 不冒充跑过那四步')
+    (m5 / 'vib_raw_manifest.json').write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+
+    # 来源自登记: 让 _provenance.json 把这些件记成 raw-derived (见 src/derived_manifest.py 的说明)
+    try:
+        from src.derived_manifest import record as _rec
+        rels = _rels_of_window(final, m5)
+        if a.with_report:
+            for p in (P.cms(a.farm)).glob('报告_CMS振动状态评估报告_*.md'):
+                # 只登记"今天生成的"这一件 (随包/厂家转录的那些不冒充自算)
+                if time.strftime('%Y-%m-%d') in p.name:
+                    rels[f'windcms/{p.name}'] = 'scripts/windcms.py report (数据窗: ' + final.name + ')'
+        _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py')
+        print(f'  自登记 {len(rels)} 件产物 → {P.out_root(a.farm) / "_derived_manifest.json"}')
+    except Exception as _e:
+        print(f'  ⚠ 来源自登记失败 ({type(_e).__name__}: {_e}) —— 台账会漏记这些件, 但不影响产物本身')
+
+    print(f'\n完成: 用时 {time.time() - t0:.0f}s; 清单 → {m5 / "vib_raw_manifest.json"}')
+    print(f'  新窗 {final.name} 已进分析集: windcms 的谱图/标量/报告页会自动收录它 '
+          f'(消费者读取时现扫 m5\\windows\\w????\\index.parquet, 无需改代码)')
+    print('  windscada 融合面读 handoff (m5_cms_tcm/handoff_vibration_v2.json) —— 那是振动线出件, 本次不动它')
+    print('  看效果: python scripts/windcms.py serve --port 8020  → http://127.0.0.1:8020/')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 206 - 0
app_ETL/app_ETL_guanlan/builders/windscada_monthly_build.py

@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""月度派生件构建器 —— 补上 v0.2.0 里"有消费者、却没生成端"的那批产物 (2026-09-12)。
+
+## 为什么有这个脚本
+
+从零重算 (`rebuild_from_raw.py`) 只覆盖 16 件; 随包里另有 15 件 parquet **没有任何生成端**
+(全库搜遍: 只有读取方、0 处写入方), 于是工作台主数据 `/api/fleet` 一缺 `temp_monthly.parquet`
+就整个回 `err=no_products` —— 页面看着像"没数据", 其实是"缺产物"。
+
+本脚本把这批件**从 data/raw 重新算出来**, 并且**逐值对齐随包件**: 随包件在这里当"标准答案"
+(存在 `_products_off/.../windscada/`), `--verify` 会报每件的命中率; 规则是从"标准答案"反推并
+用全量比对确认的, 不是猜的。
+
+## 已确认的口径 (2026-09-12, 用随包件反推 + 逐值验证)
+
+    temp_monthly          每台 × 每月 × 每个温度通道, 在 **发电态功率门 p ≥ 500kW** 行上的**中位数**
+                          形状 38×19×27 = 19494 行; 实测 WTG01 的 513 格 **逐值 100% 相同**
+                          (对照: 全行中位数只有 5.8% 命中 —— 因为停机时绕组是冷的, 会把中位拉低)
+                          口径出处: `src/windscada/subsys/temp_nbm.py` 的"发电态×功率档(500kW)", PBINS 步长 500
+
+## 用法
+
+    python scripts/windscada_monthly_build.py --verify        # 只比对(不写盘), 报每件命中率
+    python scripts/windscada_monthly_build.py                 # 算并写盘
+    python scripts/windscada_monthly_build.py --turbines WTG01,WTG02   # 只算几台(调试用)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+import argparse
+import pathlib
+import sys
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+sys.path.insert(0, str(ROOT))
+
+import pandas as pd                                          # noqa: E402
+
+from src import paths as P                                   # noqa: E402
+from src.windscada.config import farm                        # noqa: E402
+from src.windscada.data import load_10min, contracted_cols   # noqa: E402
+
+POWER_GATE = 500.0        # 发电态功率门 (kW); 出处见 temp_nbm 的"发电态×功率档(500kW)"
+GROUPS = ['A.功率', 'B.温度NBM']
+
+
+def baseline_dir():
+    """"标准答案"目录: 随包件在哪 (整目录视角; 单件比对用 baseline_for)。"""
+    inplace = P.store() / '_pre_rebuild_20260911'
+    if inplace.is_dir():
+        return inplace
+    for p in sorted((ROOT / '_products_off').rglob('windscada')):
+        if p.is_dir():
+            return p
+    return None
+
+
+def baseline_for(name: str):
+    """这一件产物的"标准答案"在哪。
+
+    注意两个暂存处**内容不同**, 必须逐件找:
+      · `outputs/<场>/windscada/_pre_rebuild_20260911/` = **台账验收基线**(3 件: alarms/workorders/oil_samples);
+      · `_products_off*/**/windscada/`                   = 随包整份产物(31 件 parquet, 含月度派生件)。
+    原先只认第一个目录, 于是 temp_monthly 会被报成"没有随包基线可比", 而结论却打印"全部逐值一致"
+    —— 2026-09-12 实测逮到(假结论比没结论更糟)。
+    """
+    cands = []
+    bd = baseline_dir()
+    if bd:
+        cands.append(bd / name)
+    for p in sorted((ROOT).glob('_products_off*/**/windscada')):
+        cands.append(p / name)
+    for c in cands:
+        if c.is_file():
+            return c
+    return None
+
+
+BASELINE = baseline_dir()
+
+
+def temp_channels(cfg) -> list:
+    """契约里 B.温度NBM 组的活通道 (以 _mean 结尾) —— temp_monthly/watch_channels 的列集。"""
+    return [c for c in contracted_cols(cfg, groups=['B.温度NBM']) if c.endswith('_mean')]
+
+
+def month_of(s: pd.Series) -> pd.Series:
+    return s.dt.to_period('M').astype(str)
+
+
+def build_temp_monthly(cfg, turbines=None, progress=True):
+    """→ (长表 DataFrame[turbine, channel, month, med], 明细 dict)
+
+    规则: 每台 × 每月 × 每个温度通道, 在 p ≥ 500kW 行上的中位数。
+
+    ★**不做"样本太少就不出月"的门** (用户令 2026-09-20 裁决: 保持现状、如实反映源件):
+      实测 2026-09 只有 1 行(补充件 `WTG01-B2.csv` 的最后一个时间戳 `2026-09-01 00:00`),
+      该月照样出一行 —— 页面时间窗按月过滤时它就"一格数据"。理由是**产物=当前 data/raw 的函数**:
+      源件里有这个时间戳, 就该看得见; 加"最少样本"门会让"数据到没到"这件事变成猜的。
+      要少这一格, 现场把那个时间戳裁掉或让现场别导出月初那一行即可, 不在本器里静默过滤。
+    """
+    chans = temp_channels(cfg)
+    rows, info = [], {}
+    for t in (turbines or cfg['turbines']):
+        d = load_10min(t, cfg, groups=GROUPS)
+        d['month'] = month_of(d['ts'])
+        n_all = len(d)
+        d = d[d['grd_wtc_ActPower_mean'] >= POWER_GATE]
+        if not len(d):
+            info[t] = dict(rows_all=n_all, rows_gate=0, months=0)
+            continue
+        med = d.groupby('month')[chans].median()
+        long = med.stack().rename('med').reset_index()
+        long.columns = ['month', 'channel', 'med']
+        long.insert(0, 'turbine', t)
+        rows.append(long)
+        info[t] = dict(rows_all=n_all, rows_gate=len(d), months=len(med))
+        if progress:
+            print(f'    {t}: 全 {n_all} 行 → 发电态 {len(d)} 行, {len(med)} 个月', flush=True)
+    return (pd.concat(rows, ignore_index=True) if rows else pd.DataFrame(columns=['turbine', 'channel', 'month', 'med'])), info
+
+
+PRODUCTS = {
+    'temp_monthly.parquet': build_temp_monthly,
+}
+
+
+def verify(cfg) -> int:
+    """逐件与随包件比对: 行数、键集合、逐值相等比例。
+
+    三种结果分开报, 不许混:
+      ✅ 逐值一致  ❌ 有差异(规则需修正)  ?? 找不到标准答案(**不能**当成一致)
+    """
+    ok_all, compared, no_base = True, 0, []
+    for name in PRODUCTS:
+        mine_p = P.store() / name
+        if not mine_p.exists():
+            print(f'  [ -- ] {name:34s} 本仓还没算 (跳过)')
+            continue
+        base_p = baseline_for(name)
+        if base_p is None:
+            no_base.append(name)
+            print(f'  [ ?? ] {name:34s} 找不到随包件可对比 (台账基线只有 3 件; 随包整份产物在 _products_off*/ 下)')
+            continue
+        compared += 1
+        a, b = pd.read_parquet(base_p), pd.read_parquet(mine_p)
+        keys = [c for c in a.columns if c != 'med']
+        m = a.merge(b, on=keys, how='outer', suffixes=('_base', '_mine'), indicator=True)
+        val = [c for c in a.columns if c not in keys][0]
+        both = m[m._merge == 'both']
+        n = len(both)
+        eq = int((both[f'{val}_base'].sub(both[f'{val}_mine']).abs() < 1e-9).sum())
+        only_b = int((m._merge == 'left_only').sum())
+        only_m = int((m._merge == 'right_only').sum())
+        good = (n > 0 and eq == n and only_b == 0 and only_m == 0)
+        ok_all &= good
+        mark = '✅' if good else '❌'
+        print(f'  [{mark}] {name:34s} 基线 {len(a)} 行 / 本仓 {len(b)} 行; 逐值相等 {eq}/{n};'
+              f' 仅基线 {only_b}; 仅本仓 {only_m}   [标准答案: {P.rel(base_p)}]')
+        if not good and n:
+            d = both.assign(_d=(both[f'{val}_base'] - both[f'{val}_mine']).abs())
+            print('       差异最大的 3 格:')
+            print(d.nlargest(3, '_d')[keys + [f'{val}_base', f'{val}_mine']].to_string(index=False))
+    if not compared:
+        print(f'\n结论: **无法比对**(没有一件找到随包件) —— 这不是"通过"。缺标准答案: {no_base}')
+        return 5
+    if no_base:
+        print(f'\n注意: 另有 {len(no_base)} 件找不到标准答案, 未比对: {no_base}')
+    print('\n结论:', '已比对的部分全部逐值一致' if ok_all else '有差异 —— 规则还需修正, 别急着写盘')
+    return 0 if ok_all else 4
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--verify', action='store_true', help='只与随包基线比对, 不写盘')
+    ap.add_argument('--turbines', default=None, help='只算这几台 (逗号分隔)')
+    a = ap.parse_args()
+    cfg = farm()
+    ts = a.turbines.split(',') if a.turbines else None
+    print(f'场站 {cfg["name"] if "name" in cfg else cfg["raw_station"]} · 温度通道 {len(temp_channels(cfg))} 个 · 发电态功率门 {POWER_GATE:.0f} kW\n')
+    if not a.verify:
+        for name, fn in PRODUCTS.items():
+            print(f'== 算 {name}')
+            df, info = fn(cfg, turbines=ts)
+            out = P.store() / name
+            out.parent.mkdir(parents=True, exist_ok=True)
+            df.to_parquet(out, index=False)
+            print(f'   → {P.rel(out)}  {df.shape[0]} 行 × {df.shape[1]} 列\n')
+    print('== 与随包基线逐值比对 ==')
+    return verify(cfg)
+
+
+if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 936): print 里的 ✅ ❌ 编不出来会抛 UnicodeEncodeError,
+    # 明明算完了却以退出码 1 结束。降级为 '?' 而不是崩(同类坑见 src/console.py)。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 84 - 0
app_ETL/app_ETL_guanlan/data.py

@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+"""windscada L0 数据层: 契约驱动加载 + 校验门 (统一技术栈, 双翼共用).
+铁律: 契约外列不加载; 校验门不过不进分析; 稀发数字量按类判活."""
+import pandas as pd, pathlib, yaml
+from src.windscada.config import farm          # config 属后端模块(P4),暂留原路径
+
+def contract(cfg=None):
+    cfg = cfg or farm()
+    return yaml.safe_load(pathlib.Path(cfg['contract']).read_text(encoding='utf-8'))
+
+def contracted_cols(cfg=None, groups=None):
+    c = contract(cfg)
+    out = []
+    for g, chans in c['groups'].items():
+        if groups and g not in groups:
+            continue
+        out += [name for name, meta in chans.items() if meta['liveness'] == '活']
+    return sorted(set(out))
+
+def gate(cfg=None):
+    """G 门: 契约存在 + 活列占比 + 死嫌清单. 不过门 → 下游拒跑 (响亮)."""
+    c = contract(cfg)
+    total = sum(len(v) for v in c['groups'].values())
+    alive = sum(1 for v in c['groups'].values() for m in v.values() if m['liveness'] == '活')
+    ok = alive / max(total, 1) >= 0.8
+    return dict(ok=ok, alive=alive, total=total, dead=c.get('dead_suspect', []),
+                note=f"契约 {c['version']}: 活 {alive}/{total}; 未契约列 {c['n_cols_uncontracted']} 已登记不假装覆盖")
+
+def load_10min(turbine, cfg=None, groups=None):
+    """一台的 10min 数据(**CSV 与 MDB 两种形态都认**,用户令 2026-09-19)。
+
+    取数走 `scada_source`:`<src_10min>/<台>.csv` 在位就用 CSV;该台没有 CSV 才回落
+    `data/raw/<场站>/scada_mdb/<年>年/<月>月/*.mdb`(现场年度归档形态)。用的哪种记在
+    返回表的 `attrs['scada_source']`(并在首次使用时打一行,不静默换源)。
+    """
+    cfg = cfg or farm()
+    g = gate(cfg)
+    if not g['ok']:
+        raise RuntimeError(f"L0 校验门不过: {g['note']}")
+    c = contract(cfg)
+    want = [c['ts_col']] + contracted_cols(cfg, groups)
+    from app_ETL.app_ETL_guanlan import scada_source as SS
+    d = SS.load('10min', turbine, columns=want, cfg=cfg)
+    src = str(d.attrs.get('scada_source') or '?')
+    _note_source(src)
+    # 契约外列不加载(两形态统一)—— CSV 侧原先在 read_csv 时用 usecols 做到, MDB 侧在这里收口
+    keep = [x for x in d.columns if x in want]
+    d = d.loc[:, keep].copy()
+    if not keep:
+        raise RuntimeError(f'{turbine}: 源件里没有契约列 (想要 {want[:4]}…; 实际列 {list(d.columns)[:6]}…)')
+    ts = c['ts_col']
+    d[ts] = pd.to_datetime(d[ts], errors='coerce')
+    out = d.dropna(subset=[ts]).rename(columns={ts: 'ts'})
+    out.attrs['scada_source'] = src
+    return out
+
+
+_NOTE_DONE: set = set()
+
+
+def _note_source(src: str) -> None:
+    """首次用到某种形态时说明一次(让人知道这次重算是从 CSV 还是从 MDB 取的)。"""
+    if src in _NOTE_DONE:
+        return
+    _NOTE_DONE.add(src)
+    print(f'  [i] SCADA 源形态: {src}'
+          + ('(<src_10min>/<台>.csv)' if src == 'csv' else '(scada_mdb 年度归档库)'), flush=True)
+
+
+def source_report(cfg=None) -> dict:
+    """本机 SCADA 收资形态盘点(页面/自检用): 各形态件数 + 逐台会走哪种。"""
+    from app_ETL.app_ETL_guanlan import scada_source as SS
+    cfg = cfg or farm()
+    out = {}
+    for cls in ('10min', '1min'):
+        s = SS.sources(cls, cfg)
+        turbs = SS.turbines(cls, cfg)
+        kinds = {}
+        for t in turbs:
+            k = SS.source_of(cls, t, cfg) or 'none'
+            kinds[k] = kinds.get(k, 0) + 1
+        out[cls] = dict(n_csv=s['n_csv'], n_mdb=s['n_mdb'], n_zip=s['n_zip'], n_turbines=len(turbs),
+                        by_source=kinds, csv_dir=str(s['csv_dir']), mdb_root=str(s['mdb_root']))
+    return out

+ 43 - 0
app_ETL/app_ETL_guanlan/mdb_names.py

@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+"""MDB 列名规则(单一实现):CSV 表头 → Access 可用的列名。
+
+为什么要有这个模块:Access 的列名不能含 `.` `!` `[` `]` 等字符,建库时要把表头"消毒"成合法名;
+**摄入侧按列名取数**(契约里的列名是 CSV 原样名)⇒ 两边必须用**同一套规则**,否则从 MDB 取数会
+静默少列(用户令 2026-09-19「scada 数据接入兼容支持 CSV、MDB」的必答题)。
+
+`scripts/csv_to_mdb.py`(写侧)与 `src/windscada/scada_source.py`(读侧)都从这里取同一实现。
+"""
+from __future__ import annotations
+
+import re
+
+KEEP = re.compile(r'[^0-9A-Za-z_\u4e00-\u9fff]')
+
+
+def safe_name(col: str, maxlen: int = 60) -> str:
+    """单个表头 → 合法列名(去掉非法字符、截断;长度上限 60 便于撞名时加后缀)。"""
+    return KEEP.sub('_', str(col).lstrip('\ufeff'))[:maxlen] or 'c'
+
+
+def safe_names(cols, keep: int = 60) -> list[str]:
+    """一串表头 → 合法且**互不重名**的列名(撞名加 `_1` `_2`…,与写侧逐一对齐)。"""
+    out, seen = [], {}
+    for i, c in enumerate(cols):
+        n = safe_name(c, keep) or f'c{i}'
+        if n in seen:
+            seen[n] += 1
+            n = f'{n[:keep - 4]}_{seen[n]}'
+        else:
+            seen[n] = 0
+        out.append(n)
+    return out
+
+
+def map_cols(union: list[str]) -> dict[str, str]:
+    """源表头 → 库内列名(按 safe_names 的顺序规则算,用于读侧把契约列名翻成库内列名)。"""
+    return dict(zip(union, safe_names(list(union))))
+
+
+def stored_to_source(union: list[str]) -> dict[str, str]:
+    """库内列名 → 源表头(读侧取回后把名字改回来,让下游看到契约里的列名)。"""
+    return {v: k for k, v in map_cols(union).items()}

+ 26 - 0
app_ETL/app_ETL_guanlan/objectstore.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+r"""源件与产物对象存储接口(**组件接入点:MinIO**)。
+
+现在:源件在 `data/raw/<场站>/`、产物在 `outputs/<场>/`。
+目标:以**对象键**(`<场站>/<族>/<件名>`)取代绝对路径,后端可换 MinIO。
+
+P0 只立契约,无实现(默认仍是本地目录)。
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Iterator
+
+
+class ObjectStore(ABC):
+    @abstractmethod
+    def put(self, key: str, path) -> str: ...
+
+    @abstractmethod
+    def get(self, key: str, dest) -> str: ...
+
+    @abstractmethod
+    def list(self, prefix: str = '') -> Iterator[str]: ...
+
+    @abstractmethod
+    def stat(self, key: str) -> dict: ...

+ 444 - 0
app_ETL/app_ETL_guanlan/scada_source.py

@@ -0,0 +1,444 @@
+# -*- coding: utf-8 -*-
+r"""SCADA 原始数据接入层:**CSV 与 MDB 两种形态都认**(用户令 2026-09-19)。
+
+## 为什么要有这一层
+
+现场交来的 SCADA 收资有两种形态,此前只有第一种能被摄入:
+
+```
+data/raw/<场站>/scada_10min/WTG01.csv … WTG38.csv          ← 已导出 CSV(38 件 / 14.4 GB)
+data/raw/<场站>/scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb   ← 现场年度归档的 Access 库(25年/26年)
+```
+
+`src/windscada/data.py::load_10min()` 原来把路径**写死**成 `<src_10min>/<台>.csv`。用户令之后取数统一走本层:
+**CSV 在位就用 CSV**(既有 10 个构建器一直在用的形态,行数已核过),**该台没有 CSV 才回落 MDB**,
+并把"这次是从哪种形态取的"记在返回表的 `attrs['scada_source']` 里,供产物台账/页面说明来路 —— 不静默换源。
+
+## MDB 那一侧的两个硬事实(决定了本层怎么写)
+
+1. **单表 ≤255 列 ⇒ 库内按 250 列拆表**(`t1/t2/…`)。新库每行前两列是 `rid`(该台该月内的行号)与
+   `turbine`(机组标识)⇒ 取一台的多列数据要**跨分片表按 (rid, turbine) 拼**。
+2. **2026-09-19 之前建的老库缺 `turbine` 列**(那时按位置堆行)。10min 老库还能救:源件自带 `WTG` 列,
+   本层用它兜底;**1min 老库救不了**(源件没有任何机组标识列,且 38 件表头实测 16 种 ⇒ 位置堆法还会串列)。
+   处置:重跑 `scripts/csv_to_mdb.py`(现已写 `turbine` 列 + 按列名对齐),见 `docs/现场收资接入_v0.1.md` §8。
+
+读 MDB 走 ACE(`Microsoft.ACE.OLEDB.12.0`)+ PowerShell —— 与写库同一套(本机无 pyodbc/mdbtools;
+实测可用路线见 `docs/现场收资接入_v0.1.md` §7)。日期字段在 PowerShell 里显式格式化成
+`yyyy-MM-dd HH:mm:ss`(否则会按系统区域设置输出,下游时区/日序解析会踩雷)。
+
+## 用法
+
+    from src.windscada import scada_source as SS
+    SS.sources('10min', cfg)                     # 有哪些形态、各多少件(如实,不猜)
+    SS.turbines('10min', cfg)                    # 可用的机组名(CSV stem ∪ MDB 里的 turbine)
+    SS.load('10min', 'WTG01', columns=[...], cfg=cfg)   # → DataFrame(列名原样,ts 未解析)
+    SS.source_of('10min', 'WTG01', cfg)          # → 'csv' / 'mdb'
+
+纪律: 两种形态都取不到时**响亮 raise**(不返回空表 —— 空表会被下游当成"这台没数据")。
+"""
+from __future__ import annotations
+
+import functools
+import pathlib
+import re
+import subprocess
+import tempfile
+
+CLS = {'10min': ('src_10min', 'scada_10min'), '1min': ('src_1min', 'scada_1min')}
+TURBINE_COL = 'turbine'          # 新库的机组列(csv_to_mdb.py 2026-09-19 起写)
+LEGACY_TURBINE_COL = 'WTG'       # 老库兜底:10min 源件自带 WTG 列
+# ★同台多件 (用户令 2026-09-20「真正把新月份的 10min 件吃进来」): 现场后来补的导出件命名与主件不同
+#   (实测 `WTG01-B2.csv` 856 列 / 2026-08 数据),而原实现只认 `<台号>.csv` ⇒ 这批数据**从未被读过**,
+#   页面时间窗自然停在旧月份。现在本台取数 = 主件 + 同台补充件,按时间戳合并去重(主件优先)。
+SUPPLEMENT_GLOBS = ('{t}-*.csv', '{t}_*.csv')
+# 新旧导出的列名差: 新件把"类型前缀族"去掉了(`din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`)。
+# 实测 WTG01 老件 598 列里 597 列能这样对上(唯一对不上的是 `WTG`,新件用 `StationId`)。
+PREFIXES = ('din_', 'dot_', 'prs_', 'tmp_', 'grd_', 'tur_', 'cnt_', 'flg_', 'int_', 'evt_')
+TIME_CANDIDATES = ('TimeStamp', 'timestamp', 'Time', 'time', 'time_stamp', 'occur_time')
+
+_PS = r'''
+$ErrorActionPreference = "Continue"
+$c = New-Object -ComObject ADODB.Connection
+$c.Open("Provider=Microsoft.ACE.OLEDB.12.0;Data Source=@@MDB@@;")
+$turb = "@@TURB@@"
+$want = @(@@WANT@@)
+# 表名清单: 用 ADO 的 OpenSchema(20=adSchemaTables)。★`$c.GetSchema("Tables")` 会报
+# "Arguments are of the wrong type..."(那是 .NET 的方法名), 实测 2026-09-19 踩过。
+$tabs = @()
+$rs0 = $c.OpenSchema(20)
+while (-not $rs0.EOF) {
+  if ($rs0.Fields.Item("TABLE_TYPE").Value -eq "TABLE") { $tabs += $rs0.Fields.Item("TABLE_NAME").Value }
+  $rs0.MoveNext()
+}
+$rs0.Close()
+foreach ($t in $tabs) {
+  $rs = $c.Execute("SELECT TOP 1 * FROM [$t]")
+  $names = @()
+  foreach ($f in $rs.Fields) { $names += $f.Name }
+  $rs.Close()
+  # 机组列: 先试 turbine(新库), 再试 WTG(老 10min 库兜底); 两个都没有 ⇒ 这库按台取不到数
+  $tcol = $null
+  foreach ($p in @("turbine", "WTG")) {
+    try { $null = $c.Execute("SELECT TOP 1 [$p] FROM [$t]"); $tcol = $p; break } catch { }
+  }
+  $turbines = @()
+  if ($tcol) {
+    $rs1 = $c.Execute("SELECT DISTINCT [$tcol] FROM [$t] WHERE [$tcol] IS NOT NULL")
+    while (-not $rs1.EOF) { $turbines += ("" + $rs1.Fields.Item(0).Value); $rs1.MoveNext() }
+    $rs1.Close()
+  }
+  Write-Output ("#SCHEMA " + $t + "|" + ($names -join ",") + "|" + ($turbines -join ";"))
+  if (-not $tcol -or $turb -eq "") { continue }
+  $keep = @()
+  foreach ($w in $want) { if (($names -contains $w) -and $w -ne "rid" -and $w -ne $tcol) { $keep += $w } }
+  if ($want.Count -eq 0) {   # 不给白名单 = 全列(与 CSV 形态 columns=None 的语义一致)
+    foreach ($w in $names) { if ($w -ne "rid" -and $w -ne $tcol) { $keep += $w } }
+  }
+  $sel = @("rid", $tcol) + $keep
+  $collist = ($sel | ForEach-Object { "[$_]" }) -join ","
+  Write-Output ("#TABLE " + $t + "|" + ($sel -join ","))
+  $rs2 = $c.Execute("SELECT $collist FROM [$t] WHERE [$tcol] = '$turb'")
+  while (-not $rs2.EOF) {
+    $vals = @()
+    for ($i = 0; $i -lt $rs2.Fields.Count; $i++) {
+      $v = $rs2.Fields.Item($i).Value
+      if ($v -is [datetime]) { $vals += $v.ToString("yyyy-MM-dd HH:mm:ss") }
+      elseif ($null -eq $v) { $vals += "" }
+      else { $vals += ("" + $v) }
+    }
+    Write-Output ($vals -join "`t")
+    $rs2.MoveNext()
+  }
+  $rs2.Close()
+}
+$c.Close()
+'''
+
+
+def dirs_of(cls: str, cfg=None) -> tuple[pathlib.Path, pathlib.Path]:
+    """→ (CSV 目录, MDB 根)。两者都不必存在。"""
+    cfg = _cfg(cfg)
+    key, sub = CLS[cls]
+    csv_dir = pathlib.Path(str(cfg.get(key) or (pathlib.Path(str(cfg['raw_station_dir'])) / sub)))
+    mdb_root = pathlib.Path(str(cfg['raw_station_dir'])) / 'scada_mdb'
+    return csv_dir, mdb_root
+
+
+def _cfg(cfg=None):
+    if cfg is not None:
+        return cfg
+    from src.windscada.config import farm          # config 属后端模块(P4),暂留原路径
+    return farm()
+
+
+def sources(cls: str = '10min', cfg=None) -> dict:
+    """盘上有哪些形态(件数)—— 供页面/自检如实说明。"""
+    csv_dir, mdb_root = dirs_of(cls, cfg)
+    csvs = sorted(csv_dir.glob('*.csv')) if csv_dir.is_dir() else []
+    mdbs = sorted(mdb_root.rglob(f'*-{cls}.mdb')) if mdb_root.is_dir() else []
+    zips = sorted(mdb_root.rglob(f'*-{cls}.zip')) if mdb_root.is_dir() else []
+    return dict(csv_dir=csv_dir, mdb_root=mdb_root, n_csv=len(csvs), n_mdb=len(mdbs), n_zip=len(zips),
+                csvs=csvs, mdbs=mdbs, zips=zips,
+                kind=('csv' if csvs else ('mdb' if (mdbs or zips) else None)))
+
+
+@functools.lru_cache(maxsize=8)
+def schema_and_turbines(mdb: str) -> tuple:
+    """一个 MDB 的 (分片表 → 列名, 分片表 → 该列里的机组值)。老库缺 turbine 列时退 WTG 列。"""
+    lines = _ps(_PS.replace('@@MDB@@', str(mdb)).replace('@@TURB@@', '').replace('@@WANT@@', ''))
+    cols, turbs = {}, {}
+    for ln in lines:
+        if ln.startswith('#SCHEMA '):
+            body = ln[8:]
+            t, rest = body.split('|', 1)
+            names, tv = rest.split('|', 1)
+            cols[t] = [x for x in names.split(',') if x]
+            turbs[t] = tuple(sorted({x for x in tv.split(';') if x}))
+    return cols, turbs
+
+
+def _ps(script: str) -> list[str]:
+    """跑一段 PowerShell(ACE 只能经 COM 用)→ stdout 行。
+
+    ★退出码不可尽信(2026-09-19 实测): ACE 在**进程退出时清理 COM** 偶发 `0xC0000005`(访问违例),
+    此时 stderr 为空、stdout 完整(脚本里每一行都打出来了)。原先 `rc != 0 ⇒ raise` 会把这种"数据都拿到了"
+    的情况报成读库失败。判据改成**看输出**: 有输出且退出码是那个崩溃码 ⇒ 收下并提示一次; 否则才 raise。
+    """
+    p = pathlib.Path(tempfile.gettempdir()) / '_scada_source.ps1'
+    p.write_text(script + '\nexit 0\n', encoding='utf-8-sig')
+    r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(p)],
+                       capture_output=True, text=True, errors='replace', timeout=3600)
+    out = [x for x in (r.stdout or '').splitlines() if x.strip()]
+    crash = r.returncode in (-1073741819, 3221225477)
+    if r.returncode != 0 and not (crash and out):
+        raise RuntimeError((r.stderr or r.stdout or '').strip()[:400] or 'PowerShell 读 MDB 失败')
+    if crash and out and 'ace_exit_crash' not in _WARNED:
+        _WARNED.add('ace_exit_crash')
+        print('  [i] ACE 退出时 COM 清理崩溃 (0xC0000005) —— 输出完整, 已按成功处理', flush=True)
+    return out
+
+
+_WARNED: set = set()
+
+
+def mdb_turbines(cls: str = '10min', cfg=None, limit: int = 3) -> set:
+    """MDB 侧的可用机组名(取前几个库的并集 —— 逐库扫全量太慢,够用)。"""
+    s = sources(cls, cfg)
+    got = set()
+    for m in s['mdbs'][:limit]:
+        _cols, turbs = schema_and_turbines(str(m))
+        for t in turbs.values():
+            got |= set(t)
+    return got
+
+
+def turbines(cls: str = '10min', cfg=None) -> list[str]:
+    """可用机组名 = CSV 文件名 stem ∪ MDB 里的 turbine 值。"""
+    s = sources(cls, cfg)
+    got = {f.stem for f in s['csvs']} | mdb_turbines(cls, cfg)
+    return sorted(got)
+
+
+def source_of(cls: str, turbine: str, cfg=None) -> str | None:
+    """这一台这次会从哪种形态取(csv 优先)。"""
+    csv_dir, _ = dirs_of(cls, cfg)
+    if (csv_dir / f'{turbine}.csv').is_file():
+        return 'csv'
+    return 'mdb' if turbine in mdb_turbines(cls, cfg, limit=1) else None
+
+
+def files_of(cls: str, turbine: str, cfg=None) -> list:
+    """本台这一次要读的 CSV 件: **主件 `<台号>.csv` 在前**, 后面是同台补充件(`<台号>-*.csv` 等)。
+
+    用户令 2026-09-20: 现场补的件命名与主件不同(实测 `WTG01-B2.csv`),只认 `<台号>.csv` 会让
+    "新来的月份"静默漏读。这里如实列出这次会读的件(顺序=优先级),供取数/台账/页面说明来路。
+    """
+    csv_dir, _ = dirs_of(cls, cfg)
+    out = []
+    pri = csv_dir / f'{turbine}.csv'
+    if pri.is_file():
+        out.append(pri)
+    for pat in SUPPLEMENT_GLOBS:
+        out += sorted(csv_dir.glob(pat.format(t=turbine)))
+    seen, res = set(), []
+    for p in out:
+        if p.name not in seen:
+            seen.add(p.name)
+            res.append(p)
+    return res
+
+
+def _align_columns(df, want):
+    """按列名对齐新旧导出: 想要的列不在、但"去掉类型前缀"后在同表里 ⇒ 认回来。
+
+    返回 (df, 认回的列数)。实测: 老 598 列 → 新 856 列, 597/598 靠这条规则对上。
+    """
+    if not want or df is None or not len(df.columns):
+        return df, 0
+    have = set(df.columns)
+    back = {}
+    for c in want:
+        if c in have:
+            continue
+        for pre in PREFIXES:
+            if c.startswith(pre) and c[len(pre):] in have:
+                back[c[len(pre):]] = c
+                break
+    return (df.rename(columns=back) if back else df), len(back)
+
+
+def _time_col(frames, first_cols):
+    """时间列: 先按已知名找(合并后的表列名可能来自任一形态),再退回主件的首列。"""
+    cols = set(first_cols)
+    for f in frames:
+        cols |= set(f.columns)
+    for c in TIME_CANDIDATES:
+        if c in cols:
+            return c
+    return first_cols[0] if first_cols else None
+
+
+def load(cls: str, turbine: str, columns=None, cfg=None, month: str | None = None):
+    """取一台的数据(**CSV 优先**;该台没有 CSV 时回落 MDB)。
+
+    columns = 白名单(两形态都按"实际有的列"取交集,缺列不报错 —— 与 `data.load_10min` 的既有口径一致)。
+    month   = 'YYYY-MM',只取该月(MDB 形态天然是按月的;CSV 形态按首列前缀过滤)。
+    ★同台多件 (用户令 2026-09-20): `<台号>.csv` + `<台号>-*.csv` 一起读, 按列名对齐 + 按时间戳去重
+      (**主件优先**,重叠条数如实写在 `attrs['scada_overlap']` 里)。
+    """
+    import pandas as pd
+    fps = files_of(cls, turbine, cfg)
+    if fps:
+        frames, per = [], {}
+        for i, fp in enumerate(fps):
+            have = set(pd.read_csv(fp, nrows=0).columns)
+            take = None
+            if columns:
+                take = []
+                for c in columns:
+                    if c in have:
+                        take.append(c)
+                        continue
+                    for pre in PREFIXES:                     # 新导出把前缀去了 ⇒ 按去前缀名取列
+                        if c.startswith(pre) and c[len(pre):] in have:
+                            take.append(c[len(pre):])
+                            break
+            d = pd.read_csv(fp, usecols=take, engine='pyarrow') if take else \
+                (pd.read_csv(fp, usecols=lambda c: c in have, engine='pyarrow') if columns else
+                 pd.read_csv(fp, engine='pyarrow'))
+            d, n_back = _align_columns(d, columns or [])
+            first_cols = list(d.columns)
+            d['_scada_rank'] = i                          # i=0 是主件 ⇒ 去重时优先
+            frames.append(d)
+            per[fp.name] = len(d)
+        ts = _time_col(frames, first_cols)
+        d = pd.concat(frames, ignore_index=True, sort=False)
+        if month and ts:
+            d = d[d[ts].astype(str).str.startswith(month)]
+        overlap = 0
+        if len(frames) > 1 and ts:
+            before = len(d)
+            d = d.sort_values(['_scada_rank'], kind='stable').drop_duplicates(subset=[ts], keep='first')
+            overlap = before - len(d)
+        d = d.drop(columns=['_scada_rank'])
+        if ts in d.columns:
+            d = d.sort_values(ts, kind='stable').reset_index(drop=True)
+        else:
+            d = d.reset_index(drop=True)
+        if len(fps) > 1:
+            detail = '、'.join(f'{k} {v} 行' for k, v in per.items())
+            print(f'  [i] {turbine}: 同台 {len(fps)} 件合并读入({detail})'
+                  + (f';同时间戳重叠 {overlap} 行按"主件优先"去重' if overlap else ';无重叠'),
+                  flush=True)
+        d.attrs['scada_source'] = 'csv'
+        d.attrs['scada_files'] = [f.name for f in fps]
+        d.attrs['scada_rows'] = per
+        d.attrs['scada_overlap'] = overlap
+        return d.reset_index(drop=True)
+    if source_of(cls, turbine, cfg) != 'mdb':
+        raise RuntimeError(
+            f'{cls} 取数失败: {turbine} 既没有 CSV ({dirs_of(cls, cfg)[0] / f"{turbine}.csv"}),也没有含该台的 MDB '
+            f'({dirs_of(cls, cfg)[1]})。放原始件到 data/raw/<场站>/ 后重跑 scripts/rebuild_all.py。')
+    d = read_mdb(cls, turbine, columns, cfg, month)
+    d.attrs['scada_source'] = 'mdb'
+    return d
+
+
+def read_mdb(cls: str, turbine: str, columns=None, cfg=None, month: str | None = None):
+    """从月库里取一台:逐分片表按 (rid, turbine) 拼列。"""
+    import pandas as pd
+    from app_ETL.app_ETL_guanlan.mdb_names import safe_name
+    s = sources(cls, cfg)
+    mdbs = [m for m in s['mdbs'] if not month or m.stem.startswith(month)]
+    if not mdbs:
+        raise RuntimeError(f'{cls} 取数失败: scada_mdb 下没有 {month or "任何"} 的 .mdb')
+    # 契约列名 → 库内列名(Access 列名不能含 `.` `!` `[]` 等, 建库时消毒过; 两边同一套规则)
+    cand = {c: safe_name(c) for c in (columns or [])}
+    want_stored = sorted(set(cand.values()))
+    frames, missing_col = [], None
+    for m in mdbs:
+        want = want_stored
+        script = (_PS.replace('@@MDB@@', str(m)).replace('@@TURB@@', str(turbine))
+                  .replace('@@WANT@@', ','.join('"' + w + '"' for w in want)))
+        cur_t, cur_cols, buf, seen_cols = None, [], [], set()
+
+        def _flush():
+            if cur_t and buf:
+                frames.append(_frame(cur_cols, buf))
+        for ln in _ps(script):
+            if ln.startswith('#SCHEMA '):
+                continue
+            if ln.startswith('#TABLE '):
+                _flush()
+                body = ln[7:]
+                cur_t, cols = body.split('|', 1)
+                if not re.fullmatch(r't\d+', cur_t):        # Access 自建的杂表(如"名称自动更正保存失败")
+                    cur_t, cur_cols, buf = None, [], []
+                    continue
+                cur_cols, buf = cols.split(','), []
+                seen_cols |= set(cur_cols)
+            else:
+                buf.append(ln)
+        _flush()
+        if columns and not any(c in seen_cols for c in columns):
+            missing_col = [c for c in columns if c not in seen_cols][:3]
+    # 列位错位检测: 用 **#SCHEMA**(每个分片表的列清单, 与本次取了哪些列无关)
+    dup = set()
+    for m2 in mdbs:
+        sc = {}
+        for ln in _ps(_PS.replace('@@MDB@@', str(m2)).replace('@@TURB@@', '').replace('@@WANT@@', '')):
+            if ln.startswith('#SCHEMA '):
+                body = ln[8:]
+                t2, rest = body.split('|', 1)
+                if re.fullmatch(r't\d+', t2):
+                    sc[t2] = set(x for x in rest.split('|', 1)[0].split(',') if x)
+        ks = list(sc)
+        for i in range(len(ks)):
+            for j in range(i + 1, len(ks)):
+                dup |= (sc[ks[i]] & sc[ks[j]]) - {'rid', TURBINE_COL}
+        if dup:
+            break
+    if not frames:
+        raise RuntimeError(
+            f'{cls} 取数失败: {turbine} 在 {[m.name for m in mdbs]} 里取不到行 —— 老库可能没有 turbine 列'
+            f'(源件也没有 WTG 列)⇒ 重跑 scripts/csv_to_mdb.py 建新库(现已写 turbine 列)')
+    frames = [f.rename(columns={LEGACY_TURBINE_COL: TURBINE_COL}) if
+              (LEGACY_TURBINE_COL in f.columns and TURBINE_COL not in f.columns) else f for f in frames]
+    # 库内列名 → 契约列名(撞名时建库侧写过 `_1` 后缀, 这里按前缀认回来)
+    if cand:
+        for f in frames:
+            back, used = {}, set()
+            for orig, st in cand.items():
+                if st in f.columns and st not in used:
+                    back[st], _ = orig, used.add(st)
+                    continue
+                stem = st[:56]
+                for c2 in f.columns:
+                    if c2 not in used and c2.startswith(stem) and c2[len(stem):].startswith('_'):
+                        back[c2], _ = orig, used.add(c2)
+                        break
+            f.rename(columns=back, inplace=True)
+    # ★2026-09-19 逮到的**老库列位错位**: 2026-09-19 之前建的 10min 库, 分片表表头与数据行差一列
+    #   (旧 build_chunks 表头写 names[lo:hi+1] 而行写 vals[lo:hi]) ⇒ 第 250 列之后的**列名整体错位一格**,
+    #   且相邻分片表会共用同一个列名(实测 t1 末列 == t2 首列 == flg_wtc_ScReToOp_endvalue)。这种库读出来的
+    #   是"名字对不上数"的数据 —— 比缺数据危险, 故**响亮拒绝**, 让人重建而不是将就着算。
+    if dup:
+        raise RuntimeError(
+            f'{cls} 取数失败: {[m.name for m in mdbs]} 是**列位错位的老库**(分片表共用列名 {sorted(dup)[:3]}…) '
+            f'—— 2026-09-19 前的建库脚本表头与数据行差一列, 第 250 列之后的列名整体错位。'
+            f'处置: python scripts/csv_to_mdb.py --month <YYYY-MM> --class {cls} 重建该月库; '
+            f'或放 CSV 到 data/raw/<场站>/{CLS[cls][1]}/ 直接用 CSV 形态。')
+    d = frames[0]
+    for f in frames[1:]:
+        d = d.merge(f, on=['rid', TURBINE_COL], how='outer')
+    # 行序回到**源件顺序**: rid 在库里是文本 ⇒ 直接合并得到的是 '1','10','100' 这种字典序
+    # (2026-09-19 实逮: 与 CSV 逐值对拍对不上, 数据没错、是行序被按字符串排了)
+    d['rid'] = pd.to_numeric(d['rid'], errors='coerce')
+    d = d.sort_values([TURBINE_COL, 'rid'], kind='stable').reset_index(drop=True)
+    if missing_col:
+        d.attrs['mdb_missing'] = missing_col
+    return d
+
+
+def _frame(cols: list[str], rows: list[str]):
+    """PowerShell 落的制表符分隔文本 → DataFrame。
+
+    数值列转成数值: MDB 里存的是**数值类型**, ACE 读出来 `100.0` 会变成 `100` —— 若原样留成字符串,
+    下游按 CSV 口径写的老构建器会拿到 object 列 (`+`/`mean` 全崩)。这里逐列试探: 非空值全能解析成数字
+    就转数值, 否则留字符串 (台号/状态字这类)。
+    """
+    import pandas as pd
+    ncol = len(cols)
+    recs = []
+    for ln in rows:
+        vals = ln.split('\t')
+        recs.append((vals + [''] * ncol)[:ncol])
+    d = pd.DataFrame(recs, columns=cols)
+    for c in d.columns:
+        if c in ('rid', TURBINE_COL, LEGACY_TURBINE_COL):
+            continue
+        s = d[c].replace('', None)
+        num = pd.to_numeric(s, errors='coerce')
+        if num.notna().sum() == s.notna().sum() and s.notna().any():
+            d[c] = num
+    return d

+ 69 - 0
app_ETL/app_ETL_guanlan/slim.py

@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+r"""slim10min 窄仓读取口 —— **判级/曲线按窗重算的底座**(用户令 2026-09-21)。
+
+窄仓由 `scripts/scada_slim_build.py` 构建(`outputs/<场>/windscada/slim10min/<台号>.parquet`,
+列 = 现成 `load_10min` 的公共列子集,**不裁剪行**)。本模块只做三件事:
+
+    have(cfg) / manifest(cfg)                     现状与手册(缺件、缺列都如实回)
+    load(t, cfg, span=(a, b), columns=[...])      取一台、按窗(**含两端**)过滤
+    loads(turbs, cfg, span, columns)              批量取(各面 build_store 按窗重算用)
+
+为什么不让各面自己读 10min:换一个窗就要重读 15 GB CSV(实测 ≈2 分钟/面)。窄仓一次建好(≈2 分钟),
+之后任何窗都只是窄仓上的过滤+分组(秒级),而口径与各面原实现**同源同列**。
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+
+import pandas as pd
+
+from src.windscada.config import farm          # config 属后端模块(P4),暂留原路径
+
+
+def dir_of(cfg=None) -> pathlib.Path:
+    """窄仓目录 = 产物仓下的 `slim10min/`(与 `scripts/scada_slim_build.py` 同一落点)。"""
+    cfg = cfg or farm()
+    return pathlib.Path(cfg['store']) / 'slim10min'
+
+
+def path_of(turbine: str, cfg=None) -> pathlib.Path:
+    return dir_of(cfg) / f'{turbine}.parquet'
+
+
+def manifest(cfg=None) -> dict:
+    p = dir_of(cfg) / '_manifest.json'
+    return json.loads(p.read_text(encoding='utf-8')) if p.is_file() else {}
+
+
+def have(cfg=None) -> bool:
+    return path_of('WTG01', cfg).is_file()
+
+
+def _mask(ts: pd.Series, span) -> pd.Series:
+    """窗(含两端):span = ('YYYY-MM-DD', 'YYYY-MM-DD');末日晚 23:59:59 也算在内。"""
+    a, b = span
+    t = pd.to_datetime(ts, errors='coerce')
+    return (t >= pd.Timestamp(a)) & (t <= pd.Timestamp(b) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1))
+
+
+def load(turbine: str, cfg=None, span=None, columns=None) -> pd.DataFrame:
+    """取一台窄仓:`span` 给了就按窗过滤(含两端),`columns` 是列白名单(缺列不报错)。"""
+    p = path_of(turbine, cfg)
+    if not p.is_file():
+        raise FileNotFoundError(f'slim10min 缺 {p} —— 先跑 python scripts/scada_slim_build.py')
+    want = None
+    if columns:
+        want = ['ts'] + [c for c in columns if c != 'ts']
+    df = pd.read_parquet(p, columns=want)
+    if span:
+        df = df[_mask(df['ts'], span)].reset_index(drop=True)
+    return df
+
+
+def loads(turbines, cfg=None, span=None, columns=None) -> dict[str, pd.DataFrame]:
+    """批量取(逐台一份,缺台**响亮**报错,不静默少台)。"""
+    out = {}
+    for t in turbines:
+        out[t] = load(t, cfg, span=span, columns=columns)
+    return out

+ 27 - 0
app_ETL/app_ETL_guanlan/store.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+r"""标准仓读写接口(**组件接入点:TiDB community**)。
+
+现在:仓库是本地 parquet 目录(`outputs/<场>/windscada/*.parquet`)。
+目标:同一接口下新增 TiDB 实现(表 ↔ 仓映射、事务边界在这一层),算法层不受影响。
+
+P0 只立契约,**没有任何实现**(默认走现有本地实现,故零行为变化)。
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+
+class Warehouse(ABC):
+    """标准仓:按"表名"读写产物(与存储介质无关)。"""
+
+    @abstractmethod
+    def list_tables(self) -> list[str]: ...
+
+    @abstractmethod
+    def read_table(self, name: str): ...
+
+    @abstractmethod
+    def write_table(self, name: str, data, *, overwrite: bool = True) -> None: ...
+
+    @abstractmethod
+    def exists(self, name: str) -> bool: ...

+ 8 - 0
app_ETL/common/README.md

@@ -0,0 +1,8 @@
+# `common/` —— 本模块的通用插件 / 组件
+
+跨模块复用的公共层是 **`app_common/`**(路径真源、版本、日志、进程、入口引用…)。
+本目录放**本模块内部**可复用的通用插件/组件(不含观澜业务语义)。
+
+现状:本模块的实现包是 `../app_ETL_guanlan/`(观澜业务实现),模块内暂未拆出"通用 vs 业务"两层,
+因此本目录当前只保留包标记与这份说明。旧导入前缀的兼容别名包
+`common/app_ETL_guanlan/`(`from app_ETL.common.app_ETL_guanlan.…`)在此保留**一个版本**,下版删除。

+ 2 - 0
app_ETL/common/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""本模块的公共组件层(跨模块可复用件放这里)。"""

+ 16 - 0
app_ETL/common/app_ETL_guanlan/__init__.py

@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+r"""兼容别名(P9,2026-09-28):实现已上提到 `app_ETL/app_ETL_guanlan/`。**本包只做前缀别名,下个版本删除。**
+
+用户令 2026-09-28 新结构:每个模块的实现包直接挂在模块根下。旧写法
+`from app_ETL.common.app_ETL_guanlan.<子件> import …` 在一个版本内继续可用,且**与新版是同一个模块对象**
+(同名同体,见 `app_common/app_common_guanlan/_alias.py`)。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.app_common_guanlan.api import alias_prefix
+from app_ETL import app_ETL_guanlan as _new
+
+alias_prefix(__name__, _new.__name__)
+sys.modules[__name__] = _new

+ 7 - 0
app_ETL/configs/README.md

@@ -0,0 +1,7 @@
+# `configs/` —— 本模块的专属配置
+
+* **本目录**:本模块专属的配置域(见安装根 `configs/registry.yaml` 的 `domains[].owner`)。
+* **安装根 `configs/`**:跨模块**共用单件**配置(`serve.json` 端口/路径真源、`models.json` 本机模型档、
+  `portal_pages.yaml` 页面归口、`modules.yaml` 模块登记、`registry.yaml` 配置登记)。
+* 代码**只许**用取用口读配置:`from app_common.app_common_guanlan.api import paths as P` →
+  `P.config('terms', 'display_map.yaml')` / `P.config_dir('farms')`(多根查找 + 单源校验,手拼路径会被门禁拦)。

+ 78 - 0
app_ETL/configs/canonical/alias/crrc_wt1500_yuxian.yaml

@@ -0,0 +1,78 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAVL_availability:
+    canonical: WTUR_availability
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGEN_torque_actual:
+    canonical: WGEN_torque_actual
+    ruling: auto
+  WGEN_torque_set:
+    canonical: WGEN_torque_set
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_setpoint_1:
+    canonical: WROT_pitch_setpoint_1
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WSTA_pc_status:
+    canonical: WTUR_pc_status
+    ruling: auto
+  WTRM_gbx_ims_de_temp:
+    canonical: WTRM_gearbox_ims_temp_de
+    ruling: auto (词序归并)
+  WTRM_gbx_ims_nde_temp:
+    canonical: WTRM_gearbox_ims_temp_nde
+    ruling: auto (词序归并)
+  WTRM_gbx_oil_inlet_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto
+  WTRM_gbx_oil_sump_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_mainbrg_outer_temp:
+    canonical: WTRM_mainbrg_outer_temp
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: crrc_wt1500_yuxian.yaml
+sha16: b817bba7b7228156

+ 3 - 0
app_ETL/configs/canonical/alias/crrc_wtd195_zhuanghe.yaml

@@ -0,0 +1,3 @@
+alias: {}
+contract: crrc_wtd195_zhuanghe.yaml
+sha16: b2312d003d703a04

+ 54 - 0
app_ETL/configs/canonical/alias/csic_h102n_2000_jishan_p2_full.yaml

@@ -0,0 +1,54 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_power_setpoint:
+    canonical: WAPC_power_setpoint
+    ruling: auto
+  WGEN_bearing_front_temp:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto (词序归并)
+  WGEN_stator_temp_u:
+    canonical: WGEN_stator_temp_u
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_hss_temp:
+    canonical: WTRM_temp_gearbox_hss
+    ruling: auto (词序归并)
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_fault_time_avail:
+    canonical: WTUR_fault_time_avail
+    ruling: auto
+  WTUR_state:
+    canonical: WTUR_state
+    ruling: auto
+  WYAW_error_relative:
+    canonical: WYAW_error_relative
+    ruling: auto
+  WYAW_nacelle_position_cumulative:
+    canonical: WYAW_position_cumulative
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: csic_h102n_2000_jishan_p2_full.yaml
+sha16: 4d32bdca1b66c906

+ 102 - 0
app_ETL/configs/canonical/alias/csic_h111n_2000_feicheng_taoyuan.yaml

@@ -0,0 +1,102 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_active_power_alt:
+    canonical: WAPC_active_power_alt
+    ruling: auto
+  WAPC_active_power_gen:
+    canonical: WAPC_active_power_gen
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u1_temp:
+    canonical: WGEN_stator_temp_1_u
+    ruling: auto (词序归并)
+  WGEN_stator_u2_temp:
+    canonical: WGEN_stator_temp_2_u
+    ruling: auto (词序归并)
+  WGEN_stator_v1_temp:
+    canonical: WGEN_stator_temp_1_v
+    ruling: auto (词序归并)
+  WGEN_stator_v2_temp:
+    canonical: WGEN_stator_temp_2_v
+    ruling: auto (词序归并)
+  WGEN_stator_w1_temp:
+    canonical: WGEN_stator_temp_1_w
+    ruling: auto (词序归并)
+  WGEN_stator_w2_temp:
+    canonical: WGEN_stator_temp_2_w
+    ruling: auto (词序归并)
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_direction_25s:
+    canonical: WMET_wind_direction_25s
+    ruling: auto
+  WMET_wind_speed_10s:
+    canonical: WMET_wind_speed_10s
+    ruling: auto
+  WMET_wind_speed_60s:
+    canonical: WMET_wind_speed_60s
+    ruling: auto
+  WMET_wind_speed_inst:
+    canonical: WMET_wind_speed_inst
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_cooling_temp:
+    canonical: WTRM_cooling_temp
+    ruling: auto
+  WTRM_gbx_oil_inlet_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_hss_bearing_temp_de:
+    canonical: WTRM_hss_bearing_temp_de
+    ruling: auto
+  WTRM_hss_bearing_temp_nde:
+    canonical: WTRM_hss_bearing_temp_nde
+    ruling: auto
+  WTRM_main_bearing_temp_de:
+    canonical: WTRM_main_bearing_temp_de
+    ruling: auto
+  WTRM_main_bearing_temp_nde:
+    canonical: WTRM_main_bearing_temp_nde
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+contract: csic_h111n_2000_feicheng_taoyuan.yaml
+sha16: 3acb5a0f3bd95bc7

+ 96 - 0
app_ETL/configs/canonical/alias/dec_1500_yangxiongshan.yaml

@@ -0,0 +1,96 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGBX_brg1_temp:
+    canonical: WTRM_bearing_temp_1
+    ruling: auto (词序归并)
+  WGBX_brg2_temp:
+    canonical: WTRM_bearing_temp_2
+    ruling: auto (词序归并)
+  WGBX_oil_inlet_temp:
+    canonical: WTRM_oil_inlet_temp
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_brg_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_brg_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp:
+    canonical: WGEN_winding_temp
+    ruling: auto
+  WGRID_reactive_power:
+    canonical: WGRD_reactive_power
+    ruling: auto
+  WHUB_temp:
+    canonical: WROT_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WHUB_temp_inner:
+    canonical: WROT_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_anemometer_1:
+    canonical: WMET_anemometer_1
+    ruling: auto
+  WMET_anemometer_2:
+    canonical: WMET_anemometer_2
+    ruling: auto
+  WMET_energy_active_total:
+    canonical: WMET_energy_active_total
+    ruling: auto
+  WMET_energy_total_mwh:
+    canonical: WMET_energy_total_mwh
+    ruling: auto
+  WMET_wind_dir_vane_mech:
+    canonical: WMET_wind_dir_vane_mech
+    ruling: auto
+  WMET_wind_dir_vane_ultra:
+    canonical: WMET_wind_dir_vane_ultra
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_temp_inner:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_vib_fore_aft:
+    canonical: WNAC_vib_fore_aft
+    ruling: auto
+  WNAC_vib_lateral:
+    canonical: WNAC_vib_lateral
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WROT_rotor_speed_2:
+    canonical: WROT_rotor_speed_2
+    ruling: auto
+  WTRM_mainbrg_temp:
+    canonical: WTRM_mainbrg_temp
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: dec_1500_yangxiongshan.yaml
+sha16: 513e2c1799698129

+ 159 - 0
app_ETL/configs/canonical/alias/dec_dew_d2500_141_dehui.yaml

@@ -0,0 +1,159 @@
+alias:
+  WCNV_GriIA:
+    canonical: WCNV_gri_ia
+    ruling: auto
+  WCNV_GriIB:
+    canonical: WCNV_gri_ib
+    ruling: auto
+  WCNV_GriIC:
+    canonical: WCNV_gri_ic
+    ruling: auto
+  WCNV_TmpCoolant:
+    canonical: WCNV_temp_coolant
+    ruling: auto
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrg:
+    canonical: WGEN_bearing_temp
+    ruling: auto (词序归并)
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGEN_Trq:
+    canonical: WGEN_trq
+    ruling: auto
+  WMET_AirDen:
+    canonical: WMET_air_den
+    ruling: auto
+  WMET_Dir_pseudo:
+    canonical: WMET_dir_pseudo
+    ruling: auto
+  WMET_EnvTmp:
+    canonical: WMET_env_temp
+    ruling: auto
+  WMET_TI:
+    canonical: WMET_ti
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WMET_horWdSpd_1s:
+    canonical: WMET_hor_wd_speed_1s
+    ruling: auto
+  WMET_horWdSpd_30s:
+    canonical: WMET_hor_wd_speed_30s
+    ruling: auto
+  WMET_horWdSpd_raw:
+    canonical: WMET_hor_wd_speed_raw
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WNAC_TmpCtrlCab:
+    canonical: WNAC_control_cabinet_temp
+    ruling: auto (词序归并)
+  WNAC_TmpDistCab:
+    canonical: WNAC_temp_dist_cabinet
+    ruling: auto
+  WPCH_TmpBupCab1:
+    canonical: WROT_temp_backup_cabinet_1
+    ruling: auto
+  WPCH_TmpBupCab2:
+    canonical: WROT_temp_backup_cabinet_2
+    ruling: auto
+  WPCH_TmpBupCab3:
+    canonical: WROT_temp_backup_cabinet_3
+    ruling: auto
+  WPCH_TmpCtrlCab1:
+    canonical: WROT_temp_control_cabinet_1
+    ruling: auto
+  WPCH_TmpCtrlCab2:
+    canonical: WROT_temp_control_cabinet_2
+    ruling: auto
+  WPCH_TmpCtrlCab3:
+    canonical: WROT_temp_control_cabinet_3
+    ruling: auto
+  WPCH_TmpMotBld1:
+    canonical: WROT_temp_motor_blade_1
+    ruling: auto
+  WPCH_TmpMotBld2:
+    canonical: WROT_temp_motor_blade_2
+    ruling: auto
+  WPCH_TmpMotBld3:
+    canonical: WROT_temp_motor_blade_3
+    ruling: auto
+  WROT_PtchAngBld1:
+    canonical: WROT_ptch_ang_blade_1
+    ruling: auto
+  WROT_PtchAngBld2:
+    canonical: WROT_ptch_ang_blade_2
+    ruling: auto
+  WROT_PtchAngBld3:
+    canonical: WROT_ptch_ang_blade_3
+    ruling: auto
+  WROT_PtchAngSpBld1:
+    canonical: WROT_ptch_ang_sp_blade_1
+    ruling: auto
+  WROT_PtchAngSpBld2:
+    canonical: WROT_ptch_ang_sp_blade_2
+    ruling: auto
+  WROT_PtchAngSpBld3:
+    canonical: WROT_ptch_ang_sp_blade_3
+    ruling: auto
+  WROT_Spd:
+    canonical: WROT_speed
+    ruling: auto
+  WROT_TmpHub:
+    canonical: WROT_hub_temp
+    ruling: auto (词序归并)
+  WSLG_stop_event_addr:
+    canonical: WSLG_stop_event_addr
+    ruling: auto
+  WSLG_turbine_state:
+    canonical: WSLG_turbine_state
+    ruling: auto
+  WTRM_TmpAuxTfmr:
+    canonical: WTRM_temp_aux_tfmr
+    ruling: auto
+  WTUR_CurtFlag:
+    canonical: WTUR_curt_flag
+    ruling: auto
+  WTUR_PcFlag:
+    canonical: WTUR_pc_flag
+    ruling: auto
+  WTUR_VAr:
+    canonical: WTUR_var
+    ruling: auto
+  WTUR_VArSp:
+    canonical: WTUR_var_sp
+    ruling: auto
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WTUR_WSpLim:
+    canonical: WTUR_wsp_lim
+    ruling: auto
+  WTUR_WSpLoLim:
+    canonical: WTUR_wsp_lo_lim
+    ruling: auto
+  WTUR_WThy:
+    canonical: WTUR_wthy
+    ruling: auto
+  WYAW_CableTwist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: dec_dew_d2500_141_dehui.yaml
+sha16: 182291ae09ba2262

+ 33 - 0
app_ETL/configs/canonical/alias/dec_df110_2500_longkou_dongqi.yaml

@@ -0,0 +1,33 @@
+alias:
+  WGEN_IStaU:
+    canonical: WGEN_ista_u
+    ruling: auto
+  WGEN_TmpBrg:
+    canonical: WGEN_bearing_temp
+    ruling: auto (词序归并)
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_ExtTmp:
+    canonical: WNAC_ext_temp
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WROT_Spd:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_TmpGbxOil:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto (词序归并)
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: dec_df110_2500_longkou_dongqi.yaml
+sha16: e53624d906b9e98f

+ 57 - 0
app_ETL/configs/canonical/alias/dec_df121_2500_jinhuashan.yaml

@@ -0,0 +1,57 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp:
+    canonical: WGEN_bearing_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp_u:
+    canonical: WGEN_winding_temp_u
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRID_voltage:
+    canonical: WGRD_voltage
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_nacelle:
+    canonical: WMET_wind_direction_nacelle
+    ruling: auto
+  WMET_wind_direction_pseudo_geo:
+    canonical: WMET_wind_direction_pseudo
+    ruling: auto
+  WMET_wind_direction_ultrasonic_nacrel_offset:
+    canonical: WMET_wind_direction_ultrasonic_nacrel_offset
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_ultrasonic:
+    canonical: WMET_wind_speed_ultrasonic
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_setpoint_1:
+    canonical: WROT_pitch_setpoint_1
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_mainbrg_temp:
+    canonical: WTRM_mainbrg_temp
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: dec_df121_2500_jinhuashan.yaml
+sha16: 440eeeff3f2cdf4b

+ 48 - 0
app_ETL/configs/canonical/alias/dec_df93_1500_longkou_dongqi.yaml

@@ -0,0 +1,48 @@
+alias:
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_Dir_unwrapped:
+    canonical: WNAC_dir_unwrapped
+    ruling: auto
+  WNAC_ExtTmp:
+    canonical: WNAC_ext_temp
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WTRM_TmpMainBrgDE:
+    canonical: WTRM_main_bearing_temp_de
+    ruling: auto (词序归并)
+  WTRM_TmpMainBrgNDE:
+    canonical: WTRM_main_bearing_temp_nde
+    ruling: auto (词序归并)
+  WTUR_TotGenTime:
+    canonical: WTUR_tot_gen_time
+    ruling: auto
+  WTUR_TotOpTime:
+    canonical: WTUR_tot_op_time
+    ruling: auto
+  WTUR_TotWh:
+    canonical: WTUR_tot_wh
+    ruling: auto
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: dec_df93_1500_longkou_dongqi.yaml
+sha16: af14c3f9a3dfce5e

+ 3 - 0
app_ETL/configs/canonical/alias/dec_fd116a_2000_feichijiao.yaml

@@ -0,0 +1,3 @@
+alias: {}
+contract: dec_fd116a_2000_feichijiao.yaml
+sha16: 5a6f1b2611d6c8de

+ 3 - 0
app_ETL/configs/canonical/alias/dec_fd116a_2000_liangshuiquan.yaml

@@ -0,0 +1,3 @@
+alias: {}
+contract: dec_fd116a_2000_liangshuiquan.yaml
+sha16: 9b888cb660ccd81c

+ 57 - 0
app_ETL/configs/canonical/alias/dec_g2000_127_yidukou.yaml

@@ -0,0 +1,57 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp:
+    canonical: WGEN_bearing_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_hss_front_bearing_temp:
+    canonical: WTRM_gearbox_hss_front_bearing_temp
+    ruling: auto
+  WTRM_gearbox_hss_rear_bearing_temp:
+    canonical: WTRM_gearbox_hss_rear_bearing_temp
+    ruling: auto
+  WTRM_gearbox_oil_inlet_pressure:
+    canonical: WTRM_gearbox_oil_inlet_pressure
+    ruling: auto
+  WTRM_gearbox_oil_inlet_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto
+  WTRM_gearbox_oil_pump_pressure:
+    canonical: WTRM_gearbox_oil_pump_pressure
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_lss_front_bearing_temp:
+    canonical: WTRM_lss_front_bearing_temp
+    ruling: auto
+  WTRM_lss_rear_bearing_temp:
+    canonical: WTRM_lss_rear_bearing_temp
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: dec_g2000_127_yidukou.yaml
+sha16: 478f6689a029ac3f

+ 33 - 0
app_ETL/configs/canonical/alias/dongqi_fd116a_ganyi.yaml

@@ -0,0 +1,33 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_temp:
+    canonical: WGEN_stator_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_mainbrg_temp:
+    canonical: WTRM_mainbrg_temp
+    ruling: auto
+contract: dongqi_fd116a_ganyi.yaml
+sha16: 77935945508b5626

+ 114 - 0
app_ETL/configs/canonical/alias/envision_1500_shenmu.yaml

@@ -0,0 +1,114 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_bearing_temp_nde:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_control_cabinet_temp:
+    canonical: WNAC_control_cabinet_temp
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_hub_temp:
+    canonical: WROT_hub_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_pitch_setpoint_blade1:
+    canonical: WROT_pitch_setpoint_blade_1
+    ruling: auto
+  WROT_pitch_setpoint_blade2:
+    canonical: WROT_pitch_setpoint_blade_2
+    ruling: auto
+  WROT_pitch_setpoint_blade3:
+    canonical: WROT_pitch_setpoint_blade_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_gbx_distributor_pressure:
+    canonical: WTRM_gearbox_distributor_pressure
+    ruling: auto
+  WTRM_gbx_interm_bearing_temp_de:
+    canonical: WTRM_gearbox_interm_bearing_temp_de
+    ruling: auto
+  WTRM_gbx_interm_bearing_temp_nde:
+    canonical: WTRM_gearbox_interm_bearing_temp_nde
+    ruling: auto
+  WTRM_gbx_oil_inlet_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto
+  WTRM_gbx_oil_pump_pressure:
+    canonical: WTRM_gearbox_oil_pump_pressure
+    ruling: auto
+  WTRM_gbx_oil_sump_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_main_bearing_temp:
+    canonical: WTRM_main_bearing_temp
+    ruling: auto
+  WTRM_torque:
+    canonical: WTRM_torque
+    ruling: auto
+  WTRM_torque_setpoint:
+    canonical: WTRM_torque_setpoint
+    ruling: auto
+  WTUR_availability:
+    canonical: WTUR_availability
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_hydraulic_accum_pressure:
+    canonical: WYAW_hydraulic_accum_pressure
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: envision_1500_shenmu.yaml
+sha16: 8e058c1ff6e56012

+ 93 - 0
app_ETL/configs/canonical/alias/envision_en110_2200_pingyin.yaml

@@ -0,0 +1,93 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WAVL_availability:
+    canonical: WTUR_availability
+    ruling: auto
+  WCNV_cooling_outlet_temp:
+    canonical: WCNV_cooling_outlet_temp
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_vib_axial:
+    canonical: WNAC_vib_axial
+    ruling: auto
+  WNAC_vib_lateral:
+    canonical: WNAC_vib_lateral
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WSTA_pc_status:
+    canonical: WTUR_pc_status
+    ruling: auto
+  WSTA_plc_status:
+    canonical: WTUR_plc_status
+    ruling: auto
+  WTRM_gbx_ims_de_temp:
+    canonical: WTRM_gearbox_ims_temp_de
+    ruling: auto (词序归并)
+  WTRM_gbx_ims_nde_temp:
+    canonical: WTRM_gearbox_ims_temp_nde
+    ruling: auto (词序归并)
+  WTRM_gbx_oil_inlet_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto
+  WTRM_gbx_oil_sump_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_mainbrg_outer_temp:
+    canonical: WTRM_mainbrg_outer_temp
+    ruling: auto
+  WTWR_base_temp:
+    canonical: WTOW_base_temp
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: envision_en110_2200_pingyin.yaml
+sha16: 9db092100078434f

+ 3 - 0
app_ETL/configs/canonical/alias/envision_en182_6250_aodu.yaml

@@ -0,0 +1,3 @@
+alias: {}
+contract: envision_en182_6250_aodu.yaml
+sha16: 7d20e79d038d4827

+ 3 - 0
app_ETL/configs/canonical/alias/envision_en182_6250_hailesi.yaml

@@ -0,0 +1,3 @@
+alias: {}
+contract: envision_en182_6250_hailesi.yaml
+sha16: 5ad99e94271956da

+ 63 - 0
app_ETL/configs/canonical/alias/envision_en87_1500.yaml

@@ -0,0 +1,63 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_vib_axial:
+    canonical: WNAC_vib_axial
+    ruling: auto
+  WNAC_vib_lateral:
+    canonical: WNAC_vib_lateral
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_setpoint_blade1:
+    canonical: WROT_pitch_setpoint_blade_1
+    ruling: auto
+  WROT_pitch_setpoint_blade2:
+    canonical: WROT_pitch_setpoint_blade_2
+    ruling: auto
+  WROT_pitch_setpoint_blade3:
+    canonical: WROT_pitch_setpoint_blade_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WSLG_turbine_state_10min_avg:
+    canonical: WSLG_turbine_state_10min_avg
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_main_bearing_temp:
+    canonical: WTRM_main_bearing_temp
+    ruling: auto
+  WTUR_torque:
+    canonical: WTUR_torque
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: envision_en87_1500.yaml
+sha16: d9de5bb03a33e9c8

+ 60 - 0
app_ETL/configs/canonical/alias/goldwind_dd_tanglong_p1.yaml

@@ -0,0 +1,60 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_power_setpoint:
+    canonical: WAPC_power_setpoint
+    ruling: auto
+  WGEN_bearing_temp_1:
+    canonical: WGEN_bearing_temp_1
+    ruling: auto
+  WGEN_bearing_temp_2:
+    canonical: WGEN_bearing_temp_2
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_temp_1:
+    canonical: WGEN_stator_temp_1
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_hub_temp:
+    canonical: WROT_hub_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTUR_id:
+    canonical: WTUR_id
+    ruling: auto
+  WTUR_state:
+    canonical: WTUR_state
+    ruling: auto
+  WYAW_error:
+    canonical: WYAW_error
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: goldwind_dd_tanglong_p1.yaml
+sha16: 454114cd49f485b0

+ 42 - 0
app_ETL/configs/canonical/alias/goldwind_likely_huaishuo.yaml

@@ -0,0 +1,42 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_ambient_temp:
+    canonical: WMET_ambient_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WYAW_error:
+    canonical: WYAW_error
+    ruling: auto
+  WYAW_nacelle_position_maybe:
+    canonical: WYAW_position_maybe
+    ruling: auto
+contract: goldwind_likely_huaishuo.yaml
+sha16: 32f6e0d25bf3dcfc

+ 99 - 0
app_ETL/configs/canonical/alias/gup_up1500_qianan_shuizi.yaml

@@ -0,0 +1,99 @@
+alias:
+  WCNV_W:
+    canonical: WCNV_w
+    ruling: auto
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WHYD_Press:
+    canonical: WHYD_press
+    ruling: auto
+  WHYD_TmpOil:
+    canonical: WHYD_oil_temp
+    ruling: auto (词序归并)
+  WMET_horWdDir:
+    canonical: WMET_hor_wd_dir
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WMET_horWdSpd_10m_avg:
+    canonical: WMET_hor_wd_speed_10m_avg
+    ruling: auto
+  WNAC_Dir_unwrapped:
+    canonical: WNAC_dir_unwrapped
+    ruling: auto
+  WNAC_ExtTmp:
+    canonical: WNAC_ext_temp
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WNAC_TmpTopBox:
+    canonical: WNAC_temp_top_box
+    ruling: auto
+  WNAC_VibRMS:
+    canonical: WNAC_vib_rms
+    ruling: auto
+  WNAC_VibX:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_VibY:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_Pos_derived:
+    canonical: WROT_pos_derived
+    ruling: auto
+  WROT_PtAngValSp_1:
+    canonical: WROT_pt_ang_val_sp_1
+    ruling: auto
+  WROT_PtAngValSp_2:
+    canonical: WROT_pt_ang_val_sp_2
+    ruling: auto
+  WROT_PtAngValSp_3:
+    canonical: WROT_pt_ang_val_sp_3
+    ruling: auto
+  WROT_RotSpd:
+    canonical: WROT_rot_speed
+    ruling: auto
+  WTOW_TmpBaseCab:
+    canonical: WTOW_base_cabinet_temp
+    ruling: auto (词序归并)
+  WTRF_Tmp:
+    canonical: WTRF_temp
+    ruling: auto
+  WTRM_TmpGbxBrgIn:
+    canonical: WTRM_temp_gearbox_bearing_in
+    ruling: auto
+  WTRM_TmpGbxBrgOut:
+    canonical: WTRM_temp_gearbox_bearing_out
+    ruling: auto
+  WTRM_TmpGbxOil:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto (词序归并)
+  WTRM_TmpMainBrgGbx:
+    canonical: WTRM_temp_main_bearing_gearbox
+    ruling: auto
+  WTRM_TmpMainBrgRot:
+    canonical: WTRM_temp_main_bearing_rot
+    ruling: auto
+  WTUR_TotWh:
+    canonical: WTUR_tot_wh
+    ruling: auto
+  WTUR_VAr:
+    canonical: WTUR_var
+    ruling: auto
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WYAW_YawSpd:
+    canonical: WYAW_yaw_speed
+    ruling: auto
+contract: gup_up1500_qianan_shuizi.yaml
+sha16: 26d07b28df0ebd5a

+ 36 - 0
app_ETL/configs/canonical/alias/gup_up2000_2mw.yaml

@@ -0,0 +1,36 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_setpoint:
+    canonical: WAPC_setpoint
+    ruling: auto
+  WMET_air_density:
+    canonical: WMET_air_density
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_free_stream_wdir:
+    canonical: WMET_free_stream_wdir
+    ruling: auto
+  WMET_free_stream_ws:
+    canonical: WMET_free_stream_ws
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_wind_speed_2:
+    canonical: WMET_wind_speed_2
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: gup_up2000_2mw.yaml
+sha16: 660eb4ee2f3e937a

+ 96 - 0
app_ETL/configs/canonical/alias/gup_up2000_fengxiang.yaml

@@ -0,0 +1,96 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAVL_avail_time:
+    canonical: WTUR_avail_time
+    ruling: auto
+  WAVL_fault_time:
+    canonical: WTUR_fault_time
+    ruling: auto
+  WGBX_hss_bearing_temp:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto
+  WGBX_oil_sump_temp:
+    canonical: WTRM_oil_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_bearing_de:
+    canonical: WGEN_bearing_de
+    ruling: auto
+  WGEN_bearing_nde:
+    canonical: WGEN_bearing_nde
+    ruling: auto
+  WGEN_cooling_air_temp:
+    canonical: WGEN_cooling_air_temp
+    ruling: auto
+  WGEN_generator_speed:
+    canonical: WGEN_generator_speed
+    ruling: auto
+  WGEN_winding_u:
+    canonical: WGEN_winding_u
+    ruling: auto
+  WGEN_winding_u2:
+    canonical: WGEN_winding_2_u
+    ruling: auto (词序归并)
+  WGEN_winding_v1:
+    canonical: WGEN_winding_1_v
+    ruling: auto (词序归并)
+  WGEN_winding_v2:
+    canonical: WGEN_winding_2_v
+    ruling: auto (词序归并)
+  WGEN_winding_w1:
+    canonical: WGEN_winding_1_w
+    ruling: auto (词序归并)
+  WGEN_winding_w2:
+    canonical: WGEN_winding_2_w
+    ruling: auto (词序归并)
+  WMBR_temp_gbx_side:
+    canonical: WTRM_temp_gearbox_side
+    ruling: auto
+  WMBR_temp_rotor_side:
+    canonical: WTRM_temp_rotor_side
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_air_temp:
+    canonical: WNAC_air_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_motor_cur_1:
+    canonical: WROT_pitch_motor_current_1
+    ruling: auto
+  WROT_pitch_motor_cur_2:
+    canonical: WROT_pitch_motor_current_2
+    ruling: auto
+  WROT_pitch_motor_cur_3:
+    canonical: WROT_pitch_motor_current_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTUR_total_energy:
+    canonical: WTUR_total_energy
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: gup_up2000_fengxiang.yaml
+sha16: 7f23342608774227

+ 192 - 0
app_ETL/configs/canonical/alias/gup_up2000_fushan.yaml

@@ -0,0 +1,192 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WAPC_reactive_setpoint:
+    canonical: WAPC_reactive_setpoint
+    ruling: auto
+  WAPC_setpoint:
+    canonical: WAPC_setpoint
+    ruling: auto
+  WCNV_torque_demand:
+    canonical: WCNV_torque_demand
+    ruling: auto
+  WCNV_torque_fb:
+    canonical: WCNV_torque_fb
+    ruling: auto
+  WGBX_hss_bearing_temp:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto
+  WGBX_lss_shaft_temp:
+    canonical: WTRM_lss_shaft_temp
+    ruling: auto
+  WGBX_oil_inlet_temp:
+    canonical: WTRM_oil_inlet_temp
+    ruling: auto
+  WGBX_oil_pressure:
+    canonical: WTRM_oil_pressure
+    ruling: auto
+  WGBX_oil_sump_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_bearing_a:
+    canonical: WGEN_bearing_a
+    ruling: auto
+  WGEN_bearing_b:
+    canonical: WGEN_bearing_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WGEN_generator_speed:
+    canonical: WGEN_generator_speed
+    ruling: auto
+  WGEN_speed_fb_scaled:
+    canonical: WGEN_speed_fb_scaled
+    ruling: auto
+  WGEN_winding_u1:
+    canonical: WGEN_winding_1_u
+    ruling: auto (词序归并)
+  WGEN_winding_u2:
+    canonical: WGEN_winding_2_u
+    ruling: auto (词序归并)
+  WGEN_winding_v1:
+    canonical: WGEN_winding_1_v
+    ruling: auto (词序归并)
+  WGEN_winding_v2:
+    canonical: WGEN_winding_2_v
+    ruling: auto (词序归并)
+  WGEN_winding_w1:
+    canonical: WGEN_winding_1_w
+    ruling: auto (词序归并)
+  WGEN_winding_w2:
+    canonical: WGEN_winding_2_w
+    ruling: auto (词序归并)
+  WMBR_temp_gbx_side:
+    canonical: WTRM_temp_gearbox_side
+    ruling: auto
+  WMBR_temp_rotor_side:
+    canonical: WTRM_temp_rotor_side
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_ambient_temp_mast:
+    canonical: WMET_ambient_temp_mast
+    ruling: auto
+  WMET_humidity:
+    canonical: WMET_humidity
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_wd_10m:
+    canonical: WMET_wd_10m
+    ruling: auto
+  WMET_wd_30m:
+    canonical: WMET_wd_30m
+    ruling: auto
+  WMET_wd_50m:
+    canonical: WMET_wd_50m
+    ruling: auto
+  WMET_wd_70m:
+    canonical: WMET_wd_70m
+    ruling: auto
+  WMET_wd_hub:
+    canonical: WMET_wd_hub
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle2:
+    canonical: WMET_wind_speed_nacelle_2
+    ruling: auto
+  WMET_ws_10m:
+    canonical: WMET_ws_10m
+    ruling: auto
+  WMET_ws_30m:
+    canonical: WMET_ws_30m
+    ruling: auto
+  WMET_ws_50m:
+    canonical: WMET_ws_50m
+    ruling: auto
+  WMET_ws_70m:
+    canonical: WMET_ws_70m
+    ruling: auto
+  WMET_ws_hub:
+    canonical: WMET_ws_hub
+    ruling: auto
+  WNAC_air_temp:
+    canonical: WNAC_air_temp
+    ruling: auto
+  WNAC_cabinet_temp:
+    canonical: WNAC_cabinet_temp
+    ruling: auto
+  WNAC_vib_foreaft_raw:
+    canonical: WNAC_vib_foreaft_raw
+    ruling: auto
+  WNAC_vib_sidebyside:
+    canonical: WNAC_vib_sidebyside
+    ruling: auto
+  WNAC_vib_x:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_vib_y:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_demand_1:
+    canonical: WROT_pitch_demand_1
+    ruling: auto
+  WROT_pitch_demand_2:
+    canonical: WROT_pitch_demand_2
+    ruling: auto
+  WROT_pitch_demand_3:
+    canonical: WROT_pitch_demand_3
+    ruling: auto
+  WROT_pitch_hub_temp_1:
+    canonical: WROT_pitch_hub_temp_1
+    ruling: auto
+  WROT_pitch_hub_temp_2:
+    canonical: WROT_pitch_hub_temp_2
+    ruling: auto
+  WROT_pitch_hub_temp_3:
+    canonical: WROT_pitch_hub_temp_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WROT_rotor_speed_2:
+    canonical: WROT_rotor_speed_2
+    ruling: auto
+  WTUR_energy_stat:
+    canonical: WTUR_energy_stat
+    ruling: auto
+  WTUR_reactive_stat:
+    canonical: WTUR_reactive_stat
+    ruling: auto
+  WTUR_total_energy:
+    canonical: WTUR_total_energy
+    ruling: auto
+  WTUR_tower_base_cabinet_temp:
+    canonical: WTUR_tower_base_cabinet_temp
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+  WYAW_yaw_error_2:
+    canonical: WYAW_yaw_error_2
+    ruling: auto
+contract: gup_up2000_fushan.yaml
+sha16: 31d99a2e966360d2

+ 57 - 0
app_ETL/configs/canonical/alias/gup_up2000_hongyang.yaml

@@ -0,0 +1,57 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_active_power_pq:
+    canonical: WAPC_active_power_pq
+    ruling: auto
+  WGBX_hss_bearing_temp:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto
+  WGBX_lss_shaft_temp:
+    canonical: WTRM_lss_shaft_temp
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_bearing_de:
+    canonical: WGEN_bearing_de
+    ruling: auto
+  WGEN_bearing_nde:
+    canonical: WGEN_bearing_nde
+    ruling: auto
+  WGEN_generator_speed:
+    canonical: WGEN_generator_speed
+    ruling: auto
+  WGEN_winding_u:
+    canonical: WGEN_winding_u
+    ruling: auto
+  WMBR_main_bearing_temp:
+    canonical: WTRM_main_bearing_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle2:
+    canonical: WMET_wind_speed_nacelle_2
+    ruling: auto
+  WNAC_air_temp:
+    canonical: WNAC_air_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: gup_up2000_hongyang.yaml
+sha16: 6e7f788f3be24901

+ 57 - 0
app_ETL/configs/canonical/alias/gup_up77_1500_gaobenshan.yaml

@@ -0,0 +1,57 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_generator_torque:
+    canonical: WCNV_generator_torque
+    ruling: auto
+  WCNV_torque_setpoint:
+    canonical: WCNV_torque_setpoint
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_direction_geo_2:
+    canonical: WMET_wind_direction_2
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle_2:
+    canonical: WMET_wind_speed_nacelle_2
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_target_1:
+    canonical: WROT_pitch_target_1
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WROT_speed_ctrl:
+    canonical: WROT_speed_control
+    ruling: auto
+  WYAW_nacelle_position_cumulative:
+    canonical: WYAW_position_cumulative
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: gup_up77_1500_gaobenshan.yaml
+sha16: 647cb9046c9430b0

+ 81 - 0
app_ETL/configs/canonical/alias/gup_up86_1500_langmushan.yaml

@@ -0,0 +1,81 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGBX_hss_bearing_temp:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto
+  WGBX_lss_bearing_temp:
+    canonical: WTRM_lss_bearing_temp
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_bearing_a_temp:
+    canonical: WGEN_bearing_temp_a
+    ruling: auto (词序归并)
+  WGEN_bearing_b_temp:
+    canonical: WGEN_bearing_temp_b
+    ruling: auto (词序归并)
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_bearing_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp:
+    canonical: WGEN_winding_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_main_bearing_temp:
+    canonical: WROT_main_bearing_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_target_1:
+    canonical: WROT_pitch_target_1
+    ruling: auto
+  WROT_pitch_target_2:
+    canonical: WROT_pitch_target_2
+    ruling: auto
+  WROT_pitch_target_3:
+    canonical: WROT_pitch_target_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTUR_energy_yield:
+    canonical: WTUR_energy_yield
+    ruling: auto
+  WYAW_error:
+    canonical: WYAW_error
+    ruling: auto
+  WYAW_nacelle_position_cumulative:
+    canonical: WYAW_position_cumulative
+    ruling: auto
+  _quality_label:
+    canonical: META_quality_label
+    ruling: auto (元数据列)
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+  turbine_id:
+    canonical: META_turbine_id
+    ruling: auto (元数据列)
+contract: gup_up86_1500_langmushan.yaml
+sha16: e6c590ea5cdf8314

+ 111 - 0
app_ETL/configs/canonical/alias/haizhuang_2000_longkou.yaml

@@ -0,0 +1,111 @@
+alias:
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgFront:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto
+  WGEN_TmpBrgRear:
+    canonical: WGEN_temp_bearing_rear
+    ruling: auto
+  WGEN_TmpCool:
+    canonical: WGEN_cooling_temp
+    ruling: auto (词序归并)
+  WGEN_TmpStaMax:
+    canonical: WGEN_temp_stator_max
+    ruling: auto
+  WGEN_TmpStaU1:
+    canonical: WGEN_stator_temp_1_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaU2:
+    canonical: WGEN_stator_temp_2_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV1:
+    canonical: WGEN_stator_temp_1_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaV2:
+    canonical: WGEN_stator_temp_2_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW1:
+    canonical: WGEN_stator_temp_1_w
+    ruling: auto (词序归并)
+  WGEN_TmpStaW2:
+    canonical: WGEN_stator_temp_2_w
+    ruling: auto (词序归并)
+  WGEN_Trq_pct:
+    canonical: WGEN_trq_pct
+    ruling: auto
+  WHYD_PressBrake:
+    canonical: WHYD_press_brake
+    ruling: auto
+  WHYD_PressGbxIn:
+    canonical: WHYD_press_gearbox_in
+    ruling: auto
+  WHYD_PressGbxPump:
+    canonical: WHYD_press_gearbox_pump
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_ExtTmp:
+    canonical: WNAC_ext_temp
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WNAC_VibRMS:
+    canonical: WNAC_vib_rms
+    ruling: auto
+  WNAC_VibX:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_VibY:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_PtAngBlade1:
+    canonical: WROT_pt_ang_blade_1
+    ruling: auto
+  WROT_PtAngBlade2:
+    canonical: WROT_pt_ang_blade_2
+    ruling: auto
+  WROT_PtAngBlade3:
+    canonical: WROT_pt_ang_blade_3
+    ruling: auto
+  WROT_Spd:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_TmpGbxCool:
+    canonical: WTRM_temp_gearbox_cooling
+    ruling: auto
+  WTRM_TmpGbxHSSFront:
+    canonical: WTRM_temp_gearbox_hssfront
+    ruling: auto
+  WTRM_TmpGbxHSSRear:
+    canonical: WTRM_temp_gearbox_hssrear
+    ruling: auto
+  WTRM_TmpGbxOil:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto (词序归并)
+  WTRM_TmpGbxOilIn:
+    canonical: WTRM_temp_gearbox_oil_in
+    ruling: auto
+  WTRM_TmpMainBrgFront:
+    canonical: WTRM_temp_main_bearing_front
+    ruling: auto
+  WTRM_TmpMainBrgRear:
+    canonical: WTRM_temp_main_bearing_rear
+    ruling: auto
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WYAW_TimeCCW:
+    canonical: WYAW_time_ccw
+    ruling: auto
+  WYAW_TimeCW:
+    canonical: WYAW_time_cw
+    ruling: auto
+contract: haizhuang_2000_longkou.yaml
+sha16: 136694479f88c74a

+ 75 - 0
app_ETL/configs/canonical/alias/huachuang_ccwe2000_2mw.yaml

@@ -0,0 +1,75 @@
+alias:
+  WALM_fault_code:
+    canonical: WALM_fault_code
+    ruling: auto
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_power_rate:
+    canonical: WAPC_power_rate
+    ruling: auto
+  WAPC_power_setpoint:
+    canonical: WAPC_power_setpoint
+    ruling: auto
+  WAPC_powerlimit_flag:
+    canonical: WAPC_powerlimit_flag
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_air_density:
+    canonical: WMET_air_density
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_free_stream_wdir_hub:
+    canonical: WMET_free_stream_wdir_hub
+    ruling: auto
+  WMET_free_stream_ws_hub:
+    canonical: WMET_free_stream_ws_hub
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_tower_base_temp:
+    canonical: WNAC_tower_base_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WSLG_turbine_state:
+    canonical: WSLG_turbine_state
+    ruling: auto
+  WTUR_avail_today:
+    canonical: WTUR_avail_today
+    ruling: auto
+  WTUR_avail_total:
+    canonical: WTUR_avail_total
+    ruling: auto
+  WYAW_nacelle_heading:
+    canonical: WYAW_heading
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_nacelle_winddir:
+    canonical: WYAW_winddir
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: huachuang_ccwe2000_2mw.yaml
+sha16: b265e8675790899a

+ 24 - 0
app_ETL/configs/canonical/alias/huachuang_ccwe_ganyi.yaml

@@ -0,0 +1,24 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp:
+    canonical: WGEN_bearing_temp
+    ruling: auto
+  WGEN_bearing_z1_temp:
+    canonical: WGEN_bearing_z_temp_1
+    ruling: auto (词序归并)
+  WGEN_bearing_z2_temp:
+    canonical: WGEN_bearing_z_temp_2
+    ruling: auto (词序归并)
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+contract: huachuang_ccwe_ganyi.yaml
+sha16: 82e33984b29c408a

+ 60 - 0
app_ETL/configs/canonical/alias/huadian_f079_2mw_yuelinbai.yaml

@@ -0,0 +1,60 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WTRM_gbx_lube_temp:
+    canonical: WTRM_gearbox_lube_temp
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_hss_bearing_temp:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto
+  WTRM_lss_bearing_temp:
+    canonical: WTRM_lss_bearing_temp
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+contract: huadian_f079_2mw_yuelinbai.yaml
+sha16: 3e8264c87006d218

+ 45 - 0
app_ETL/configs/canonical/alias/mingyang_my1_5_lingmen.yaml

@@ -0,0 +1,45 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_angle_collective:
+    canonical: WROT_pitch_collective
+    ruling: auto
+  WTRM_gbx_bearing_temp_hs:
+    canonical: WTRM_gearbox_bearing_temp_hs
+    ruling: auto
+  WTRM_gbx_bearing_temp_ls:
+    canonical: WTRM_gearbox_bearing_temp_ls
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_main_bearing_temp:
+    canonical: WTRM_main_bearing_temp
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_wind_dir_deviation:
+    canonical: WYAW_wind_dir_deviation
+    ruling: auto
+contract: mingyang_my1_5_lingmen.yaml
+sha16: 36b5c3481a54bfd9

+ 3 - 0
app_ETL/configs/canonical/alias/mingyang_my1_5_mima.yaml

@@ -0,0 +1,3 @@
+alias: {}
+contract: mingyang_my1_5_mima.yaml
+sha16: 0262f08bd009bc80

+ 141 - 0
app_ETL/configs/canonical/alias/mingyang_my1_5_shenmu.yaml

@@ -0,0 +1,141 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_active_power_1s:
+    canonical: WAPC_active_power_1s
+    ruling: auto
+  WAPC_active_power_30s:
+    canonical: WAPC_active_power_30s
+    ruling: auto
+  WAPC_active_power_max:
+    canonical: WAPC_active_power_max
+    ruling: auto
+  WAPC_active_power_min:
+    canonical: WAPC_active_power_min
+    ruling: auto
+  WGEN_cooling_temp_30s:
+    canonical: WGEN_cooling_temp_30s
+    ruling: auto
+  WGEN_cooling_temp_b:
+    canonical: WGEN_cooling_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_de_bearing_temp_b:
+    canonical: WGEN_bearing_temp_b_de
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp_b:
+    canonical: WGEN_bearing_temp_b_nde
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_speed_1s:
+    canonical: WGEN_speed_1s
+    ruling: auto
+  WGEN_speed_30s:
+    canonical: WGEN_speed_30s
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_u_temp_b:
+    canonical: WGEN_stator_temp_b_u
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp_b:
+    canonical: WGEN_stator_temp_b_v
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp_b:
+    canonical: WGEN_stator_temp_b_w
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle_1s:
+    canonical: WMET_wind_speed_1s
+    ruling: auto
+  WMET_wind_speed_nacelle_1s_max:
+    canonical: WMET_wind_speed_1s_max
+    ruling: auto
+  WMET_wind_speed_nacelle_1s_min:
+    canonical: WMET_wind_speed_1s_min
+    ruling: auto
+  WMET_wind_speed_nacelle_max:
+    canonical: WMET_wind_speed_max
+    ruling: auto
+  WMET_wind_speed_nacelle_min:
+    canonical: WMET_wind_speed_min
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_tower_base_temp:
+    canonical: WNAC_tower_base_temp
+    ruling: auto
+  WNAC_tower_base_temp_b:
+    canonical: WNAC_tower_base_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WROT_pitch_angle_collective:
+    canonical: WROT_pitch_collective
+    ruling: auto
+  WROT_pitch_angle_collective_1s:
+    canonical: WROT_pitch_collective_1s
+    ruling: auto
+  WROT_pitch_angle_collective_30s:
+    canonical: WROT_pitch_collective_30s
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WTRM_gbx_bearing_temp_de:
+    canonical: WTRM_gearbox_bearing_temp_de
+    ruling: auto
+  WTRM_gbx_bearing_temp_de_b:
+    canonical: WTRM_gearbox_bearing_temp_b_de
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WTRM_gbx_bearing_temp_nde:
+    canonical: WTRM_gearbox_bearing_temp_nde
+    ruling: auto
+  WTRM_gbx_bearing_temp_nde_b:
+    canonical: WTRM_gearbox_bearing_temp_b_nde
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核 (词序归并)'
+  WTRM_gbx_cooling_water_temp:
+    canonical: WTRM_gearbox_cooling_water_temp
+    ruling: auto
+  WTRM_gbx_cooling_water_temp_b:
+    canonical: WTRM_gearbox_cooling_water_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_gbx_oil_temp_b:
+    canonical: WTRM_gearbox_oil_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WTRM_main_bearing_temp_a:
+    canonical: WTRM_main_bearing_temp_a
+    ruling: auto
+  WTRM_main_bearing_temp_b:
+    canonical: WTRM_main_bearing_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+contract: mingyang_my1_5_shenmu.yaml
+sha16: 9e3110087cedfb13

+ 126 - 0
app_ETL/configs/canonical/alias/mingyang_my2_0_changpingao.yaml

@@ -0,0 +1,126 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_bearing_temp_nde:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_temp_u:
+    canonical: WGEN_stator_temp_u
+    ruling: auto
+  WGEN_stator_temp_v:
+    canonical: WGEN_stator_temp_v
+    ruling: auto
+  WGEN_stator_temp_w:
+    canonical: WGEN_stator_temp_w
+    ruling: auto
+  WGRID_current_l1:
+    canonical: WGRD_current_u
+    ruling: auto
+  WGRID_current_l2:
+    canonical: WGRD_current_v
+    ruling: auto
+  WGRID_current_l3:
+    canonical: WGRD_current_w
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRID_voltage_l1:
+    canonical: WGRD_voltage_u
+    ruling: auto
+  WGRID_voltage_l2:
+    canonical: WGRD_voltage_v
+    ruling: auto
+  WGRID_voltage_l3:
+    canonical: WGRD_voltage_w
+    ruling: auto
+  WMET_air_density:
+    canonical: WMET_air_density
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_alt1:
+    canonical: WMET_wind_speed_alt_1
+    ruling: auto
+  WMET_wind_speed_alt2:
+    canonical: WMET_wind_speed_alt_2
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle_real:
+    canonical: WMET_wind_speed_real
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WNAC_vibration_z:
+    canonical: WNAC_vibration_z
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_main_bearing_temp_a:
+    canonical: WTRM_main_bearing_temp_a
+    ruling: auto
+  WTRM_main_bearing_temp_b:
+    canonical: WTRM_main_bearing_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WTUR_avail_today:
+    canonical: WTUR_avail_today
+    ruling: auto
+  WTUR_avail_total:
+    canonical: WTUR_avail_total
+    ruling: auto
+  WTUR_fault_code:
+    canonical: WTUR_fault_code
+    ruling: auto
+  WTUR_op_mode:
+    canonical: WTUR_op_mode
+    ruling: auto
+  WTUR_power_limit_flag:
+    canonical: WTUR_power_limit_flag
+    ruling: auto
+  WTUR_run_code:
+    canonical: WTUR_run_code
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_nacelle_position_total:
+    canonical: WYAW_position_total
+    ruling: auto
+  WYAW_winddir_nacelle:
+    canonical: WYAW_winddir_nacelle
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: mingyang_my2_0_changpingao.yaml
+sha16: 06d282d425610dae

+ 237 - 0
app_ETL/configs/canonical/alias/mingyang_my2_0_gucheng.yaml

@@ -0,0 +1,237 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_net_power:
+    canonical: WAPC_net_power
+    ruling: auto
+  WAPC_power_setpoint:
+    canonical: WAPC_power_setpoint
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_chopper_temp:
+    canonical: WCNV_chopper_temp
+    ruling: auto
+  WCNV_grid_cabinet_temp:
+    canonical: WCNV_grid_cabinet_temp
+    ruling: auto
+  WCNV_grid_module_temp:
+    canonical: WCNV_grid_module_temp
+    ruling: auto
+  WCNV_grid_reactor_temp:
+    canonical: WCNV_grid_reactor_temp
+    ruling: auto
+  WCNV_power_cabinet_temp:
+    canonical: WCNV_power_cabinet_temp
+    ruling: auto
+  WCNV_rotor_module_temp:
+    canonical: WCNV_rotor_module_temp
+    ruling: auto
+  WCNV_rotor_reactor_temp:
+    canonical: WCNV_rotor_reactor_temp
+    ruling: auto
+  WGEN_cooling_temp:
+    canonical: WGEN_cooling_temp
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_speed_setpoint:
+    canonical: WGEN_speed_setpoint
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGRI_current_l1:
+    canonical: WGRD_current_u
+    ruling: auto
+  WGRI_current_l2:
+    canonical: WGRD_current_v
+    ruling: auto
+  WGRI_current_l3:
+    canonical: WGRD_current_w
+    ruling: auto
+  WGRI_pf_setpoint:
+    canonical: WGRD_pf_setpoint
+    ruling: auto
+  WGRI_power_factor:
+    canonical: WGRD_power_factor
+    ruling: auto
+  WGRI_voltage_l1:
+    canonical: WGRD_voltage_u
+    ruling: auto
+  WGRI_voltage_l2:
+    canonical: WGRD_voltage_v
+    ruling: auto
+  WGRI_voltage_l3:
+    canonical: WGRD_voltage_w
+    ruling: auto
+  WMET_air_density:
+    canonical: WMET_air_density
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_ambient_temp_2:
+    canonical: WMET_ambient_temp_2
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_turbulence_intensity:
+    canonical: WMET_turbulence_intensity
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_direction_nacelle:
+    canonical: WMET_wind_direction_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_cabinet_temp:
+    canonical: WNAC_cabinet_temp
+    ruling: auto
+  WNAC_control_cabinet_temp:
+    canonical: WNAC_control_cabinet_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WNAC_ctrl_cabinet_temp:
+    canonical: WNAC_control_cabinet_temp
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_tower_base_temp:
+    canonical: WNAC_tower_base_temp
+    ruling: auto
+  WNAC_tower_cabinet_temp:
+    canonical: WNAC_tower_cabinet_temp
+    ruling: auto
+  WROT_hub_temp:
+    canonical: WROT_hub_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_axisctrl_temp_1:
+    canonical: WROT_pitch_axisctrl_temp_1
+    ruling: auto
+  WROT_pitch_axisctrl_temp_2:
+    canonical: WROT_pitch_axisctrl_temp_2
+    ruling: auto
+  WROT_pitch_axisctrl_temp_3:
+    canonical: WROT_pitch_axisctrl_temp_3
+    ruling: auto
+  WROT_pitch_batt_temp_1:
+    canonical: WROT_pitch_batt_temp_1
+    ruling: auto
+  WROT_pitch_batt_temp_2:
+    canonical: WROT_pitch_batt_temp_2
+    ruling: auto
+  WROT_pitch_batt_temp_3:
+    canonical: WROT_pitch_batt_temp_3
+    ruling: auto
+  WROT_pitch_motor_current_1:
+    canonical: WROT_pitch_motor_current_1
+    ruling: auto
+  WROT_pitch_motor_current_2:
+    canonical: WROT_pitch_motor_current_2
+    ruling: auto
+  WROT_pitch_motor_current_3:
+    canonical: WROT_pitch_motor_current_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_pitch_pmc_temp_1:
+    canonical: WROT_pitch_pmc_temp_1
+    ruling: auto
+  WROT_pitch_pmc_temp_2:
+    canonical: WROT_pitch_pmc_temp_2
+    ruling: auto
+  WROT_pitch_pmc_temp_3:
+    canonical: WROT_pitch_pmc_temp_3
+    ruling: auto
+  WROT_pitch_pmm_temp_1:
+    canonical: WROT_pitch_pmm_temp_1
+    ruling: auto
+  WROT_pitch_pmm_temp_2:
+    canonical: WROT_pitch_pmm_temp_2
+    ruling: auto
+  WROT_pitch_pmm_temp_3:
+    canonical: WROT_pitch_pmm_temp_3
+    ruling: auto
+  WROT_pitch_rate_1:
+    canonical: WROT_pitch_rate_1
+    ruling: auto
+  WROT_pitch_rate_2:
+    canonical: WROT_pitch_rate_2
+    ruling: auto
+  WROT_pitch_rate_3:
+    canonical: WROT_pitch_rate_3
+    ruling: auto
+  WTRF_temp:
+    canonical: WTRF_temp
+    ruling: auto
+  WTRM_cooling_return_temp:
+    canonical: WTRM_cooling_return_temp
+    ruling: auto
+  WTRM_cooling_supply_temp:
+    canonical: WTRM_cooling_supply_temp
+    ruling: auto
+  WTRM_gbx_bearing_temp_de:
+    canonical: WTRM_gearbox_bearing_temp_de
+    ruling: auto
+  WTRM_gbx_bearing_temp_nde:
+    canonical: WTRM_gearbox_bearing_temp_nde
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_main_bearing_temp_a:
+    canonical: WTRM_main_bearing_temp_a
+    ruling: auto
+  WTRM_main_bearing_temp_b:
+    canonical: WTRM_main_bearing_temp_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WTRM_torque_setpoint:
+    canonical: WTRM_torque_setpoint
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_nacelle_total:
+    canonical: WYAW_total
+    ruling: auto
+contract: mingyang_my2_0_gucheng.yaml
+sha16: 43b9a20ce2d998b1

+ 96 - 0
app_ETL/configs/canonical/alias/mingyang_my2_0se_121.yaml

@@ -0,0 +1,96 @@
+alias:
+  WALM_fault_code:
+    canonical: WALM_fault_code
+    ruling: auto
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_power_rate:
+    canonical: WAPC_power_rate
+    ruling: auto
+  WAPC_powerlimit_flag:
+    canonical: WAPC_powerlimit_flag
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_bearing_temp_nde:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_air_density:
+    canonical: WMET_air_density
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_free_stream_wdir_hub:
+    canonical: WMET_free_stream_wdir_hub
+    ruling: auto
+  WMET_free_stream_ws_hub:
+    canonical: WMET_free_stream_ws_hub
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_pressure_2:
+    canonical: WMET_pressure_2
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_ws_10m:
+    canonical: WMET_ws_10m
+    ruling: auto
+  WMET_ws_70m:
+    canonical: WMET_ws_70m
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WSLG_turbine_state:
+    canonical: WSLG_turbine_state
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_main_bearing_temp_a:
+    canonical: WTRM_main_bearing_temp_a
+    ruling: auto
+  WTUR_avail_today:
+    canonical: WTUR_avail_today
+    ruling: auto
+  WTUR_avail_total:
+    canonical: WTUR_avail_total
+    ruling: auto
+  WTUR_theoretical_power:
+    canonical: WTUR_theoretical_power
+    ruling: auto
+  WTUR_total_energy:
+    canonical: WTUR_total_energy
+    ruling: auto
+  WYAW_abs_winddir_10min:
+    canonical: WYAW_abs_winddir_10min
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: mingyang_my2_0se_121.yaml
+sha16: faff3e2691e55594

+ 105 - 0
app_ETL/configs/canonical/alias/saic_feicheng.yaml

@@ -0,0 +1,105 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_power_setpoint:
+    canonical: WAPC_power_setpoint
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_cool_water_temp:
+    canonical: WCNV_cooling_water_temp
+    ruling: auto
+  WGEN_cool_water_temp:
+    canonical: WGEN_cooling_water_temp
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_slipring_temp:
+    canonical: WGEN_slipring_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_inst:
+    canonical: WMET_wind_speed_inst
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_indoor_temp:
+    canonical: WNAC_indoor_temp
+    ruling: auto
+  WNAC_outdoor_temp:
+    canonical: WNAC_outdoor_temp
+    ruling: auto
+  WNAC_tower_base_cab_temp:
+    canonical: WNAC_tower_base_cabinet_temp
+    ruling: auto
+  WNAC_vib_rms:
+    canonical: WNAC_vib_rms
+    ruling: auto
+  WNAC_vib_x:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_vib_y:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_hub_temp:
+    canonical: WROT_hub_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gbx_cool_water_temp:
+    canonical: WTRM_gearbox_cooling_water_temp
+    ruling: auto
+  WTRM_gbx_inlet_oil_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto (词序归并)
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_hss_bearing_temp:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto
+  WTRM_lss_bearing_temp:
+    canonical: WTRM_lss_bearing_temp
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+contract: saic_feicheng.yaml
+sha16: 536271287265ad6b

+ 39 - 0
app_ETL/configs/canonical/alias/sewind_w2000_110_80.yaml

@@ -0,0 +1,39 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_power_limited:
+    canonical: WAPC_power_limited
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WSLG_turbine_state:
+    canonical: WSLG_turbine_state
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: sewind_w2000_110_80.yaml
+sha16: 8fe2bc7c594a4c95

+ 60 - 0
app_ETL/configs/canonical/alias/sewind_w2000_116_tanglong_p3.yaml

@@ -0,0 +1,60 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_temp_u:
+    canonical: WGEN_stator_temp_u
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_hss_bearing_temp:
+    canonical: WTRM_gearbox_hss_bearing_temp
+    ruling: auto
+  WTRM_gearbox_lss_bearing_temp:
+    canonical: WTRM_gearbox_lss_bearing_temp
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_energy_10min:
+    canonical: WTUR_energy_10min
+    ruling: auto
+  WTUR_id:
+    canonical: WTUR_id
+    ruling: auto
+  WTUR_standard_state:
+    canonical: WTUR_standard_state
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_error_vane:
+    canonical: WYAW_error_vane
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: sewind_w2000_116_tanglong_p3.yaml
+sha16: 67de72650cd809f6

+ 63 - 0
app_ETL/configs/canonical/alias/sewind_w2000_135_xiaqiu.yaml

@@ -0,0 +1,63 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgFront:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto
+  WGEN_TmpBrgRear:
+    canonical: WGEN_temp_bearing_rear
+    ruling: auto
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WMET_EnvTmp:
+    canonical: WMET_env_temp
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WROT_PtAng:
+    canonical: WROT_pt_ang
+    ruling: auto
+  WROT_RotSpd:
+    canonical: WROT_rot_speed
+    ruling: auto
+  WROT_TmpPtMotor1:
+    canonical: WROT_temp_pt_motor_1
+    ruling: auto
+  WROT_TmpPtMotor2:
+    canonical: WROT_temp_pt_motor_2
+    ruling: auto
+  WROT_TmpPtMotor3:
+    canonical: WROT_temp_pt_motor_3
+    ruling: auto
+  WTOW_TmpTowerBaseCab:
+    canonical: WTOW_temp_tower_base_cabinet
+    ruling: auto
+  WTUR_status:
+    canonical: WTUR_status
+    ruling: auto
+  WYAW_CableTwist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: sewind_w2000_135_xiaqiu.yaml
+sha16: ff59c0439f0cd3be

+ 201 - 0
app_ETL/configs/canonical/alias/sewind_w2000_93_jincheng.yaml

@@ -0,0 +1,201 @@
+alias:
+  WAPC_PowerFactor:
+    canonical: WAPC_power_factor
+    ruling: auto
+  WAPC_SetpointPct:
+    canonical: WAPC_setpoint_pct
+    ruling: auto
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_TmpCoolWater:
+    canonical: WCNV_cooling_water_temp
+    ruling: auto (词序归并)
+  WGDT_TmpBrgHSS:
+    canonical: WTRM_hss_bearing_temp
+    ruling: auto (词序归并)
+  WGDT_TmpBrgLSS:
+    canonical: WTRM_lss_bearing_temp
+    ruling: auto (词序归并)
+  WGDT_TmpCoolWater:
+    canonical: WTRM_temp_cooling_water
+    ruling: auto
+  WGDT_TmpLubOil:
+    canonical: WTRM_temp_lube_oil
+    ruling: auto
+  WGDT_TmpOil:
+    canonical: WTRM_oil_temp
+    ruling: auto (词序归并)
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgDE:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_TmpBrgNDE:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_TmpCoolWater:
+    canonical: WGEN_cooling_water_temp
+    ruling: auto (词序归并)
+  WGEN_TmpSlipRing:
+    canonical: WGEN_temp_slip_ring
+    ruling: auto
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGRD_CurA:
+    canonical: WGRD_current_a
+    ruling: auto
+  WGRD_CurB:
+    canonical: WGRD_current_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WGRD_CurC:
+    canonical: WGRD_current_c
+    ruling: auto
+  WGRD_Freq:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRD_VoltA:
+    canonical: WGRD_voltage_a
+    ruling: auto
+  WGRD_VoltB:
+    canonical: WGRD_voltage_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WGRD_VoltC:
+    canonical: WGRD_voltage_c
+    ruling: auto
+  WHYD_Press:
+    canonical: WHYD_press
+    ruling: auto
+  WMAIN_TmpShaft:
+    canonical: WTRM_temp_shaft
+    ruling: auto
+  WMET_EnvTmp:
+    canonical: WMET_env_temp
+    ruling: auto
+  WMET_WdDir_60s:
+    canonical: WMET_wd_dir_60s
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WMET_horWdSpd_30s:
+    canonical: WMET_hor_wd_speed_30s
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WNAC_TmpCab:
+    canonical: WNAC_cabinet_temp
+    ruling: auto (词序归并)
+  WNAC_VibX:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_VibY:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_PtAngBlade1:
+    canonical: WROT_pt_ang_blade_1
+    ruling: auto
+  WROT_PtAngBlade1_redEnc:
+    canonical: WROT_pt_ang_blade_red_enc_1
+    ruling: auto (词序归并)
+  WROT_PtAngBlade2:
+    canonical: WROT_pt_ang_blade_2
+    ruling: auto
+  WROT_PtAngBlade2_redEnc:
+    canonical: WROT_pt_ang_blade_red_enc_2
+    ruling: auto (词序归并)
+  WROT_PtAngBlade3:
+    canonical: WROT_pt_ang_blade_3
+    ruling: auto
+  WROT_PtAngBlade3_redEnc:
+    canonical: WROT_pt_ang_blade_red_enc_3
+    ruling: auto (词序归并)
+  WROT_PtCapVolt1:
+    canonical: WROT_pt_cap_voltage_1
+    ruling: auto
+  WROT_PtCapVolt2:
+    canonical: WROT_pt_cap_voltage_2
+    ruling: auto
+  WROT_PtCapVolt3:
+    canonical: WROT_pt_cap_voltage_3
+    ruling: auto
+  WROT_TmpHub:
+    canonical: WROT_hub_temp
+    ruling: auto (词序归并)
+  WROT_TmpPtAxisCab1:
+    canonical: WROT_temp_pt_axis_cabinet_1
+    ruling: auto
+  WROT_TmpPtAxisCab2:
+    canonical: WROT_temp_pt_axis_cabinet_2
+    ruling: auto
+  WROT_TmpPtAxisCab3:
+    canonical: WROT_temp_pt_axis_cabinet_3
+    ruling: auto
+  WROT_TmpPtCapCab1:
+    canonical: WROT_temp_pt_cap_cabinet_1
+    ruling: auto
+  WROT_TmpPtCapCab2:
+    canonical: WROT_temp_pt_cap_cabinet_2
+    ruling: auto
+  WROT_TmpPtCapCab3:
+    canonical: WROT_temp_pt_cap_cabinet_3
+    ruling: auto
+  WROT_TmpPtMotor1:
+    canonical: WROT_temp_pt_motor_1
+    ruling: auto
+  WROT_TmpPtMotor2:
+    canonical: WROT_temp_pt_motor_2
+    ruling: auto
+  WROT_TmpPtMotor3:
+    canonical: WROT_temp_pt_motor_3
+    ruling: auto
+  WTOW_TmpTower:
+    canonical: WTOW_temp_tower
+    ruling: auto
+  WTOW_TmpTowerBaseCab:
+    canonical: WTOW_temp_tower_base_cabinet
+    ruling: auto
+  WTRM_TmpOil:
+    canonical: WTRM_oil_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号) (词序归并)
+  WTUR_AlarmFlag:
+    canonical: WTUR_alarm_flag
+    ruling: auto
+  WTUR_BrakeFlag:
+    canonical: WTUR_brake_flag
+    ruling: auto
+  WTUR_CurtailFlag:
+    canonical: WTUR_curtail_flag
+    ruling: auto
+  WTUR_FaultFlag:
+    canonical: WTUR_fault_flag
+    ruling: auto
+  WTUR_ServiceFlag:
+    canonical: WTUR_service_flag
+    ruling: auto
+  WYAW_ActiveFlag:
+    canonical: WYAW_active_flag
+    ruling: auto
+  WYAW_CableTwist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_UnwindFlag:
+    canonical: WYAW_unwind_flag
+    ruling: auto
+contract: sewind_w2000_93_jincheng.yaml
+sha16: f2b1c0f743ec1986

+ 90 - 0
app_ETL/configs/canonical/alias/sewind_w2000_99_guojiadian.yaml

@@ -0,0 +1,90 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_active_setpoint:
+    canonical: WAPC_active_setpoint
+    ruling: auto
+  WAPC_power_factor:
+    canonical: WAPC_power_factor
+    ruling: auto
+  WAPC_powerlimit_flag:
+    canonical: WAPC_powerlimit_flag
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_torque:
+    canonical: WCNV_torque
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_sliprng_temp:
+    canonical: WGEN_sliprng_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_temp_u:
+    canonical: WGEN_stator_temp_u
+    ruling: auto
+  WGRD_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_wind_dir:
+    canonical: WMET_wind_dir
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WPCS_cap_voltage_1:
+    canonical: WROT_pitch_cap_voltage_1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WPCS_motor_current_1:
+    canonical: WROT_pitch_motor_current_1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_avail_builtin_month:
+    canonical: WTUR_avail_builtin_month
+    ruling: auto
+  WTUR_brake_active:
+    canonical: WTUR_brake_active
+    ruling: auto
+  WTUR_fault_flag:
+    canonical: WTUR_fault_flag
+    ruling: auto
+  WTUR_id:
+    canonical: WTUR_id
+    ruling: auto
+  WTUR_service_mode:
+    canonical: WTUR_service_mode
+    ruling: auto
+  WTUR_state:
+    canonical: WTUR_state
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: sewind_w2000_99_guojiadian.yaml
+sha16: 2f2973beb1b323d3

+ 72 - 0
app_ETL/configs/canonical/alias/sewind_w2000_mixed_taierzhuang.yaml

@@ -0,0 +1,72 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_bearing_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_temp_u:
+    canonical: WGEN_stator_temp_u
+    ruling: auto
+  WGEN_stator_temp_v:
+    canonical: WGEN_stator_temp_v
+    ruling: auto
+  WGEN_stator_temp_w:
+    canonical: WGEN_stator_temp_w
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WNAC_internal_temp:
+    canonical: WNAC_internal_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_collective:
+    canonical: WROT_pitch_collective
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_hss_bearing_temp:
+    canonical: WTRM_gearbox_hss_bearing_temp
+    ruling: auto
+  WTRM_gearbox_lss_bearing_temp:
+    canonical: WTRM_gearbox_lss_bearing_temp
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_id:
+    canonical: WTUR_id
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_error_vane:
+    canonical: WYAW_error_vane
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: sewind_w2000_mixed_taierzhuang.yaml
+sha16: '9728252892140567'

+ 63 - 0
app_ETL/configs/canonical/alias/sewind_w2000_nanshu.yaml

@@ -0,0 +1,63 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgFront:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto
+  WGEN_TmpBrgRear:
+    canonical: WGEN_temp_bearing_rear
+    ruling: auto
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_ExtTmp:
+    canonical: WNAC_ext_temp
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WROT_PtAngBlade1:
+    canonical: WROT_pt_ang_blade_1
+    ruling: auto
+  WROT_PtAngBlade2:
+    canonical: WROT_pt_ang_blade_2
+    ruling: auto
+  WROT_PtAngBlade3:
+    canonical: WROT_pt_ang_blade_3
+    ruling: auto
+  WROT_RotSpd:
+    canonical: WROT_rot_speed
+    ruling: auto
+  WTRM_TmpGbxCool:
+    canonical: WTRM_temp_gearbox_cooling
+    ruling: auto
+  WTRM_TmpGbxHSS:
+    canonical: WTRM_temp_gearbox_hss
+    ruling: auto
+  WTRM_TmpGbxOil:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto (词序归并)
+  WTRM_TmpMainBrg:
+    canonical: WTRM_main_bearing_temp
+    ruling: auto (词序归并)
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: sewind_w2000_nanshu.yaml
+sha16: 7dd254584143b35a

+ 72 - 0
app_ETL/configs/canonical/alias/sewind_w2000n_105_jishan_p1.yaml

@@ -0,0 +1,72 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp_u:
+    canonical: WGEN_winding_temp_u
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_10min:
+    canonical: WMET_wind_speed_10min
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_collective:
+    canonical: WROT_pitch_collective
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_hsb_temp:
+    canonical: WTRM_hsb_temp
+    ruling: auto
+  WTRM_lsb_temp:
+    canonical: WTRM_lsb_temp
+    ruling: auto
+  WTUR_availability_daily:
+    canonical: WTUR_availability_daily
+    ruling: auto
+  WTUR_fault_bits:
+    canonical: WTUR_fault_bits
+    ruling: auto
+  WTUR_status:
+    canonical: WTUR_status
+    ruling: auto
+  WVIB_acc_x:
+    canonical: WNAC_acc_x
+    ruling: auto
+  WVIB_acc_y:
+    canonical: WNAC_acc_y
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+contract: sewind_w2000n_105_jishan_p1.yaml
+sha16: 2328a9b614e110db

+ 33 - 0
app_ETL/configs/canonical/alias/sewind_w2500_135_2p5mw.yaml

@@ -0,0 +1,33 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_ambient_temp_2:
+    canonical: WMET_ambient_temp_2
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_density_norm:
+    canonical: WMET_wind_speed_density_norm
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_tower_base_cabinet_temp:
+    canonical: WNAC_tower_base_cabinet_temp
+    ruling: auto
+  WSLG_turbine_state:
+    canonical: WSLG_turbine_state
+    ruling: auto
+  WTRM_main_shaft_temp:
+    canonical: WTRM_main_shaft_temp
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: sewind_w2500_135_2p5mw.yaml
+sha16: 7fcb784ceb420028

+ 57 - 0
app_ETL/configs/canonical/alias/sewind_w2500_146_dehui.yaml

@@ -0,0 +1,57 @@
+alias:
+  WCNV_TmpCab:
+    canonical: WCNV_cabinet_temp
+    ruling: auto (词序归并)
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgDE:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_TmpBrgNDE:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WMET_EnvTmp:
+    canonical: WMET_env_temp
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WPCH_TmpMotBld1:
+    canonical: WROT_temp_motor_blade_1
+    ruling: auto
+  WPCH_TmpMotBld2:
+    canonical: WROT_temp_motor_blade_2
+    ruling: auto
+  WPCH_TmpMotBld3:
+    canonical: WROT_temp_motor_blade_3
+    ruling: auto
+  WROT_PtchAngValBld:
+    canonical: WROT_ptch_ang_val_blade
+    ruling: auto
+  WSLG_turbine_state:
+    canonical: WSLG_turbine_state
+    ruling: auto
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WYAW_CableTwist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: sewind_w2500_146_dehui.yaml
+sha16: e3a669f83f220c6a

+ 96 - 0
app_ETL/configs/canonical/alias/sewind_w2500_zhaijia_shangqi.yaml

@@ -0,0 +1,96 @@
+alias:
+  WAPC_setpoint_pct:
+    canonical: WAPC_setpoint_pct
+    ruling: auto
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgFront:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto
+  WGEN_TmpBrgRear:
+    canonical: WGEN_temp_bearing_rear
+    ruling: auto
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WHYD_PitchPress:
+    canonical: WHYD_pitch_press
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WMET_horWdSpd_inst:
+    canonical: WMET_hor_wd_speed_inst
+    ruling: auto
+  WNAC_Dir:
+    canonical: WNAC_dir
+    ruling: auto
+  WNAC_Dir_unwrapped:
+    canonical: WNAC_dir_unwrapped
+    ruling: auto
+  WNAC_ExtTmp:
+    canonical: WNAC_ext_temp
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WNAC_VibRMS:
+    canonical: WNAC_vib_rms
+    ruling: auto
+  WNAC_VibX:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_VibY:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_PtAngBlade1:
+    canonical: WROT_pt_ang_blade_1
+    ruling: auto
+  WROT_PtAngBlade2:
+    canonical: WROT_pt_ang_blade_2
+    ruling: auto
+  WROT_PtAngBlade3:
+    canonical: WROT_pt_ang_blade_3
+    ruling: auto
+  WROT_PtchAngColl:
+    canonical: WROT_ptch_ang_coll
+    ruling: auto
+  WROT_RotSpd:
+    canonical: WROT_rot_speed
+    ruling: auto
+  WROT_TmpHub:
+    canonical: WROT_hub_temp
+    ruling: auto (词序归并)
+  WTRM_TmpBrakePad:
+    canonical: WTRM_temp_brake_pad
+    ruling: auto
+  WTRM_TmpGbxCool:
+    canonical: WTRM_temp_gearbox_cooling
+    ruling: auto
+  WTRM_TmpGbxHSS:
+    canonical: WTRM_temp_gearbox_hss
+    ruling: auto
+  WTRM_TmpGbxOil:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto (词序归并)
+  WTRM_TmpGbxOilIn:
+    canonical: WTRM_temp_gearbox_oil_in
+    ruling: auto
+  WTRM_TmpMainBrg:
+    canonical: WTRM_main_bearing_temp
+    ruling: auto (词序归并)
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WYAW_YawErr:
+    canonical: WYAW_yaw_err
+    ruling: auto
+contract: sewind_w2500_zhaijia_shangqi.yaml
+sha16: f85cda6d712addb9

+ 96 - 0
app_ETL/configs/canonical/alias/shanghaielectric_w2000_pinglu.yaml

@@ -0,0 +1,96 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_torque:
+    canonical: WCNV_torque
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_slipring_temp:
+    canonical: WGEN_slipring_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_stator_u_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_v_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_w_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_instant:
+    canonical: WMET_wind_speed_instant
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_position:
+    canonical: WNAC_position
+    ruling: auto
+  WNAC_vib_rms:
+    canonical: WNAC_vib_rms
+    ruling: auto
+  WNAC_vib_x:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_vib_y:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_hub_temp:
+    canonical: WROT_hub_temp
+    ruling: auto
+  WROT_pitch_angle:
+    canonical: WROT_pitch
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WSTA_generating_flag:
+    canonical: WTUR_generating_flag
+    ruling: auto
+  WTRM_gbx_coolant_temp:
+    canonical: WTRM_gearbox_coolant_temp
+    ruling: auto
+  WTRM_gbx_hss_bearing_temp:
+    canonical: WTRM_gearbox_hss_bearing_temp
+    ruling: auto
+  WTRM_gbx_lss_bearing_temp:
+    canonical: WTRM_gearbox_lss_bearing_temp
+    ruling: auto
+  WTRM_gbx_oil_inlet_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: shanghaielectric_w2000_pinglu.yaml
+sha16: 820e72cc45682017

+ 201 - 0
app_ETL/configs/canonical/alias/siemens_swt40_rudong.yaml

@@ -0,0 +1,201 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGBX_filter_p_after:
+    canonical: WTRM_filter_p_after
+    ruling: auto
+  WGBX_filter_p_before:
+    canonical: WTRM_filter_p_before
+    ruling: auto
+  WGBX_hss_gen_temp:
+    canonical: WTRM_hss_gen_temp
+    ruling: auto
+  WGBX_hss_rot_temp:
+    canonical: WTRM_hss_rot_temp
+    ruling: auto
+  WGBX_ims_gen_temp:
+    canonical: WTRM_ims_gen_temp
+    ruling: auto
+  WGBX_ims_rot_temp:
+    canonical: WTRM_ims_rot_temp
+    ruling: auto
+  WGBX_offline_filter_p:
+    canonical: WTRM_offline_filter_p
+    ruling: auto
+  WGBX_oil_pressure:
+    canonical: WTRM_oil_pressure
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGBX_oilfilter_b1_temp:
+    canonical: WTRM_oilfilter_temp_1_b
+    ruling: auto (词序归并)
+  WGBX_oilfilter_b2_temp:
+    canonical: WTRM_oilfilter_temp_2_b
+    ruling: auto (词序归并)
+  WGBX_oilfilter_b3_temp:
+    canonical: WTRM_oilfilter_temp_3_b
+    ruling: auto (词序归并)
+  WGEN_bearing_DE_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_bearing_NDE_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_generator_speed:
+    canonical: WGEN_generator_speed
+    ruling: auto
+  WGEN_stator_U_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_V_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_W_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WHYD_oil_temp:
+    canonical: WHYD_oil_temp
+    ruling: auto
+  WHYD_pressure:
+    canonical: WHYD_pressure
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_compensated:
+    canonical: WMET_wind_speed_compensated
+    ruling: auto
+  WMET_wind_speed_nacelle2:
+    canonical: WMET_wind_speed_nacelle_2
+    ruling: auto
+  WNAC_cabin_temp:
+    canonical: WNAC_cabin_temp
+    ruling: auto
+  WNAC_cabin_temp2:
+    canonical: WNAC_cabin_temp_2
+    ruling: auto
+  WNAC_cabinet_ext_temp:
+    canonical: WNAC_cabinet_ext_temp
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_hub_p_A:
+    canonical: WROT_hub_p_a
+    ruling: auto
+  WROT_hub_p_B:
+    canonical: WROT_hub_p_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WROT_hub_p_C:
+    canonical: WROT_hub_p_c
+    ruling: auto
+  WROT_hub_temp:
+    canonical: WROT_hub_temp
+    ruling: auto
+  WROT_main_bearing_outer_temp:
+    canonical: WROT_main_bearing_outer_temp
+    ruling: auto
+  WROT_main_bearing_temp:
+    canonical: WROT_main_bearing_temp
+    ruling: auto
+  WROT_mainbrg_G_temp:
+    canonical: WROT_mainbrg_g_temp
+    ruling: auto
+  WROT_mainbrg_H_temp:
+    canonical: WROT_mainbrg_h_temp
+    ruling: auto
+  WROT_pitch1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_hyd_A:
+    canonical: WROT_pitch_hyd_a
+    ruling: auto
+  WROT_pitch_hyd_B:
+    canonical: WROT_pitch_hyd_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WROT_pitch_hyd_C:
+    canonical: WROT_pitch_hyd_c
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRF_oil_temp:
+    canonical: WTRF_oil_temp
+    ruling: auto
+  WTRF_oil_temp_dup:
+    canonical: WTRF_oil_temp_dup
+    ruling: auto
+  WTRF_room_air_temp:
+    canonical: WTRF_room_air_temp
+    ruling: auto
+  WTRM_gbx_IMS_NDE_temp:
+    canonical: WTRM_gearbox_ims_temp_nde
+    ruling: auto (词序归并)
+  WTRM_gbx_IMS_temp:
+    canonical: WTRM_gearbox_ims_temp
+    ruling: auto
+  WTRM_gbx_oil_inlet_prs:
+    canonical: WTRM_gearbox_oil_inlet_prs
+    ruling: auto
+  WTRM_gbx_oil_inlet_prs_2:
+    canonical: WTRM_gearbox_oil_inlet_prs_2
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_active_power:
+    canonical: WTUR_active_power_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WTUR_avail_builtin_daily:
+    canonical: WTUR_avail_builtin_daily
+    ruling: auto
+  WTUR_available_power:
+    canonical: WTUR_available_power
+    ruling: auto
+  WTUR_brake_state:
+    canonical: WTUR_brake_state
+    ruling: auto
+  WTUR_current_phase_r:
+    canonical: WTUR_current_phase_r
+    ruling: auto
+  WTUR_current_phase_s:
+    canonical: WTUR_current_phase_s
+    ruling: auto
+  WTUR_current_phase_t:
+    canonical: WTUR_current_phase_t
+    ruling: auto
+  WTUR_fault_code:
+    canonical: WTUR_fault_code
+    ruling: auto
+  WTUR_state_static:
+    canonical: WTUR_state_static
+    ruling: auto
+  WTUR_voltage_phase_r:
+    canonical: WTUR_voltage_phase_r
+    ruling: auto
+  WTUR_voltage_phase_s:
+    canonical: WTUR_voltage_phase_s
+    ruling: auto
+  WTUR_voltage_phase_t:
+    canonical: WTUR_voltage_phase_t
+    ruling: auto
+  WYAW_pressure:
+    canonical: WYAW_pressure
+    ruling: auto
+  WYAW_wind_direction_inst:
+    canonical: WYAW_wind_direction_inst
+    ruling: auto
+  WYAW_wind_direction_pseudo:
+    canonical: WYAW_wind_direction_pseudo
+    ruling: auto
+contract: siemens_swt40_rudong.yaml
+sha16: 03d201ca2255a6f0

+ 60 - 0
app_ETL/configs/canonical/alias/sinovel_sl1500_77_jingtaixingquan.yaml

@@ -0,0 +1,60 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_energy_daily:
+    canonical: WAPC_energy_daily
+    ruling: auto
+  WAPC_energy_total:
+    canonical: WAPC_energy_total
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRID_voltage_a:
+    canonical: WGRD_voltage_a
+    ruling: auto
+  WMET_wind_direction:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_torque_1:
+    canonical: WROT_pitch_torque_1
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_status_group_std:
+    canonical: WTUR_status_group_std
+    ruling: auto
+  WTUR_substatus_bitmask:
+    canonical: WTUR_substatus_bitmask
+    ruling: auto
+  WVIB_acc_x:
+    canonical: WNAC_acc_x
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_status:
+    canonical: WYAW_status
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: sinovel_sl1500_77_jingtaixingquan.yaml
+sha16: 43d836d0bddc8e9d

+ 93 - 0
app_ETL/configs/canonical/alias/sinovel_sl1500_77_tangzhen.yaml

@@ -0,0 +1,93 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WCNV_igbt_temp:
+    canonical: WCNV_igbt_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRID_voltage_a:
+    canonical: WGRD_voltage_a
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_mast_wd_10m:
+    canonical: WMET_mast_wd_10m
+    ruling: auto
+  WMET_mast_wd_80m:
+    canonical: WMET_mast_wd_80m
+    ruling: auto
+  WMET_mast_ws_10m:
+    canonical: WMET_mast_ws_10m
+    ruling: auto
+  WMET_mast_ws_80m:
+    canonical: WMET_mast_ws_80m
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_nacelle_avg:
+    canonical: WMET_wind_speed_avg
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_torque_1:
+    canonical: WROT_pitch_torque_1
+    ruling: auto
+  WROT_pitch_torque_2:
+    canonical: WROT_pitch_torque_2
+    ruling: auto
+  WROT_pitch_torque_3:
+    canonical: WROT_pitch_torque_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_hss_bearing_temp:
+    canonical: WTRM_gearbox_hss_bearing_temp
+    ruling: auto
+  WTRM_gearbox_lss_bearing_temp:
+    canonical: WTRM_gearbox_lss_bearing_temp
+    ruling: auto
+  WTUR_fault_1:
+    canonical: WTUR_fault_1
+    ruling: auto
+  WTUR_standard_state:
+    canonical: WTUR_standard_state
+    ruling: auto
+  WTUR_state_received:
+    canonical: WTUR_state_received
+    ruling: auto
+  WTUR_theoretical_power:
+    canonical: WTUR_theoretical_power
+    ruling: auto
+  WTUR_total_energy:
+    canonical: WTUR_total_energy
+    ruling: auto
+  WYAW_nacelle_position_cumulative:
+    canonical: WYAW_position_cumulative
+    ruling: auto
+contract: sinovel_sl1500_77_tangzhen.yaml
+sha16: 0025e62e11ce962c

+ 108 - 0
app_ETL/configs/canonical/alias/sinovel_sl1500_77_wendeng.yaml

@@ -0,0 +1,108 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WCNV_igbt_temp_gen:
+    canonical: WCNV_igbt_temp_gen
+    ruling: auto
+  WCNV_power:
+    canonical: WCNV_power
+    ruling: auto
+  WCNV_state:
+    canonical: WCNV_state
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_bearing_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp_1:
+    canonical: WGEN_winding_temp_1
+    ruling: auto
+  WGRID_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WMET_wind_direction_rel:
+    canonical: WMET_wind_direction_rel
+    ruling: auto
+  WMET_wind_speed_5min:
+    canonical: WMET_wind_speed_5min
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_vibration_drive:
+    canonical: WNAC_vibration_drive
+    ruling: auto
+  WNAC_vibration_nondrive:
+    canonical: WNAC_vibration_nondrive
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_torque_1:
+    canonical: WROT_pitch_torque_1
+    ruling: auto
+  WROT_pitch_torque_2:
+    canonical: WROT_pitch_torque_2
+    ruling: auto
+  WROT_pitch_torque_3:
+    canonical: WROT_pitch_torque_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_bearing_temp_1:
+    canonical: WTRM_gearbox_bearing_temp_1
+    ruling: auto
+  WTRM_gearbox_bearing_temp_2:
+    canonical: WTRM_gearbox_bearing_temp_2
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_fault_1:
+    canonical: WTUR_fault_1
+    ruling: auto
+  WTUR_id:
+    canonical: WTUR_id
+    ruling: auto
+  WTUR_state:
+    canonical: WTUR_state
+    ruling: auto
+  WTUR_substate:
+    canonical: WTUR_substate
+    ruling: auto
+  WTUR_theoretical_power:
+    canonical: WTUR_theoretical_power
+    ruling: auto
+  WTUR_total_energy:
+    canonical: WTUR_total_energy
+    ruling: auto
+  WYAW_error:
+    canonical: WYAW_error
+    ruling: auto
+  WYAW_nacelle_position_cumulative:
+    canonical: WYAW_position_cumulative
+    ruling: auto
+  WYAW_state:
+    canonical: WYAW_state
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: sinovel_sl1500_77_wendeng.yaml
+sha16: ff07f9602dc86ee5

+ 30 - 0
app_ETL/configs/canonical/alias/sinovel_sl1500_82.yaml

@@ -0,0 +1,30 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_bearing_temp_de:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: sinovel_sl1500_82.yaml
+sha16: 032299dffceba0bb

+ 156 - 0
app_ETL/configs/canonical/alias/sinovel_sl1500_hutouya.yaml

@@ -0,0 +1,156 @@
+alias:
+  WCNV_filter_board_temp:
+    canonical: WCNV_filter_board_temp
+    ruling: auto
+  WCNV_igbt_gen_side_temp:
+    canonical: WCNV_igbt_gen_side_temp
+    ruling: auto
+  WCNV_igbt_grid_side_temp:
+    canonical: WCNV_igbt_grid_side_temp
+    ruling: auto
+  WCNV_inductor_gen_side_temp:
+    canonical: WCNV_inductor_gen_side_temp
+    ruling: auto
+  WCNV_inductor_grid_side_temp:
+    canonical: WCNV_inductor_grid_side_temp
+    ruling: auto
+  WCNV_max_fault_current:
+    canonical: WCNV_max_fault_current
+    ruling: auto
+  WCNV_ncc300_temp:
+    canonical: WCNV_ncc300_temp
+    ruling: auto
+  WCNV_ncc320_temp:
+    canonical: WCNV_ncc320_temp
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_bearing_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed_avg:
+    canonical: WGEN_speed_avg
+    ruling: auto
+  WGEN_speed_ctrl_avg:
+    canonical: WGEN_speed_control_avg
+    ruling: auto
+  WGEN_speed_ctrl_max:
+    canonical: WGEN_speed_control_max
+    ruling: auto
+  WGEN_speed_ctrl_min:
+    canonical: WGEN_speed_control_min
+    ruling: auto
+  WGEN_speed_max:
+    canonical: WGEN_speed_max
+    ruling: auto
+  WGEN_speed_min:
+    canonical: WGEN_speed_min
+    ruling: auto
+  WGEN_winding_max_temp:
+    canonical: WGEN_winding_max_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_dir_rel:
+    canonical: WMET_wind_dir_rel
+    ruling: auto
+  WMET_wind_speed_nac_avg:
+    canonical: WMET_wind_speed_nac_avg
+    ruling: auto
+  WMET_wind_speed_nac_max:
+    canonical: WMET_wind_speed_nac_max
+    ruling: auto
+  WMET_wind_speed_nac_min:
+    canonical: WMET_wind_speed_nac_min
+    ruling: auto
+  WNAC_battery_temp:
+    canonical: WNAC_battery_temp
+    ruling: auto
+  WNAC_battery_voltage:
+    canonical: WNAC_battery_voltage
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_tower_swing_fa_pct:
+    canonical: WNAC_tower_swing_fa_pct
+    ruling: auto
+  WNAC_tower_swing_ss_pct:
+    canonical: WNAC_tower_swing_ss_pct
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_max_1:
+    canonical: WROT_pitch_max_1
+    ruling: auto
+  WROT_pitch_max_2:
+    canonical: WROT_pitch_max_2
+    ruling: auto
+  WROT_pitch_max_3:
+    canonical: WROT_pitch_max_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_pitch_motor_torque_1:
+    canonical: WROT_pitch_motor_torque_1
+    ruling: auto
+  WROT_pitch_motor_torque_2:
+    canonical: WROT_pitch_motor_torque_2
+    ruling: auto
+  WROT_pitch_motor_torque_3:
+    canonical: WROT_pitch_motor_torque_3
+    ruling: auto
+  WTRM_driveline_swing_pct:
+    canonical: WTRM_driveline_swing_pct
+    ruling: auto
+  WTRM_gbx_bearing_temp:
+    canonical: WTRM_gearbox_bearing_temp
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WTUR_energy_10min:
+    canonical: WTUR_energy_10min
+    ruling: auto
+  WTUR_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WYAW_converter_temp:
+    canonical: WYAW_converter_temp
+    ruling: auto
+  WYAW_nacelle_pos_avg:
+    canonical: WYAW_pos_avg
+    ruling: auto
+  WYAW_nacelle_pos_max:
+    canonical: WYAW_pos_max
+    ruling: auto
+  WYAW_nacelle_pos_min:
+    canonical: WYAW_pos_min
+    ruling: auto
+  WYAW_power_avg:
+    canonical: WYAW_power_avg
+    ruling: auto
+  WYAW_power_max:
+    canonical: WYAW_power_max
+    ruling: auto
+contract: sinovel_sl1500_hutouya.yaml
+sha16: 6b523611f8a5369f

+ 141 - 0
app_ETL/configs/canonical/alias/sinovel_sl2000_huishan.yaml

@@ -0,0 +1,141 @@
+alias:
+  WCNV_internal_temp:
+    canonical: WCNV_internal_temp
+    ruling: auto
+  WCNV_voltage:
+    canonical: WCNV_voltage
+    ruling: auto
+  WGEN_bearing_front_temp:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto (词序归并)
+  WGEN_bearing_rear_temp:
+    canonical: WGEN_temp_bearing_rear
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_u1_temp:
+    canonical: WGEN_winding_temp_1_u
+    ruling: auto (词序归并)
+  WGEN_winding_u2_temp:
+    canonical: WGEN_winding_temp_2_u
+    ruling: auto (词序归并)
+  WGEN_winding_v1_temp:
+    canonical: WGEN_winding_temp_1_v
+    ruling: auto (词序归并)
+  WGEN_winding_v2_temp:
+    canonical: WGEN_winding_temp_2_v
+    ruling: auto (词序归并)
+  WGEN_winding_w1_temp:
+    canonical: WGEN_winding_temp_1_w
+    ruling: auto (词序归并)
+  WGEN_winding_w2_temp:
+    canonical: WGEN_winding_temp_2_w
+    ruling: auto (词序归并)
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_dir:
+    canonical: WMET_wind_dir
+    ruling: auto
+  WMET_wind_speed_nac:
+    canonical: WMET_wind_speed_nac
+    ruling: auto
+  WNAC_cabinet_temp:
+    canonical: WNAC_cabinet_temp
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_vib_x:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_vib_y:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_main_bearing_front_temp:
+    canonical: WROT_main_bearing_front_temp
+    ruling: auto
+  WROT_main_bearing_rear_temp:
+    canonical: WROT_main_bearing_rear_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_current_1:
+    canonical: WROT_pitch_current_1
+    ruling: auto
+  WROT_pitch_current_2:
+    canonical: WROT_pitch_current_2
+    ruling: auto
+  WROT_pitch_current_3:
+    canonical: WROT_pitch_current_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_pitch_rate_1:
+    canonical: WROT_pitch_rate_1
+    ruling: auto
+  WROT_pitch_rate_2:
+    canonical: WROT_pitch_rate_2
+    ruling: auto
+  WROT_pitch_rate_3:
+    canonical: WROT_pitch_rate_3
+    ruling: auto
+  WTOW_base_cabinet_temp:
+    canonical: WTOW_base_cabinet_temp
+    ruling: auto
+  WTRM_gbx_hss_front_temp:
+    canonical: WTRM_gearbox_hss_front_temp
+    ruling: auto
+  WTRM_gbx_hss_rear_temp:
+    canonical: WTRM_gearbox_hss_rear_temp
+    ruling: auto
+  WTRM_gbx_inlet_oil_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto (词序归并)
+  WTRM_gbx_inlet_pressure:
+    canonical: WTRM_gearbox_inlet_pressure
+    ruling: auto
+  WTRM_gbx_oil_sump_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTRM_gbx_pump_outlet_pressure:
+    canonical: WTRM_gearbox_pump_outlet_pressure
+    ruling: auto
+  WTUR_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WTUR_curtail_flag:
+    canonical: WTUR_curtail_flag
+    ruling: auto
+  WTUR_fault_downtime:
+    canonical: WTUR_fault_downtime
+    ruling: auto
+  WTUR_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_pressure:
+    canonical: WYAW_pressure
+    ruling: auto
+  WYAW_speed:
+    canonical: WYAW_speed
+    ruling: auto
+contract: sinovel_sl2000_huishan.yaml
+sha16: 8c928f32cdb2952d

+ 282 - 0
app_ETL/configs/canonical/alias/sinovel_sl2000_mahuanggouxi.yaml

@@ -0,0 +1,282 @@
+alias:
+  WCNV_dc_voltage:
+    canonical: WCNV_dc_voltage
+    ruling: auto
+  WCNV_internal_temp:
+    canonical: WCNV_internal_temp
+    ruling: auto
+  WGEN_active_power:
+    canonical: WTUR_active_power_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WGEN_bearing_front_temp:
+    canonical: WGEN_temp_bearing_front
+    ruling: auto (词序归并)
+  WGEN_bearing_rear_temp:
+    canonical: WGEN_temp_bearing_rear
+    ruling: auto (词序归并)
+  WGEN_coolant_temp:
+    canonical: WGEN_coolant_temp
+    ruling: auto
+  WGEN_reactive_power:
+    canonical: WTUR_reactive_power_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WGEN_sliprings_temp:
+    canonical: WGEN_sliprings_temp
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_torque:
+    canonical: WGEN_torque
+    ruling: auto
+  WGEN_winding_max_temp:
+    canonical: WGEN_winding_max_temp
+    ruling: auto
+  WGEN_winding_u1_temp:
+    canonical: WGEN_winding_temp_1_u
+    ruling: auto (词序归并)
+  WGEN_winding_u2_temp:
+    canonical: WGEN_winding_temp_2_u
+    ruling: auto (词序归并)
+  WGEN_winding_v1_temp:
+    canonical: WGEN_winding_temp_1_v
+    ruling: auto (词序归并)
+  WGEN_winding_v2_temp:
+    canonical: WGEN_winding_temp_2_v
+    ruling: auto (词序归并)
+  WGEN_winding_w1_temp:
+    canonical: WGEN_winding_temp_1_w
+    ruling: auto (词序归并)
+  WGEN_winding_w2_temp:
+    canonical: WGEN_winding_temp_2_w
+    ruling: auto (词序归并)
+  WGRD_active_power:
+    canonical: WGRD_active_power
+    ruling: auto
+  WGRD_current_a:
+    canonical: WGRD_current_a
+    ruling: auto
+  WGRD_current_b:
+    canonical: WGRD_current_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WGRD_current_c:
+    canonical: WGRD_current_c
+    ruling: auto
+  WGRD_frequency:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRD_reactive_power:
+    canonical: WGRD_reactive_power
+    ruling: auto
+  WGRD_voltage_a:
+    canonical: WGRD_voltage_a
+    ruling: auto
+  WGRD_voltage_b:
+    canonical: WGRD_voltage_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WGRD_voltage_c:
+    canonical: WGRD_voltage_c
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_dir_25s:
+    canonical: WMET_wind_dir_25s
+    ruling: auto
+  WMET_wind_dir_600s:
+    canonical: WMET_wind_dir_600s
+    ruling: auto
+  WMET_wind_dir_ext:
+    canonical: WMET_wind_dir_ext
+    ruling: auto
+  WMET_wind_dir_nac:
+    canonical: WMET_wind_dir_nac
+    ruling: auto
+  WMET_wind_dir_nac_rel:
+    canonical: WMET_wind_dir_nac_rel
+    ruling: auto
+  WMET_wind_dir_vane1:
+    canonical: WMET_wind_dir_vane_1
+    ruling: auto
+  WMET_wind_dir_vane2:
+    canonical: WMET_wind_dir_vane_2
+    ruling: auto
+  WMET_wind_speed_10s:
+    canonical: WMET_wind_speed_10s
+    ruling: auto
+  WMET_wind_speed_600s:
+    canonical: WMET_wind_speed_600s
+    ruling: auto
+  WMET_wind_speed_60s:
+    canonical: WMET_wind_speed_60s
+    ruling: auto
+  WMET_wind_speed_ane1:
+    canonical: WMET_wind_speed_ane_1
+    ruling: auto
+  WMET_wind_speed_ane2:
+    canonical: WMET_wind_speed_ane_2
+    ruling: auto
+  WMET_wind_speed_nac:
+    canonical: WMET_wind_speed_nac
+    ruling: auto
+  WNAC_cabinet_temp:
+    canonical: WNAC_cabinet_temp
+    ruling: auto
+  WNAC_front_temp:
+    canonical: WNAC_front_temp
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_vib_rms:
+    canonical: WNAC_vib_rms
+    ruling: auto
+  WNAC_vib_x:
+    canonical: WNAC_vib_x
+    ruling: auto
+  WNAC_vib_xy_max:
+    canonical: WNAC_vib_xy_max
+    ruling: auto
+  WNAC_vib_y:
+    canonical: WNAC_vib_y
+    ruling: auto
+  WROT_main_bearing_front_temp:
+    canonical: WROT_main_bearing_front_temp
+    ruling: auto
+  WROT_main_bearing_rear_temp:
+    canonical: WROT_main_bearing_rear_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_cabinet_temp_1:
+    canonical: WROT_pitch_cabinet_temp_1
+    ruling: auto
+  WROT_pitch_cabinet_temp_2:
+    canonical: WROT_pitch_cabinet_temp_2
+    ruling: auto
+  WROT_pitch_cabinet_temp_3:
+    canonical: WROT_pitch_cabinet_temp_3
+    ruling: auto
+  WROT_pitch_current_1:
+    canonical: WROT_pitch_current_1
+    ruling: auto
+  WROT_pitch_current_2:
+    canonical: WROT_pitch_current_2
+    ruling: auto
+  WROT_pitch_current_3:
+    canonical: WROT_pitch_current_3
+    ruling: auto
+  WROT_pitch_emergency:
+    canonical: WROT_pitch_emergency
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_pitch_rate_1:
+    canonical: WROT_pitch_rate_1
+    ruling: auto
+  WROT_pitch_rate_2:
+    canonical: WROT_pitch_rate_2
+    ruling: auto
+  WROT_pitch_rate_3:
+    canonical: WROT_pitch_rate_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WSHF_brake_pressure:
+    canonical: WHYD_brake_pressure
+    ruling: auto
+  WTOW_base_cabinet_temp:
+    canonical: WTOW_base_cabinet_temp
+    ruling: auto
+  WTOW_base_temp:
+    canonical: WTOW_base_temp
+    ruling: auto
+  WTRM_gbx_coolant_temp:
+    canonical: WTRM_gearbox_coolant_temp
+    ruling: auto
+  WTRM_gbx_hss_front_temp:
+    canonical: WTRM_gearbox_hss_front_temp
+    ruling: auto
+  WTRM_gbx_hss_max_temp:
+    canonical: WTRM_gearbox_hss_max_temp
+    ruling: auto
+  WTRM_gbx_hss_rear_temp:
+    canonical: WTRM_gearbox_hss_rear_temp
+    ruling: auto
+  WTRM_gbx_inlet_oil_temp:
+    canonical: WTRM_gearbox_oil_inlet_temp
+    ruling: auto (词序归并)
+  WTRM_gbx_oil_sump_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WTUR_active_power_10s:
+    canonical: WTUR_active_power_10s
+    ruling: auto
+  WTUR_active_power_600s:
+    canonical: WTUR_active_power_600s
+    ruling: auto
+  WTUR_active_power_60s:
+    canonical: WTUR_active_power_60s
+    ruling: auto
+  WTUR_consumption_daily:
+    canonical: WTUR_consumption_daily
+    ruling: auto
+  WTUR_consumption_total:
+    canonical: WTUR_consumption_total
+    ruling: auto
+  WTUR_derate_feedback:
+    canonical: WTUR_derate_feedback
+    ruling: auto
+  WTUR_energy_daily:
+    canonical: WTUR_energy_daily
+    ruling: auto
+  WTUR_energy_total:
+    canonical: WTUR_total_energy
+    ruling: auto (词序归并)
+  WTUR_fault_code:
+    canonical: WTUR_fault_code
+    ruling: auto
+  WTUR_power_factor:
+    canonical: WTUR_power_factor
+    ruling: auto
+  WTUR_power_setpoint_max:
+    canonical: WTUR_power_setpoint_max
+    ruling: auto
+  WTUR_reactive_energy:
+    canonical: WTUR_reactive_energy
+    ruling: auto
+  WTUR_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WYAW_cable_twist_register:
+    canonical: WYAW_cable_twist_register
+    ruling: auto
+  WYAW_position_cum:
+    canonical: WYAW_position_cum
+    ruling: auto
+  WYAW_pressure:
+    canonical: WYAW_pressure
+    ruling: auto
+  WYAW_speed:
+    canonical: WYAW_speed
+    ruling: auto
+contract: sinovel_sl2000_mahuanggouxi.yaml
+sha16: d14241d9934255e1

+ 45 - 0
app_ETL/configs/canonical/alias/taizhong_tz2000_93_fuxin_rushan.yaml

@@ -0,0 +1,45 @@
+alias:
+  WGEN_Spd:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_TmpBrgDE:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_TmpBrgNDE:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_TmpStaU:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_TmpStaV:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_TmpStaW:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGdV_TmpHSShaft:
+    canonical: WTRM_temp_hss
+    ruling: auto
+  WGdV_TmpLSShaft:
+    canonical: WTRM_temp_lss
+    ruling: auto
+  WGdV_TmpOil:
+    canonical: WTRM_oil_temp
+    ruling: auto (词序归并)
+  WMET_EnvTmp:
+    canonical: WMET_env_temp
+    ruling: auto
+  WMET_horWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WNAC_IntTmp:
+    canonical: WNAC_int_temp
+    ruling: auto
+  WROT_TmpMainBrg:
+    canonical: WROT_main_bearing_temp
+    ruling: auto (词序归并)
+  WTUR_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+contract: taizhong_tz2000_93_fuxin_rushan.yaml
+sha16: 5be045ec904db6ab

+ 93 - 0
app_ETL/configs/canonical/alias/unknown_0p85mw_baishan.yaml

@@ -0,0 +1,93 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WCNV_cabinet_temp:
+    canonical: WCNV_cabinet_temp
+    ruling: auto
+  WCNV_current_phase_a:
+    canonical: WCNV_current_phase_a
+    ruling: auto
+  WCNV_current_phase_b:
+    canonical: WCNV_current_phase_b
+    ruling: 'manual: 尾缀 _b 是相位还是第二套传感器, 待核'
+  WCNV_current_phase_c:
+    canonical: WCNV_current_phase_c
+    ruling: auto
+  WGBX_bearing_temp:
+    canonical: WTRM_bearing_temp
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_bearing_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_bearing_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp:
+    canonical: WGEN_winding_temp
+    ruling: auto
+  WGRD_freq:
+    canonical: WGRD_frequency
+    ruling: auto
+  WHYD_oil_temp:
+    canonical: WHYD_oil_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_relative:
+    canonical: WMET_wind_direction_relative
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_raw:
+    canonical: WMET_wind_speed_raw
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch:
+    canonical: WROT_pitch
+    ruling: auto
+  WROT_pitch_setpoint:
+    canonical: WROT_pitch_setpoint
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTUR_power_instant:
+    canonical: WTUR_power_instant
+    ruling: auto
+  WTUR_theoretical_power:
+    canonical: WTUR_theoretical_power
+    ruling: auto
+  WTWR_ambient_temp:
+    canonical: WMET_ambient_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  _rated_power_const:
+    canonical: META_rated_power_const
+    ruling: auto (元数据列)
+  _running_flag:
+    canonical: META_running_flag
+    ruling: auto (元数据列)
+  _status_calc:
+    canonical: META_status_calc
+    ruling: auto (元数据列)
+  _status_code:
+    canonical: META_status_code
+    ruling: auto (元数据列)
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: unknown_0p85mw_baishan.yaml
+sha16: 57fb92bd25126a3f

+ 45 - 0
app_ETL/configs/canonical/alias/unknown_1p5mw_huitengliang.yaml

@@ -0,0 +1,45 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_mainshaft_front_bearing_temp:
+    canonical: WROT_mainshaft_front_bearing_temp
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WYAW_cable_twist:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: unknown_1p5mw_huitengliang.yaml
+sha16: ceed3b0c03606480

+ 51 - 0
app_ETL/configs/canonical/alias/unknown_1p5mw_taiyangshan.yaml

@@ -0,0 +1,51 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_winding_temp:
+    canonical: WGEN_winding_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_direction_geo:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WTRM_gbx_hss_brg_temp:
+    canonical: WTRM_gearbox_hss_bearing_temp
+    ruling: auto
+  WTRM_gbx_lss_brg_temp:
+    canonical: WTRM_gearbox_lss_bearing_temp
+    ruling: auto
+  WTRM_gbx_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: unknown_1p5mw_taiyangshan.yaml
+sha16: ff4b17a4218e4543

+ 54 - 0
app_ETL/configs/canonical/alias/unknown_1p5mw_tangxing_haoyao.yaml

@@ -0,0 +1,54 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_reactive_power:
+    canonical: WTUR_reactive_power
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_ambient_temp:
+    canonical: WMET_ambient_temp_dup2
+    ruling: auto (同契约多列, 加 dup 序号)
+  WNAC_vibration_x:
+    canonical: WNAC_vibration_x
+    ruling: auto
+  WNAC_vibration_y:
+    canonical: WNAC_vibration_y
+    ruling: auto
+  WROT_pitch_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WTRM_gearbox_oil_temp:
+    canonical: WTRM_gearbox_oil_temp
+    ruling: auto
+  WTUR_id:
+    canonical: WTUR_id
+    ruling: auto
+  WYAW_error_vane:
+    canonical: WYAW_error_vane
+    ruling: auto
+  WYAW_nacelle_position_cumulative:
+    canonical: WYAW_position_cumulative
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: unknown_1p5mw_tangxing_haoyao.yaml
+sha16: ba472d56faae0e16

+ 147 - 0
app_ETL/configs/canonical/alias/unknown_2p0mw_jigongdong.yaml

@@ -0,0 +1,147 @@
+alias:
+  TIME_local:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_energy_total_cum:
+    canonical: WAPC_energy_total_cum
+    ruling: auto
+  WCNV_grid_module_temp:
+    canonical: WCNV_grid_module_temp
+    ruling: auto
+  WCNV_measured_power:
+    canonical: WCNV_measured_power
+    ruling: auto
+  WCNV_rotor_module_temp:
+    canonical: WCNV_rotor_module_temp
+    ruling: auto
+  WCNV_state:
+    canonical: WCNV_state
+    ruling: auto
+  WGBX_hss_bearing_front_temp:
+    canonical: WTRM_hss_bearing_front_temp
+    ruling: auto
+  WGBX_hss_bearing_rear_temp:
+    canonical: WTRM_hss_bearing_rear_temp
+    ruling: auto
+  WGBX_lube_inlet_temp:
+    canonical: WTRM_lube_inlet_temp
+    ruling: auto
+  WGBX_oil_filter_inlet_pressure:
+    canonical: WTRM_oil_filter_inlet_pressure
+    ruling: auto
+  WGBX_oil_filter_outlet_pressure:
+    canonical: WTRM_oil_filter_outlet_pressure
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_active_power_pcs:
+    canonical: WGEN_active_power_pcs
+    ruling: auto
+  WGEN_de_bearing_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_nde_bearing_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_speed_3s_avg:
+    canonical: WGEN_speed_3s_avg
+    ruling: auto
+  WGEN_speed_pcs:
+    canonical: WGEN_speed_pcs
+    ruling: auto
+  WGEN_stator_l1_temp:
+    canonical: WGEN_stator_temp_u
+    ruling: auto (词序归并)
+  WGEN_stator_l2_temp:
+    canonical: WGEN_stator_temp_v
+    ruling: auto (词序归并)
+  WGEN_stator_l3_temp:
+    canonical: WGEN_stator_temp_w
+    ruling: auto (词序归并)
+  WGEN_torque_pcs:
+    canonical: WGEN_torque_pcs
+    ruling: auto
+  WHUB_temp:
+    canonical: WROT_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_icing_state:
+    canonical: WMET_icing_state
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_cabinet_temp:
+    canonical: WNAC_cabinet_temp
+    ruling: auto
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTUR_grid_fault_hours_cum:
+    canonical: WTUR_grid_fault_hours_cum
+    ruling: auto
+  WTUR_normal_hours_cum:
+    canonical: WTUR_normal_hours_cum
+    ruling: auto
+  WTUR_power_curve1_active:
+    canonical: WTUR_power_curve_active_1
+    ruling: auto (词序归并)
+  WTUR_scada_state:
+    canonical: WTUR_scada_state
+    ruling: auto
+  WTUR_state:
+    canonical: WTUR_state
+    ruling: auto
+  WTWR_base_cabinet_temp:
+    canonical: WTOW_base_cabinet_temp
+    ruling: auto
+  WYAW_nacelle_position_accum:
+    canonical: WYAW_position_accum
+    ruling: auto
+  WYAW_nacelle_position_wrapped:
+    canonical: WYAW_position_wrapped
+    ruling: auto
+  WYAW_state:
+    canonical: WYAW_state
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+  WYAW_yaw_error_1_dead:
+    canonical: WYAW_yaw_error_dead_1
+    ruling: auto (词序归并)
+  WYAW_yaw_error_2:
+    canonical: WYAW_yaw_error_2
+    ruling: auto
+contract: unknown_2p0mw_jigongdong.yaml
+sha16: c451e389896eeea5

+ 279 - 0
app_ETL/configs/canonical/alias/unknown_2p0mw_shangdu.yaml

@@ -0,0 +1,279 @@
+alias:
+  TIME_local:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_active_power_max:
+    canonical: WAPC_active_power_max
+    ruling: auto
+  WAPC_active_power_min:
+    canonical: WAPC_active_power_min
+    ruling: auto
+  WAPC_energy_10min:
+    canonical: WAPC_energy_10min
+    ruling: auto
+  WAPC_energy_consumed_10min:
+    canonical: WAPC_energy_consumed_10min
+    ruling: auto
+  WCNV_cabinet_temp_1:
+    canonical: WCNV_cabinet_temp_1
+    ruling: auto
+  WCNV_cabinet_temp_2:
+    canonical: WCNV_cabinet_temp_2
+    ruling: auto
+  WCNV_gsc_igbt_temp_1:
+    canonical: WCNV_gsc_igbt_temp_1
+    ruling: auto
+  WCNV_gsc_igbt_temp_2:
+    canonical: WCNV_gsc_igbt_temp_2
+    ruling: auto
+  WCNV_lsc_igbt_temp_1:
+    canonical: WCNV_lsc_igbt_temp_1
+    ruling: auto
+  WCNV_lsc_igbt_temp_2:
+    canonical: WCNV_lsc_igbt_temp_2
+    ruling: auto
+  WCNV_water_plate_temp_1:
+    canonical: WCNV_water_plate_temp_1
+    ruling: auto
+  WCNV_water_plate_temp_2:
+    canonical: WCNV_water_plate_temp_2
+    ruling: auto
+  WELE_battery_voltage_1:
+    canonical: WELE_battery_voltage_1
+    ruling: auto
+  WELE_battery_voltage_2:
+    canonical: WELE_battery_voltage_2
+    ruling: auto
+  WELE_battery_voltage_3:
+    canonical: WELE_battery_voltage_3
+    ruling: auto
+  WGBX_bearing_temp3:
+    canonical: WTRM_bearing_temp_3
+    ruling: auto
+  WGBX_bearing_temp3_max:
+    canonical: WTRM_bearing_temp_max_3
+    ruling: auto (词序归并)
+  WGBX_oil_heater_temp:
+    canonical: WTRM_oil_heater_temp
+    ruling: auto
+  WGBX_oil_heater_temp_max:
+    canonical: WTRM_oil_heater_temp_max
+    ruling: auto
+  WGBX_oil_inlet_pressure:
+    canonical: WTRM_oil_inlet_pressure
+    ruling: auto
+  WGBX_oil_inlet_temp:
+    canonical: WTRM_oil_inlet_temp
+    ruling: auto
+  WGBX_oil_inlet_temp_max:
+    canonical: WTRM_oil_inlet_temp_max
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGBX_oil_temp_max:
+    canonical: WTRM_oil_temp_max
+    ruling: auto
+  WGEN_brg_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_brg_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_choke_temp_1:
+    canonical: WGEN_choke_temp_1
+    ruling: auto
+  WGEN_choke_temp_2:
+    canonical: WGEN_choke_temp_2
+    ruling: auto
+  WGEN_coolant_inlet_temp:
+    canonical: WGEN_coolant_inlet_temp
+    ruling: auto
+  WGEN_coolant_inlet_temp_max:
+    canonical: WGEN_coolant_inlet_temp_max
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_speed_max:
+    canonical: WGEN_speed_max
+    ruling: auto
+  WGEN_speed_min:
+    canonical: WGEN_speed_min
+    ruling: auto
+  WGEN_winding1_u:
+    canonical: WGEN_winding_1_u
+    ruling: auto
+  WGEN_winding1_v:
+    canonical: WGEN_winding_1_v
+    ruling: auto
+  WGEN_winding1_w:
+    canonical: WGEN_winding_1_w
+    ruling: auto
+  WGEN_winding2_u:
+    canonical: WGEN_winding_2_u
+    ruling: auto
+  WGEN_winding2_v:
+    canonical: WGEN_winding_2_v
+    ruling: auto
+  WGEN_winding2_w:
+    canonical: WGEN_winding_2_w
+    ruling: auto
+  WGRID_choke_temp_1:
+    canonical: WGRD_choke_temp_1
+    ruling: auto
+  WGRID_choke_temp_2:
+    canonical: WGRD_choke_temp_2
+    ruling: auto
+  WGRID_reactive_power:
+    canonical: WGRD_reactive_power
+    ruling: auto
+  WGRID_reactive_power_max:
+    canonical: WGRD_reactive_power_max
+    ruling: auto
+  WGRID_reactive_power_min:
+    canonical: WGRD_reactive_power_min
+    ruling: auto
+  WHUB_cabinet_temp_1:
+    canonical: WROT_cabinet_temp_1
+    ruling: auto
+  WHUB_cabinet_temp_2:
+    canonical: WROT_cabinet_temp_2
+    ruling: auto
+  WHUB_cabinet_temp_3:
+    canonical: WROT_cabinet_temp_3
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_dir_geo_derived:
+    canonical: WMET_wind_dir_derived
+    ruling: auto
+  WMET_wind_speed_max:
+    canonical: WMET_wind_speed_max
+    ruling: auto
+  WMET_wind_speed_min:
+    canonical: WMET_wind_speed_min
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_ti:
+    canonical: WMET_wind_ti
+    ruling: auto
+  WNAC_cabinet1_temp:
+    canonical: WNAC_cabinet_temp_1
+    ruling: auto (词序归并)
+  WNAC_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_vib_drive_p2p:
+    canonical: WNAC_vib_drive_p2p
+    ruling: auto
+  WNAC_vib_drive_p2p_max:
+    canonical: WNAC_vib_drive_p2p_max
+    ruling: auto
+  WNAC_vib_drive_p2p_min:
+    canonical: WNAC_vib_drive_p2p_min
+    ruling: auto
+  WNAC_vib_nondrive_p2p:
+    canonical: WNAC_vib_nondrive_p2p
+    ruling: auto
+  WNAC_vib_nondrive_p2p_max:
+    canonical: WNAC_vib_nondrive_p2p_max
+    ruling: auto
+  WNAC_vib_nondrive_p2p_min:
+    canonical: WNAC_vib_nondrive_p2p_min
+    ruling: auto
+  WPMP_oil_pressure:
+    canonical: WHYD_oil_pressure
+    ruling: auto
+  WROT_blade_converter_temp_1:
+    canonical: WROT_blade_converter_temp_1
+    ruling: auto
+  WROT_blade_converter_temp_2:
+    canonical: WROT_blade_converter_temp_2
+    ruling: auto
+  WROT_blade_converter_temp_3:
+    canonical: WROT_blade_converter_temp_3
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_min_1:
+    canonical: WROT_pitch_min_1
+    ruling: auto
+  WROT_pitch_min_2:
+    canonical: WROT_pitch_min_2
+    ruling: auto
+  WROT_pitch_min_3:
+    canonical: WROT_pitch_min_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WROT_rotor_speed_max:
+    canonical: WROT_rotor_speed_max
+    ruling: auto
+  WROT_rotor_speed_min:
+    canonical: WROT_rotor_speed_min
+    ruling: auto
+  WSHD_PLC之前慢速状态:
+    canonical: WTUR_plc_status_prev_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_PLC状态:
+    canonical: WTUR_plc_status_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_功率曲线有效性:
+    canonical: WTUR_pc_status_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_动态安全链状态:
+    canonical: WTUR_safety_chain_dyn_status_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_变频器状态:
+    canonical: WCNV_state_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_安全链状态:
+    canonical: WTUR_safety_chain_status_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_电池1状态:
+    canonical: WELE_battery_status_dup1_1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_电池2状态:
+    canonical: WELE_battery_status_dup1_2
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_电池3状态:
+    canonical: WELE_battery_status_dup1_3
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WSHD_风测量有效性:
+    canonical: WMET_wind_measurement_valid_dup1
+    ruling: 'manual: 用户裁决 2026-09-07'
+  WTWR_tower_temp:
+    canonical: WTOW_temp_tower
+    ruling: auto (词序归并)
+  WYAW_converter_power:
+    canonical: WYAW_converter_power
+    ruling: auto
+  WYAW_converter_power_max:
+    canonical: WYAW_converter_power_max
+    ruling: auto
+  WYAW_nacelle_position_accum:
+    canonical: WYAW_position_accum
+    ruling: auto
+  WYAW_position_deviation:
+    canonical: WYAW_position_deviation
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: unknown_2p0mw_shangdu.yaml
+sha16: 9616711f1e4a8106

+ 45 - 0
app_ETL/configs/canonical/alias/unknown_5p0mw_baiyushan.yaml

@@ -0,0 +1,45 @@
+alias:
+  TIME_local:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_energy_10min:
+    canonical: WAPC_energy_10min
+    ruling: auto
+  WAPC_energy_end:
+    canonical: WAPC_energy_end
+    ruling: auto
+  WAPC_energy_start:
+    canonical: WAPC_energy_start
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_dir_raw:
+    canonical: WMET_wind_dir_raw
+    ruling: auto
+  WMET_wind_speed_max:
+    canonical: WMET_wind_speed_max
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_ti_std:
+    canonical: WMET_wind_ti_std
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+contract: unknown_5p0mw_baiyushan.yaml
+sha16: 8f665b35e0b5bc11

+ 192 - 0
app_ETL/configs/canonical/alias/unknown_6p3mw_wumeishan.yaml

@@ -0,0 +1,192 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_active_power_selfcalc:
+    canonical: WAPC_active_power_selfcalc
+    ruling: auto
+  WCNV_current_l1:
+    canonical: WCNV_current_u
+    ruling: auto
+  WCNV_current_l2:
+    canonical: WCNV_current_v
+    ruling: auto
+  WCNV_current_l3:
+    canonical: WCNV_current_w
+    ruling: auto
+  WGBX_hss_de_temp:
+    canonical: WTRM_hss_temp_de
+    ruling: auto (词序归并)
+  WGBX_hss_nde_temp:
+    canonical: WTRM_hss_temp_nde
+    ruling: auto (词序归并)
+  WGBX_ims_de_temp:
+    canonical: WTRM_ims_temp_de
+    ruling: auto (词序归并)
+  WGBX_ims_nde_temp:
+    canonical: WTRM_ims_temp_nde
+    ruling: auto (词序归并)
+  WGBX_oil_inlet_temp:
+    canonical: WTRM_oil_inlet_temp
+    ruling: auto
+  WGBX_oil_prs_filter:
+    canonical: WTRM_oil_prs_filter
+    ruling: auto
+  WGBX_oil_prs_inlet:
+    canonical: WTRM_oil_prs_inlet
+    ruling: auto
+  WGBX_oil_temp:
+    canonical: WTRM_oil_temp
+    ruling: auto
+  WGEN_brg_de_temp:
+    canonical: WGEN_bearing_temp_de
+    ruling: auto (词序归并)
+  WGEN_brg_nde_temp:
+    canonical: WGEN_bearing_temp_nde
+    ruling: auto (词序归并)
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_torque:
+    canonical: WGEN_torque
+    ruling: auto
+  WGEN_torque_setpoint:
+    canonical: WGEN_torque_setpoint
+    ruling: auto
+  WGEN_winding_u:
+    canonical: WGEN_winding_u
+    ruling: auto
+  WGEN_winding_v:
+    canonical: WGEN_winding_v
+    ruling: auto
+  WGEN_winding_w:
+    canonical: WGEN_winding_w
+    ruling: auto
+  WGRID_freq:
+    canonical: WGRD_frequency
+    ruling: auto
+  WGRID_power_factor:
+    canonical: WGRD_power_factor
+    ruling: auto
+  WGRID_reactive_power:
+    canonical: WGRD_reactive_power
+    ruling: auto
+  WHUB_temp:
+    canonical: WROT_temp
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_dir:
+    canonical: WMET_wind_dir
+    ruling: auto
+  WMET_wind_speed_est:
+    canonical: WMET_wind_speed_est
+    ruling: auto
+  WMET_wind_speed_mech:
+    canonical: WMET_wind_speed_mech
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WMET_wind_speed_ultra:
+    canonical: WMET_wind_speed_ultra
+    ruling: auto
+  WNAC_temp_inner:
+    canonical: WNAC_temp
+    ruling: auto
+  WNAC_vib_fore_aft_filt:
+    canonical: WNAC_vib_fore_aft_filt
+    ruling: auto
+  WNAC_vib_fore_aft_raw:
+    canonical: WNAC_vib_fore_aft_raw
+    ruling: auto
+  WNAC_vib_lateral_filt:
+    canonical: WNAC_vib_lateral_filt
+    ruling: auto
+  WNAC_vib_lateral_raw:
+    canonical: WNAC_vib_lateral_raw
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_pitch_angle_3:
+    canonical: WROT_pitch_3
+    ruling: auto
+  WROT_pitch_driver_temp_1:
+    canonical: WROT_pitch_driver_temp_1
+    ruling: auto
+  WROT_pitch_driver_temp_2:
+    canonical: WROT_pitch_driver_temp_2
+    ruling: auto
+  WROT_pitch_driver_temp_3:
+    canonical: WROT_pitch_driver_temp_3
+    ruling: auto
+  WROT_pitch_motor_current_1:
+    canonical: WROT_pitch_motor_current_1
+    ruling: auto
+  WROT_pitch_motor_current_2:
+    canonical: WROT_pitch_motor_current_2
+    ruling: auto
+  WROT_pitch_motor_current_3:
+    canonical: WROT_pitch_motor_current_3
+    ruling: auto
+  WROT_pitch_motor_temp_1:
+    canonical: WROT_pitch_motor_temp_1
+    ruling: auto
+  WROT_pitch_motor_temp_2:
+    canonical: WROT_pitch_motor_temp_2
+    ruling: auto
+  WROT_pitch_motor_temp_3:
+    canonical: WROT_pitch_motor_temp_3
+    ruling: auto
+  WROT_pitch_setpoint_1:
+    canonical: WROT_pitch_setpoint_1
+    ruling: auto
+  WROT_pitch_setpoint_2:
+    canonical: WROT_pitch_setpoint_2
+    ruling: auto
+  WROT_pitch_setpoint_3:
+    canonical: WROT_pitch_setpoint_3
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTRM_mainbrg_temp:
+    canonical: WTRM_mainbrg_temp
+    ruling: auto
+  WTUR_rated_power_limit:
+    canonical: WTUR_rated_power_limit
+    ruling: auto
+  WTUR_rated_power_param:
+    canonical: WTUR_rated_power_param
+    ruling: auto
+  WYAW_cable_twist_angle:
+    canonical: WYAW_cable_twist
+    ruling: auto
+  WYAW_nacelle_position_accum:
+    canonical: WYAW_position_accum
+    ruling: auto
+  WYAW_state:
+    canonical: WYAW_state
+    ruling: auto
+  WYAW_wind_align_angle:
+    canonical: WYAW_wind_align
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+  WYAW_yaw_error_mech:
+    canonical: WYAW_yaw_error_mech
+    ruling: auto
+  WYAW_yaw_error_ultra:
+    canonical: WYAW_yaw_error_ultra
+    ruling: auto
+  WYAW_yaw_position_accum:
+    canonical: WYAW_yaw_position_accum
+    ruling: auto
+contract: unknown_6p3mw_wumeishan.yaml
+sha16: 3d83148d140b97ee

+ 36 - 0
app_ETL/configs/canonical/alias/unknown_mixed_guochong.yaml

@@ -0,0 +1,36 @@
+alias:
+  TIME_local:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+  WMET_HorWdSpd:
+    canonical: WMET_hor_wd_speed
+    ruling: auto
+  WMET_HorWdSpd_max:
+    canonical: WMET_hor_wd_speed_max
+    ruling: auto
+  WMET_HorWdSpd_min:
+    canonical: WMET_hor_wd_speed_min
+    ruling: auto
+  WMET_HorWdSpd_ti_proxy:
+    canonical: WMET_hor_wd_speed_ti_proxy
+    ruling: auto
+  WMET_ti_n_samples:
+    canonical: WMET_ti_n_samples
+    ruling: auto
+  WTUR_W:
+    canonical: WTUR_w
+    ruling: auto
+  WTUR_faultcode:
+    canonical: WTUR_faultcode
+    ruling: auto
+  WTUR_kWh_10min:
+    canonical: WTUR_k_wh_10min
+    ruling: auto
+  WTUR_kWh_counter_max:
+    canonical: WTUR_k_wh_counter_max
+    ruling: auto
+  WTUR_kWh_counter_min:
+    canonical: WTUR_k_wh_counter_min
+    ruling: auto
+contract: unknown_mixed_guochong.yaml
+sha16: 46be9dd26bb1695b

+ 33 - 0
app_ETL/configs/canonical/alias/unknown_xinaili.yaml

@@ -0,0 +1,33 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WGEN_speed:
+    canonical: WGEN_speed
+    ruling: auto
+  WGEN_torque:
+    canonical: WGEN_torque
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WROT_pitch:
+    canonical: WROT_pitch
+    ruling: auto
+  WROT_speed:
+    canonical: WROT_speed
+    ruling: auto
+  WYAW_error_vane:
+    canonical: WYAW_error_vane
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  timestamp:
+    canonical: META_timestamp
+    ruling: auto (元数据列)
+contract: unknown_xinaili.yaml
+sha16: 7057c2223f48450b

+ 60 - 0
app_ETL/configs/canonical/alias/xemc_xe2000_2mw.yaml

@@ -0,0 +1,60 @@
+alias:
+  WAPC_active_power:
+    canonical: WTUR_active_power
+    ruling: auto
+  WAPC_plc_power_limit:
+    canonical: WAPC_plc_power_limit
+    ruling: auto
+  WAPC_power_limit:
+    canonical: WAPC_power_limit
+    ruling: auto
+  WGEN_bearing_temp_1:
+    canonical: WGEN_bearing_temp_1
+    ruling: auto
+  WGEN_stator_temp_1:
+    canonical: WGEN_stator_temp_1
+    ruling: auto
+  WMET_ambient_temp:
+    canonical: WMET_ambient_temp
+    ruling: auto
+  WMET_pressure:
+    canonical: WMET_pressure
+    ruling: auto
+  WMET_pressure_mast:
+    canonical: WMET_pressure_mast
+    ruling: auto
+  WMET_wind_direction:
+    canonical: WMET_wind_direction
+    ruling: auto
+  WMET_wind_speed_mast_hub:
+    canonical: WMET_wind_speed_mast_hub
+    ruling: auto
+  WMET_wind_speed_nacelle:
+    canonical: WMET_wind_speed_nacelle
+    ruling: auto
+  WNAC_nacelle_temp:
+    canonical: WNAC_temp
+    ruling: auto
+  WROT_pitch_angle_1:
+    canonical: WROT_pitch_1
+    ruling: auto
+  WROT_pitch_angle_2:
+    canonical: WROT_pitch_2
+    ruling: auto
+  WROT_rotor_speed:
+    canonical: WROT_rotor_speed
+    ruling: auto
+  WTUR_state:
+    canonical: WTUR_state
+    ruling: auto
+  WTUR_total_active_energy:
+    canonical: WTUR_total_active_energy
+    ruling: auto
+  WYAW_nacelle_position:
+    canonical: WYAW_position
+    ruling: auto
+  WYAW_yaw_error:
+    canonical: WYAW_yaw_error
+    ruling: auto
+contract: xemc_xe2000_2mw.yaml
+sha16: 8549c7bb87108273

Vissa filer visades inte eftersom för många filer har ändrats