| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578 |
- #!/usr/bin/env python3
- """Find directional 10-degree local feature evolution around repair events."""
- from __future__ import annotations
- import argparse
- import csv
- import math
- import sys
- from collections import defaultdict
- from datetime import date, datetime
- from pathlib import Path
- import numpy as np
- BACKEND = Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(BACKEND))
- from indicator_features.extract_from_db import ( # noqa: E402
- connect, fetch_cycles, fetch_file_rows, point_name, sample_per_day,
- )
- from indicator_features.indicator_features import _plateau_levels, smooth # noqa: E402
- CYLINDERS = ("一", "二", "三", "四", "五", "六")
- SIDES = ("盖侧", "轴侧")
- METRICS = ("p50", "slope", "std", "iqr", "d_rms", "d2_abs_max")
- METRIC_CN = {
- "p50": "局部中位压力", "slope": "局部斜率", "std": "标准差",
- "iqr": "四分位距", "d_rms": "导数RMS", "d2_abs_max": "最大二阶导",
- }
- FAMILY = {"p50": "位置", "slope": "斜率", "std": "波动", "iqr": "波动",
- "d_rms": "导数", "d2_abs_max": "导数"}
- BAD = {
- "4": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
- "6": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
- "7": {("四", "盖侧"), ("六", "盖侧")},
- }
- EVENTS = [
- {
- "id": "7号_2025维修过程", "unit": "7", "label": "7号2025停机维修过程",
- "start": "2025-04-29", "end": "2026-02-17", "event": "2025-08-08",
- "pre": [("2025-04-29", "2025-05-04"), ("2025-06-11", "2025-06-14"), ("2025-07-25", "2025-07-28")],
- "pre_segments": [("2025-04-29", "2025-05-04"), ("2025-06-11", "2025-06-14"), ("2025-07-25", "2025-07-28")],
- "early": [("2025-04-29", "2025-05-04")], "late": [("2025-07-25", "2025-07-28")],
- "post_base": [("2025-11-19", "2025-11-21")],
- "post_check": [("2026-02-10", "2026-02-17")],
- "mode": "跨运行段", "markers": "2025-08-08;2025-10-07",
- },
- {
- "id": "7号_2026-05-21", "unit": "7", "label": "7号2026-05-21气阀事件",
- "start": "2026-02-10", "end": "2026-06-07", "event": "2026-05-21",
- "pre": [("2026-05-06", "2026-05-21")],
- "pre_segments": [("2026-02-10", "2026-02-17"), ("2026-03-12", "2026-04-05"), ("2026-05-06", "2026-05-21")],
- "early": [("2026-05-06", "2026-05-09")], "late": [("2026-05-18", "2026-05-21")],
- "post_base": [("2026-05-31", "2026-06-04")],
- "post_check": [("2026-06-04", "2026-06-07")],
- "mode": "连续运行段", "markers": "2026-05-21",
- },
- {
- "id": "4号_2026-06-10", "unit": "4", "label": "4号2026-06-10气阀事件",
- "start": "2026-04-21", "end": "2026-07-31", "event": "2026-06-10",
- "pre": [("2026-05-29", "2026-05-31"), ("2026-06-06", "2026-06-10")],
- "pre_segments": [("2026-04-21", "2026-05-02"), ("2026-05-29", "2026-05-31"), ("2026-06-06", "2026-06-10")],
- "early": [("2026-06-06", "2026-06-08")], "late": [("2026-06-08", "2026-06-10")],
- "post_base": [("2026-06-11", "2026-06-15")],
- "post_check": [("2026-06-21", "2026-06-22"), ("2026-06-30", "2026-07-08"),
- ("2026-07-11", "2026-07-14"), ("2026-07-22", "2026-07-31")],
- "mode": "短运行段", "markers": "2026-06-10",
- },
- {
- "id": "6号_2026-04-22_26", "unit": "6", "label": "6号2026-04-22~26气阀维修过程",
- "start": "2026-04-12", "end": "2026-08-01", "event": "2026-04-22",
- "pre": [("2026-04-12", "2026-04-22")],
- "pre_segments": [("2026-04-12", "2026-04-22")],
- "early": [("2026-04-12", "2026-04-15")], "late": [("2026-04-19", "2026-04-22")],
- "post_base": [("2026-05-10", "2026-05-17")],
- "post_check": [("2026-05-17", "2026-05-31"), ("2026-06-21", "2026-07-02"),
- ("2026-07-24", "2026-08-01")],
- "mode": "连续运行段", "markers": "2026-04-22;2026-04-26",
- },
- ]
- def in_ranges(day: str, ranges) -> bool:
- return any(start <= day < end for start, end in ranges)
- def robust_scale(values) -> float:
- values = np.asarray(values, dtype=float)
- if values.size == 0:
- return 0.0
- scale = float(np.median(np.abs(values - np.median(values))) * 1.4826)
- return scale if scale > 1e-12 else float(np.std(values))
- def rankdata(values):
- values = np.asarray(values)
- order = np.argsort(values, kind="mergesort")
- ranks = np.empty(len(values), dtype=float)
- start = 0
- while start < len(values):
- end = start + 1
- while end < len(values) and values[order[end]] == values[order[start]]:
- end += 1
- ranks[order[start:end]] = (start + end - 1) / 2
- start = end
- return ranks
- def spearman(values) -> float:
- if len(values) < 3:
- return float("nan")
- x = np.arange(len(values), dtype=float)
- y = rankdata(values)
- if np.std(y) <= 1e-12:
- return 0.0
- return float(np.corrcoef(x, y)[0, 1])
- def normalized_bins(signal):
- signal = np.asarray(signal, dtype=float)
- signal = signal[np.isfinite(signal)]
- if len(signal) < 60:
- return None
- pressure = smooth(signal, 21)
- suction, discharge, _ = _plateau_levels(pressure)
- span = discharge - suction
- if not np.isfinite(span) or span < .3:
- return None
- normalized = (pressure - suction) / span
- angles = np.arange(len(normalized), dtype=float) * 360 / len(normalized)
- result = []
- for index in range(36):
- mask = (angles >= index * 10) & (angles < (index + 1) * 10)
- x, y = angles[mask], normalized[mask]
- if len(y) < 5:
- return None
- slope = float(np.polyfit(x, y, 1)[0])
- dy = np.diff(y)
- d2 = np.diff(dy)
- result.append({
- "bin": index, "bin_start": index * 10,
- "p50": float(np.median(y)), "slope": slope, "std": float(np.std(y)),
- "iqr": float(np.percentile(y, 75) - np.percentile(y, 25)),
- "d_rms": float(np.sqrt(np.mean(dy ** 2))),
- "d2_abs_max": float(np.max(np.abs(d2))) if len(d2) else 0.0,
- })
- return result
- def extract(output: Path, per_day: int):
- grouped = defaultdict(list)
- connection = connect()
- fields = ["事件", "机组", "测点", "缸", "腔", "文件ID", "采样时间", "日期", "角度箱"] + list(METRICS)
- output.parent.mkdir(parents=True, exist_ok=True)
- with output.open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=fields)
- writer.writeheader()
- try:
- for event in EVENTS:
- periods = 0
- for cylinder in CYLINDERS:
- for side in SIDES:
- if (cylinder, side) in BAD[event["unit"]]:
- continue
- files = fetch_file_rows(connection, point_name(event["unit"], cylinder, side), event["start"], event["end"])
- picked = sample_per_day(files, per_day)
- cycles = fetch_cycles(connection, [int(row["id"]) for row in picked], 100) if picked else {}
- for row in picked:
- features = normalized_bins(cycles.get(int(row["id"]), np.array([])))
- if features is None:
- continue
- periods += 1
- day = str(row["sample_time"])[:10]
- point = f"{cylinder}缸{side}"
- for item in features:
- record = {
- "事件": event["id"], "机组": event["unit"], "测点": point,
- "缸": cylinder, "腔": side, "文件ID": int(row["id"]),
- "采样时间": str(row["sample_time"]), "日期": day,
- "角度箱": item["bin_start"], **{metric: item[metric] for metric in METRICS},
- }
- writer.writerow(record)
- for metric in METRICS:
- grouped[(event["id"], point, day, item["bin_start"], metric)].append(item[metric])
- print(f"{event['label']}: 有效周期 {periods:,}", flush=True)
- finally:
- connection.close()
- return grouped
- def load_extracted(path: Path):
- grouped = defaultdict(list)
- with path.open(encoding="utf-8-sig", newline="") as handle:
- for row in csv.DictReader(handle):
- for metric in METRICS:
- grouped[(row["事件"], row["测点"], row["日期"], int(row["角度箱"]), metric)].append(float(row[metric]))
- return grouped
- def baseline_stats(series, ranges):
- daily_values = [daily for day, daily, _ in series if in_ranges(day, ranges)]
- cycle_values = [value for day, _, values in series if in_ranges(day, ranges) for value in values]
- center = float(np.median(daily_values)) if daily_values else float("nan")
- # Keep dates equal-weighted for the center, but estimate natural variation
- # from cycles so a 2-3 day baseline cannot produce near-zero scales.
- scale = robust_scale(cycle_values)
- return center, scale, len(daily_values), len(cycle_values)
- def merge_bands(candidates):
- grouped = defaultdict(list)
- for row in candidates:
- if row["可信等级"] == "低":
- continue
- start = int(row["角度范围"].split("-")[0])
- grouped[(row["事件"], row["测点"], row["故障主方向"], row["可信等级"])].append((start, row))
- bands = []
- for (event, point, direction, confidence), values in grouped.items():
- values.sort()
- runs = []
- for start, row in values:
- if runs and start == runs[-1][-1][0] + 10:
- runs[-1].append((start, row))
- else:
- runs.append([(start, row)])
- # Merge the circular 350-360 and 0-10 runs.
- if len(runs) > 1 and runs[0][0][0] == 0 and runs[-1][-1][0] == 350:
- runs[0] = runs[-1] + runs[0]
- runs.pop()
- for run in runs:
- starts = [item[0] for item in run]
- rows = [item[1] for item in run]
- if starts[0] > starts[-1]:
- band = f"{starts[0]}-360°及0-{starts[-1] + 10}°"
- else:
- band = f"{starts[0]}-{starts[-1] + 10}°"
- bands.append({
- "事件": event, "测点": point, "角度带": band, "故障主方向": direction,
- "包含10度箱数": len(rows), "可信等级": confidence,
- "最高评分": max(row["候选评分"] for row in rows),
- "演化指标数": max(row["演化指标数"] for row in rows),
- "长期演化指标数": max(row["长期演化指标数"] for row in rows),
- "恢复指标数": max(row["恢复指标数"] for row in rows),
- "长期稳定恢复指标数": max(row["长期稳定恢复指标数"] for row in rows),
- "长期恢复比例平均": max((row["长期恢复比例平均"] for row in rows
- if row["长期恢复比例平均"] != ""), default=""),
- "局部幅度平均Z": max(row["局部幅度平均Z"] for row in rows),
- "局部幅度综合RMS": max(row["局部幅度综合RMS"] for row in rows),
- "最早持续日期": min((row["最早持续日期"] for row in rows if row["最早持续日期"]), default=""),
- "代表指标": max(rows, key=lambda row: row["候选评分"])["指标摘要"],
- })
- bands.sort(key=lambda row: (row["事件"], -row["最高评分"], -row["包含10度箱数"]))
- return bands
- def analyze(grouped, daily_output: Path, candidate_output: Path, repeat_output: Path, band_output: Path,
- segment_output: Path):
- daily_raw = {key: float(np.median(values)) for key, values in grouped.items()}
- counts = {key: len(values) for key, values in grouped.items()}
- event_by_id = {event["id"]: event for event in EVENTS}
- points_by_event = defaultdict(set)
- for event_id, point, _, _, _ in daily_raw:
- points_by_event[event_id].add(point)
- series_index = defaultdict(list)
- for (event_id, point, day, angle, metric), values in grouped.items():
- series_index[(event_id, point, angle, metric)].append((day, daily_raw[(event_id, point, day, angle, metric)], values))
- stats = {}
- for event in EVENTS:
- for point in points_by_event[event["id"]]:
- for metric in METRICS:
- scales = []
- provisional = {}
- for angle in range(0, 360, 10):
- series = series_index[(event["id"], point, angle, metric)]
- post = baseline_stats(series, event["post_base"])
- early = baseline_stats(series, event["early"])
- provisional[angle] = (post, early)
- scales.extend([post[1], early[1]])
- positive = [scale for scale in scales if np.isfinite(scale) and scale > 1e-12]
- floor = .2 * float(np.median(positive)) if positive else 1e-9
- for angle, (post, early) in provisional.items():
- stats[(event["id"], point, angle, metric)] = {
- "post_center": post[0], "post_scale": max(post[1], floor), "post_days": post[2],
- "post_cycles": post[3],
- "early_center": early[0], "early_scale": max(early[1], floor), "early_days": early[2],
- "early_cycles": early[3],
- }
- daily_rows = []
- for key, value in daily_raw.items():
- event_id, point, day, angle, metric = key
- stat = stats[(event_id, point, angle, metric)]
- z_post = (value - stat["post_center"]) / stat["post_scale"] if np.isfinite(stat["post_center"]) else float("nan")
- z_early = (value - stat["early_center"]) / stat["early_scale"] if np.isfinite(stat["early_center"]) else float("nan")
- if not (np.isfinite(z_post) and np.isfinite(z_early)):
- continue
- daily_rows.append({
- "事件": event_id, "测点": point, "日期": day, "角度箱": angle,
- "指标": metric, "指标中文": METRIC_CN[metric], "特征族": FAMILY[metric],
- "每日中位": value, "当日周期数": counts[key],
- "维修后基线中心": stat["post_center"], "维修后基线尺度": stat["post_scale"],
- "维修后基线日数": stat["post_days"], "相对维修后Z": z_post,
- "维修后基线周期数": stat["post_cycles"],
- "早期基线中心": stat["early_center"], "早期基线尺度": stat["early_scale"],
- "早期基线日数": stat["early_days"], "早期基线周期数": stat["early_cycles"],
- "相对早期Z": z_early,
- })
- common = defaultdict(list)
- for row in daily_rows:
- if np.isfinite(row["相对维修后Z"]):
- common[(row["事件"], row["日期"], row["角度箱"], row["指标"])].append(row["相对维修后Z"])
- common = {key: float(np.median(values)) for key, values in common.items()}
- for row in daily_rows:
- public = common.get((row["事件"], row["日期"], row["角度箱"], row["指标"]), float("nan"))
- row["全机公共Z"] = public
- row["局部残差Z"] = row["相对维修后Z"] - public
- segment_rows = []
- for event in EVENTS:
- ranges = [("故障前运行段", start, end) for start, end in event["pre_segments"]]
- ranges += [("维修后基线", start, end) for start, end in event["post_base"]]
- ranges += [("维修后长期验证", start, end) for start, end in event["post_check"]]
- event_rows = [row for row in daily_rows if row["事件"] == event["id"]]
- keys = sorted({(row["测点"], row["角度箱"], row["指标"], row["指标中文"], row["特征族"])
- for row in event_rows})
- for segment_type, start, end in ranges:
- for point, angle, metric, metric_cn, family in keys:
- selected = [row for row in event_rows if row["测点"] == point and row["角度箱"] == angle
- and row["指标"] == metric and start <= row["日期"] < end]
- if not selected:
- continue
- segment_rows.append({
- "事件": event["id"], "阶段类型": segment_type, "阶段开始": start, "阶段结束": end,
- "测点": point, "角度箱": angle, "指标": metric, "指标中文": metric_cn, "特征族": family,
- "有效日期数": len({row["日期"] for row in selected}),
- "周期数": sum(int(row["当日周期数"]) for row in selected),
- "阶段每日中位的中位": float(np.median([row["每日中位"] for row in selected])),
- "相对维修后Z中位": float(np.median([row["相对维修后Z"] for row in selected])),
- "全机公共Z中位": float(np.median([row["全机公共Z"] for row in selected])),
- "局部残差Z中位": float(np.median([row["局部残差Z"] for row in selected])),
- })
- daily_output.parent.mkdir(parents=True, exist_ok=True)
- with daily_output.open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(daily_rows[0]))
- writer.writeheader(); writer.writerows(daily_rows)
- with segment_output.open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(segment_rows[0]))
- writer.writeheader(); writer.writerows(segment_rows)
- rows_index = defaultdict(list)
- for row in daily_rows:
- rows_index[(row["事件"], row["测点"], row["角度箱"], row["指标"])].append(row)
- metric_candidates = []
- for key, rows in rows_index.items():
- event_id, point, angle, metric = key
- event = event_by_id[event_id]
- rows.sort(key=lambda row: row["日期"])
- pre = [row for row in rows if in_ranges(row["日期"], event["pre"])]
- late = [row for row in rows if in_ranges(row["日期"], event["late"])]
- check = [row for row in rows if in_ranges(row["日期"], event["post_check"])]
- if len(pre) < 3 or not late:
- continue
- late_z = float(np.median([row["相对维修后Z"] for row in late]))
- late_residual = float(np.median([row["局部残差Z"] for row in late]))
- direction = 1 if late_z >= 0 else -1
- directed_late_residual = direction * late_residual
- directed = [direction * row["相对维修后Z"] for row in pre]
- directed_local = [direction * row["局部残差Z"] for row in pre]
- rho = spearman(directed)
- last_days = directed[-min(5, len(directed)):]
- persistent = sum(value >= 3 for value in last_days)
- local_persistent = sum(value >= 2 for value in directed_local[-min(5, len(directed_local)):])
- late_early_z = float(np.median([direction * row["相对早期Z"] for row in late]))
- check_z = float(np.median([abs(row["相对维修后Z"]) for row in check])) if check else float("nan")
- check_segments = []
- for start, end in event["post_check"]:
- segment_check = [row for row in rows if start <= row["日期"] < end]
- if segment_check:
- check_segments.append(float(np.median([abs(row["相对维修后Z"]) for row in segment_check])))
- recovery_flags = [value <= 2 or value <= .5 * abs(late_z) for value in check_segments]
- recovered = bool(recovery_flags and recovery_flags[0])
- long_recovery_ratio = float(np.mean(recovery_flags)) if recovery_flags else float("nan")
- long_recovery_stable = bool(recovery_flags and all(recovery_flags))
- segment_values = []
- segment_local_values = []
- segment_labels = []
- for start, end in event["pre_segments"]:
- segment_rows = [row for row in rows if start <= row["日期"] < end]
- if not segment_rows:
- continue
- segment_values.append(float(np.median([direction * row["相对维修后Z"] for row in segment_rows])))
- segment_local_values.append(float(np.median([direction * row["局部残差Z"] for row in segment_rows])))
- segment_labels.append(f"{start}~{end}")
- long_rho = spearman(segment_local_values) if len(segment_local_values) >= 3 else float("nan")
- long_change = segment_local_values[-1] - segment_local_values[0] if len(segment_local_values) >= 2 else float("nan")
- long_evolution = len(segment_local_values) >= 2 and long_change >= 2 and (
- len(segment_local_values) == 2 or long_rho >= .5
- )
- onset = ""
- for index in range(len(pre)):
- window = directed[index:index + 3]
- if len(window) >= 2 and sum(value >= 3 for value in window) >= 2:
- onset = pre[index]["日期"]
- break
- support = abs(late_z) >= 3 and directed_late_residual >= 2
- evolution = (persistent >= 2 and rho >= .35) or late_early_z >= 2
- score = min(abs(late_residual), 12) + min(abs(late_z), 12) * .5
- score += 3 if evolution else 0
- score += 3 if recovered else 0
- score += min(persistent, 3) + min(local_persistent, 3)
- if event["mode"] != "连续运行段":
- score -= 2
- if min(rows[0]["维修后基线日数"], rows[0]["早期基线日数"]) < 3:
- score -= 2
- metric_candidates.append({
- "事件": event_id, "模式": event["mode"], "测点": point, "角度起点": angle,
- "指标": metric, "指标中文": METRIC_CN[metric], "特征族": FAMILY[metric],
- "故障方向": "升高" if direction > 0 else "降低", "维修前末段Z": late_z,
- "维修前末段局部残差Z": late_residual, "定向局部幅度Z": directed_late_residual,
- "末段相对早期定向Z": late_early_z,
- "故障前Spearman": rho, "末5日超3Z天数": persistent,
- "末5日局部超2Z天数": local_persistent, "首次持续日期": onset,
- "独立维修后验证Z": check_z, "维修后恢复": int(recovered),
- "后期验证运行段数": len(check_segments),
- "后期各段绝对Z": ";".join(f"{value:.3f}" for value in check_segments),
- "长期恢复比例": long_recovery_ratio,
- "长期恢复稳定": int(long_recovery_stable),
- "基础支持": int(support), "演化支持": int(evolution), "单指标评分": score,
- "长期运行段数": len(segment_local_values), "长期段间Spearman": long_rho,
- "长期段间局部变化Z": long_change, "长期演化支持": int(long_evolution),
- "长期各段定向Z": ";".join(f"{label}:{value:.3f}" for label, value in zip(segment_labels, segment_local_values)),
- "维修后基线日数": rows[0]["维修后基线日数"], "早期基线日数": rows[0]["早期基线日数"],
- })
- groups = defaultdict(list)
- for row in metric_candidates:
- if row["基础支持"]:
- groups[(row["事件"], row["测点"], row["角度起点"])].append(row)
- candidates = []
- for (event_id, point, angle), items in groups.items():
- families = {item["特征族"] for item in items}
- directions = defaultdict(list)
- for item in items:
- directions[item["故障方向"]].append(item)
- direction, aligned = max(directions.items(), key=lambda pair: len(pair[1]))
- evolution_n = sum(item["演化支持"] for item in items)
- long_evolution_n = sum(item["长期演化支持"] for item in items)
- recovery_n = sum(item["维修后恢复"] for item in items)
- stable_recovery_n = sum(item["长期恢复稳定"] for item in items)
- recovery_ratios = [item["长期恢复比例"] for item in items if np.isfinite(item["长期恢复比例"])]
- directed_amplitudes = [item["定向局部幅度Z"] for item in items]
- amplitude_mean = float(np.mean(directed_amplitudes))
- amplitude_rms = float(np.sqrt(np.mean(np.square(directed_amplitudes))))
- amplitude_median = float(np.median(directed_amplitudes))
- score = sum(max(0, item["单指标评分"]) for item in items) + 2 * len(families) + 2 * evolution_n + 2 * recovery_n
- score += 2 * long_evolution_n + min(amplitude_mean, 8) + min(amplitude_rms, 10)
- candidates.append({
- "事件": event_id, "模式": items[0]["模式"], "测点": point,
- "角度范围": f"{angle}-{angle + 10}°", "故障主方向": direction,
- "支持指标数": len(items), "特征族数": len(families),
- "演化指标数": evolution_n, "恢复指标数": recovery_n,
- "长期稳定恢复指标数": stable_recovery_n,
- "长期恢复比例平均": round(float(np.mean(recovery_ratios)), 3) if recovery_ratios else "",
- "长期演化指标数": long_evolution_n,
- "局部幅度平均Z": round(amplitude_mean, 3),
- "局部幅度中位Z": round(amplitude_median, 3),
- "局部幅度综合RMS": round(amplitude_rms, 3),
- "候选评分": round(score, 3),
- "最早持续日期": min((item["首次持续日期"] for item in items if item["首次持续日期"]), default=""),
- "指标摘要": ";".join(
- f"{item['指标中文']}{'↑' if item['故障方向']=='升高' else '↓'}"
- f"(局部{item['维修前末段局部残差Z']:.1f}Z,ρ={item['故障前Spearman']:.2f},恢复={item['维修后恢复']})"
- for item in sorted(items, key=lambda item: -item["单指标评分"])
- ),
- "可信等级": "高" if items[0]["模式"] == "连续运行段" and len(families) >= 2 and evolution_n >= 2 and recovery_n >= 2 and amplitude_mean >= 2
- else "中" if items[0]["模式"] == "连续运行段" and len(families) >= 2 and evolution_n >= 2 and recovery_n >= 1
- else "短期线索" if items[0]["模式"] == "短运行段" and len(families) >= 2 and evolution_n >= 2 and recovery_n >= 1 and amplitude_mean >= 2
- else "跨段线索" if items[0]["模式"] == "跨运行段" and len(families) >= 2 and long_evolution_n >= 2 and amplitude_mean >= 2
- else "低",
- })
- candidates.sort(key=lambda row: (row["事件"], -row["候选评分"]))
- with candidate_output.open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(candidates[0]))
- writer.writeheader(); writer.writerows(candidates)
- bands = merge_bands(candidates)
- with band_output.open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(bands[0]))
- writer.writeheader(); writer.writerows(bands)
- repeats = []
- by_band = defaultdict(list)
- for row in candidates:
- if row["可信等级"] in ("高", "中", "短期线索", "跨段线索"):
- by_band[row["角度范围"]].append(row)
- for band, items in by_band.items():
- events = sorted({item["事件"] for item in items})
- if len(events) < 2:
- continue
- repeats.append({
- "角度范围": band, "事件数": len(events), "事件": ";".join(events),
- "测点": ";".join(sorted({item["测点"] for item in items})),
- "最高评分": max(item["候选评分"] for item in items),
- "共同特征": ";".join(item["指标摘要"] for item in sorted(items, key=lambda row: -row["候选评分"])[:4]),
- })
- repeats.sort(key=lambda row: (-row["事件数"], -row["最高评分"]))
- with repeat_output.open("w", encoding="utf-8-sig", newline="") as handle:
- fields = ["角度范围", "事件数", "事件", "测点", "最高评分", "共同特征"]
- writer = csv.DictWriter(handle, fieldnames=fields); writer.writeheader(); writer.writerows(repeats)
- return metric_candidates, candidates, repeats, bands
- def write_report(path: Path, metric_candidates, candidates, repeats, bands):
- lines = ["# 局部10度特征演化分析", "", "## 方法", "",
- "- 不使用整周期欧氏距离。每测点每天均匀抽取最多12个周期。",
- "- 每周期先平滑并按吸排气平台归一化,再在原始键相角度上划分36个10度箱。",
- "- 核心特征:局部中位压力、斜率、标准差、四分位距、导数RMS、最大二阶导。",
- "- 同时使用运行段早期基线和维修后稳定基线;逐日结果扣除全机同角度、同指标的公共变化。",
- "- 候选强调故障前定向演化、局部残差和独立维修后日期的反向恢复。",
- "- 幅度评估不增加原始特征:对支持指标的定向局部残差计算平均值、中位数和综合RMS。",
- "- 恢复分为事件后首个验证段的短期恢复,以及所有后续运行段都满足的长期稳定恢复。", ""]
- for event in EVENTS:
- rows = [row for row in candidates if row["事件"] == event["id"]]
- high = [row for row in rows if row["可信等级"] == "高"]
- medium = [row for row in rows if row["可信等级"] in ("中", "短期线索", "跨段线索")]
- lines += [f"## {event['label']}", "", f"- 分析模式:{event['mode']}。", f"- 高可信候选:{len(high)};其他可复核线索:{len(medium)}。"]
- if event["id"] == "7号_2025维修过程":
- lines.append("- 维修后基线仅2个日期且中间长期缺测,只能解释为跨段状态线索,不能证明连续故障前演化。")
- for index, row in enumerate((high + medium)[:12], 1):
- lines.append(
- f"{index}. {row['测点']} {row['角度范围']},{row['故障主方向']},评分{row['候选评分']:.1f},"
- f"{row['可信等级']}可信;幅度平均{row['局部幅度平均Z']:.1f}Z,综合RMS{row['局部幅度综合RMS']:.1f},"
- f"近期演化{row['演化指标数']}项,长期跨段演化{row['长期演化指标数']}项,"
- f"长期稳定恢复{row['长期稳定恢复指标数']}项;{row['指标摘要']}"
- )
- event_bands = [row for row in bands if row["事件"] == event["id"]]
- if event_bands:
- lines.append("- 相邻角度合并后的主要变化带:" + ";".join(
- f"{row['测点']} {row['角度带']}({row['故障主方向']},平均{row['局部幅度平均Z']:.1f}Z,RMS{row['局部幅度综合RMS']:.1f})"
- for row in event_bands[:10]
- ) + "。")
- if not high and not medium:
- lines.append("- 未找到同时满足局部残差、演化或维修后恢复条件的中高可信候选。")
- lines.append("")
- lines += ["## 跨事件重复角度", ""]
- if repeats:
- for row in repeats[:20]:
- lines.append(f"- {row['角度范围']}:{row['事件数']}个事件;{row['事件']};涉及{row['测点']}。")
- else:
- lines.append("- 暂无完全相同10度箱在至少两个事件中形成中高可信候选。")
- lines += ["", "## 限制", "", "- 角度为键相原始坐标,没有自动相位旋转;不同测点的物理过程区间可能不同。",
- "- 基线日期较少时稳健尺度仍可能不稳定,结果保留原始方向、日数和独立恢复验证供后续复核。",
- "- 当前候选是维修相关局部特征,不自动等于通用气阀故障规则。"]
- path.write_text("\n".join(lines) + "\n", encoding="utf-8")
- def main():
- parser = argparse.ArgumentParser(description="10度局部特征双基线演化分析")
- parser.add_argument("--out-dir", default="cache/indicator/局部演化分析")
- parser.add_argument("--per-day", type=int, default=12)
- parser.add_argument("--reuse", action="store_true", help="复用已抽取的逐周期CSV")
- args = parser.parse_args()
- out = Path(args.out_dir); out.mkdir(parents=True, exist_ok=True)
- cycle_path = out / "局部特征_逐周期.csv"
- grouped = load_extracted(cycle_path) if args.reuse and cycle_path.exists() else extract(cycle_path, args.per_day)
- metric, candidates, repeats, bands = analyze(
- grouped, out / "局部特征_逐日.csv", out / "局部演化候选.csv", out / "跨事件重复角度.csv",
- out / "局部演化角度带.csv", out / "局部特征_逐运行段.csv",
- )
- write_report(out / "局部演化结论.md", metric, candidates, repeats, bands)
- print(f"逐日组合 {len(grouped):,},单指标候选 {len(metric):,},角度候选 {len(candidates):,},角度带 {len(bands):,},重复角度 {len(repeats):,}")
- print(f"结果 -> {out}")
- if __name__ == "__main__":
- main()
|