| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- import csv
- import json
- import os
- import subprocess
- import sys
- from datetime import datetime, timedelta
- import pymysql
- from docx import Document
- from docx.shared import Inches
- sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "backend"))
- from app.db import get_connection # noqa: E402
- from app.services.data_service import DataService, PHASES # noqa: E402
- ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "故障分析包")
- ECHARTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend", "node_modules", "echarts", "dist", "echarts.min.js")
- CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
- SIDES = ("压力盖侧", "压力轴侧")
- DATASETS = [
- {
- "key": "6号机_20260422_气阀故障_一级3缸",
- "unit": "6",
- "device_code": "6#",
- "device_part": "6号机组三缸",
- "cylinder": "三缸(一级3缸)",
- "event_date": "2026-04-22",
- "fault_desc": "6#机一级3缸排气温度高",
- "pks_item": "YSJ6_22",
- "pks_col": "YSJ_22",
- "pks_desc": "6#压缩机一级3#缸排气温度",
- "thr_high": 115.0,
- "thr_highhigh": 125.0,
- "raw_dir": r"E:\RAW\exp_6#_2026.1.1-2026.7.31_20260101000000_20260731235959",
- "segments": {
- "低": ("2026-04-12 13:44:00", "2026-04-12 15:31:00"),
- "高": ("2026-04-20 11:59:00", "2026-04-20 13:46:00"),
- "修复后": ("2026-04-25 09:59:00", "2026-04-25 11:46:00"),
- },
- },
- {
- "key": "4号机_20260610_气阀故障_一级2缸",
- "unit": "4",
- "device_code": "4#",
- "device_part": "4号机组二缸",
- "cylinder": "二缸(一级2缸)",
- "event_date": "2026-06-10",
- "fault_desc": "4#压缩机一级2#3#缸温度异常",
- "pks_item": "YSJ4_10",
- "pks_col": "YSJ_10",
- "pks_desc": "4#压缩机一级2#缸排气温度",
- "thr_high": 130.0,
- "thr_highhigh": 140.0,
- "raw_dir": r"E:\RAW\exp_4#_2026.1.1-2026.7.31_20260101000000_20260731235959",
- "segments": {
- "低": ("2026-06-08 01:59:00", "2026-06-08 03:46:00"),
- "高": ("2026-06-10 05:59:00", "2026-06-10 07:46:00"),
- "修复后": ("2026-06-11 18:59:00", "2026-06-11 20:46:00"),
- },
- },
- {
- "key": "4号机_20260610_气阀故障_一级3缸",
- "unit": "4",
- "device_code": "4#",
- "device_part": "4号机组三缸",
- "cylinder": "三缸(一级3缸)",
- "event_date": "2026-06-10",
- "fault_desc": "4#压缩机一级2#3#缸温度异常",
- "pks_item": "YSJ4_11",
- "pks_col": "YSJ_11",
- "pks_desc": "4#压缩机一级3#缸排气温度",
- "thr_high": 130.0,
- "thr_highhigh": 140.0,
- "raw_dir": r"E:\RAW\exp_4#_2026.1.1-2026.7.31_20260101000000_20260731235959",
- "segments": {
- "低": ("2026-06-08 01:59:00", "2026-06-08 03:46:00"),
- "高": ("2026-06-10 05:59:00", "2026-06-10 07:46:00"),
- "修复后": ("2026-06-11 18:59:00", "2026-06-11 20:46:00"),
- },
- },
- ]
- SVC = DataService()
- COPY_LIST = []
- def ensure_dirs(ds):
- base = os.path.join(ROOT, ds["key"])
- subs = {name: os.path.join(base, name) for name in
- ("01_原始首周期数据", "02_PKS温度数据", "03_功图", "04_曲线图")}
- for path in subs.values():
- os.makedirs(path, exist_ok=True)
- return base, subs
- def query_wave_files(device_part, side, start, end):
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- "SELECT id, sample_time, file_name, rpm, cycle_start, cycle_end "
- "FROM wave_file WHERE device_part=%s AND device_point=%s "
- "AND sample_time>=%s AND sample_time<%s ORDER BY sample_time LIMIT 8",
- (device_part, side, start, end),
- )
- return cursor.fetchall()
- def query_pks(device_code, column, start, end):
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- f"SELECT sample_time, `{column}` AS value FROM pks_long_sample "
- "WHERE device_code=%s AND sample_time>=%s AND sample_time<%s ORDER BY sample_time",
- (device_code, start, end),
- )
- return cursor.fetchall()
- def write_first_cycle_csv(path, samples):
- with open(path, "w", newline="", encoding="utf-8-sig") as handle:
- writer = csv.writer(handle)
- writer.writerow(["sample_index", "signal_value", "second_value"])
- for row in samples:
- writer.writerow([int(row[0]), float(row[1]), "" if row[2] != row[2] else float(row[2])])
- def write_pks_csv(path, rows):
- with open(path, "w", newline="", encoding="utf-8-sig") as handle:
- writer = csv.writer(handle)
- writer.writerow(["sample_time", "value"])
- for row in rows:
- writer.writerow([row["sample_time"].strftime("%Y-%m-%d %H:%M:%S"), row["value"]])
- def screenshot(html_path, png_path):
- subprocess.run(
- [CHROME, "--headless=new", "--disable-gpu", "--hide-scrollbars",
- "--window-size=1180,660", "--force-device-scale-factor=2",
- "--virtual-time-budget=2000", f"--screenshot={png_path}",
- "file://" + html_path],
- check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- )
- def render_html(html_path, option, title):
- html = f"""<!doctype html><html><head><meta charset="utf-8"><title>{title}</title>
- <style>html,body{{margin:0;background:#fff}}#c{{width:1160px;height:620px}}</style>
- <script src="../../echarts.min.js"></script></head>
- <body><div id="c"></div><script>var chart=echarts.init(document.getElementById('c'));
- chart.setOption({json.dumps(option, ensure_ascii=False)});</script></body></html>"""
- with open(html_path, "w", encoding="utf-8") as handle:
- handle.write(html)
- def pv_option(detail, side, title):
- volume = detail["volume"]
- series = []
- for name, color, start, end in PHASES:
- last = name == PHASES[-1][0]
- data = []
- for index, angle in enumerate(detail["angles360"]):
- x = volume[index]
- if x is None:
- continue
- in_phase = angle >= start if last else (start <= angle < end)
- if in_phase:
- data.append([x, detail["pressure"][index]])
- series.append({"name": name, "type": "line", "showSymbol": False,
- "lineStyle": {"color": color, "width": 2}, "itemStyle": {"color": color}, "data": data})
- return {
- "title": {"text": title, "left": "center", "textStyle": {"fontSize": 14}},
- "animation": False,
- "grid": {"left": 70, "right": 40, "top": 50, "bottom": 60},
- "legend": {"top": 22, "textStyle": {"fontSize": 12}},
- "tooltip": {"trigger": "axis"},
- "xAxis": {"type": "value", "name": "气缸体积 (L)", "nameLocation": "middle", "nameGap": 30},
- "yAxis": {"type": "value", "name": "压力原始值", "nameLocation": "middle", "nameGap": 50},
- "series": series,
- }
- def ptheta_option(detail, side, title):
- series = []
- for name, color, start, end in PHASES:
- last = name == PHASES[-1][0]
- data = []
- for index, angle360 in enumerate(detail["angles360"]):
- in_phase = angle360 >= start if last else (start <= angle360 < end)
- if in_phase:
- data.append([detail["angles"][index], detail["pressure"][index]])
- series.append({"name": name, "type": "line", "showSymbol": False,
- "lineStyle": {"color": color, "width": 2}, "itemStyle": {"color": color}, "data": data})
- return {
- "title": {"text": title, "left": "center", "textStyle": {"fontSize": 14}},
- "animation": False,
- "grid": {"left": 70, "right": 40, "top": 50, "bottom": 60},
- "legend": {"top": 22, "textStyle": {"fontSize": 12}},
- "tooltip": {"trigger": "axis"},
- "xAxis": {"type": "value", "name": "曲轴角度 (°)", "nameLocation": "middle", "nameGap": 30, "min": 0, "max": 180},
- "yAxis": {"type": "value", "name": "压力原始值", "nameLocation": "middle", "nameGap": 50},
- "series": series,
- }
- def pressure_overlay_option(series_map, title):
- series = []
- colors = {"压力盖侧": "#e05252", "压力轴侧": "#287f9e"}
- for side, values in series_map.items():
- series.append({"name": side, "type": "line", "showSymbol": False,
- "lineStyle": {"color": colors[side], "width": 1.6},
- "itemStyle": {"color": colors[side]}, "data": values})
- return {
- "title": {"text": title, "left": "center", "textStyle": {"fontSize": 14}},
- "animation": False,
- "grid": {"left": 70, "right": 40, "top": 50, "bottom": 60},
- "legend": {"top": 22, "textStyle": {"fontSize": 12}},
- "tooltip": {"trigger": "axis"},
- "xAxis": {"type": "value", "name": "首周期采样点序号", "nameLocation": "middle", "nameGap": 30},
- "yAxis": {"type": "value", "name": "压力原始值", "nameLocation": "middle", "nameGap": 50},
- "series": series,
- }
- def temp_option(times, values, title, thr_high, thr_highhigh):
- data = [[time, value] for time, value in zip(times, values) if value is not None]
- return {
- "title": {"text": title, "left": "center", "textStyle": {"fontSize": 14}},
- "animation": False,
- "grid": {"left": 70, "right": 60, "top": 50, "bottom": 60},
- "tooltip": {"trigger": "axis"},
- "xAxis": {"type": "time", "axisLabel": {"fontSize": 10, "hideOverlap": True},
- "name": "时间", "nameLocation": "middle", "nameGap": 30},
- "yAxis": {"type": "value", "name": "温度 (℃)", "nameLocation": "middle", "nameGap": 50},
- "series": [{
- "name": "温度", "type": "line", "showSymbol": False,
- "lineStyle": {"color": "#c62828", "width": 1.6}, "itemStyle": {"color": "#c62828"},
- "data": data,
- "markLine": {"silent": True, "symbol": "none", "data": [
- {"yAxis": thr_high, "label": {"formatter": f"高报 {thr_high:g}"}, "lineStyle": {"color": "#e6a23c"}},
- {"yAxis": thr_highhigh, "label": {"formatter": f"高高报 {thr_highhigh:g}"}, "lineStyle": {"color": "#f56c6c"}},
- ]},
- }],
- }
- def temp_compare_option(series_list, title):
- series = []
- colors = {"低": "#409eff", "高": "#f56c6c", "修复后": "#67c23a"}
- for segment, values in series_list:
- series.append({"name": segment, "type": "line", "showSymbol": False,
- "lineStyle": {"color": colors[segment], "width": 2},
- "itemStyle": {"color": colors[segment]}, "data": values})
- return {
- "title": {"text": title, "left": "center", "textStyle": {"fontSize": 14}},
- "animation": False,
- "grid": {"left": 70, "right": 40, "top": 50, "bottom": 60},
- "legend": {"top": 22, "textStyle": {"fontSize": 12}},
- "tooltip": {"trigger": "axis"},
- "xAxis": {"type": "category", "data": [f"{m}" for m in range(0, 121, 5)], "name": "段内分钟"},
- "yAxis": {"type": "value", "name": "温度 (℃)", "nameLocation": "middle", "nameGap": 50},
- "series": series,
- }
- def process_dataset(ds):
- base, subs = ensure_dirs(ds)
- temp_windows = {}
- for segment, (start, end) in ds["segments"].items():
- tag = f"{ds['unit']}号机_{ds['cylinder'].split('(')[0]}_{segment}"
- first_times = None
- last_times = None
- overlay_map = {}
- for side in SIDES:
- rows = query_wave_files(ds["device_part"], side, start, end)
- if not rows:
- print(f"WARN no wave files: {ds['key']} {segment} {side}")
- continue
- side_tag = "盖侧" if side == "压力盖侧" else "轴侧"
- if first_times is None:
- first_times = rows[0]["sample_time"]
- last_times = rows[-1]["sample_time"]
- overlay_values = []
- for row in rows:
- meta, samples = SVC._load_db_wave(int(row["id"]))
- time_label = row["sample_time"].strftime("%Y%m%d%H%M%S")
- csv_name = f"提取首周期_{tag}_{side_tag}_{time_label}.csv"
- write_first_cycle_csv(os.path.join(subs["01_原始首周期数据"], csv_name), samples)
- detail = DataService._build_period_detail(meta, samples, 1)
- if side == "压力盖侧":
- overlay_values = [[i, float(v)] for i, v in enumerate(samples[:, 1])]
- pv = pv_option(detail, side, f"{ds['device_part']} {segment} {side} P-V 功图 ({row['sample_time']:%Y-%m-%d %H:%M})")
- pt = ptheta_option(detail, side, f"{ds['device_part']} {segment} {side} 压力-角度图 ({row['sample_time']:%Y-%m-%d %H:%M})")
- pv_html = os.path.join(subs["03_功图"], f"PV_{tag}_{side_tag}_{time_label}.html")
- pt_html = os.path.join(subs["03_功图"], f"Ptheta_{tag}_{side_tag}_{time_label}.html")
- render_html(pv_html, pv, "PV")
- render_html(pt_html, pt, "Ptheta")
- screenshot(pv_html, pv_html.replace(".html", ".png"))
- screenshot(pt_html, pt_html.replace(".html", ".png"))
- os.remove(pv_html)
- os.remove(pt_html)
- if side == "压力盖侧":
- overlay_map["压力盖侧"] = overlay_values
- else:
- overlay_map["压力轴侧"] = [[i, float(v)] for i, v in enumerate(samples[:, 1])]
- COPY_LIST.append({
- "数据集": ds["key"], "段": segment, "机组": f"{ds['unit']}号机",
- "缸": ds["cylinder"], "测点": side, "wave_file_id": int(row["id"]),
- "device_part": ds["device_part"], "device_point": side,
- "sample_time": row["sample_time"].strftime("%Y-%m-%d %H:%M:%S"),
- "原始文件相对路径": row["file_name"],
- "原始目录": ds["raw_dir"],
- "完整路径": ds["raw_dir"] + "\\" + str(row["file_name"]),
- })
- if first_times and last_times:
- temp_start = first_times
- temp_end = last_times + timedelta(minutes=15)
- temp_rows = query_pks(ds["device_code"], ds["pks_col"], temp_start, temp_end)
- temp_csv = os.path.join(subs["02_PKS温度数据"], f"PKS温度_{tag}_{temp_start:%Y%m%d%H%M}-{temp_end:%Y%m%d%H%M}.csv")
- write_pks_csv(temp_csv, temp_rows)
- times = [r["sample_time"].strftime("%Y-%m-%d %H:%M:%S") for r in temp_rows]
- values = [None if r["value"] is None else round(float(r["value"]), 3) for r in temp_rows]
- temp_option_json = temp_option(times, values, f"{ds['device_part']} {segment} {ds['pks_desc']}", ds["thr_high"], ds["thr_highhigh"])
- html = os.path.join(subs["04_曲线图"], f"温度曲线_{tag}.html")
- render_html(html, temp_option_json, "temp")
- screenshot(html, html.replace(".html", ".png"))
- os.remove(html)
- step = max(1, len(values) // 24)
- temp_windows[segment] = values[::step][:25]
- if overlay_map:
- overlay_html = os.path.join(subs["04_曲线图"], f"首周期压力叠图_{tag}.html")
- render_html(overlay_html, pressure_overlay_option(overlay_map, f"{ds['device_part']} {segment} 首周期压力(盖侧/轴侧)"), "overlay")
- screenshot(overlay_html, overlay_html.replace(".html", ".png"))
- os.remove(overlay_html)
- if len(temp_windows) == 3:
- compare = temp_compare_option(
- [(seg, temp_windows[seg]) for seg in ("低", "高", "修复后")],
- f"{ds['device_part']} {ds['pks_desc']} 三段对比",
- )
- html = os.path.join(subs["04_曲线图"], f"温度三段对比_{ds['key']}.html")
- render_html(html, compare, "compare")
- screenshot(html, html.replace(".html", ".png"))
- os.remove(html)
- return base
- def build_word(bases):
- document = Document()
- document.add_heading("压缩机气阀故障分析数据包说明", 0)
- document.add_paragraph(
- "本数据包围绕两起气阀故障(6号机 2026-04-22 一级3缸排气温度高;"
- "4号机 2026-06-10 一级2#3#缸温度异常)整理,覆盖 3 个气缸数据集。"
- "每个数据集按“温度低 / 温度高 / 停机修复后”三段各取约 2 小时(8 个首周期)的"
- "压力盖侧与压力轴侧数据,并配套 PKS 温度数据、功图与曲线图。"
- )
- document.add_heading("一、目录结构", level=1)
- document.add_paragraph("每个数据集文件夹包含 4 个子目录:")
- for name, desc in [
- ("01_原始首周期数据", "从数据库 wave_sample_one 导出的首周期采样(sample_index/signal_value/second_value)。原始 CSV 需按《原始文件拷贝清单.csv》从线下目录复制到此。"),
- ("02_PKS温度数据", "该段 2 小时窗口内的 PKS 温度原始数据(5 秒采样,DCS 系统)。"),
- ("03_功图", "每个首周期的压力-体积功图(P-V)与压力-角度图(P-θ),盖侧/轴侧分别成图,一幅图一个文件。"),
- ("04_曲线图", "PKS 温度曲线(含报警阈值线)、三段温度对比、首周期压力盖侧/轴侧叠图。"),
- ]:
- document.add_paragraph(f"{name}:{desc}", style="List Bullet")
- document.add_heading("二、数据集与时间窗", level=1)
- for ds in DATASETS:
- document.add_heading(f"{ds['key']}({ds['device_part']})", level=2)
- document.add_paragraph(f"故障:{ds['fault_desc']}({ds['event_date']})")
- document.add_paragraph(f"PKS 温度点:{ds['pks_item']} {ds['pks_desc']};报警阈值:高报 {ds['thr_high']:g}℃ / 高高报 {ds['thr_highhigh']:g}℃")
- table = document.add_table(rows=1, cols=3)
- table.style = "Light Grid Accent 1"
- header = table.rows[0].cells
- header[0].text, header[1].text, header[2].text = "段", "起始", "结束"
- for segment, (start, end) in ds["segments"].items():
- cells = table.add_row().cells
- cells[0].text, cells[1].text, cells[2].text = segment, start, end
- document.add_heading("三、文件命名规则", level=1)
- for line in [
- "提取首周期_<机组>_<缸>_<段>_<盖侧|轴侧>_<时间YYYYMMDDHHMMSS>.csv",
- "PKS温度_<机组>_<缸>_<段>_<起>-<止>.csv",
- "PV_<机组>_<缸>_<段>_<盖侧|轴侧>_<时间>.png / Ptheta_同.png",
- "温度曲线_<机组>_<缸>_<段>.png / 温度三段对比_<数据集>.png / 首周期压力叠图_<机组>_<缸>_<段>.png",
- ]:
- document.add_paragraph(line, style="List Bullet")
- document.add_heading("四、CSV 字段说明", level=1)
- def add_field_table(title, fields):
- document.add_paragraph(title, style="List Bullet")
- table = document.add_table(rows=1, cols=2)
- table.style = "Light Grid Accent 1"
- table.rows[0].cells[0].text = "字段"
- table.rows[0].cells[1].text = "说明"
- for name, desc in fields:
- cells = table.add_row().cells
- cells[0].text, cells[1].text = name, desc
- add_field_table("01_原始首周期数据/提取首周期_*.csv(每个波形文件该周期内的原始采样)", [
- ("sample_index", "该波形文件内的原始采样点序号(从 0 开始,连续)。"),
- ("signal_value", "传感器信号原始值(压力盖侧/压力轴侧的压力值,未做标定换算)。"),
- ("second_value", "键相信号(二值与/或多值),用于识别每转脉冲与周期起点,>=30 视为脉冲触发。"),
- ])
- add_field_table("02_PKS温度数据/PKS温度_*.csv(该段 2 小时窗口)", [
- ("sample_time", "PKS 温度采样时刻,5 秒一条。"),
- ("value", "对应温度测点(见各数据集说明)的原始值,单位 ℃。"),
- ])
- add_field_table("原始文件拷贝清单.csv(供复制线下原始波形文件)", [
- ("数据集", "所属数据集(机组_日期_故障_缸)。"),
- ("段", "低 / 高 / 修复后。"),
- ("机组", "4号机 / 6号机。"),
- ("缸", "对应气缸(如 三缸(一级3缸))。"),
- ("测点", "压力盖侧 / 压力轴侧。"),
- ("wave_file_id", "数据库 wave_file 主键 id。"),
- ("device_part", "机组与部位,如 6号机组三缸。"),
- ("device_point", "测点,压力盖侧 / 压力轴侧。"),
- ("sample_time", "该波形文件的采样时刻。"),
- ("原始文件相对路径", "wave_file.file_name(相对路径)。"),
- ("原始目录", "该批次导入目录(compressor_data_import.directory_path)。"),
- ("完整路径", "原始目录 + 相对路径,可直接用于拷贝。"),
- ])
- for base in bases:
- document.add_heading(f"五、文件清单:{os.path.basename(base)}", level=1)
- for sub in ("01_原始首周期数据", "02_PKS温度数据", "03_功图", "04_曲线图"):
- folder = os.path.join(base, sub)
- files = sorted(os.listdir(folder)) if os.path.isdir(folder) else []
- document.add_paragraph(f"{sub}({len(files)} 个文件)", style="List Bullet")
- doc_path = os.path.join(ROOT, "故障分析说明.docx")
- document.save(doc_path)
- return doc_path
- def main():
- os.makedirs(ROOT, exist_ok=True)
- with open(ECHARTS, "rb") as src, open(os.path.join(ROOT, "echarts.min.js"), "wb") as dst:
- dst.write(src.read())
- bases = []
- for ds in DATASETS:
- print("== processing", ds["key"])
- bases.append(process_dataset(ds))
- if COPY_LIST:
- list_path = os.path.join(ROOT, "原始文件拷贝清单.csv")
- with open(list_path, "w", newline="", encoding="utf-8-sig") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(COPY_LIST[0].keys()))
- writer.writeheader()
- writer.writerows(COPY_LIST)
- print("copy list rows:", len(COPY_LIST))
- build_word(bases)
- print("DONE ->", ROOT)
- if __name__ == "__main__":
- main()
|