| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- #!/usr/bin/env python3
- """PKS vibration anomaly-segment algorithm (vibration half of the scanner).
- Vibration-only reconstruction of ``backend/scan_pks_fault_evolution.py``. The
- ported functions are byte-for-byte equivalent to the original so the exported
- ``振动异常段.csv`` stays identical. Shared query/IO/statistics helpers live in
- :mod:`models.common`.
- CLI
- ---
- ::
- python models/vibration/vibration_algorithm.py \
- --hourly-csv models/datas/hourly/pks_hourly_full.csv \
- --output-dir models/results/vibration
- """
- from __future__ import annotations
- import argparse
- import sys
- from collections import defaultdict
- from datetime import timedelta
- from pathlib import Path
- from statistics import median
- if __package__ in (None, ""):
- sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
- from models.common import db, devices, io, paths, queries
- from models.common.constants import (
- MIN_HOUR_COVERAGE,
- VIBRATION_BASELINE_DAYS,
- VIBRATION_MERGE_GAP_HOURS,
- VIBRATION_RESTART_GAP_HOURS,
- )
- from models.common.stats import median_value, robust_baseline
- def vibration_hour_signals(rows: list[dict], configs: dict[int, dict]):
- signals = []
- grouped = defaultdict(list)
- for row in rows:
- if row["运行覆盖率"] >= MIN_HOUR_COVERAGE:
- grouped[row["批次"]].append(row)
- for batch, series in grouped.items():
- series.sort(key=lambda row: row["_dt"])
- config = configs[batch]
- valid = []
- last_valid_dt = None
- for row in series:
- if row["运行覆盖率"] < MIN_HOUR_COVERAGE:
- continue
- restart = last_valid_dt is None or row["_dt"] - last_valid_dt > timedelta(hours=VIBRATION_RESTART_GAP_HOURS)
- if restart:
- # The first complete running hour after a restart is a
- # transition hour. Keep older valid hours for the long-term
- # robust reference, but do not evaluate this transition hour.
- last_valid_dt = row["_dt"]
- continue
- baseline_start = row["_dt"] - timedelta(days=VIBRATION_BASELINE_DAYS)
- prior = [item for item in valid if item["_dt"] >= baseline_start]
- side_signals = []
- for side in ("coupling", "chain"):
- avg_center, avg_scale = robust_baseline([item[f"{side}_avg"] for item in prior])
- max_center, max_scale = robust_baseline([item[f"{side}_max"] for item in prior])
- current_avg = row[f"{side}_avg"]
- current_max = row[f"{side}_max"]
- if current_max is None:
- continue
- avg_z = ((current_avg - avg_center) / avg_scale) if avg_center is not None and current_avg is not None else 0.0
- peak_z = (current_max - max_center) / max_scale if max_center is not None and max_scale else 0.0
- running = max(row["运行样本数"], 1)
- high_ratio = (row[f"{side}_high_count"] or 0) / running
- high_high_ratio = (row[f"{side}_high_high_count"] or 0) / running
- limits = config[side]
- local_prior = valid[:6] if len(valid) >= 6 else []
- local_avg = median_value([item[f"{side}_avg"] for item in local_prior])
- local_max = median_value([item[f"{side}_max"] for item in local_prior])
- local_rise = False
- severe = (
- high_high_ratio >= 0.01
- or current_max >= limits["high_high"]
- or peak_z >= 8
- )
- abnormal = (
- high_ratio >= 0.01
- or current_max >= limits["high"]
- or peak_z >= 5
- or avg_z >= 4
- or local_rise
- )
- mild = peak_z >= 4 or avg_z >= 3 or local_rise
- if severe or abnormal or mild:
- side_signals.append({
- "side": side,
- "avg_z": avg_z,
- "peak_z": peak_z,
- "high_ratio": high_ratio,
- "high_high_ratio": high_high_ratio,
- "severe": severe,
- "abnormal": abnormal,
- "value": current_max,
- "avg": current_avg,
- "prior_max_center": max_center,
- "prior_max_scale": max_scale,
- "prior_avg_center": avg_center,
- })
- if side_signals:
- strongest = max(side_signals, key=lambda item: (item["severe"], item["peak_z"], item["avg_z"]))
- signal_level = 3 if any(item["severe"] for item in side_signals) else 2 if any(item["abnormal"] for item in side_signals) else 1
- reasons = []
- for item in side_signals:
- label = "联轴器端" if item["side"] == "coupling" else "链轮端"
- reasons.append(
- f"{label}均值偏离{item['avg_z']:.1f}倍、峰值偏离{item['peak_z']:.1f}倍、"
- f"高报警比例{item['high_ratio']:.1%}"
- )
- signals.append({
- "批次": batch,
- "机组": config["unit"],
- "_dt": row["_dt"],
- "_level": signal_level,
- "_severe": signal_level == 3,
- "_side_signals": side_signals,
- "_strongest": strongest,
- "_reason": ";".join(reasons),
- })
- valid.append(row)
- last_valid_dt = row["_dt"]
- return signals
- def merge_vibration_signals(signals: list[dict], configs: dict[int, dict]):
- grouped = defaultdict(list)
- for signal in signals:
- grouped[signal["批次"]].append(signal)
- events = []
- for batch, series in grouped.items():
- series.sort(key=lambda item: item["_dt"])
- current = []
- for signal in series:
- if not current or signal["_dt"] - current[-1]["_dt"] <= timedelta(hours=VIBRATION_MERGE_GAP_HOURS):
- current.append(signal)
- else:
- events.append(build_vibration_event(current, configs[batch]))
- current = [signal]
- if current:
- events.append(build_vibration_event(current, configs[batch]))
- events.sort(key=lambda row: (queries.unit_sort_key(row["机组"]), row["开始小时"], row["批次"]))
- unit_indexes = defaultdict(int)
- for event in events:
- unit_indexes[event["机组"]] += 1
- event["周期编号"] = f'{event["机组"]}-V{unit_indexes[event["机组"]]:02d}'
- filtered = []
- for event in events:
- # A single post-start relative-rise signal is not sufficient evidence;
- # absolute severe signals remain eligible on their own.
- if event["_严重信号小时数"] > 0 or event["_触发小时数"] >= 2:
- filtered.append(event)
- return sorted(filtered, key=io.output_sort_key)
- def vibration_score(duration_hours: int, valid_hours: int, avg_change: float, max_value: float, peak_z: float, high_hours: int, high_high_hours: int, config: dict, side: str):
- duration_score = min(40.0, max(0.0, (duration_hours - 1) / 12 * 40))
- avg_score = min(20.0, max(0.0, (avg_change - 0.10) / 0.40 * 20)) if avg_change >= 0.10 else 0.0
- high = config[side]["high"]
- high_high = config[side]["high_high"]
- if max_value < high:
- max_score = min(10.0, max(0.0, (max_value / max(high, 1e-6) - 0.8) / 0.2 * 10))
- elif max_value < high_high:
- max_score = 10.0 + (max_value - high) / max(high_high - high, 1e-6) * 10.0
- else:
- max_score = 20.0
- peak_score = min(20.0, max(0.0, (peak_z - 4.0) / 8.0 * 20.0)) if peak_z >= 4.0 else 0.0
- total = duration_score + avg_score + max_score + peak_score
- hard_severe = max_value >= high_high or high_high_hours > 0 or (peak_z >= 8.0 and valid_hours >= 2)
- if hard_severe or total >= 60.0:
- level = "严重异常"
- elif total >= 30.0:
- level = "异常"
- else:
- level = "轻微"
- return total, level, duration_score, avg_score, max_score, peak_score
- def build_vibration_event(signals: list[dict], config: dict):
- start = signals[0]["_dt"]
- end = signals[-1]["_dt"]
- all_side = [item for signal in signals for item in signal["_side_signals"]]
- strongest = max(all_side, key=lambda item: (item["severe"], item["peak_z"], item["value"]))
- main_side = "联轴器端" if strongest["side"] == "coupling" else "链轮端"
- max_value = max(item["value"] for item in all_side if item["value"] is not None)
- max_peak_z = max(item["peak_z"] for item in all_side)
- high_hours = sum(
- 1 for signal in signals
- if any(item["high_ratio"] > 0 for item in signal["_side_signals"])
- )
- high_high_hours = sum(
- 1 for signal in signals
- if any(item["high_high_ratio"] > 0 for item in signal["_side_signals"])
- )
- severe_hours = sum(1 for signal in signals if signal["_severe"])
- duration_hours = int((end - start).total_seconds() / 3600) + 1
- running_days = len({signal["_dt"].date() for signal in signals})
- side_avg_changes = []
- for side in ("coupling", "chain"):
- values = [item["avg"] for item in all_side if item["side"] == side and item["avg"] is not None]
- prior_values = [item["prior_avg_center"] for item in all_side if item["side"] == side and item["prior_avg_center"] is not None]
- if values and prior_values and median(prior_values) != 0:
- side_avg_changes.append((median(values) / median(prior_values) - 1, side))
- avg_change, _ = max(side_avg_changes, key=lambda item: item[0], default=(0.0, strongest["side"]))
- peak_ratio = max_value / max(strongest["prior_max_center"], 1e-6)
- if max_value >= config[strongest["side"]]["high_high"] or severe_hours > 0 and (high_high_hours > 0 or max_peak_z >= 8):
- level = "严重异常"
- elif max_value >= config[strongest["side"]]["high"] and high_hours >= 2:
- level = "严重异常"
- elif max_value >= config[strongest["side"]]["high"] or max_peak_z >= 5 or avg_change >= 0.10:
- level = "异常"
- else:
- level = "轻微"
- if peak_ratio >= 1.5 and avg_change < 0.25:
- shape = "间歇冲击型"
- elif avg_change >= 0.10 and len(signals) >= 3:
- shape = "持续抬升型"
- else:
- shape = "混合型"
- peak_signal = max(signals, key=lambda signal: signal["_strongest"]["peak_z"])
- first_signal = signals[0]
- last_signal = signals[-1]
- first_value = first_signal["_strongest"]["value"]
- last_value = last_signal["_strongest"]["value"]
- baseline = strongest["prior_max_center"]
- scale = max(strongest.get("prior_max_scale", 0.0), 0.0)
- normal_band = f"{baseline - 2 * scale:.3f}~{baseline + 2 * scale:.3f} mm/s" if baseline is not None else ""
- consecutive = 0
- longest_consecutive = 0
- for signal in signals:
- if signal["_level"] >= 2:
- consecutive += 1
- longest_consecutive = max(longest_consecutive, consecutive)
- else:
- consecutive = 0
- score, level, duration_score, avg_score, max_score, peak_score = vibration_score(
- duration_hours, len(signals), avg_change, max_value, max_peak_z,
- high_hours, high_high_hours, config, strongest["side"]
- )
- confirmed = "是" if level != "轻微" else "否"
- if shape == "持续抬升型":
- basis = f"{main_side}振动均值持续抬升,阶段内{longest_consecutive or len(signals)}个有效运行小时触发"
- elif shape == "间歇冲击型":
- basis = f"{main_side}重复出现振动冲击,阶段内{len(signals)}个有效运行小时触发"
- else:
- basis = f"{main_side}振动峰值或均值偏离近期基线,阶段内{len(signals)}个有效运行小时触发"
- if high_high_hours:
- basis += f";高高报警小时{high_high_hours}个"
- elif high_hours:
- basis += f";高报警小时{high_hours}个"
- basis += f";最大振动值{max_value:.3f}mm/s,峰值高于近期基线{max_peak_z:.2f}个稳健尺度(峰值/基线{peak_ratio:.2f}倍);综合评分{score:.1f}分"
- return {
- "机组": config["unit"],
- "批次": config["batch"],
- "周期编号": "",
- "振动阶段": level,
- "开始小时": start.strftime("%Y-%m-%d %H:%M:%S"),
- "结束小时": end.strftime("%Y-%m-%d %H:%M:%S"),
- "持续自然小时数": duration_hours,
- "有效运行小时数": len(signals),
- "近期基线振动": baseline,
- "正常波动带": normal_band,
- "阶段开始小时振动": first_value,
- "阶段结束小时振动": last_value,
- "阶段最大振动值": max_value,
- "阶段开始相对近期基线变化": (first_value / baseline - 1) if baseline else None,
- "阶段结束相对近期基线变化": (last_value / baseline - 1) if baseline else None,
- "阶段最大连续异常有效小时数": longest_consecutive,
- "阶段综合评分": score,
- "持续时间得分": duration_score,
- "平均振动得分": avg_score,
- "最大振动值得分": max_score,
- "峰值偏离得分": peak_score,
- "最强异常侧": main_side,
- "最强侧近期峰值基线": baseline,
- "最强侧近期稳健尺度": scale,
- "阶段最大峰值/基线比例": peak_ratio,
- "阶段高报警小时数": high_hours,
- "阶段高高报警小时数": high_high_hours,
- "_严重信号小时数": severe_hours,
- "_触发小时数": len(signals),
- "是否形成确认等级": confirmed,
- "阶段触发依据": basis,
- }
- VIBRATION_FIELDS = [
- "机组", "批次", "周期编号", "振动阶段", "开始小时", "结束小时", "持续自然小时数", "有效运行小时数",
- "近期基线振动", "正常波动带", "阶段开始小时振动", "阶段结束小时振动", "阶段最大振动值",
- "阶段开始相对近期基线变化", "阶段结束相对近期基线变化", "阶段最大连续异常有效小时数",
- "阶段高报警小时数", "阶段高高报警小时数", "是否形成确认等级", "阶段触发依据",
- "阶段综合评分", "持续时间得分", "平均振动得分", "最大振动值得分", "峰值偏离得分",
- "最强异常侧", "最强侧近期峰值基线", "最强侧近期稳健尺度", "阶段最大峰值/基线比例",
- ]
- def run(rows: list[dict], configs: dict[int, dict]):
- """Return vibration anomaly events."""
- signals = vibration_hour_signals(rows, configs)
- return merge_vibration_signals(signals, configs)
- def write_results(output_dir: Path, events):
- output_dir = Path(output_dir)
- io.write_csv(output_dir / "振动异常段.csv", io.clean_rows(events), VIBRATION_FIELDS, {field: field for field in VIBRATION_FIELDS})
- def load_hourly(args, selected_batches, unit_by_batch, configs):
- if args.hourly_csv:
- selected_set = set(selected_batches)
- return [row for row in queries.load_hourly_csv(Path(args.hourly_csv)) if row["批次"] in selected_set]
- start, end = queries.parse_dt(args.start), queries.parse_dt(args.end)
- return queries.fetch_all_hourly(start, end, args.chunk_days, unit_by_batch, configs, selected_batches)
- def main() -> None:
- parser = argparse.ArgumentParser(description="PKS 振动异常段算法(独立于压力)")
- parser.add_argument("--device", nargs="+", default=list(devices.DEVICE_CODES), help="压缩机编号,如 7# 8# 9#")
- parser.add_argument("--start", default="2025-01-01 00:00:00")
- parser.add_argument("--end", default="2026-09-01 00:00:00")
- parser.add_argument("--chunk-days", type=int, default=14)
- parser.add_argument("--hourly-csv", default=None)
- parser.add_argument("--output-dir", default=str(paths.VIBRATION_RESULTS))
- args = parser.parse_args()
- devices_selected = devices.normalize_devices(args.device)
- device_batches = devices.load_device_batches(devices_selected)
- unit_by_batch = devices.batch_to_unit(device_batches)
- configs = db.load_alarm_config(unit_by_batch)
- selected_batches = sorted(unit_by_batch)
- rows = load_hourly(args, selected_batches, unit_by_batch, configs)
- events = run(rows, configs)
- write_results(Path(args.output_dir), events)
- print(f"设备:{devices_selected}")
- print(f"小时特征:{len(rows):,} 行")
- print(f"振动异常段:{len(events):,} 段")
- print(f"输出目录:{args.output_dir}")
- if __name__ == "__main__":
- main()
|