Просмотр исходного кода

场站扫描辨识 + 目录结构文档 + 两个验收场景实测

用户令 (2026-09-11): 离线数据统一放 data\raw, 该目录下一级目录约定为「场站名称」, 系统扫描辨识。

实现:
- src/windscada/config.py: scan_stations() / station_scan() / station_report(); _expand() 按扫描结果
  派生 src_* (外场配置显式给了 src_* 则不覆盖)。辨识规则逐条命中: ① raw_station 全等
  ② 与 src_farm_names 互为子串 ③ 只有一个场站目录(单站部署) ④ 都不中 → 不猜, 报"未识别"并列出扫到的目录;
  命中依据 (how/note) 随配置带出, 供 CLI 与维护页显示。farm(refresh=True) 可重扫不重启。
- scripts/scan_stations.py: 打印扫到的场站、辨识依据、五个约定子目录件数。
- src/ontology/maintenance.py: 维护页数据层新增「场站数据目录 (扫描辨识)」一行 (产物=场站目录本身,
  于是扫不到时页面就是 存在=False/覆盖=—, 与四项无数据一起构成场景① 的可视证据)。
- scripts/windscada_serve.py: adhoc_query 在原始件缺失时不再把异常抛给 HTTP 处理器
  (前端只看到 500 "服务器错误", 人分不清没放数据还是程序坏了), 改为 HTTP 200 + 结构化无数据
  (err=no_source + 明确提示"本机应在 …\scada_10min\WTG01.csv; 放入后重跑 rebuild_from_raw.py --scada")。
- docs/数据目录结构与落位约定_v0.2.md: 目录结构设计及功用 —— 一页树、data\raw 落位与四条辨识规则、
  五类源的消费方式(实时读 vs 摄入)、**可重建性边界**(哪些产物能从 raw 重建、哪些包内没有生成端)、
  两个验收场景的实测记录与操作步骤。

场景实测:
① 清空 data\raw + 挪开产物 → scan_stations: "扫描到 0 个场站目录 / how=none"; 维护页五项
   (含扫描行) 全部 条数=0 / 存在=False; 实时接口 500 → (修复后) 200 + err=no_source。✔
② 按约定放回 → scan_stations: "1 个场站目录 如东 (592 件) / how=raw_station", 子目录 38/16/134/404;
   rebuild_from_raw.py: 报警 0→39211、工单 0→5876、油样 0→404; SCADA 两步: powercurve 31s、
   availability 88s → loss_monthly 3729; 维护页五项覆盖区间全部回来, 实时接口 months=7。✔

产物等价 (从 raw 重算 vs 随包基线):
  loss_monthly       3729 / 3729   逐值完全一致
  powercurve_dev       38 /   38   逐值完全一致
  powercurve_bins     912 /  912   逐值完全一致
  alarms            39211 / 39211  逐值完全一致
  workorders         5876 / 5876   仅 1 条记录纳秒尾差 (同一时刻 .0000015 vs .0000010)
  oil_samples_index   506 /  404   ← 清空重建丢 102 行华标 2026-07 批

发现并记录的缺口: 油样那 102 行的源件是一份**合并报告** BG-2026-07-YP013 中广核新能源如东海上风电场.pdf
(一份覆盖多台, 台号/部件只在 PDF 表格里), 且该文件**不在现场数据包里** → 从 raw 无法全量重建;
摄入脚本按合并语义保住了老行, 本次已恢复完整件(506 行), 并写入文档 §4 与修复记录。
zhouyang.xie 1 месяц назад
Родитель
Сommit
b574fb484d

+ 27 - 0
_修复记录_20260911/README.md

@@ -80,6 +80,33 @@ start.bat.bak-healthz
 
 
 
