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"""
{title}
"""
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()