config.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. # -*- coding: utf-8 -*-
  2. """windscada 场配置 (契约化可迁场 + 场站目录扫描辨识).
  3. ## 一、离线数据怎么放 (2026-09-11 用户令)
  4. 所有离线数据统一放 **`data/raw/`**, 其**下一级目录即「场站名称」**; 系统**扫描辨识**, 不写死目录名:
  5. data/raw/
  6. <场站名称>/ ← 扫描辨识的抓手: 下一级目录就是场站
  7. scada_10min/ SCADA 10min 导出, 逐台平铺 WTG01.csv…WTG38.csv
  8. scada_1min/ (可选) 1min 导出
  9. 故障报警/ 报警事件导出 (SpreadsheetML *.xls)
  10. 风机故障记录/ 检修工单台账 (*.xls/xlsx, 内按 {年}年故障记录/ 分年)
  11. 油样报告/ 油液化验报告 (*.pdf)
  12. 西门子4.0技术资料/ 机理层 (厂商资料), 仍在 raw 根下, 按 A2 未入场站目录
  13. 辨识规则 (逐条, 命中即止, 依据可打印):
  14. ① 场配置的 `raw_station` 与目录名**完全相同**;
  15. ② 目录名与场配置 `src_farm_names` (原始件里的场站名写法) 互为子串 —— 如配置「如东」对上目录「如东海上风电场」;
  16. ③ `data/raw` 下**只有一个**场站目录 (单站部署) → 用它, 但报告里标出"凭单站唯一性", 不假装是精确匹配;
  17. ④ 其余情形 (多个目录且都不匹配) → **不猜**, 返回"未识别", 页面显示无数据并列出扫到的目录。
  18. 命中/未命中的依据由 station_report() 给出, 供 CLI 与维护页显示。
  19. ## 二、场配置怎么来
  20. 2026-08-28 多场化改造: 场定义 = 外部配置 (`app_ETL/configs/farms/<场名>.yaml`, 历史 `.json` 兼容) + 内置默认
  21. (rudong 是已跑通的正本, 保证零配置也能起)。当前场由 `WINDSCADA_FARM` 或 set_current() 指定。
  22. **外场配置若显式给了 src_*, 以它为准**; 没给就按上面的扫描结果派生 (外场不必再抄一遍路径)。
  23. ★ 2026-09-17 (用户令 2 统一配置): 路径一律走 `src/paths.py` 的 `P.farm_config()` / `P.config_dir('farms')`,
  24. 不再在本文件里手拼 `app_ETL/configs/farms/...`; 格式约定 = **YAML**(新场写 yaml), 且 yaml 与 json 都能被
  25. `available()` 列出 —— 此前只认 json, 目录里 8 个 yaml 场定义等于"配了看不见"。
  26. """
  27. try:
  28. from app_common.app_common_guanlan.api import install_root as _install_root
  29. except ImportError: # 极端兜底(包结构异常时按位置上跳)
  30. from pathlib import Path as _P
  31. def _install_root(_f): return _P(_f).resolve().parents[3]
  32. from pathlib import Path
  33. import json
  34. import os
  35. import sys as _sys
  36. _sys.path.insert(0, str(_install_root(__file__))) # 便于 `python src/windscada/config.py`
  37. from src import paths as P # 路径唯一真源 (ROOT/相对解析都在它那)
  38. ROOT = P.ROOT
  39. FARM_DIR = P.FARMS
  40. # 一个场必须给齐的键 — 缺任何一个都会在加载时响亮报错, 而不是等到分析中途才崩
  41. REQUIRED = ('name', 'n_turbines', 'turbines', 'src_10min', 'src_alarm', 'store', 'rated_kw')
  42. # 原始件根: data/raw (env WINDSCADA_RUDONG_SRC 或 configs/serve.json 的 raw_dir 可覆盖, 启动器会导出)。
  43. # 注意这里给的是**绝对路径对象**, 但它是由 ROOT 派生的 —— 安装目录整个拷走也不会失配。
  44. RUDONG_SRC = str(P.RAW_ROOT)
  45. RAW_ROOT = P.RAW_ROOT
  46. # 场站目录下的**约定子目录名** — 这些名字是摄入侧的接口, 改名等于换接口, 要同步 README 与维护页
  47. # 振动侧两目录 (2026-09-12 用户令: 「CMS 振动评估报告」遵循 data/raw/如东/windcms、
  48. # 「振动线 handoff」遵循 data/raw/如东/m5_cms_tcm) —— 它们此前只在 outputs/ 下以**产物**形式存在,
  49. # 源件不在 data/raw, 于是"从零重算"时振动侧无源可算。加进来后扫描/落位/数据层页都能认它们。
  50. STATION_SUBDIRS = ('scada_10min', 'scada_1min', '故障报警', '风机故障记录', '油样报告',
  51. 'windcms', 'm5_cms_tcm',
  52. # 2026-09-17 用户令: 现场交来的**年度 MDB 归档**(25年.zip / 26年.zip, 内层是
  53. # `<年>/<月>/<年-月-类>.zip → .mdb`)就地放在场站目录下, 取代此前的"已导出 CSV"
  54. # 那两个 zip(10分钟数据.zip / 1分钟数据.zip)。原始收资是 Access 库, 故单独一层。
  55. 'scada_mdb')
  56. _SRC_KEYS = (('src_10min', 'scada_10min'), ('src_1min', 'scada_1min'), ('src_alarm', '故障报警'),
  57. ('src_workorder', '风机故障记录'), ('src_oil', '油样报告'),
  58. ('src_windcms', 'windcms'), ('src_m5', 'm5_cms_tcm'), ('src_mdb', 'scada_mdb'))
  59. _BUILTIN = {
  60. 'rudong': {
  61. 'name': '如东海上风电场', 'n_turbines': 38,
  62. 'turbines': [f'WTG{i:02d}' for i in range(1, 39)],
  63. 'raw_station': '如东', # data/raw/<场站名称> 的**首选**目录名
  64. # 原始件里的**场站名写法**: 集团口径导出件 (月度/年度台账) 用「如海风电场」,
  65. # 而本场配置名是「如东海上风电场」。台账/报警摄入按这些别名把本场行筛出来 ——
  66. # 实测 2021年3月那张汇总表里混着民勤/来福/宝力格/大岗子/宏基… 十来个场站的行。
  67. 'src_farm_names': ['如海', '如东'],
  68. 'store': P.store('rudong'), # L0 标准仓 (outputs/rudong/windscada)
  69. 'contract': P.contract('rudong'), # M1 产出 (reference/rudong/windscada_contract.yaml)
  70. 'rated_kw': 4000,
  71. }
  72. }
  73. _CACHE = {}
  74. _CURRENT = [os.environ.get('WINDSCADA_FARM') or 'rudong']
  75. def scan_stations(root=None):
  76. """扫 data/raw 的**下一级目录** = 场站目录。→ {场站名: dict(dir, subdirs, n_files)}
  77. 只列目录, 不读内容 —— 现场目录里可能是十几 GB 的 CSV。
  78. """
  79. root = Path(root or RAW_ROOT)
  80. out = {}
  81. if not root.is_dir():
  82. return out
  83. for d in sorted(p for p in root.iterdir() if p.is_dir()):
  84. subdirs = {n: d / n for n in STATION_SUBDIRS if (d / n).is_dir()}
  85. n_files = sum(1 for p in d.rglob('*') if p.is_file())
  86. out[d.name] = dict(dir=d, subdirs=subdirs, n_files=n_files)
  87. return out
  88. def station_scan(cfg=None, root=None):
  89. """扫 + 辨 → dict(stations={...}, matched=Path|None, name=str|None, how=str, note=str)。
  90. how: 'raw_station' | 'alias' | 'single' | 'none' —— 页面/CLI 显示"凭什么认出这个场站目录"。
  91. """
  92. stations = scan_stations(root)
  93. cfg = cfg or {}
  94. want = str(cfg.get('raw_station') or '')
  95. aliases = [str(a) for a in (cfg.get('src_farm_names') or []) if a]
  96. if not stations:
  97. return dict(stations=stations, matched=None, name=None, how='none',
  98. note=f'{Path(root or RAW_ROOT)} 下没有任何场站目录 → 系统无数据可呈现')
  99. if want and want in stations:
  100. return dict(stations=stations, matched=stations[want]['dir'], name=want, how='raw_station',
  101. note=f'目录名与场配置 raw_station 完全相同: {want}')
  102. for nm, info in stations.items():
  103. if any(a in nm or nm in a for a in aliases):
  104. return dict(stations=stations, matched=info['dir'], name=nm, how='alias',
  105. note=f'目录名 {nm} 与场名写法 {aliases} 匹配')
  106. if len(stations) == 1:
  107. nm = next(iter(stations))
  108. return dict(stations=stations, matched=stations[nm]['dir'], name=nm, how='single',
  109. note=f'data/raw 下只有这一个场站目录 {nm} (单站部署) → 采用它; '
  110. f'若这不是本场, 请把目录名改成 {want or "场配置里的场站名"}')
  111. return dict(stations=stations, matched=None, name=None, how='none',
  112. note=f'data/raw 下有 {len(stations)} 个场站目录 {sorted(stations)}, 但没有一个能对上'
  113. f'本场 (raw_station={want!r} / 场名写法={aliases}) → 不猜, 页面显示无数据')
  114. def station_report(cfg=None, root=None):
  115. """给 CLI 与维护页用的报告: 辨识结论 + 各约定子目录的件数。"""
  116. r = station_scan(cfg or farm(), root)
  117. counts = {}
  118. if r['matched']:
  119. for n in STATION_SUBDIRS:
  120. d = Path(r['matched']) / n
  121. counts[n] = sum(1 for p in d.rglob('*') if p.is_file()) if d.is_dir() else 0
  122. r['counts'] = counts
  123. return r
  124. def _expand(cfg, name):
  125. """展开: turbines 简写 / 相对路径 / **按扫描结果派生 src_***。
  126. 显式给了 src_* 的 (外场配置常见) 一律不动 —— 扫描只填空白, 不覆盖声明。
  127. """
  128. c = dict(cfg)
  129. t = c.get('turbines')
  130. if isinstance(t, str) and ':' in t:
  131. pat, rng = t.rsplit(':', 1)
  132. a, b = (int(x) for x in rng.split('-'))
  133. c['turbines'] = [pat.format(i) for i in range(a, b + 1)]
  134. c.setdefault('n_turbines', len(c.get('turbines') or []))
  135. for k in ('store', 'contract'):
  136. if c.get(k) and not str(c[k]).startswith('/'):
  137. c[k] = ROOT / str(c[k])
  138. c.setdefault('store', P.store(name))
  139. # ★场站目录: 扫描辨识 (见模块头)。扫不到就指向"约定位置", 让页面能告诉人该往哪放。
  140. scan = station_scan(c)
  141. expected = RAW_ROOT / str(c.get('raw_station') or name)
  142. station = Path(scan['matched']) if scan['matched'] else expected
  143. c['raw_station_dir'] = str(station)
  144. c['station_how'] = scan['how']
  145. c['station_note'] = scan['note']
  146. c['n_stations'] = len(scan['stations'])
  147. for key, sub in _SRC_KEYS:
  148. c.setdefault(key, str(station / sub))
  149. miss = [k for k in REQUIRED if not c.get(k)]
  150. if miss:
  151. raise SystemExit(f'场配置 {name} 缺必需键: {miss} (见 config.REQUIRED)')
  152. return c
  153. def farm_files():
  154. """`app_ETL/configs/farms/` 下的候选场定义文件 (yaml/json), 不含模板/CSV。"""
  155. if not FARM_DIR.is_dir():
  156. return []
  157. pats = ('*.yaml', '*.yml', '*.json')
  158. out = []
  159. for p in pats:
  160. out += [f for f in FARM_DIR.glob(p) if not f.name.startswith('_')]
  161. return sorted(out)
  162. def is_farm_def(f) -> bool:
  163. """这个文件是不是一份**能加载的场定义**(必需键齐)。"""
  164. try:
  165. c = _load_farm_file(Path(f))
  166. except Exception:
  167. return False
  168. return isinstance(c, dict) and all(c.get(k) for k in REQUIRED)
  169. def available():
  170. """列出可用场: 内置 + 能加载的 `app_ETL/configs/farms/<场名>.{yaml,json}`。
  171. ★ 2026-09-17 修两件事 (用户令 2 "统一配置"):
  172. ① 原来只认 `*.json` ⇒ 目录里的 `.yaml` **配了看不见**; 现在 yaml/json 都认 (新场写 yaml);
  173. ② 但这个目录里混着**另一种 schema** 的文件 (机型/场站物理约束 profile: `meta` + `physical_constraints`),
  174. 它们**不是**场定义, 列进"可用场"会在加载时炸。所以只列真能加载的, 其余的由
  175. `foreign_farm_files()` 报出来, 由 `scripts/config_audit.py` 记账 (见 docs §11)。
  176. """
  177. out = dict.fromkeys(_BUILTIN, '内置')
  178. for f in farm_files():
  179. if is_farm_def(f):
  180. out[f.stem] = str(f.relative_to(ROOT))
  181. return out
  182. def foreign_farm_files():
  183. """`app_ETL/configs/farms/` 下"不是场定义"的文件 → [(文件, 依据)] (登记在册, 不参与场加载)。"""
  184. bad = []
  185. for f in farm_files():
  186. if is_farm_def(f):
  187. continue
  188. try:
  189. c = _load_farm_file(f)
  190. keys = ','.join(sorted(c)[:4]) if isinstance(c, dict) else type(c).__name__
  191. except Exception as e:
  192. keys = f'解析失败: {type(e).__name__}'
  193. bad.append((f, keys))
  194. return bad
  195. def _load_farm_file(f: Path):
  196. """读场定义 —— 按后缀选解析器 (yaml 优先/约定; json 兼容历史)。"""
  197. text = f.read_text(encoding='utf-8-sig')
  198. if f.suffix.lower() in ('.yaml', '.yml'):
  199. import yaml
  200. return yaml.safe_load(text) or {}
  201. return json.loads(text)
  202. def set_current(name):
  203. farm(name) # 先验证能加载
  204. _CURRENT[0] = name
  205. return name
  206. def current():
  207. return _CURRENT[0]
  208. def farm(name=None, refresh=False):
  209. """取场配置; refresh=True 重扫场站目录 (放了新数据、又不想重启服务时用)。"""
  210. name = name or _CURRENT[0]
  211. if name in _CACHE and not refresh:
  212. return _CACHE[name]
  213. if name in _BUILTIN:
  214. _CACHE[name] = _expand(_BUILTIN[name], name)
  215. return _CACHE[name]
  216. f = P.farm_config(name) # yaml 优先, json 兼容 (路径真源在 src/paths.py)
  217. if f is None or not is_farm_def(f):
  218. extra = ''
  219. if f is not None:
  220. extra = (f' —— 该文件存在但**不是场定义**(缺必需键 {list(REQUIRED)}); '
  221. f'如果是机型/物理约束 profile, 它属于另一种 schema, 见 docs §11')
  222. raise SystemExit(f'未知场 {name}; 可用: {list(available())}{extra} '
  223. f'(新场请在 {FARM_DIR.relative_to(ROOT)}/ 放 <场名>.yaml, 必需键见 config.REQUIRED)')
  224. _CACHE[name] = _expand(_load_farm_file(f), name)
  225. return _CACHE[name]
  226. def raw_station_dir(name=None):
  227. """本场原始件根目录 = data/raw/<场站名称>。
  228. 扫描辨识到就用它; 没辨识到则返回**约定位置** <raw_root>/<raw_station>, 这样维护页仍能
  229. 指着正确的地方让人补数据 (而不是给一个空路径)。辨识依据见 farm()['station_note']。
  230. """
  231. return Path(str(farm(name)['raw_station_dir']))