| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- #!/usr/bin/env python3
- """Simple PKS evolution report.
- This scanner intentionally avoids the previous state-machine implementation.
- It reports smoothed hourly features first, then derives oil-pressure stages and
- vibration segments from those features. It is read-only and uses the actual
- site_point alarm limits for each machine.
- """
- from __future__ import annotations
- import argparse
- import csv
- import os
- import sys
- from collections import defaultdict
- from datetime import datetime, timedelta
- from pathlib import Path
- from statistics import median
- import pymysql
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
- from app.config import settings # noqa: E402
- BATCH_TO_UNIT = {30: "7号机", 31: "8号机", 32: "9号机"}
- EXPECTED_PER_HOUR = 720
- MIN_COVERAGE = 0.80
- SMOOTH_HOURS = 6
- CONFIRM_HOURS = 3
- MERGE_GAP_HOURS = 6
- SQL = """
- SELECT
- FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
- COUNT(*) AS samples,
- SUM(YSJ_41 > 0) AS running_samples,
- AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
- MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
- MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
- AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
- MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
- AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
- MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
- SUM(YSJ_41 > 0 AND YSJ_5 <= %s) AS oil_low_count,
- SUM(YSJ_41 > 0 AND YSJ_5 <= %s) AS oil_low_low_count,
- SUM(YSJ_41 > 0 AND YSJ_10 >= %s) AS coupling_high_count,
- SUM(YSJ_41 > 0 AND YSJ_10 >= %s) AS coupling_high_high_count,
- SUM(YSJ_41 > 0 AND YSJ_11 >= %s) AS chain_high_count,
- SUM(YSJ_41 > 0 AND YSJ_11 >= %s) AS chain_high_high_count
- FROM pks_long_sample
- WHERE import_batch_id = %s AND sample_time >= %s AND sample_time < %s
- GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
- ORDER BY hour_start
- """
- def connect():
- return pymysql.connect(
- host=settings.db_host, port=settings.db_port, user=settings.db_user,
- password=settings.db_password, database=settings.db_name,
- charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
- connect_timeout=settings.db_connect_timeout, read_timeout=600,
- write_timeout=60, autocommit=True,
- )
- def alarm_value(row, alarm_type):
- for i in range(1, 5):
- if str(row.get(f"AlarmType{i}") or "").strip() == alarm_type:
- return float(row[f"AlarmLimit{i}"])
- raise RuntimeError(f"site_point 缺少 {alarm_type}")
- def load_limits():
- names = [f"YSJ{unit}_{point}" for unit in (7, 8, 9) for point in (5, 10, 11)]
- conn = connect()
- try:
- with conn.cursor() as cur:
- marks = ",".join(["%s"] * len(names))
- cur.execute(
- "SELECT ItemName, ItemDescription, Units, AlarmType1, AlarmType2, AlarmType3, AlarmType4, "
- "AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4 "
- f"FROM site_point WHERE ItemName IN ({marks})", names,
- )
- rows = {r["ItemName"]: r for r in cur.fetchall()}
- finally:
- conn.close()
- limits = {}
- for batch, unit in BATCH_TO_UNIT.items():
- n = unit[0]
- oil = rows[f"YSJ{n}_5"]
- coupling = rows[f"YSJ{n}_10"]
- chain = rows[f"YSJ{n}_11"]
- limits[batch] = {
- "unit": unit,
- "oil_low": alarm_value(oil, "PVLow"),
- "oil_low_low": alarm_value(oil, "PVLowLow"),
- "coupling_high": alarm_value(coupling, "PVHigh"),
- "coupling_high_high": alarm_value(coupling, "PVHighHigh"),
- "chain_high": alarm_value(chain, "PVHigh"),
- "chain_high_high": alarm_value(chain, "PVHighHigh"),
- }
- return limits
- def fetch(batch, start, end, chunk_days, limit):
- result = []
- conn = connect()
- try:
- with conn.cursor() as cur:
- cursor = start
- while cursor < end:
- nxt = min(cursor + timedelta(days=chunk_days), end)
- params = (limit["oil_low"], limit["oil_low_low"], limit["coupling_high"],
- limit["coupling_high_high"], limit["chain_high"], limit["chain_high_high"],
- batch, cursor, nxt)
- cur.execute("EXPLAIN " + SQL, params)
- plan = cur.fetchone()
- if not plan or plan.get("key") != "PRIMARY":
- raise RuntimeError(f"EXPLAIN 未使用 PRIMARY: batch={batch}, chunk={cursor}")
- cur.execute(SQL, params)
- for raw in cur.fetchall():
- row = {"机组": limit["unit"], "批次": batch,
- "小时": raw["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
- "_dt": raw["hour_start"], "样本数": int(raw["samples"] or 0),
- "运行样本数": int(raw["running_samples"] or 0)}
- row["运行覆盖率"] = row["运行样本数"] / EXPECTED_PER_HOUR
- for key in ("oil_avg", "oil_min", "oil_max", "coupling_avg", "coupling_max",
- "chain_avg", "chain_max", "oil_low_count", "oil_low_low_count",
- "coupling_high_count", "coupling_high_high_count",
- "chain_high_count", "chain_high_high_count"):
- row[key] = float(raw[key]) if raw[key] is not None else None
- result.append(row)
- cursor = nxt
- finally:
- conn.close()
- return result
- def rolling_median(values):
- values = [v for v in values if v is not None]
- return median(values) if values else None
- def slope(values):
- pairs = [(i, v) for i, v in enumerate(values) if v is not None]
- if len(pairs) < 3:
- return None
- xm = sum(i for i, _ in pairs) / len(pairs)
- ym = sum(v for _, v in pairs) / len(pairs)
- den = sum((i - xm) ** 2 for i, _ in pairs)
- return sum((i - xm) * (v - ym) for i, v in pairs) / den if den else None
- def prepare(rows, limits):
- by_batch = defaultdict(list)
- for row in rows:
- if row["运行覆盖率"] >= MIN_COVERAGE and row["oil_avg"] is not None:
- by_batch[row["批次"]].append(row)
- output = []
- for batch, series in by_batch.items():
- series.sort(key=lambda r: r["_dt"])
- limit = limits[batch]
- history = []
- cycle = 0
- previous = None
- for row in series:
- if previous is None or row["_dt"] - previous > timedelta(hours=12):
- cycle += 1
- cycle_rows = []
- cycle_rows.append(row)
- baseline = rolling_median([r["oil_avg"] for r in cycle_rows[:24]])
- smooth = rolling_median([r["oil_avg"] for r in history[-(SMOOTH_HOURS - 1):]] + [row["oil_avg"]])
- trend = slope([r["oil_avg"] for r in history[-5:]] + [row["oil_avg"]])
- stable_values = [r["oil_avg"] for r in cycle_rows[:24]]
- stable_mad = median(abs(value - baseline) for value in stable_values) if baseline is not None else 0
- fluctuation_band = max(3 * stable_mad, 0.0015)
- low = smooth is not None and smooth <= limit["oil_low"]
- low_low = smooth is not None and smooth <= limit["oil_low_low"]
- mild_zone = baseline is not None and smooth <= baseline - fluctuation_band
- abnormal_zone = baseline is not None and smooth <= baseline - 2 * fluctuation_band
- current = (
- "严重异常" if low_low
- else "异常" if low or (abnormal_zone and trend is not None and trend < 0)
- else "轻微" if mild_zone and trend is not None and trend < 0
- else "正常"
- )
- row.update({"周期编号": f"{limit['unit']}-P{cycle:02d}", "周期基线油压": baseline,
- "6小时油压中位数": smooth, "6小时油压趋势": trend,
- "正常波动带": fluctuation_band,
- "低报警阈值": limit["oil_low"], "低低报警阈值": limit["oil_low_low"],
- "原始压力等级": current,
- "低报警样本比例": (row["oil_low_count"] or 0) / max(row["运行样本数"], 1),
- "低低报警样本比例": (row["oil_low_low_count"] or 0) / max(row["运行样本数"], 1)})
- output.append(row)
- history.append(row)
- previous = row["_dt"]
- return sorted(output, key=lambda r: (r["批次"], r["_dt"]))
- def build_oil_segments(rows):
- result = []
- for batch, series in defaultdict(list).items():
- pass
- grouped = defaultdict(list)
- for row in rows:
- if row["原始压力等级"] != "正常":
- grouped[(row["批次"], row["周期编号"], row["原始压力等级"])].append(row)
- for (_batch, _cycle, level), series in grouped.items():
- series.sort(key=lambda r: r["_dt"])
- current = []
- for row in series:
- if not current or row["_dt"] - current[-1]["_dt"] <= timedelta(hours=MERGE_GAP_HOURS):
- current.append(row)
- else:
- result.append(oil_segment(current, level)); current = [row]
- if current:
- result.append(oil_segment(current, level))
- return sorted(result, key=lambda r: (r["机组"], r["开始小时"]))
- def oil_segment(series, level):
- first, last = series[0], series[-1]
- confirmed = len(series) >= CONFIRM_HOURS or level == "严重异常"
- return {"机组": first["机组"], "批次": first["批次"], "周期编号": first["周期编号"],
- "压力阶段": level if confirmed else "轻微", "开始小时": first["小时"], "结束小时": last["小时"],
- "持续小时数": len(series), "周期基线油压": first["周期基线油压"],
- "阶段开始油压": first["6小时油压中位数"], "阶段结束油压": last["6小时油压中位数"],
- "阶段最低油压": min(r["6小时油压中位数"] for r in series),
- "低报警小时数": sum(r["6小时油压中位数"] <= r["低报警阈值"] for r in series),
- "低低报警小时数": sum(r["6小时油压中位数"] <= r["低低报警阈值"] for r in series),
- "是否形成确认等级": "是" if confirmed else "否",
- "阶段判定依据": f"6小时中位数{first['6小时油压中位数']:.3f}~{last['6小时油压中位数']:.3f}MPa,"
- f"连续{len(series)}个有效运行小时;基线波动带{first.get('正常波动带', 0):.3f}MPa;"
- f"{'达到低低报警阈值' if level == '严重异常' else '超过正常波动带并持续下降'}"}
- def build_vibration_segments(rows, limits):
- signals = []
- by_batch = defaultdict(list)
- for row in rows:
- by_batch[row["批次"]].append(row)
- for batch, series in by_batch.items():
- series.sort(key=lambda r: r["_dt"])
- history = []
- limit = limits[batch]
- for row in series:
- for side in ("coupling", "chain"):
- avg = rolling_median([r[f"{side}_avg"] for r in history[-5:]] + [row[f"{side}_avg"]])
- peak = row[f"{side}_max"]
- base = rolling_median([r[f"{side}_avg"] for r in history[-24:]])
- if avg is None or peak is None or base is None:
- continue
- if peak >= limit[f"{side}_high_high"] or avg >= base * 1.20:
- level = "严重异常"
- elif peak >= limit[f"{side}_high"] or avg >= base * 1.10:
- level = "异常"
- elif avg > base * 1.05:
- level = "轻微"
- else:
- continue
- signals.append({"机组": row["机组"], "批次": batch, "_dt": row["_dt"], "侧": side,
- "等级": level, "均值": avg, "峰值": peak})
- history.append(row)
- result = []
- grouped = defaultdict(list)
- for signal in signals: grouped[(signal["批次"], signal["侧"])].append(signal)
- for (batch, side), series in grouped.items():
- series.sort(key=lambda r: r["_dt"]); current=[]
- for signal in series:
- if not current or signal["_dt"] - current[-1]["_dt"] <= timedelta(hours=MERGE_GAP_HOURS): current.append(signal)
- else: result.append(vibration_segment(current, limits[batch])); current=[signal]
- if current: result.append(vibration_segment(current, limits[batch]))
- return sorted(result, key=lambda r: (r["机组"], r["开始时间"]))
- def vibration_segment(series, limit):
- level_order={"轻微":1,"异常":2,"严重异常":3}; first=series[0]; last=series[-1]
- strongest=max(series,key=lambda r:(level_order[r["等级"]],r["峰值"]))
- side_name="联轴器端" if first["侧"]=="coupling" else "链轮端"
- return {"机组":first["机组"],"批次":first["批次"],"开始时间":first["_dt"].strftime("%Y-%m-%d %H:%M:%S"),"结束时间":last["_dt"].strftime("%Y-%m-%d %H:%M:%S"),"峰值时间":strongest["_dt"].strftime("%Y-%m-%d %H:%M:%S"),"振动等级":max(series,key=lambda r:level_order[r["等级"]])["等级"],"主要振动侧":side_name,"持续小时数":int((last["_dt"]-first["_dt"]).total_seconds()/3600)+1,"触发小时数":len(series),"最大振动值":max(r["峰值"] for r in series),"高报警阈值":limit[f"{first['侧']}_high"],"高高报警阈值":limit[f"{first['侧']}_high_high"],"阶段判定依据":f"{side_name}6小时中位数/小时峰值触发,连续{len(series)}个有效运行小时"}
- def write(path, rows, fields):
- path.parent.mkdir(parents=True, exist_ok=True)
- with path.open("w", encoding="utf-8-sig", newline="") as f:
- w=csv.DictWriter(f,fieldnames=fields,extrasaction="ignore");w.writeheader()
- for row in rows:
- w.writerow({k: "" if row.get(k) is None else round(row[k],6) if isinstance(row.get(k),float) else row.get(k) for k in fields})
- def main():
- p=argparse.ArgumentParser();p.add_argument("--batch",nargs="+",type=int,choices=sorted(BATCH_TO_UNIT),default=list(BATCH_TO_UNIT));p.add_argument("--start",default="2025-04-01 00:00:00");p.add_argument("--end",default="2026-05-01 00:00:00");p.add_argument("--chunk-days",type=int,default=14);p.add_argument("--output-dir",default="cache/pks_scanner/clean_evolution");a=p.parse_args();start=datetime.strptime(a.start,"%Y-%m-%d %H:%M:%S");end=datetime.strptime(a.end,"%Y-%m-%d %H:%M:%S")
- limits=load_limits(); rows=[]
- for batch in a.batch: rows.extend(fetch(batch,start,end,a.chunk_days,limits[batch]))
- rows=prepare(rows,limits); oil=build_oil_segments(rows); vib=build_vibration_segments(rows,limits); out=Path(a.output_dir)
- write(out/"润滑油压力小时特征.csv",rows,["机组","批次","周期编号","小时","运行样本数","运行覆盖率","油压均值","油压最小值","油压最大值","6小时油压中位数","6小时油压趋势","周期基线油压","正常波动带","低报警阈值","低低报警阈值","原始压力等级"])
- for r in rows:r["油压均值"]=r.pop("oil_avg");r["油压最小值"]=r.pop("oil_min");r["油压最大值"]=r.pop("oil_max")
- write(out/"润滑油压力小时特征.csv",rows,["机组","批次","周期编号","小时","运行样本数","运行覆盖率","油压均值","油压最小值","油压最大值","6小时油压中位数","6小时油压趋势","周期基线油压","低报警阈值","低低报警阈值","原始压力等级"])
- write(out/"润滑油压力小时阶段段.csv",oil,list(oil[0]) if oil else ["机组","压力阶段","开始小时","结束小时"]);write(out/"振动异常段.csv",vib,list(vib[0]) if vib else ["机组","振动等级","开始时间","结束时间"])
- print(f"小时特征:{len(rows):,} 行");print(f"油压阶段:{len(oil):,} 段");print(f"振动阶段:{len(vib):,} 段");print(f"输出:{out}")
- if __name__ == "__main__": main()
|