| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- # -*- coding: utf-8 -*-
- """数据层: 离线导出 → 本系统视图. 只读 M5 产物 (各窗 index/spectra, 模型产物), 不重算诊断.
- 窗自动发现: config.windows (w0127 特殊路径) + m5/windows/*/index.parquet (新导入的窗自动进界面; 排除 exclude_windows)."""
- import os, json, hashlib
- import numpy as np
- import pandas as pd
- from .config import KEY_SCALARS, SENSORS
- # 验证/测试窗不进生产集 (名字含 test / _reimport)。★2026-09-20 补 `_superseded`:
- # 重算按"当前数据"替换同名窗时, 旧窗留档为 `<窗>_superseded_<时分>` —— 它是留档, 不该参与生产集。
- EXCLUDE_DEFAULT = ('_reimport', '_superseded', 'test')
- def windows(cfg):
- """{window: dict(index=Path, spectra_dir=Path|None)} 按名排序."""
- out = {}
- for w, p in cfg['windows'].items():
- if p.exists():
- d = p.parent if p.name == 'index.parquet' else None
- out[w] = dict(index=p, spectra_dir=(d / 'spectra') if d and (d / 'spectra').exists() else None)
- root = cfg['m5'] / 'windows'
- if root.exists():
- for d in sorted(root.iterdir()):
- if (d / 'index.parquet').exists() and d.name not in out and not any(x in d.name for x in cfg.get('exclude_windows', EXCLUDE_DEFAULT)):
- out[d.name] = dict(index=d / 'index.parquet', spectra_dir=(d / 'spectra') if (d / 'spectra').exists() else None)
- sel = os.environ.get('M5_WINDOWS') # 与六层链脚本同一开关: 设了则只取该子集 (冲击轴/融合与 oem_scan/model_run 窗集合一致)
- if sel:
- keep = set(sel.split(','))
- out = {k: v for k, v in out.items() if k in keep}
- return dict(sorted(out.items()))
- _SPAN_CACHE: dict = {}
- def window_spans(cfg):
- """{窗: dict(rows, t_min, t_max, turbines, start_late)} —— 每个窗**真实覆盖的日期区间**。
- 为什么要有它(用户令 2026-09-19 实逮): 窗名 `w0316` 只是"窗起始日 03-16"的代号, 页面上到处只有
- 这个名字与"证据窗末 2026-04-21", **看不出这个窗从哪天到哪天** —— 用户拿到的振动导出是
- `CMS_RuDong_CGN_202603-04`(2026-03 至 2026-04), 于是他看到逐台页只有 4 月的谱, 就以为
- "3 月的数据没被呈现"。实测源件本身最早只到 **2026-03-16 17:03**(逐台起始不等: 最晚 03-30),
- 也就是说数据没错、是**呈现上没把覆盖区间说出来**。这里把区间算出来, 供报告/页面如实写明。
- 只读每个窗索引的 `trigger_time` + `turbine` 两列(百万行级也只几秒), 进程内缓存。
- """
- key = tuple(sorted(windows(cfg)))
- if _SPAN_CACHE.get('key') == key:
- return _SPAN_CACHE['val']
- out = {}
- for w, info in windows(cfg).items():
- try:
- d = pd.read_parquet(info['index'], columns=['trigger_time', 'turbine'])
- t = pd.to_datetime(d['trigger_time'], errors='coerce')
- per_first = t.groupby(d['turbine']).min()
- out[w] = dict(rows=int(len(d)), t_min=str(t.min()), t_max=str(t.max()),
- turbines=int(d['turbine'].nunique()),
- first_min=str(per_first.min()), first_max=str(per_first.max()),
- start_late=bool(str(per_first.min())[:10] != str(per_first.max())[:10]))
- except Exception as e:
- out[w] = dict(rows=0, t_min='', t_max='', turbines=0, err=f'{type(e).__name__}: {e}')
- _SPAN_CACHE.update(key=key, val=out)
- return out
- def span_text(cfg, win: str | None = None) -> str:
- """给人看的一句话: `w0316 2026-03-16 → 2026-04-21`(多窗时逗号分隔; 逐台起始不等时如实写出来)。"""
- sp = window_spans(cfg)
- ws = [win] if win else list(sp)
- parts = []
- for w in ws:
- s = sp.get(w) or {}
- if not s.get('rows'):
- continue
- rng = f"{str(s['t_min'])[:10]} → {str(s['t_max'])[:10]}"
- if s.get('start_late'):
- rng += f"(逐台起始不等: 最早 {str(s['first_min'])[:10]} · 最晚 {str(s['first_max'])[:10]})"
- parts.append(f'{w} {rng}')
- return ' · '.join(parts)
- def _read_index(p):
- cols = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'rpm', 'condition_key', 'alarm_type', 'ds_size', 'scalar_value', 'overload']
- d = pd.read_parquet(p)
- return d[[c for c in cols if c in d.columns]]
- def load_scalars(cfg, meas=KEY_SCALARS):
- """各窗标量 (ds_size==1) 拼接; 缓存按窗集合命名。
- ★ 2026-09-17 修: 产物被"清除产物"删掉后 `windows()` 返回空集 ⇒ 原来 `pd.concat([])` 直接抛
- `ValueError: No objects to concatenate`, **把整个 CMS 服务进程带走**(现场实测: 清产物后点"登录"
- → /detail/ 与 /cms/ 全挂)。现在空集返回**空表**(列齐、dtype 对), 页面照常起来显示"无产物";
- 空结果不写缓存 (否则下次放了产物还会读到这份空缓存)。
- """
- ws = windows(cfg)
- key = hashlib.md5(('|'.join(ws) + '|' + ','.join(meas)).encode()).hexdigest()[:10]
- cache = cfg['out'] / 'cache' / f'scalars_{key}.parquet'
- if cache.exists():
- return pd.read_parquet(cache)
- parts = []
- for w, info in ws.items():
- d = _read_index(info['index'])
- d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
- d['window'] = w
- parts.append(d)
- if not parts:
- return _empty_scalars()
- s = pd.concat(parts, ignore_index=True)
- s['trigger_time'] = pd.to_datetime(s.trigger_time)
- s['scalar_value'] = pd.to_numeric(s.scalar_value, errors='coerce')
- cache.parent.mkdir(parents=True, exist_ok=True)
- s.to_parquet(cache, index=False)
- return s
- def _empty_scalars() -> pd.DataFrame:
- """无窗(无产物)时的空标量表 —— 列名与 _read_index 下的标量子集一致, 供下游直接 groupby/取值。"""
- cols = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'rpm', 'condition_key',
- 'alarm_type', 'ds_size', 'scalar_value', 'overload', 'window']
- d = pd.DataFrame({c: pd.Series(dtype='float64' if c in ('rpm', 'ds_size', 'scalar_value') else 'object')
- for c in cols})
- d['trigger_time'] = pd.to_datetime(d['trigger_time'])
- return d
- def load_alarm_counts(cfg):
- """各窗报警计数 —— 无窗时返回空表而不是 `pd.concat([])` 崩掉 (同上, 2026-09-17)。"""
- rows = []
- for w, info in windows(cfg).items():
- d = pd.read_parquet(info['index'], columns=['turbine', 'alarm_type'])
- c = d.groupby(['turbine', 'alarm_type']).size().unstack(fill_value=0)
- c['window'] = w
- rows.append(c.reset_index())
- if not rows:
- return pd.DataFrame(columns=['turbine', 'window'])
- return pd.concat(rows, ignore_index=True).fillna(0)
- _META = {}
- def _mtime_stamp(paths) -> str:
- """一组文件的 (路径, mtime_ns, size) 摘要 —— 用来判断"产物是否换过"。
- 2026-09-16 用户令"重算后的产物可即时用于页面呈现": 本模块的 _META 是**进程内**缓存,
- 长期跑着的服务 (windcms serve / 观澜组件) 在摄入新窗之后会一直用旧 meta (实测同类问题
- 在 scripts/windscada_serve.py 的 _CACHE 上出现过)。把文件指纹并进缓存键即可自动失效,
- 不必重启服务。"""
- h = hashlib.sha1()
- for p in paths:
- try:
- st = p.stat()
- h.update(f'{p}|{st.st_mtime_ns}|{st.st_size}\n'.encode('utf-8'))
- except OSError:
- h.update(f'{p}|MISSING\n'.encode('utf-8'))
- return h.hexdigest()[:12]
- def spectra_meta(cfg):
- """合并谱元数据: 合并库 (cfg.spectra_dir, 覆盖 w0127) + 各窗 spectra 库; 加列 store (npz 所在目录).
- 缓存键 = 产物目录 + **各窗 index/spectra_meta 的指纹** → 重算/新摄入后自动失效 (见 _mtime_stamp)。"""
- base = cfg['spectra_dir']
- wins = windows(cfg)
- stamp_files = [base / 'spectra_meta.parquet']
- for _w, info in wins.items():
- stamp_files.append(info['index'])
- if info['spectra_dir']:
- sd = info['spectra_dir']
- stamp_files.append(sd.parent / 'spectra_meta.parquet')
- stamp_files.append(sd / 'spectra_meta.parquet')
- key = f'{cfg["out"]}|{_mtime_stamp(stamp_files)}'
- if key in _META:
- return _META[key]
- parts = []
- if (base / 'spectra_meta.parquet').exists():
- m = pd.read_parquet(base / 'spectra_meta.parquet')
- m['store'] = str(base)
- m['window'] = 'w0127'
- parts.append(m)
- for w, info in wins.items():
- sd = info['spectra_dir']
- if not sd:
- continue
- mp = sd.parent / 'spectra_meta.parquet' if (sd.parent / 'spectra_meta.parquet').exists() else sd / 'spectra_meta.parquet'
- if mp.exists():
- m = pd.read_parquet(mp)
- m['store'] = str(sd)
- m['window'] = w
- parts.append(m)
- M = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame(columns=['turbine', 'sensor', 'meas_name', 'trigger_time', 'store'])
- M['trigger_time'] = M['trigger_time'].astype(str)
- # 只留最近 4 个指纹键, 免得长期运行的服务把每代 meta 都攒在内存里
- if len(_META) > 4:
- _META.clear()
- _META[key] = M
- return M
- def spectrum(cfg, turbine, sensor, meas_name, which='latest'):
- """取一条谱 (最新或指定 trigger_time) → (x_hz, y, meta)."""
- m = spectra_meta(cfg)
- q = m[(m.turbine == turbine) & (m.sensor == sensor) & (m.meas_name == meas_name)]
- if q.empty:
- return None
- r = q.sort_values('trigger_time').iloc[-1] if which == 'latest' else q[q.trigger_time == str(which)].iloc[0]
- shard = str(r.shard)
- npz = np.load(f"{r.store}/{shard if shard.endswith('.npz') else shard + '.npz'}")
- v = npz['values'][int(r.shard_row)]
- x = float(r.x_offset) + np.arange(len(v)) * float(r.x_delta)
- return x, v.astype(float), r.to_dict()
- def load_model(cfg):
- l6 = pd.read_parquet(cfg['model_l6']) if cfg['model_l6'].exists() else pd.DataFrame()
- if len(l6) and '台' in l6.columns:
- # 归一化台号: 源文件补零 ('03#') 而消费端全用 f'{n}#' ('3#') — 不归一化则个位数台 L4 线整体查空
- # (2026-08-24 实逮: 03# 发电机 候选 16.15× 线因此丢失, 台被标优秀)
- l6['台'] = l6['台'].astype(str).str.replace(r'^0(\d#)$', r'\1', regex=True)
- fusion = pd.read_csv(cfg['fusion'], dtype=str) if cfg['fusion'].exists() else pd.DataFrame()
- summ = json.load(open(cfg['model_summary'], encoding='utf-8')) if cfg['model_summary'].exists() else {}
- return l6, fusion, summ
- def load_findings(cfg):
- return json.load(open(cfg['findings'], encoding='utf-8')) if cfg['findings'].exists() else {}
- def findings_for_turbine(findings, tid):
- n = int(tid[3:])
- toks = [tid, f'{n}#', f'{n:02d}#']
- out = []
- for k, v in findings.items():
- s = json.dumps(v, ensure_ascii=False)
- if any(t in s for t in toks):
- out.append(k)
- return out
- def load_masks(cfg):
- p = cfg.get('mask_thresholds')
- if not p or not p.exists():
- return {}
- m = pd.read_csv(p, sep='\t', dtype=str, usecols=['state', 'measurement_key', 'condition_key', 'mask_time', 'value_median', 'yellow_scalar', 'red_scalar'])
- m = m[m.state == 'Active'].copy()
- for c in ['value_median', 'yellow_scalar', 'red_scalar']:
- m[c] = pd.to_numeric(m[c], errors='coerce')
- m = m.sort_values('mask_time').drop_duplicates(['measurement_key', 'condition_key'], keep='last')
- return {(r.measurement_key, r.condition_key): dict(median=r.value_median, yellow=r.yellow_scalar, red=r.red_scalar, mask_time=r.mask_time) for r in m.itertuples()}
- def mask_key(farm_prefix, turbine, sensor, meas):
- return f'{farm_prefix}/{turbine}/{sensor}/{meas}'
|