+## 场站扫描辨识 + 两个验收场景 (2026-09-11)
+
+**用户令**: 离线数据统一放 `data\raw\`, **下一级目录约定为「场站名称」**, 观澜系统**扫描辨识**。
+
+- `src/windscada/config.py`: 新增 `scan_stations()` / `station_scan()` / `station_report()`; `_expand()`
+  按扫描结果**派生** `src_*`(外场配置若显式给了 `src_*` 则不覆盖)。辨识规则: ①`raw_station` 全等
+  → ②与 `src_farm_names` 互为子串 → ③只有一个场站目录(单站部署)→ ④都不中 = 不猜, 报"未识别"。
+- `scripts/scan_stations.py`: 打印扫到的场站、辨识依据(`how`)、五个约定子目录的件数与存量。
+- 维护页新增一行「场站数据目录 (扫描辨识)」: 扫到 → `覆盖=如东/存在=True`; 扫不到 → `存在=False/覆盖=—`。
+- `scripts/windscada_serve.py`: `adhoc_query` 在原始件缺失时不再抛给 HTTP 处理器(前端只看到 500),
+  改为 **HTTP 200 + 结构化无数据**(`err=no_source` + "本机应在 …\scada_10min\WTG01.csv" 的提示)。
+
+**场景① 清空 `data\raw` + 挪开产物** → 扫描"0 个场站目录 / how=none"; 维护页五项全部 `条数=0 / 存在=False`;
+实时接口从 500 变 200+`no_source`。 ✅
+
+**场景② 按约定放回** → 扫描"1 个场站目录 如东(592 件), how=raw_station", 子目录 38/16/134/404;
+摄入后 报警 **0→39211**、工单 **0→5876**、油样 **0→404**、loss_monthly 3729(powercurve 31s + availability 88s);
+维护页五项覆盖区间全部回来, 实时接口 `months=7`。 ✅
+
+**产物等价**(从 raw 重算 vs 随包基线): `loss_monthly` 3729/3729、`powercurve_dev` 38/38、
+`powercurve_bins` 912/912、`alarms` 39211/39211 **逐值完全一致**; `workorders` 5876 行仅 1 条记录
+**纳秒尾差**(同一时刻 `.0000015` vs `.0000010`); `oil_samples_index` **506→404** —— 清空重建会丢
+**102 行华标 2026-07 批**(源件 `BG-2026-07-YP013 …pdf` 不在现场数据包里, 且一份覆盖多台), 已恢复完整件
+并写入文档 §4 作为缺口。
+
+文档: `docs\数据目录结构与落位约定_v0.2.md`(目录结构、落位约定、消费方式、可重建性边界、两个场景实测)。
+
 ## 数据链补齐 · 让系统真正吃 data/raw/如东 (2026-09-11)
 
 **问题**: 说明书 §11 写着「不含从原始数据重生成产物的链 (P0)」, 而维护页四条「摄入命令」指向的文件

+ 172 - 0
docs/数据目录结构与落位约定_v0.2.md

@@ -0,0 +1,172 @@
+# 观澜 · 如东样板 v2 目录结构设计与落位约定
+
+> 版本: v0.2.0 现场版 · 2026-09-11
+> 适用: `F:\temp\guanlan-rudong-v2_0.2.0`(安装目录, 下称 `<安装目录>`)
+> 本文回答三件事: ①离线数据往哪放、系统怎么认;②每个目录干什么用;③放/不放数据时系统分别表现什么。
+
+---
+
+## 1. 一页速览
+
+```
+<安装目录>\
+├─ data\raw\                         ★离线数据唯一入口 (现场数据只放这里)
+│    └─ <场站名称>\                    ← 扫描辨识的抓手: 下一级目录就是场站名
+│         ├─ scada_10min\             SCADA 10min 导出 (逐台 WTG01.csv…WTG38.csv)
+│         ├─ scada_1min\              (可选) 1min 导出
+│         ├─ 故障报警\                 报警事件导出 (SpreadsheetML *.xls)
+│         ├─ 风机故障记录\             检修工单台账 (*.xls/xlsx, 内按 {年}年故障记录\ 分年)
+│         └─ 油样报告\                 油液化验报告 (*.pdf)
+├─ outputs\rudong\                   ★系统取数用的**产物仓** (页面 99% 读这里, 不直接读 raw)
+│    ├─ windscada\                   L0 标准仓: 37 个 parquet + 索引/日志
+│    ├─ ontology\                    本体对象库 objects.json + 检索索引 + release_r1/r2
+│    ├─ windcms\                     CMS 振动诊断产物 (报告/缓存/单机页)
+│    ├─ m5_cms_tcm\                  振动线 handoff 与 TCM 兼容件
+│    ├─ guanlan\facts_contract_v0.json  事实契约 (问答引文的依据)
+│    └─ sop\                         SOP 中间件/评审/台账 (含少量历史装配脚本)
+├─ configs\                          端口/模型/场配置/机型契约
+│    ├─ serve.json                   端口、发布目录、Python 路径 (install 脚本写)
+│    ├─ models.json                  Ollama 档位与 pull 命令
+│    ├─ farms\<场名>.json             外场配置 (内置 rudong 可不配); _模板.json.example 是模板
+│    ├─ contracts\                   机型契约 (变桨/偏航/发电机…判据参数)
+│    ├─ canonical\ terms\            术语与词典 (canonical 决策/字典/手册; terms 显示映射)
+├─ src\                              库源码: windscada(分析) / windcms(振动) / ontology(本体) / sop(方法)
+├─ scripts\                          服务与工具: 网关/工作台/CMS + 摄入链 + 扫描/落位
+├─ release\                          交付层: portal.html(门户) viewer(三维) sim_sys_server.py
+│    └─ 如东\                         客户交付件 (治理清单/取数单), 按约定不入 git
+├─ resources\oem_envision_sc1_rudong2014\   仿真回放资产
+├─ reference\rudong\                 契约与语言资产 (windscada_contract.yaml / 英文语言库 / 报警码表 …)
+├─ wheels\win_amd64\                 离线轮子 (随包分发, 不入 git)
+├─ vendor\                           便携 Python / Ollama 离线包 (不入 git)
+├─ docs\                             说明书与本文
+├─ logs\ run\                        运行日志 / pids.json (不入 git)
+└─ _修复记录_20260911\                现场修复记录与可重放脚本
+```
+
+---
+
+## 2. `data\raw\` 落位约定与扫描辨识
+
+**一句话**: `data\raw\` 的**下一级目录就是场站名称**, 系统在取数时**扫描辨识**, 不写死目录名。
+
+辨识规则(逐条命中即止, 依据会打印出来, 见 `scripts\scan_stations.py` 与维护页那一行「场站数据目录 (扫描辨识)」):
+
+| # | 规则 | 例 |
+|---|---|---|
+| ① | 目录名与场配置 `raw_station` **完全相同** | 配置 `"raw_station": "如东"` ↔ 目录 `data\raw\如东\` |
+| ② | 目录名与场名写法 `src_farm_names` **互为子串** | 配置 `["如海","如东"]` ↔ 目录 `如东海上风电场\` 也能认 |
+| ③ | `data\raw\` 下**只有这一个**场站目录(单站部署) | 采用它, 但报告里标"凭单站唯一性", 不假装精确匹配 |
+| ④ | 多目录且都不匹配 | **不猜**: 报"未识别", 页面显示无数据并列出扫到的目录 |
+
+约定子目录(这五个名字是摄入接口, 改名等于换接口):
+
+| 子目录 | 放什么 | 文件形态要求 |
+|---|---|---|
+| `scada_10min\` | SCADA 10min 导出 | **逐台平铺** `WTG01.csv…WTG38.csv`(放文件本身, 不是压缩包) |
+| `scada_1min\` | 1min 导出(可选) | 同上, 台号须在场配置的 38 台内 |
+| `故障报警\` | 报警事件导出 | `.xls` 实为 SpreadsheetML(XML), `<row>` 需带 `TimeOn` + `Alarmcode`;「全年/年至今」累计快照件会自动跳过 |
+| `风机故障记录\` | 检修工单台账 | 模板表(表头含 `机组编号`+`故障名称`+`故障代码`);表里 `风场名称` 必须属本场(集团导出件混着十来个场);按年分组不限层级 |
+| `油样报告\` | 油液化验报告 | `.pdf`, 文件名须含 `日期_台号_部件`(如 `17072025_10303681_…_1#_主轴后.pdf`) |
+
+机理层(厂商资料)按 A2 仍放 `data\raw\西门子4.0技术资料\`, **不在场站目录下**。
+
+---
+
+## 3. 各目录的功用:谁在读、什么时候读
+
+四类源的消费方式不同 —— 这决定了"放进去"和"用上"之间差哪一步:
+
+| 源 | 运行期是否实时读 | 要跑什么才进系统 | 影响的页面/面板 |
+|---|---|---|---|
+| `scada_10min\` | **是**(`windscada_serve.py` 的 `adhoc_query` 请求时现读 `WTGnn.csv`) | 派生产物需 `rebuild_from_raw.py --scada` | 工作台「台内即时查询」等原始窗**立即**生效;损失/可用率/温度/偏航等派生面需重建 |
+| `故障报警\` | 否(只有摄入脚本读) | `scripts\windscada_alarms_ingest.py` | 数据层「报警事件」、故障分析、停机事件、限电绑定 |
+| `风机故障记录\` | 否 | `scripts\windscada_workorder_ingest.py` | 数据层「检修工单台账」、检修面、闭环验证 |
+| `油样报告\` | 否 | `scripts\windscada_watch_channels_build.py` | 数据层「油液化验」、油液时效胶囊、融合面油样轴 |
+
+命令(在 `<安装目录>` 下跑):
+
+```powershell
+.venv\Scripts\python.exe scripts\scan_stations.py            # 看扫到了什么、辨识依据
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py         # 跑三类台账摄入 (秒级~分钟级)
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --scada # 再加 SCADA 侧 10 个构建器 (慢)
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --verify# 与随包基线逐值等价验收
+.venv\Scripts\python.exe scripts\place_raw_data.py           # 现场压缩包 → 约定子目录 (映射表)
+```
+
+---
+
+## 4. `data\raw` 能重建什么、不能重建什么(实测, 2026-09-11)
+
+`outputs\rudong\windscada\` 是页面取数的**产物仓**。其中哪些能由 `data\raw` 重算出来, 是"数据驱动程度"的边界:
+
+**✅ 能从 `data\raw` 重算**(构建器都在包内, 已实测等价)
+
+| 产物 | 来源 | 入口 |
+|---|---|---|
+| `alarms.parquet` | 故障报警 | `windscada_alarms_ingest.py`(39211/39211 行逐值一致) |
+| `workorders.parquet` | 风机故障记录 | `windscada_workorder_ingest.py`(1574 种内容复现 1566 种, 8 种差异已逐条定位) |
+| `oil_samples_index.parquet` | 油样报告 | `windscada_watch_channels_build.py`——**部分可重建**: 404/506 行(SGS 批, 文件名可解析)逐值一致; 见下表缺口 |
+| `powercurve_dev/bins` · `loss_monthly` · `curve_lenses/liveness` · `control_profile/schedule` · `stop_events` · `temp_bins` · `yaw_daily` · `hydraulic_accum` · `thermal_chain` · `system_aux` | scada_10min + alarms | `rebuild_from_raw.py --scada`(实测: `loss_monthly` 3729/3729、`powercurve_dev` 38/38、`powercurve_bins` 912/912 与随包基线**逐值完全一致**) |
+
+**⛔ 包内没有生成端 / 源件不在数据包内**(只读不写, 因此 `data\raw` 换数据不会更新它们)
+
+| 产物 | 谁在读它 | 说明 |
+|---|---|---|
+| `oil_samples_index` 里的 **102 行华标 2026-07 批** | 数据层「油液化验」、油液时效胶囊 | 源件是一份**合并报告** `BG-2026-07-YP013 中广核新能源如东海上风电场.pdf`(一份覆盖多台, 台号/部件只在 PDF 表格里), **且该文件不在现场数据包里** → 清空重建会丢这 102 行。摄入脚本因此采取**合并语义**: 认不出的老行原样保留、绝不删(场景② 实测就出现了 506→404 的落差, 需现场补那份 PDF 才能从 raw 全量重建) |
+| `pc_monthly_bins` · `duty_monthly` · `thermal_monthly` · `sector_power` | 趋势件/热链/扇区 | 组级产物, v0.2.0 未附构建脚本 |
+| `yaw_dynamic_monthly` · `yaw_press_monthly` · `yaw1min_liveness` · `yaw_err_clean` · `genbearing_monthly` · `mblub_monthly` | 偏航/温度/润滑面 | 同上 |
+| `structure.parquet` · `watch_channels_monthly.parquet` | 结构面/温度联动/融合 | 后者连包内注释都写着"须重建 `scripts\windscada_watch_channels_build.py`" —— 本版该脚本只产油样索引, 未含 watch_channels 月表 |
+| `objects.json`(本体)· `windcms\*` · `m5_cms_tcm\*` | 图谱/CMS/融合 | 来源是 `data\raw\西门子4.0技术资料` 与振动线, **不属于**上面四类 |
+
+---
+
+## 5. 两个验收场景(已实测, 2026-09-11 14:2x)
+
+**场景① 清空 `data\raw` + 挪开产物 → 系统无数据呈现** ✅ 通过
+
+```powershell
+Move-Item .\data\raw\如东 F:\temp\_raw_off            # 数据挪走
+# 把「这四类源产出的产物」也挪走 (只清源不动产物, 页面照旧显示旧数 —— 设计如此, 见 §3)
+Move-Item .\outputs\rudong\windscada\{alarms,workorders,oil_samples_index,loss_monthly,powercurve_bins,powercurve_dev}.parquet F:\temp\_prod_off\
+.venv\Scripts\python.exe guanlan.py stop; .venv\Scripts\python.exe guanlan.py serve
+```
+
+实测结果:
+
+| 检查项 | 结果 |
+|---|---|
+| `scan_stations.py` | `扫描到 0 个场站目录`;`辨识结论 how=none`;依据: “data\raw 下没有任何场站目录 → 系统无数据可呈现” |
+| 维护页「数据层」 | 「场站数据目录 (扫描辨识)」`存在=False / 覆盖=—`;四项 `条数=0 / 覆盖=— / 存在=False` |
+| 工作台实时原始窗 `/api/query` | 首次暴露 **HTTP 500**(异常直接抛给前端,人分不清"没放数据"还是"程序坏了")→ **已修**: 改为 HTTP 200 + 结构化无数据 `err=no_source`,并给出"本机应在 `<…>\scada_10min\WTG01.csv`、放入后重跑 rebuild_from_raw.py --scada" |
+
+**场景② 按约定结构放回 `data\raw` → 系统有数据呈现** ✅ 通过
+
+```powershell
+Move-Item F:\temp\_raw_off .\data\raw\如东
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py        # 三类台账 (实测: 报警 0→39211, 工单 0→5876, 油样 0→404)
+# loss_monthly 另跑 SCADA 两步 (powercurve 31s + availability 88s)
+.venv\Scripts\python.exe guanlan.py serve
+```
+
+实测结果:
+
+| 检查项 | 结果 |
+|---|---|
+| `scan_stations.py` | `扫描到 1 个场站目录: 如东 (592 件)`;`how=raw_station`(依据: 目录名与场配置 raw_station 完全相同);四个子目录件数 38 / 16 / 134 / 404 |
+| 维护页「数据层」 | 扫描行 `覆盖=如东 / 存在=True`;10min `2025-01 ~ 2026-07 / 3729`;报警 `39211`;工单 `5876`;油样 `506`(恢复完整件后) |
+| 工作台实时原始窗 | `err=None, months=7, series 7 点` —— 数据回来了 |
+| 产物等价 | `loss_monthly` 3729/3729、`powercurve_dev` 38/38、`powercurve_bins` 912/912、`alarms` 39211/39211 **逐值完全一致**;`workorders` 5876 行仅 1 条记录有**纳秒尾差**(同一时刻 `…16:23:00.0000015` vs `…0000010`);`oil_samples_index` 需恢复那 102 行(见 §4) |
+
+> 注意: 清 `data\raw` **不会**自动清产物 —— 这是刻意的(产物是"上一次算好的结果", 现场拔盘不该让页面变空)。要验证"无数据呈现", 必须同时挪开产物, 如上。
+
+---
+
+## 6. 变更记录
+
+| 日期 | 变更 |
+|---|---|
+| 2026-09-11 | A2: 四项数据源改为 `data\raw\<场站名称>\…` |
+| 2026-09-11 | 场站目录改为**扫描辨识**(`config.scan_stations()` / `station_scan()`,不再写死目录名),维护页新增「场站数据目录 (扫描辨识)」一行 |
+| 2026-09-11 | 补三类摄入(报警/工单/油样)+ 总入口 `rebuild_from_raw.py` + 等价验收 `--verify`;新增 `scan_stations.py`、`place_raw_data.py` |
+| 2026-09-11 | 无数据时实时接口不再抛 500,改为结构化"无数据"回答(场景① 逮到) |
+| 2026-09-11 | 两个验收场景实测通过(见 §5);记录油样 102 行华标批的源件缺口(见 §4);补本文件 |

+ 75 - 0
scripts/scan_stations.py

@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""场站目录扫描辨识 —— 看观澜系统在 data/raw 下认出了什么 (2026-09-11 用户令)。
+
+约定: **`data/raw/` 的下一级目录就是「场站名称」**, 其下五个约定子目录是摄入接口:
+
+    data/raw/<场站名称>/scada_10min/      SCADA 10min 导出 (逐台 WTG*.csv)
+    data/raw/<场站名称>/scada_1min/        (可选) 1min 导出
+    data/raw/<场站名称>/故障报警/          报警事件导出 (SpreadsheetML *.xls)
+    data/raw/<场站名称>/风机故障记录/      检修工单台账 (*.xls/xlsx)
+    data/raw/<场站名称>/油样报告/          油液化验报告 (*.pdf)
+
+辨识依据 (逐条命中即止): ① 场配置 raw_station 全等 → ② 与场名写法(srg_farm_names)互为子串 →
+③ 只有一个场站目录 (单站部署) → ④ 都不过 = 不猜, 报"未识别"。
+
+用法:
+    python scripts/scan_stations.py                 # 扫 data/raw, 打印辨识结论与各子目录件数
+    python scripts/scan_stations.py --json          # 机器可读
+    python scripts/scan_stations.py --root D:\\别的raw
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--root', default=None, help='覆盖 data/raw 根 (默认取场配置)')
+    ap.add_argument('--json', action='store_true')
+    a = ap.parse_args()
+
+    from src.windscada import config as C
+    from src.windscada.config import farm, station_report, STATION_SUBDIRS
+
+    cfg = farm()
+    rep = station_report(cfg, a.root)
+    if a.json:
+        print(json.dumps({k: (str(v) if isinstance(v, pathlib.Path) else v)
+                          for k, v in rep.items() if k != 'stations'}
+                         | {'stations': {k: dict(n_files=v['n_files'],
+                                                 dir=str(v['dir']),
+                                                 subdirs=sorted(v['subdirs']))
+                                         for k, v in rep['stations'].items()}},
+                         ensure_ascii=False, indent=2))
+        return 0
+
+    print(f'原始件根 : {pathlib.Path(a.root) if a.root else C.RAW_ROOT}')
+    print(f'当前场   : {cfg["name"]} ({C.current()})  raw_station={cfg.get("raw_station")!r} '
+          f'场名写法={cfg.get("src_farm_names")}')
+    print(f'\n扫描到 {len(rep["stations"])} 个场站目录:')
+    for nm, info in rep['stations'].items():
+        mark = '← 本场' if rep['matched'] and info['dir'] == rep['matched'] else ''
+        print(f'  {nm}  ({info["n_files"]} 件)  {info["dir"]}  {mark}')
+        for sd, p in info['subdirs'].items():
+            n = sum(1 for x in p.rglob('*') if x.is_file())
+            print(f'      {sd:14s} {n:6d} 件')
+    print(f'\n辨识结论 : how={rep["how"]}  →  {rep["matched"] or "(未识别)"}')
+    print(f'依据     : {rep["note"]}')
+    if rep['matched']:
+        print('\n各约定子目录件数:')
+        for n in STATION_SUBDIRS:
+            d = pathlib.Path(rep['matched']) / n
+            print(f'  {n:14s} {rep["counts"].get(n, 0):6d} 件   {"存在" if d.is_dir() else "缺"}')
+    print('\n下一步: 放入/更新数据后跑  python scripts/rebuild_from_raw.py  (等价验收加 --verify)')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 11 - 1
scripts/windscada_serve.py

@@ -1509,7 +1509,17 @@ def adhoc_query(t, ch, win, agg):
     with LOCK:
         if key not in _RAW_CACHE:
             if len(_RAW_CACHE) > 6: _RAW_CACHE.clear()
-            _RAW_CACHE[key] = load_10min(t, CFG, groups=['A.功率', 'A.风况', 'A.转速', 'A.状态', 'B.变桨', 'B.温度NBM', 'B.偏航', 'B.润滑液压'])
+            try:
+                _RAW_CACHE[key] = load_10min(t, CFG, groups=['A.功率', 'A.风况', 'A.转速', 'A.状态', 'B.变桨', 'B.温度NBM', 'B.偏航', 'B.润滑液压'])
+            except Exception as e:
+                # 2026-09-11 场景① 逮: 原始件不在时这里原来直接把异常抛给 HTTP 处理器 → 前端拿到 500,
+                # 面板只显示"服务器错误", 人看不出是"没放数据"还是"程序坏了"。改为**结构化的无数据**回答:
+                # 与成功路径同一个形状 (kind/title/unit/months/series/note), 前端按正常渲染即可显示原因。
+                src = CFG.get('src_10min')
+                return dict(kind='multiline', title=f'{t} {i18n.ch_cn(ch)}', unit='', months=[], series=[],
+                            err='no_source',
+                            note=f'无原始件: 本机应在 {src}\\{t}.csv (即 data\\raw\\<场站名称>\\scada_10min\\)'
+                                 f' — 放入后重跑 scripts\\rebuild_from_raw.py --scada')
     d = _RAW_CACHE[key]
     if ch not in d.columns: return dict(err=f'通道 {ch} 不在契约内')
     ms = months_of(win)

+ 18 - 5
src/ontology/maintenance.py

@@ -26,14 +26,18 @@ try:
 except Exception:
     _RAW_STR = os.environ.get('WINDSCADA_RUDONG_SRC') or str(ROOT / 'data' / 'raw')
 RAW = pathlib.Path(_RAW_STR)
-# ★2026-09-11 用户令 A2: 四项数据层源改为 data/raw/<场站名称>/…。场站目录名只在场配置里写
-#   (src/windscada/config.py 的 raw_station), 这里取用不另抄 —— 抄一份就会出现"页面指着一个
-#   不存在的目录让人补数据"。
+# ★2026-09-11 用户令 A2 + 场站扫描辨识: 数据层源在 data/raw/<场站名称>/ 下。场站目录名不再写死在
+#   代码里 —— config.farm() 扫 data/raw 的下一级目录辨识 (辨识依据见 station_note), 这里取结果用。
 try:
-    from src.windscada.config import raw_station_dir as _station_dir
+    from src.windscada.config import farm as _farm, raw_station_dir as _station_dir
+    _CFG = _farm()
     STATION = _station_dir()
-except Exception:
+    _SCAN_NOTE = str(_CFG.get('station_note') or '')
+    _N_STATIONS = int(_CFG.get('n_stations') or 0)
+except Exception as _e:                                    # 配置读不了也别让整页崩
     STATION = RAW / '如东'
+    _SCAN_NOTE = f'场配置/扫描不可用: {type(_e).__name__}: {_e}'
+    _N_STATIONS = 0
 # 机理层: 技术资料仍在原始件根下 (A2 只点名数据层那四项, 机理层不动)
 TECH = RAW / '西门子4.0技术资料'
 
@@ -75,6 +79,15 @@ SOURCES = [
          频率='振动线每轮出件后', 责任='振动线 → 图谱',
          说明='在升(末三窗/早三窗≥1.3)与换件闭环两类实证入图谱; 补前这类信息只在链盘里, 图谱检索不到'),
     # ── 数据层 (2026-09-11 A2: 四项数据源目录 = data/raw/<场站名称>/{scada_10min,故障报警,风机故障记录,油样报告}) ──
+    # 场站目录由 config 扫 data/raw 的下一级目录辨识 (用户令 2026-09-11); 这一行专门把它摆出来 ——
+    # 扫不到场站目录时本行 存在=False/覆盖=—, 与下面四行的"无数据"一起构成场景① 的可视证据。
+    dict(层='数据', 名='场站数据目录 (扫描辨识)', 产物=STATION, 时间列=None,
+         位置=disp(RAW) + os.sep + '<场站名称>' + os.sep,
+         摄入='python scripts/place_raw_data.py  (现场压缩包按约定落位)',
+         频率='随现场', 责任='现场/数据线',
+         说明=f'data/raw 的下一级目录即场站名称, 取数时扫描辨识; 约定子目录: '
+              f'{" / ".join(("scada_10min", "故障报警", "风机故障记录", "油样报告"))}。'
+              f'{_SCAN_NOTE}'),
     dict(层='数据', 名='SCADA 10min 月度衍生', 产物=ST / 'loss_monthly.parquet', 时间列='month',
          位置=disp(STATION / 'scada_10min') + os.sep,
          摄入='python scripts/rebuild_from_raw.py --scada  (从 scada_10min/*.csv 重算 loss_monthly 等)',

+ 115 - 49
src/windscada/config.py

@@ -1,12 +1,31 @@
 # -*- coding: utf-8 -*-
-"""windscada 场配置 (契约化可迁场).
+"""windscada 场配置 (契约化可迁场 + 场站目录扫描辨识).
 
-2026-08-28 多场化改造: 场定义从代码内字典改为**外部配置 + 内置默认**。
-加一个新场不再需要改这个文件 —— 在 configs/farms/<场名>.json 放一份配置即可;
-当前场由 WINDSCADA_FARM 环境变量或 set_current() 指定, 默认 rudong。
+## 一、离线数据怎么放 (2026-09-11 用户令)
 
-为什么不做成必须外部化: 如东是已跑通的正本, 内置它保证零配置也能起;
-新场走外部文件, 两者同一套 schema (下面 REQUIRED 列的键)。
+所有离线数据统一放 **`data/raw/`**, 其**下一级目录即「场站名称」**; 系统**扫描辨识**, 不写死目录名:
+
+    data/raw/
+      <场站名称>/                     ← 扫描辨识的抓手: 下一级目录就是场站
+        scada_10min/                  SCADA 10min 导出, 逐台平铺 WTG01.csv…WTG38.csv
+        scada_1min/                   (可选) 1min 导出
+        故障报警/                      报警事件导出 (SpreadsheetML *.xls)
+        风机故障记录/                  检修工单台账 (*.xls/xlsx, 内按 {年}年故障记录/ 分年)
+        油样报告/                      油液化验报告 (*.pdf)
+      西门子4.0技术资料/              机理层 (厂商资料), 仍在 raw 根下, 按 A2 未入场站目录
+
+辨识规则 (逐条, 命中即止, 依据可打印):
+  ① 场配置的 `raw_station` 与目录名**完全相同**;
+  ② 目录名与场配置 `src_farm_names` (原始件里的场站名写法) 互为子串 —— 如配置「如东」对上目录「如东海上风电场」;
+  ③ `data/raw` 下**只有一个**场站目录 (单站部署) → 用它, 但报告里标出"凭单站唯一性", 不假装是精确匹配;
+  ④ 其余情形 (多个目录且都不匹配) → **不猜**, 返回"未识别", 页面显示无数据并列出扫到的目录。
+命中/未命中的依据由 station_report() 给出, 供 CLI 与维护页显示。
+
+## 二、场配置怎么来
+
+2026-08-28 多场化改造: 场定义 = 外部配置 (`configs/farms/<场名>.json`) + 内置默认 (rudong 是已跑通的正本,
+保证零配置也能起)。当前场由 `WINDSCADA_FARM` 或 set_current() 指定。
+**外场配置若显式给了 src_*, 以它为准**; 没给就按上面的扫描结果派生 (外场不必再抄一遍路径)。
 """
 from pathlib import Path
 import json
@@ -18,45 +37,25 @@ FARM_DIR = ROOT / 'configs' / 'farms'
 # 一个场必须给齐的键 — 缺任何一个都会在加载时响亮报错, 而不是等到分析中途才崩
 REQUIRED = ('name', 'n_turbines', 'turbines', 'src_10min', 'src_alarm', 'store', 'rated_kw')
 
-# 如东原始件所在盘 (2026-09-07 订正): 旧值 /Users/yuanying/rudong/如东风场数据 **已不存在**,
-# 实际在工作盘 T5 EVO。数据盘已搬过两次 (T9 → WINDDATA; 如东现落 T5 EVO), 再写死一次必然再崩 →
-# 改为 env 可覆盖, 默认指向现址。搬盘时设 WINDSCADA_RUDONG_SRC 即可, 不必改代码。
-# v2 (2026-09-08, xzy 测试报告): 默认值改为**仓内 data/raw** — 跨平台且随包走; 开发机/别处放数据时设
-# WINDSCADA_RUDONG_SRC (或 configs/serve.json 的 raw_dir, 启动器会导出为该环境变量)。
-# 原始件不随包分发, 目录不存在时页面照常显示"约定位置", 只是摄入命令跑不了。
+# 原始件根: env WINDSCADA_RUDONG_SRC (或 configs/serve.json 的 raw_dir, 启动器会导出为该环境变量);
+# 默认 <安装目录>/data/raw。原始件不随包分发, 目录不存在时页面照常显示"约定位置"。
 RUDONG_SRC = os.environ.get('WINDSCADA_RUDONG_SRC') or str(ROOT / 'data' / 'raw')
+RAW_ROOT = Path(RUDONG_SRC)
 
-# 如东在原始件根下的**场站目录名** (2026-09-11 用户令 A2)。原始件不再平铺在 data/raw 下,
-# 而是按场分目录 —— 同一台机器上多场/多份现场数据不会互相混:
-#   data/raw/<场站名称>/scada_10min/     SCADA 10min 导出, 逐台平铺 WTG01.csv…WTG38.csv
-#                                        (读法见 src/windscada/data.py: src_10min/<turbine>.csv)
-#   data/raw/<场站名称>/故障报警/         报警事件台账, 年度/季度 .xls (实为 XML 导出)
-#   data/raw/<场站名称>/风机故障记录/     检修工单台账, 内按 {年}年故障记录/ 分年
-#   data/raw/<场站名称>/油样报告/         油液化验报告
-# 目录名只在这一处写死; 系统维护页「数据层」显示的就是这四个目录, 路径经 raw_station_dir() 取,
-# 不各自拼字符串 (拼歪了页面就会指着一个不存在的目录让人补数据)。
-_RUDONG_STATION = '如东'
-
-
-def _src(*parts):
-    """原始件子目录 → data/raw/<场站名称>/… (A2 约定)。"""
-    return '/'.join([RUDONG_SRC, _RUDONG_STATION, *parts])
-
+# 场站目录下的**约定子目录名** — 这五个名字是摄入侧的接口, 改名等于换接口, 要同步 README 与维护页
+STATION_SUBDIRS = ('scada_10min', 'scada_1min', '故障报警', '风机故障记录', '油样报告')
+_SRC_KEYS = (('src_10min', 'scada_10min'), ('src_1min', 'scada_1min'), ('src_alarm', '故障报警'),
+             ('src_workorder', '风机故障记录'), ('src_oil', '油样报告'))
 
 _BUILTIN = {
     'rudong': {
         'name': '如东海上风电场', 'n_turbines': 38,
         'turbines': [f'WTG{i:02d}' for i in range(1, 39)],
-        'raw_station': _RUDONG_STATION,                      # data/raw/<场站名称>
+        'raw_station': '如东',                               # data/raw/<场站名称> 的**首选**目录名
         # 原始件里的**场站名写法**: 集团口径导出件 (月度/年度台账) 用「如海风电场」,
         # 而本场配置名是「如东海上风电场」。台账/报警摄入按这些别名把本场行筛出来 ——
         # 实测 2021年3月那张汇总表里混着民勤/来福/宝力格/大岗子/宏基… 十来个场站的行。
         'src_farm_names': ['如海', '如东'],
-        'src_10min': _src('scada_10min'),
-        'src_1min': _src('scada_1min'),
-        'src_alarm': _src('故障报警'),
-        'src_workorder': _src('风机故障记录'),
-        'src_oil': _src('油样报告'),
         'store': ROOT / 'outputs/rudong/windscada',          # L0 标准仓
         'contract': ROOT / 'reference/rudong/windscada_contract.yaml',   # M1 产出
         'rated_kw': 4000,
@@ -66,8 +65,68 @@ _CACHE = {}
 _CURRENT = [os.environ.get('WINDSCADA_FARM') or 'rudong']
 
 
+def scan_stations(root=None):
+    """扫 data/raw 的**下一级目录** = 场站目录。→ {场站名: dict(dir, subdirs, n_files)}
+
+    只列目录, 不读内容 —— 现场目录里可能是十几 GB 的 CSV。
+    """
+    root = Path(root or RAW_ROOT)
+    out = {}
+    if not root.is_dir():
+        return out
+    for d in sorted(p for p in root.iterdir() if p.is_dir()):
+        subdirs = {n: d / n for n in STATION_SUBDIRS if (d / n).is_dir()}
+        n_files = sum(1 for p in d.rglob('*') if p.is_file())
+        out[d.name] = dict(dir=d, subdirs=subdirs, n_files=n_files)
+    return out
+
+
+def station_scan(cfg=None, root=None):
+    """扫 + 辨 → dict(stations={...}, matched=Path|None, name=str|None, how=str, note=str)。
+
+    how: 'raw_station' | 'alias' | 'single' | 'none' —— 页面/CLI 显示"凭什么认出这个场站目录"。
+    """
+    stations = scan_stations(root)
+    cfg = cfg or {}
+    want = str(cfg.get('raw_station') or '')
+    aliases = [str(a) for a in (cfg.get('src_farm_names') or []) if a]
+    if not stations:
+        return dict(stations=stations, matched=None, name=None, how='none',
+                    note=f'{Path(root or RAW_ROOT)} 下没有任何场站目录 → 系统无数据可呈现')
+    if want and want in stations:
+        return dict(stations=stations, matched=stations[want]['dir'], name=want, how='raw_station',
+                    note=f'目录名与场配置 raw_station 完全相同: {want}')
+    for nm, info in stations.items():
+        if any(a in nm or nm in a for a in aliases):
+            return dict(stations=stations, matched=info['dir'], name=nm, how='alias',
+                        note=f'目录名 {nm} 与场名写法 {aliases} 匹配')
+    if len(stations) == 1:
+        nm = next(iter(stations))
+        return dict(stations=stations, matched=stations[nm]['dir'], name=nm, how='single',
+                    note=f'data/raw 下只有这一个场站目录 {nm} (单站部署) → 采用它; '
+                         f'若这不是本场, 请把目录名改成 {want or "场配置里的场站名"}')
+    return dict(stations=stations, matched=None, name=None, how='none',
+                note=f'data/raw 下有 {len(stations)} 个场站目录 {sorted(stations)}, 但没有一个能对上'
+                     f'本场 (raw_station={want!r} / 场名写法={aliases}) → 不猜, 页面显示无数据')
+
+
+def station_report(cfg=None, root=None):
+    """给 CLI 与维护页用的报告: 辨识结论 + 各约定子目录的件数。"""
+    r = station_scan(cfg or farm(), root)
+    counts = {}
+    if r['matched']:
+        for n in STATION_SUBDIRS:
+            d = Path(r['matched']) / n
+            counts[n] = sum(1 for p in d.rglob('*') if p.is_file()) if d.is_dir() else 0
+    r['counts'] = counts
+    return r
+
+
 def _expand(cfg, name):
-    """外部配置里的相对路径按 ROOT 展开; turbines 支持 'WTG{:02d}:1-38' 简写。"""
+    """展开: turbines 简写 / 相对路径 / **按扫描结果派生 src_***。
+
+    显式给了 src_* 的 (外场配置常见) 一律不动 —— 扫描只填空白, 不覆盖声明。
+    """
     c = dict(cfg)
     t = c.get('turbines')
     if isinstance(t, str) and ':' in t:
@@ -79,6 +138,18 @@ def _expand(cfg, name):
         if c.get(k) and not str(c[k]).startswith('/'):
             c[k] = ROOT / str(c[k])
     c.setdefault('store', ROOT / f'outputs/{name}/windscada')
+
+    # ★场站目录: 扫描辨识 (见模块头)。扫不到就指向"约定位置", 让页面能告诉人该往哪放。
+    scan = station_scan(c)
+    expected = RAW_ROOT / str(c.get('raw_station') or name)
+    station = Path(scan['matched']) if scan['matched'] else expected
+    c['raw_station_dir'] = str(station)
+    c['station_how'] = scan['how']
+    c['station_note'] = scan['note']
+    c['n_stations'] = len(scan['stations'])
+    for key, sub in _SRC_KEYS:
+        c.setdefault(key, str(station / sub))
+
     miss = [k for k in REQUIRED if not c.get(k)]
     if miss:
         raise SystemExit(f'场配置 {name} 缺必需键: {miss} (见 config.REQUIRED)')
@@ -104,9 +175,10 @@ def current():
     return _CURRENT[0]
 
 
-def farm(name=None):
+def farm(name=None, refresh=False):
+    """取场配置; refresh=True 重扫场站目录 (放了新数据、又不想重启服务时用)。"""
     name = name or _CURRENT[0]
-    if name in _CACHE:
+    if name in _CACHE and not refresh:
         return _CACHE[name]
     if name in _BUILTIN:
         _CACHE[name] = _expand(_BUILTIN[name], name)
@@ -120,15 +192,9 @@ def farm(name=None):
 
 
 def raw_station_dir(name=None):
-    """本场原始件根目录 = data/raw/<场站名称> (2026-09-11 用户令 A2)。
-
-    A2 之后四项数据源 (scada_10min / 故障报警 / 风机故障记录 / 油样报告) 都在它下面;
-    维护页与摄入侧统一从这里取, 不再各自拼字符串。场配置给了 raw_station 就用它
-    (相对 RUDONG_SRC); 给了绝对路径则原样用; 没给就退到 src_10min 的上一级
-    (外场配置常只写四个 src_*)。"""
-    c = farm(name)
-    st = c.get('raw_station')
-    if st:
-        p = Path(st)
-        return p if p.is_absolute() else Path(RUDONG_SRC) / p
-    return Path(str(c['src_10min'])).parent
+    """本场原始件根目录 = data/raw/<场站名称>。
+
+    扫描辨识到就用它; 没辨识到则返回**约定位置** <raw_root>/<raw_station>, 这样维护页仍能
+    指着正确的地方让人补数据 (而不是给一个空路径)。辨识依据见 farm()['station_note']。
+    """
+    return Path(str(farm(name)['raw_station_dir']))