Kaynağa Gözat

Add pretraining sample pipeline and TSPulse fingerprint model

- Fix cycle detection zero-marker threshold with Otsu method
- Add pretrain CSV record/delete endpoints and frontend toggle
- Expose pointName/rpm/fileName in wave-file metadata
- Add LabelingPreTraining pipeline: sample generation/validation,
  TSPulse transformer autoencoder training, per-part baseline table,
  and capability validation report
18922397810 1 hafta önce
ebeveyn
işleme
cbeb0556cb

+ 2 - 0
.gitignore

@@ -11,3 +11,5 @@ frontend/dist/
 *.bak
 数据库.md
 web 账号.txt
+预训练之文件头.csv
+backend/LabelingPreTraining/inputdatas/

BIN
backend/LabelingPreTraining/baseline/baseline.npz


+ 36 - 0
backend/LabelingPreTraining/baseline/baseline_summary.csv

@@ -0,0 +1,36 @@
+part,count,radius
+7号机组一缸压力盖侧_压力,84,0.126219
+7号机组一缸压力轴侧_压力,84,0.159425
+7号机组三缸压力盖侧_压力,82,0.164711
+7号机组三缸压力轴侧_压力,84,0.132081
+7号机组二缸压力盖侧_压力,82,0.137495
+7号机组二缸压力轴侧_压力,82,0.133279
+7号机组五缸压力盖侧_压力,84,0.261120
+7号机组五缸压力轴侧_压力,83,0.301723
+7号机组六缸压力盖侧_压力,83,0.124256
+7号机组六缸压力轴侧_压力,83,0.204148
+7号机组四缸压力盖侧_压力,84,0.129498
+7号机组四缸压力轴侧_压力,82,0.112543
+8号机组一缸压力盖侧_压力,84,0.299309
+8号机组一缸压力轴侧_压力,84,0.174872
+8号机组三缸压力盖侧_压力,82,0.089240
+8号机组三缸压力轴侧_压力,82,0.104497
+8号机组二缸压力盖侧_压力,82,0.085525
+8号机组二缸压力轴侧_压力,82,0.087815
+8号机组五缸压力盖侧_压力,82,0.106637
+8号机组五缸压力轴侧_压力,82,0.123400
+8号机组六缸压力轴侧_压力,83,0.171220
+8号机组四缸压力盖侧_压力,84,0.192413
+8号机组四缸压力轴侧_压力,82,0.153695
+9号机组一缸压力盖侧_压力,82,0.229602
+9号机组一缸压力轴侧_压力,84,0.304303
+9号机组三缸压力盖侧_压力,82,0.141863
+9号机组三缸压力轴侧_压力,82,0.149451
+9号机组二缸压力盖侧_压力,82,0.081894
+9号机组二缸压力轴侧_压力,82,0.119654
+9号机组五缸压力盖侧_压力,84,0.144385
+9号机组五缸压力轴侧_压力,82,0.145575
+9号机组六缸压力盖侧_压力,84,0.103386
+9号机组六缸压力轴侧_压力,83,0.111798
+9号机组四缸压力盖侧_压力,83,0.151684
+9号机组四缸压力轴侧_压力,84,0.117607

+ 49 - 0
backend/LabelingPreTraining/baseline/validation_report.txt

@@ -0,0 +1,49 @@
+TSPulse 模型能力验证报告
+========================================
+设备: Apple MPS
+评估集样本数: 140
+
+[1] 损失收敛曲线 (epoch: train / val)
+  epoch   1: train 0.49377 / val 0.33041
+  epoch   2: train 0.35256 / val 0.32077
+  epoch   3: train 0.34754 / val 0.31910
+  epoch   4: train 0.34129 / val 0.31998
+  epoch   5: train 0.33505 / val 0.29960
+  epoch   6: train 0.29839 / val 0.26043
+  epoch   7: train 0.27106 / val 0.23832
+  epoch   8: train 0.24574 / val 0.21504
+  epoch   9: train 0.21309 / val 0.17202
+  epoch  10: train 0.17596 / val 0.14037
+  epoch  11: train 0.14785 / val 0.11632
+  epoch  12: train 0.11950 / val 0.09108
+  epoch  13: train 0.10143 / val 0.07853
+  epoch  14: train 0.09263 / val 0.07861
+  epoch  15: train 0.08870 / val 0.07181
+  epoch  16: train 0.08490 / val 0.07039
+  epoch  17: train 0.08113 / val 0.06641
+  epoch  18: train 0.07840 / val 0.06707
+  epoch  19: train 0.07561 / val 0.06043
+  epoch  20: train 0.07447 / val 0.06288
+  epoch  21: train 0.07330 / val 0.05902
+  epoch  22: train 0.07147 / val 0.06108
+  epoch  23: train 0.07110 / val 0.05982
+  epoch  24: train 0.06957 / val 0.05904
+  epoch  25: train 0.06943 / val 0.05856
+  epoch  26: train 0.06864 / val 0.05805
+  epoch  27: train 0.06786 / val 0.05800
+  epoch  28: train 0.06890 / val 0.05940
+  epoch  29: train 0.06792 / val 0.05725
+
+[3] 评估集重构 MSE: 0.05667
+
+[4] 指纹健康检查
+  向量形状: (140, 128)
+  全部有限值: True
+  128 维中退化维数(std<1e-6): 0
+
+[2] 指纹区分度
+  平均同部位距离: 0.160397
+  平均跨部位距离: 4.646139
+  同部位/跨部位比值: 0.0345 (应明显 < 1)
+
+结论: 通过

+ 115 - 0
backend/LabelingPreTraining/build_baseline.py

@@ -0,0 +1,115 @@
+"""Step 2: build the per-part baseline table.
+
+Loads the frozen TSPulse checkpoint, encodes every normal cycle sample, and
+for each part computes the centroid (mean of its 128-dim fingerprints) and the
+radius (max distance from the centroid). Results are saved as a single npz.
+
+Output baseline/baseline.npz:
+    part_names  : (P,)    string array of part names
+    centroids   : (P,128) float32 centroid vectors
+    radii       : (P,)    float32 max-distance radii
+    counts      : (P,)    number of samples used per part
+"""
+
+import argparse
+import csv
+from pathlib import Path
+
+import numpy as np
+import torch
+from torch.utils.data import DataLoader
+from tqdm import tqdm
+
+from tspulse import CycleDataset, TSPulse, build_split_datasets, describe, get_device
+
+ROOT = Path(__file__).resolve().parent
+CHECKPOINT_PATH = ROOT / "checkpoints" / "tspulse_frozen.pt"
+BASELINE_DIR = ROOT / "baseline"
+BASELINE_PATH = BASELINE_DIR / "baseline.npz"
+
+
+def load_model(checkpoint: dict, device: torch.device) -> TSPulse:
+    config = checkpoint["config"]
+    model = TSPulse(dim=config["dim"], depth=config["depth"], heads=config["heads"]).to(device)
+    model.load_state_dict(checkpoint["state_dict"])
+    model.eval()
+    return model
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--batch-size", type=int, default=128)
+    args = parser.parse_args()
+
+    if not CHECKPOINT_PATH.exists():
+        print(f"未找到模型 {CHECKPOINT_PATH}, 请先运行 tspulse/train.py")
+        return 1
+
+    device = get_device()
+    print(f"设备: {describe(device)}")
+
+    checkpoint = torch.load(CHECKPOINT_PATH, map_location="cpu", weights_only=False)
+    mean = checkpoint["mean"]
+    std = checkpoint["std"]
+    model = load_model(checkpoint, device)
+
+    samples, _, _ = build_split_datasets()
+
+    parts = sorted({item["part"] for item in samples})
+    part_index = {part: i for i, part in enumerate(parts)}
+    centroids = np.zeros((len(parts), model.dim), dtype=np.float32)
+    radii = np.zeros(len(parts), dtype=np.float32)
+    counts = np.zeros(len(parts), dtype=np.int64)
+
+    all_set = CycleDataset(samples, mean, std)
+    loader = DataLoader(all_set, batch_size=args.batch_size, shuffle=False)
+
+    fingerprints: list[np.ndarray] = []
+    part_of: list[str] = []
+    with torch.no_grad():
+        for batch, _lengths in tqdm(loader, desc="编码所有样本"):
+            batch = batch.to(device)
+            vectors, _ = model(batch)
+            fingerprints.append(vectors.cpu().numpy())
+        # DataLoader without shuffle preserves order for non-drop_last batches.
+        ordered = sorted(samples, key=lambda item: item["path"])
+        part_of = [item["part"] for item in ordered]
+
+    all_vectors = np.concatenate(fingerprints, axis=0)
+    by_part: dict[str, list[np.ndarray]] = {part: [] for part in parts}
+    for vector, part in zip(all_vectors, part_of):
+        by_part[part].append(vector)
+
+    for part in tqdm(parts, desc="计算质心与半径"):
+        vectors = np.stack(by_part[part])
+        centroid = vectors.mean(axis=0)
+        distances = np.linalg.norm(vectors - centroid, axis=1)
+        index = part_index[part]
+        centroids[index] = centroid
+        radii[index] = float(distances.max())
+        counts[index] = len(vectors)
+
+    BASELINE_DIR.mkdir(parents=True, exist_ok=True)
+    np.savez(
+        BASELINE_PATH,
+        part_names=np.asarray(parts),
+        centroids=centroids,
+        radii=radii,
+        counts=counts,
+    )
+    print(f"基准表已保存: {BASELINE_PATH}")
+
+    with (BASELINE_DIR / "baseline_summary.csv").open("w", encoding="utf-8", newline="") as handle:
+        writer = csv.writer(handle)
+        writer.writerow(["part", "count", "radius"])
+        for part, count, radius in zip(parts, counts, radii):
+            writer.writerow([part, int(count), f"{radius:.6f}"])
+
+    print("\n部位 / 样本数 / 半径:")
+    for part, count, radius in zip(parts, counts, radii):
+        print(f"  {part}: {count} 组, 半径 {radius:.4f}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

BIN
backend/LabelingPreTraining/checkpoints/tspulse_frozen.pt


+ 190 - 0
backend/LabelingPreTraining/generate_samples.py

@@ -0,0 +1,190 @@
+"""Generate per-cycle pretraining samples from the wave database.
+
+Reads 预训练之文件头.csv, groups the files by (point_name, measurement_type),
+and for every wave file runs the project's cycle detector. Each detected cycle
+is saved as one .npy matrix with columns
+    [signal_value, second_value, 1, 1]
+in time order. The file name is
+    {wave_file_id}_{cycle_number}_{start_sample_index}_{end_sample_index_exclusive}.npy
+"""
+
+import csv
+import sys
+import traceback
+from collections import defaultdict
+from pathlib import Path
+
+import numpy as np
+from tqdm import tqdm
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from app.algorithms.cycles import detect_cycles  # noqa: E402
+from app.db import get_connection  # noqa: E402
+
+HERE = Path(__file__).resolve().parent
+CSV_PATH = HERE / "预训练之文件头.csv"
+OUTPUT_DIR = HERE / "inputdatas"
+MANIFEST_PATH = HERE / "manifest.csv"
+
+
+def load_file_rows() -> list[dict[str, str]]:
+    with CSV_PATH.open(encoding="utf-8-sig") as handle:
+        return list(csv.DictReader(handle))
+
+
+def read_wave_samples(connection, file_id: int) -> np.ndarray:
+    with connection.cursor() as cursor:
+        cursor.execute(
+            """
+            SELECT sample_index, signal_value, second_value
+            FROM wave_sample
+            WHERE wave_file_id = %s
+            ORDER BY wave_file_id, sample_index
+            """,
+            (file_id,),
+        )
+        rows = cursor.fetchall()
+    return np.asarray(
+        [
+            (
+                float(row["sample_index"]),
+                float(row["signal_value"]),
+                float(row["second_value"]) if row["second_value"] is not None else np.nan,
+            )
+            for row in rows
+        ],
+        dtype=float,
+    )
+
+
+def build_cycle_matrix(samples: np.ndarray, start: int, end: int) -> np.ndarray:
+    signal = samples[start:end, 1]
+    second = samples[start:end, 2]
+    count = end - start
+    return np.column_stack([signal, second, np.ones(count), np.ones(count)])
+
+
+def cycle_sample_bounds(samples: np.ndarray, start_offset: int, end_offset: int) -> tuple[int, int]:
+    start_sample_index = int(samples[start_offset, 0])
+    if end_offset < len(samples):
+        end_exclusive = int(samples[end_offset, 0])
+    else:
+        end_exclusive = int(samples[-1, 0]) + 1
+    return start_sample_index, end_exclusive
+
+
+def main() -> int:
+    rows = load_file_rows()
+    if not rows:
+        print("CSV 为空,退出")
+        return 1
+
+    groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+    for row in rows:
+        groups[(row["point_name"], row["measurement_type"])].append(row)
+
+    manifest: list[dict[str, object]] = []
+    total_cycles = 0
+    connection = get_connection()
+    try:
+        group_progress = tqdm(sorted(groups.items()), desc="分组处理", unit="组")
+        for group_index, (group_key, group_rows) in enumerate(group_progress, start=1):
+            point_name, measurement_type = group_key
+            folder = OUTPUT_DIR / f"{point_name}_{measurement_type}"
+            folder.mkdir(parents=True, exist_ok=True)
+            group_progress.set_postfix(group=f"{point_name}_{measurement_type}")
+
+            for row in tqdm(
+                sorted(group_rows, key=lambda item: int(item["id"])),
+                desc=f"  {point_name}_{measurement_type}",
+                unit="文件",
+                leave=False,
+            ):
+                file_id = int(row["id"])
+                manifest_row: dict[str, object] = {
+                    "group": f"{point_name}_{measurement_type}",
+                    "point_name": point_name,
+                    "measurement_type": measurement_type,
+                    "wave_file_id": file_id,
+                    "rpm": row["rpm"],
+                    "sample_time": row["sample_time"],
+                    "cycle_count": 0,
+                    "total_samples": 0,
+                    "start_sample_index": "",
+                    "end_sample_index": "",
+                    "error": "",
+                }
+                try:
+                    samples = read_wave_samples(connection, file_id)
+                    if len(samples) == 0:
+                        raise ValueError("没有采样数据")
+                    cycles, diagnostics = detect_cycles(samples)
+                    manifest_row["cycle_count"] = len(cycles)
+                    saved = 0
+                    total_cycle_rows = 0
+                    first_start = ""
+                    last_end = ""
+                    for cycle in cycles:
+                        start_sample_index, end_exclusive = cycle_sample_bounds(
+                            samples, cycle.start_offset, cycle.end_offset
+                        )
+                        if not first_start:
+                            first_start = str(start_sample_index)
+                        last_end = str(end_exclusive)
+                        matrix = build_cycle_matrix(
+                            samples, cycle.start_offset, cycle.end_offset
+                        )
+                        if not np.all(np.isfinite(matrix[:, :2])):
+                            raise ValueError("周期内存在非有限值")
+                        path = folder / f"{file_id}_{cycle.number}_{start_sample_index}_{end_exclusive}.npy"
+                        np.save(path, matrix)
+                        saved += 1
+                        total_cycle_rows += len(matrix)
+                    total_cycles += saved
+                    manifest_row["cycle_count"] = saved
+                    manifest_row["total_samples"] = total_cycle_rows
+                    manifest_row["start_sample_index"] = first_start
+                    manifest_row["end_sample_index"] = last_end
+                    if saved == 0:
+                        manifest_row["error"] = (
+                            f"未检出完整周期 (触发脉冲 {diagnostics.get('triggerRunCount', '?')} 个, "
+                            f"零标记 {diagnostics.get('zeroMarkerCount', '?')} 个)"
+                        )
+                except Exception as error:
+                    manifest_row["error"] = f"{error}: {traceback.format_exc(limit=1).strip()}"
+                manifest.append(manifest_row)
+                if manifest_row["error"]:
+                    print(f"  id={file_id} 失败: {manifest_row['error'][:160]}")
+    finally:
+        connection.close()
+
+    with MANIFEST_PATH.open("w", encoding="utf-8-sig", newline="") as handle:
+        writer = csv.DictWriter(
+            handle,
+            fieldnames=[
+                "group",
+                "point_name",
+                "measurement_type",
+                "wave_file_id",
+                "rpm",
+                "sample_time",
+                "cycle_count",
+                "total_samples",
+                "start_sample_index",
+                "end_sample_index",
+                "error",
+            ],
+        )
+        writer.writeheader()
+        writer.writerows(manifest)
+
+    ok = sum(1 for item in manifest if not item["error"])
+    failed = sum(1 for item in manifest if item["error"])
+    print(f"\n完成: {ok} 个文件成功, {failed} 个失败, 共生成 {total_cycles} 个周期样本")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 71 - 0
backend/LabelingPreTraining/manifest.csv

@@ -0,0 +1,71 @@
+group,point_name,measurement_type,wave_file_id,rpm,sample_time,cycle_count,total_samples,start_sample_index,end_sample_index,error
+7号机组一缸压力盖侧_压力,7号机组一缸压力盖侧,压力,288423,996.8,2026-02-10 10:45:04,42,64826,53,64879,
+7号机组一缸压力盖侧_压力,7号机组一缸压力盖侧,压力,288424,996.8,2026-02-10 11:00:04,42,64822,87,64909,
+7号机组一缸压力轴侧_压力,7号机组一缸压力轴侧,压力,292277,996.8,2026-02-10 10:45:04,42,64826,53,64879,
+7号机组一缸压力轴侧_压力,7号机组一缸压力轴侧,压力,292335,996.8,2026-02-10 11:00:04,42,64822,87,64909,
+7号机组三缸压力盖侧_压力,7号机组三缸压力盖侧,压力,19025,995.2,2025-04-29 18:30:04,41,63380,1151,64531,
+7号机组三缸压力盖侧_压力,7号机组三缸压力盖侧,压力,19070,995.3,2025-04-29 18:45:03,41,63378,697,64075,
+7号机组三缸压力轴侧_压力,7号机组三缸压力轴侧,压力,20938,996.4,2025-04-29 15:45:00,42,64847,209,65056,
+7号机组三缸压力轴侧_压力,7号机组三缸压力轴侧,压力,20946,996.6,2025-04-29 16:00:02,42,64834,515,65349,
+7号机组二缸压力盖侧_压力,7号机组二缸压力盖侧,压力,298530,995.9,2026-03-14 02:30:00,41,63342,1066,64408,
+7号机组二缸压力盖侧_压力,7号机组二缸压力盖侧,压力,298693,995.6,2026-03-14 02:15:01,41,63359,993,64352,
+7号机组二缸压力轴侧_压力,7号机组二缸压力轴侧,压力,16831,996.8,2025-04-30 01:15:00,41,63283,1165,64448,
+7号机组二缸压力轴侧_压力,7号机组二缸压力轴侧,压力,16871,995.6,2025-04-30 01:00:03,41,63355,1468,64823,
+7号机组五缸压力盖侧_压力,7号机组五缸压力盖侧,压力,26882,996.2,2025-04-29 15:15:00,42,64863,619,65482,
+7号机组五缸压力盖侧_压力,7号机组五缸压力盖侧,压力,26999,996.6,2025-04-29 15:30:00,42,64840,488,65328,
+7号机组五缸压力轴侧_压力,7号机组五缸压力轴侧,压力,28850,995.3,2025-04-29 16:15:01,42,64924,236,65160,
+7号机组五缸压力轴侧_压力,7号机组五缸压力轴侧,压力,333831,996.9,2026-03-14 07:45:03,41,63277,1246,64523,
+7号机组六缸压力盖侧_压力,7号机组六缸压力盖侧,压力,338002,995.6,2026-03-13 14:16:25,41,63357,1314,64671,
+7号机组六缸压力盖侧_压力,7号机组六缸压力盖侧,压力,338272,996.8,2026-03-13 12:45:04,42,64829,97,64926,
+7号机组六缸压力轴侧_压力,7号机组六缸压力轴侧,压力,342667,997.2,2026-02-10 11:15:01,42,64800,383,65183,
+7号机组六缸压力轴侧_压力,7号机组六缸压力轴侧,压力,342757,998.5,2026-02-10 10:30:05,41,63177,971,64148,
+7号机组四缸压力盖侧_压力,7号机组四缸压力盖侧,压力,22750,995.8,2025-04-30 00:00:04,42,64892,419,65311,
+7号机组四缸压力盖侧_压力,7号机组四缸压力盖侧,压力,22775,996.3,2025-04-30 00:45:03,42,64860,432,65292,
+7号机组四缸压力轴侧_压力,7号机组四缸压力轴侧,压力,24781,995.7,2025-04-30 00:15:04,41,63353,1042,64395,
+7号机组四缸压力轴侧_压力,7号机组四缸压力轴侧,压力,24849,996.8,2025-04-29 23:30:00,41,63283,1159,64442,
+8号机组一缸压力盖侧_压力,8号机组一缸压力盖侧,压力,360911,997.8,2026-03-15 11:00:01,42,64758,171,64929,
+8号机组一缸压力盖侧_压力,8号机组一缸压力盖侧,压力,360921,997.1,2026-03-15 10:45:06,42,64806,250,65056,
+8号机组一缸压力轴侧_压力,8号机组一缸压力轴侧,压力,364619,997.1,2026-03-15 10:45:06,42,64806,250,65056,
+8号机组一缸压力轴侧_压力,8号机组一缸压力轴侧,压力,364620,997.8,2026-03-15 11:00:01,42,64758,171,64929,
+8号机组三缸压力盖侧_压力,8号机组三缸压力盖侧,压力,381256,997.5,2026-03-15 11:30:02,41,63239,945,64184,
+8号机组三缸压力盖侧_压力,8号机组三缸压力盖侧,压力,381257,997.6,2026-03-15 12:00:02,41,63230,1434,64664,
+8号机组三缸压力轴侧_压力,8号机组三缸压力轴侧,压力,384951,997.6,2026-03-15 12:00:02,41,63230,1434,64664,
+8号机组三缸压力轴侧_压力,8号机组三缸压力轴侧,压力,384959,997.8,2026-03-15 11:15:00,41,63221,804,64025,
+8号机组二缸压力盖侧_压力,8号机组二缸压力盖侧,压力,370923,997.6,2026-03-15 12:00:02,41,63230,1434,64664,
+8号机组二缸压力盖侧_压力,8号机组二缸压力盖侧,压力,370926,997.8,2026-03-15 11:15:00,41,63221,804,64025,
+8号机组二缸压力轴侧_压力,8号机组二缸压力轴侧,压力,374626,997.8,2026-03-15 11:15:00,41,63221,804,64025,
+8号机组二缸压力轴侧_压力,8号机组二缸压力轴侧,压力,374638,997.6,2026-03-15 12:00:02,41,63230,1434,64664,
+8号机组五缸压力盖侧_压力,8号机组五缸压力盖侧,压力,401056,997.8,2026-03-15 11:15:00,41,63221,804,64025,
+8号机组五缸压力盖侧_压力,8号机组五缸压力盖侧,压力,401062,997.6,2026-03-15 12:00:02,41,63230,1434,64664,
+8号机组五缸压力轴侧_压力,8号机组五缸压力轴侧,压力,196528,997.3,2026-03-18 00:00:31,41,63252,775,64027,
+8号机组五缸压力轴侧_压力,8号机组五缸压力轴侧,压力,196618,997.2,2026-03-17 23:15:04,41,63258,752,64010,
+8号机组六缸压力轴侧_压力,8号机组六缸压力轴侧,压力,204220,997.2,2026-03-17 05:15:07,41,63259,1387,64646,
+8号机组六缸压力轴侧_压力,8号机组六缸压力轴侧,压力,204282,995.9,2026-03-17 04:30:00,42,64884,614,65498,
+8号机组四缸压力盖侧_压力,8号机组四缸压力盖侧,压力,184949,996.0,2026-03-16 22:15:02,42,64877,28,64905,
+8号机组四缸压力盖侧_压力,8号机组四缸压力盖侧,压力,184980,997.2,2026-03-16 23:00:01,42,64797,358,65155,
+8号机组四缸压力轴侧_压力,8号机组四缸压力轴侧,压力,188004,996.1,2026-03-17 13:30:00,41,63328,1502,64830,
+8号机组四缸压力轴侧_压力,8号机组四缸压力轴侧,压力,188054,996.9,2026-03-17 14:15:02,41,63274,706,63980,
+9号机组一缸压力盖侧_压力,9号机组一缸压力盖侧,压力,434803,997.1,2026-01-30 13:30:06,41,63263,1434,64697,
+9号机组一缸压力盖侧_压力,9号机组一缸压力盖侧,压力,434810,997.3,2026-01-30 12:45:05,41,63251,1399,64650,
+9号机组一缸压力轴侧_压力,9号机组一缸压力轴侧,压力,438740,997.1,2026-01-30 13:00:07,42,64806,216,65022,
+9号机组一缸压力轴侧_压力,9号机组一缸压力轴侧,压力,438871,996.2,2026-01-30 12:15:01,42,64869,364,65233,
+9号机组三缸压力盖侧_压力,9号机组三缸压力盖侧,压力,8828,996.7,2025-03-13 11:09:07,41,63292,1386,64678,
+9号机组三缸压力盖侧_压力,9号机组三缸压力盖侧,压力,8874,997.1,2025-03-13 11:45:04,41,63263,1254,64517,
+9号机组三缸压力轴侧_压力,9号机组三缸压力轴侧,压力,11667,996.7,2025-03-13 11:09:07,41,63292,1386,64678,
+9号机组三缸压力轴侧_压力,9号机组三缸压力轴侧,压力,11677,997.1,2025-03-13 11:45:04,41,63263,1254,64517,
+9号机组二缸压力盖侧_压力,9号机组二缸压力盖侧,压力,4335,996.7,2025-03-13 11:09:07,41,63292,1386,64678,
+9号机组二缸压力盖侧_压力,9号机组二缸压力盖侧,压力,4343,997.1,2025-03-13 11:45:04,41,63263,1254,64517,
+9号机组二缸压力轴侧_压力,9号机组二缸压力轴侧,压力,6778,997.1,2025-03-13 11:45:04,41,63263,1254,64517,
+9号机组二缸压力轴侧_压力,9号机组二缸压力轴侧,压力,6837,996.7,2025-03-13 11:09:07,41,63292,1386,64678,
+9号机组五缸压力盖侧_压力,9号机组五缸压力盖侧,压力,480059,996.4,2026-01-30 17:45:06,42,64850,332,65182,
+9号机组五缸压力盖侧_压力,9号机组五缸压力盖侧,压力,480081,997.2,2026-01-30 17:00:02,42,64798,577,65375,
+9号机组五缸压力轴侧_压力,9号机组五缸压力轴侧,压力,483701,996.1,2026-01-30 16:45:00,41,63328,1109,64437,
+9号机组五缸压力轴侧_压力,9号机组五缸压力轴侧,压力,483741,997.3,2026-01-30 16:00:01,41,63251,1219,64470,
+9号机组六缸压力盖侧_压力,9号机组六缸压力盖侧,压力,491684,996.3,2026-01-31 00:30:03,42,64857,341,65198,
+9号机组六缸压力盖侧_压力,9号机组六缸压力盖侧,压力,491873,997.2,2026-01-30 23:45:04,42,64798,469,65267,
+9号机组六缸压力轴侧_压力,9号机组六缸压力轴侧,压力,495215,996.3,2026-01-31 00:30:03,42,64857,341,65198,
+9号机组六缸压力轴侧_压力,9号机组六缸压力轴侧,压力,495369,997.2,2026-01-31 01:15:04,41,63253,1183,64436,
+9号机组四缸压力盖侧_压力,9号机组四缸压力盖侧,压力,468712,997.2,2026-01-31 00:15:06,42,64801,112,64913,
+9号机组四缸压力盖侧_压力,9号机组四缸压力盖侧,压力,468773,996.1,2026-01-31 01:00:02,41,63328,725,64053,
+9号机组四缸压力轴侧_压力,9号机组四缸压力轴侧,压力,472568,996.8,2026-01-31 03:45:01,42,64831,85,64916,
+9号机组四缸压力轴侧_压力,9号机组四缸压力轴侧,压力,472771,996.0,2026-01-31 04:30:03,42,64878,317,65195,

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2901 - 0
backend/LabelingPreTraining/split.csv


+ 14 - 0
backend/LabelingPreTraining/tspulse/__init__.py

@@ -0,0 +1,14 @@
+"""TSPulse package for unsupervised cycle-waveform fingerprinting."""
+
+from .dataset import CycleDataset, build_split_datasets
+from .device import describe, get_device
+from .model import TSPulse, make_random_mask
+
+__all__ = [
+    "CycleDataset",
+    "TSPulse",
+    "build_split_datasets",
+    "describe",
+    "get_device",
+    "make_random_mask",
+]

+ 134 - 0
backend/LabelingPreTraining/tspulse/dataset.py

@@ -0,0 +1,134 @@
+"""Dataset loading, stratified splitting and per-channel normalisation.
+
+Every cycle sample is an n x 4 matrix [signal_value, second_value, 1, 1].
+Samples are zero-padded to a fixed sequence length (divisible by patch size)
+and normalised per channel using statistics computed on the training split
+only. The train / validation / evaluation split is stratified per part
+(8 : 1.5 : 0.5) so every part contributes samples to all three groups.
+"""
+
+import csv
+import random
+from pathlib import Path
+
+import numpy as np
+import torch
+from torch.utils.data import Dataset
+from tqdm import tqdm
+
+ROOT = Path(__file__).resolve().parents[1]
+INPUT_DIR = ROOT / "inputdatas"
+SPLIT_PATH = ROOT / "split.csv"
+
+PATCH_SIZE = 8
+N_CHANNELS = 4
+# 1552 = 194 patches of 8, covers the observed 1540..1546 cycle lengths.
+SEQ_LEN = 1552
+
+SPLIT_SEED = 42
+TRAIN_RATIO = 0.80
+VAL_RATIO = 0.15
+EVAL_RATIO = 0.05
+
+
+def discover_samples() -> list[dict]:
+    samples: list[dict] = []
+    for path in sorted(INPUT_DIR.glob("**/*.npy")):
+        samples.append(
+            {
+                "path": str(path),
+                "part": path.parent.name,
+                "name": path.name,
+            }
+        )
+    return samples
+
+
+def ensure_split(samples: list[dict]) -> list[dict]:
+    """Attach a split label to every sample, writing split.csv on first run."""
+    if SPLIT_PATH.exists():
+        split_map: dict[str, str] = {}
+        with SPLIT_PATH.open(encoding="utf-8") as handle:
+            for row in csv.DictReader(handle):
+                split_map[row["path"]] = row["split"]
+        for item in samples:
+            item["split"] = split_map.get(item["path"], "train")
+        return samples
+
+    rng = random.Random(SPLIT_SEED)
+    parts: dict[str, list[dict]] = {}
+    for item in samples:
+        parts.setdefault(item["part"], []).append(item)
+
+    for part, items in parts.items():
+        order = sorted(items, key=lambda item: item["name"])
+        rng.shuffle(order)
+        n = len(order)
+        n_eval = max(1, round(n * EVAL_RATIO))
+        n_val = max(1, round(n * VAL_RATIO))
+        n_train = n - n_val - n_eval
+        for index, item in enumerate(order):
+            if index < n_train:
+                item["split"] = "train"
+            elif index < n_train + n_val:
+                item["split"] = "val"
+            else:
+                item["split"] = "eval"
+
+    with SPLIT_PATH.open("w", encoding="utf-8", newline="") as handle:
+        writer = csv.DictWriter(handle, fieldnames=["path", "part", "name", "split"])
+        writer.writeheader()
+        for item in sorted(samples, key=lambda item: item["path"]):
+            writer.writerow(
+                {"path": item["path"], "part": item["part"], "name": item["name"], "split": item["split"]}
+            )
+    return samples
+
+
+def _load_raw(path: str) -> np.ndarray:
+    return np.load(path).astype(np.float32)
+
+
+def compute_stats(samples: list[dict]) -> tuple[np.ndarray, np.ndarray]:
+    """Per-channel mean/std from the training split only (std==0 -> scale 1)."""
+    train = [item for item in samples if item["split"] == "train"]
+    sums = np.zeros(N_CHANNELS, dtype=np.float64)
+    squares = np.zeros(N_CHANNELS, dtype=np.float64)
+    total = 0
+    for item in tqdm(train, desc="统计归一化参数"):
+        values = _load_raw(item["path"])
+        sums += values.sum(axis=0)
+        squares += (values.astype(np.float64) ** 2).sum(axis=0)
+        total += len(values)
+    mean = (sums / total).astype(np.float32)
+    variance = squares / total - (mean.astype(np.float64) ** 2)
+    std = np.sqrt(np.clip(variance, 0, None)).astype(np.float32)
+    std[std == 0] = 1.0
+    return mean, std
+
+
+class CycleDataset(Dataset):
+    def __init__(self, samples: list[dict], mean: np.ndarray, std: np.ndarray):
+        self.mean = mean
+        self.std = std
+        self.items: list[tuple[dict, torch.Tensor, int]] = []
+        for item in tqdm(samples, desc="加载样本到内存"):
+            raw = _load_raw(item["path"])
+            length = raw.shape[0]
+            normalised = (raw - mean) / std
+            padded = np.zeros((SEQ_LEN, N_CHANNELS), dtype=np.float32)
+            padded[:length] = normalised
+            self.items.append((item, torch.from_numpy(padded), length))
+
+    def __len__(self) -> int:
+        return len(self.items)
+
+    def __getitem__(self, index: int) -> tuple[torch.Tensor, int]:
+        _, tensor, length = self.items[index]
+        return tensor, length
+
+
+def build_split_datasets() -> tuple[list[dict], np.ndarray, np.ndarray]:
+    samples = ensure_split(discover_samples())
+    mean, std = compute_stats(samples)
+    return samples, mean, std

+ 42 - 0
backend/LabelingPreTraining/tspulse/device.py

@@ -0,0 +1,42 @@
+"""Device selection for TSPulse training/inference.
+
+Supports CUDA, Apple MPS, Huawei Ascend NPU and CPU, chosen automatically in
+that priority order. Set TSPULSE_DEVICE to force a specific backend.
+"""
+
+import os
+
+import torch
+
+
+def get_device() -> torch.device:
+    forced = os.getenv("TSPULSE_DEVICE", "").strip().lower()
+    if forced:
+        return torch.device(forced)
+
+    if torch.cuda.is_available():
+        return torch.device("cuda")
+
+    if torch.backends.mps.is_available():
+        return torch.device("mps")
+
+    try:
+        import torch_npu  # noqa: F401
+
+        if torch.npu.is_available():
+            return torch.device("npu")
+    except ImportError:
+        pass
+
+    return torch.device("cpu")
+
+
+def describe(device: torch.device) -> str:
+    if device.type == "cuda":
+        name = torch.cuda.get_device_name(device)
+        return f"CUDA ({name})"
+    if device.type == "mps":
+        return "Apple MPS"
+    if device.type == "npu":
+        return "Huawei Ascend NPU"
+    return "CPU"

+ 107 - 0
backend/LabelingPreTraining/tspulse/model.py

@@ -0,0 +1,107 @@
+"""TSPulse: a small time-series pulse Transformer autoencoder.
+
+Encodes an arbitrary cycle waveform (sequence of [signal, second, 1, 1]
+channels) into a 128-dim fingerprint vector. Trained with masked
+reconstruction: 15% of the patches are replaced by a learnable [MASK] token
+and the decoder head must recover the original signal, forcing the encoder to
+build a compact representation of the waveform.
+"""
+
+from __future__ import annotations
+
+import torch
+from torch import nn
+
+from .dataset import N_CHANNELS, PATCH_SIZE, SEQ_LEN
+
+NUM_PATCHES = SEQ_LEN // PATCH_SIZE
+
+
+class TSPulse(nn.Module):
+    def __init__(
+        self,
+        dim: int = 128,
+        depth: int = 4,
+        heads: int = 4,
+        mlp_ratio: float = 4.0,
+        patch_size: int = PATCH_SIZE,
+        in_channels: int = N_CHANNELS,
+        seq_len: int = SEQ_LEN,
+    ):
+        super().__init__()
+        self.dim = dim
+        self.patch_size = patch_size
+        self.in_channels = in_channels
+        self.seq_len = seq_len
+        self.num_patches = seq_len // patch_size
+
+        self.patch_embed = nn.Conv1d(in_channels, dim, kernel_size=patch_size, stride=patch_size)
+        self.mask_token = nn.Parameter(torch.zeros(1, 1, dim))
+        self.pos_embed = nn.Parameter(torch.zeros(1, self.num_patches, dim))
+
+        layer = nn.TransformerEncoderLayer(
+            d_model=dim,
+            nhead=heads,
+            dim_feedforward=int(dim * mlp_ratio),
+            activation="gelu",
+            batch_first=True,
+            norm_first=True,
+        )
+        self.encoder = nn.TransformerEncoder(layer, num_layers=depth)
+        self.norm = nn.LayerNorm(dim)
+        self.head = nn.Linear(dim, patch_size * in_channels)
+
+        self._reset_parameters()
+
+    def _reset_parameters(self) -> None:
+        nn.init.trunc_normal_(self.pos_embed, std=0.02)
+        nn.init.trunc_normal_(self.mask_token, std=0.02)
+        nn.init.xavier_uniform_(self.head.weight)
+        nn.init.zeros_(self.head.bias)
+
+    def _embed(self, x: torch.Tensor) -> torch.Tensor:
+        # x: (B, L, C) -> (B, C, L) -> conv -> (B, dim, P) -> (B, P, dim)
+        return self.patch_embed(x.transpose(1, 2)).transpose(1, 2)
+
+    def forward(
+        self,
+        x: torch.Tensor,
+        mask: torch.BoolTensor | None = None,
+    ) -> tuple[torch.Tensor, torch.Tensor]:
+        """Return (fingerprint, reconstruction).
+
+        fingerprint: (B, dim)
+        reconstruction: (B, L, C)
+        """
+        tokens = self._embed(x) + self.pos_embed
+        if mask is not None:
+            tokens = tokens.masked_fill(mask.unsqueeze(-1), 0.0)
+            tokens = tokens + self.mask_token.masked_fill(~mask.unsqueeze(-1), 0.0)
+        encoded = self.encoder(tokens)
+        encoded = self.norm(encoded)
+        fingerprint = encoded.mean(dim=1)
+        recon = self.head(encoded)  # (B, P, patch*C)
+        recon = recon.reshape(-1, self.num_patches, self.patch_size, self.in_channels)
+        recon = recon.permute(0, 2, 1, 3).reshape(-1, self.seq_len, self.in_channels)
+        return fingerprint, recon
+
+    def encode_cycle(self, x: torch.Tensor) -> torch.Tensor:
+        """Encode a single cycle (L, C) -> 128-dim fingerprint (no masking)."""
+        self.eval()
+        with torch.no_grad():
+            fingerprint, _ = self.forward(x.unsqueeze(0))
+        return fingerprint.squeeze(0)
+
+
+def make_random_mask(
+    batch_size: int,
+    num_patches: int,
+    ratio: float,
+    device: torch.device,
+) -> torch.BoolTensor:
+    num_masked = max(1, int(num_patches * ratio))
+    mask = torch.zeros(batch_size, num_patches, dtype=torch.bool, device=device)
+    for row in range(batch_size):
+        indices = torch.randperm(num_patches, device=device)[:num_masked]
+        mask[row, indices] = True
+    return mask

+ 144 - 0
backend/LabelingPreTraining/tspulse/train.py

@@ -0,0 +1,144 @@
+"""Step 1: unsupervised TSPulse training (masked reconstruction).
+
+Trains a single TSPulse encoder on all normal cycle samples from every part,
+then freezes the weights into a single checkpoint file used later for
+inference. Progress is printed via tqdm (epochs and batches with live loss).
+
+Usage:
+    python3 tspulse/train.py [--epochs 30] [--batch-size 64] [--lr 1e-3]
+"""
+
+import argparse
+import json
+import random
+from pathlib import Path
+
+import numpy as np
+import torch
+from torch.utils.data import DataLoader
+from tqdm import tqdm
+
+from tspulse import CycleDataset, TSPulse, build_split_datasets, describe, get_device, make_random_mask
+from tspulse.dataset import N_CHANNELS, SEQ_LEN
+
+ROOT = Path(__file__).resolve().parents[1]
+CHECKPOINT_DIR = ROOT / "checkpoints"
+CHECKPOINT_PATH = CHECKPOINT_DIR / "tspulse_frozen.pt"
+
+
+def set_seed(seed: int) -> None:
+    random.seed(seed)
+    np.random.seed(seed)
+    torch.manual_seed(seed)
+
+
+def masked_mse(recon: torch.Tensor, target: torch.Tensor, mask: torch.BoolTensor, patch_size: int) -> torch.Tensor:
+    sample_mask = mask.repeat_interleave(patch_size, dim=1)  # (B, L)
+    diff = (recon - target) ** 2
+    masked_diff = diff[sample_mask]
+    if masked_diff.numel() == 0:
+        return torch.tensor(0.0, device=recon.device)
+    return masked_diff.mean()
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--epochs", type=int, default=30)
+    parser.add_argument("--batch-size", type=int, default=64)
+    parser.add_argument("--lr", type=float, default=1e-3)
+    parser.add_argument("--mask-ratio", type=float, default=0.15)
+    parser.add_argument("--dim", type=int, default=128)
+    parser.add_argument("--depth", type=int, default=4)
+    parser.add_argument("--heads", type=int, default=4)
+    parser.add_argument("--seed", type=int, default=42)
+    args = parser.parse_args()
+
+    set_seed(args.seed)
+    device = get_device()
+    print(f"设备: {describe(device)}")
+
+    samples, mean, std = build_split_datasets()
+    train_samples = [item for item in samples if item["split"] == "train"]
+    val_samples = [item for item in samples if item["split"] == "val"]
+    print(f"样本: 训练 {len(train_samples)}, 验证 {len(val_samples)}, 评估 {len(samples) - len(train_samples) - len(val_samples)}")
+
+    train_set = CycleDataset(train_samples, mean, std)
+    val_set = CycleDataset(val_samples, mean, std)
+    train_loader = DataLoader(train_set, batch_size=args.batch_size, shuffle=True, drop_last=True)
+    val_loader = DataLoader(val_set, batch_size=args.batch_size, shuffle=False)
+
+    model = TSPulse(dim=args.dim, depth=args.depth, heads=args.heads).to(device)
+    optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.05)
+    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.epochs)
+
+    config = {
+        "dim": args.dim,
+        "depth": args.depth,
+        "heads": args.heads,
+        "seq_len": SEQ_LEN,
+        "n_channels": N_CHANNELS,
+        "mask_ratio": args.mask_ratio,
+    }
+
+    best_val = float("inf")
+    CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
+    history: list[dict] = []
+
+    for epoch in range(1, args.epochs + 1):
+        model.train()
+        epoch_loss = 0.0
+        seen = 0
+        bar = tqdm(train_loader, desc=f"epoch {epoch}/{args.epochs}", leave=False)
+        for batch in bar:
+            x, _lengths = batch
+            x = x.to(device)
+            mask = make_random_mask(x.shape[0], model.num_patches, args.mask_ratio, device)
+            _, recon = model(x, mask)
+            loss = masked_mse(recon, x, mask, model.patch_size)
+            optimizer.zero_grad()
+            loss.backward()
+            optimizer.step()
+            epoch_loss += loss.item() * x.shape[0]
+            seen += x.shape[0]
+            bar.set_postfix(loss=f"{loss.item():.5f}")
+        scheduler.step()
+        train_loss = epoch_loss / max(seen, 1)
+
+        model.eval()
+        val_loss = 0.0
+        val_seen = 0
+        with torch.no_grad():
+            for batch in val_loader:
+                x, _lengths = batch
+                x = x.to(device)
+                mask = make_random_mask(x.shape[0], model.num_patches, args.mask_ratio, device)
+                _, recon = model(x, mask)
+                val_loss += masked_mse(recon, x, mask, model.patch_size).item() * x.shape[0]
+                val_seen += x.shape[0]
+        val_loss /= max(val_seen, 1)
+
+        history.append({"epoch": epoch, "train_loss": train_loss, "val_loss": val_loss})
+        print(f"epoch {epoch:>3}/{args.epochs}  训练 loss {train_loss:.5f}  验证 loss {val_loss:.5f}")
+
+        if val_loss < best_val:
+            best_val = val_loss
+            torch.save(
+                {
+                    "state_dict": model.state_dict(),
+                    "config": config,
+                    "mean": mean,
+                    "std": std,
+                    "parts": sorted({item["part"] for item in samples}),
+                    "history": history,
+                    "device": describe(device),
+                },
+                CHECKPOINT_PATH,
+            )
+            print(f"  -> 已保存最佳模型 {CHECKPOINT_PATH}")
+
+    print(f"训练完成: 最佳验证 loss {best_val:.5f}, 模型固化于 {CHECKPOINT_PATH}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 152 - 0
backend/LabelingPreTraining/validate_model.py

@@ -0,0 +1,152 @@
+"""Step 5: validate the trained TSPulse model capability.
+
+Uses the held-out evaluation split (5%) to verify the model produces a useful
+fingerprint without having seen any part label during training:
+
+1. Training/validation loss convergence (from checkpoint history).
+2. Intra-part vs inter-part fingerprint distances (should be clearly < 1).
+3. Evaluation reconstruction MSE.
+4. Fingerprint health: 128-dim vectors finite, no collapsed dimensions.
+
+Writes a human-readable report to baseline/validation_report.txt.
+"""
+
+import argparse
+from collections import defaultdict
+from pathlib import Path
+
+import numpy as np
+import torch
+from torch.utils.data import DataLoader
+from tqdm import tqdm
+
+from tspulse import CycleDataset, TSPulse, build_split_datasets, describe, get_device, make_random_mask
+
+ROOT = Path(__file__).resolve().parent
+CHECKPOINT_PATH = ROOT / "checkpoints" / "tspulse_frozen.pt"
+BASELINE_DIR = ROOT / "baseline"
+REPORT_PATH = BASELINE_DIR / "validation_report.txt"
+
+
+def masked_mse(recon: torch.Tensor, target: torch.Tensor, mask: torch.BoolTensor, patch_size: int) -> torch.Tensor:
+    sample_mask = mask.repeat_interleave(patch_size, dim=1)
+    diff = (recon - target) ** 2
+    masked_diff = diff[sample_mask]
+    if masked_diff.numel() == 0:
+        return torch.tensor(0.0, device=recon.device)
+    return masked_diff.mean()
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--batch-size", type=int, default=128)
+    parser.add_argument("--mask-ratio", type=float, default=0.15)
+    args = parser.parse_args()
+
+    if not CHECKPOINT_PATH.exists():
+        print(f"未找到模型 {CHECKPOINT_PATH}, 请先运行 tspulse/train.py")
+        return 1
+
+    device = get_device()
+    print(f"设备: {describe(device)}")
+
+    checkpoint = torch.load(CHECKPOINT_PATH, map_location="cpu", weights_only=False)
+    config = checkpoint["config"]
+    model = TSPulse(dim=config["dim"], depth=config["depth"], heads=config["heads"]).to(device)
+    model.load_state_dict(checkpoint["state_dict"])
+    model.eval()
+
+    samples, mean, std = build_split_datasets()
+    eval_samples = [item for item in samples if item["split"] == "eval"]
+    if not eval_samples:
+        print("没有评估集样本")
+        return 1
+
+    lines: list[str] = []
+    lines.append("TSPulse 模型能力验证报告")
+    lines.append("=" * 40)
+    lines.append(f"设备: {describe(device)}")
+    lines.append(f"评估集样本数: {len(eval_samples)}")
+
+    # 1. Loss convergence
+    history = checkpoint.get("history", [])
+    lines.append("\n[1] 损失收敛曲线 (epoch: train / val)")
+    for record in history:
+        lines.append(
+            f"  epoch {record['epoch']:>3}: train {record['train_loss']:.5f} / val {record['val_loss']:.5f}"
+        )
+
+    # Encode all eval samples
+    eval_set = CycleDataset(eval_samples, mean, std)
+    loader = DataLoader(eval_set, batch_size=args.batch_size, shuffle=False)
+    vectors: list[np.ndarray] = []
+    recon_losses: list[float] = []
+    total_rows = 0
+    with torch.no_grad():
+        for batch, _lengths in tqdm(loader, desc="编码评估集"):
+            batch = batch.to(device)
+            mask = make_random_mask(batch.shape[0], model.num_patches, args.mask_ratio, device)
+            fingerprint, recon = model(batch, mask)
+            vectors.append(fingerprint.cpu().numpy())
+            recon_losses.append(masked_mse(recon, batch, mask, model.patch_size).item() * batch.shape[0])
+            total_rows += batch.shape[0]
+
+    ordered = sorted(eval_samples, key=lambda item: item["path"])
+    all_vectors = np.concatenate(vectors, axis=0)
+    parts_of = [item["part"] for item in ordered]
+
+    # 3. Reconstruction MSE
+    recon_mse = sum(recon_losses) / max(total_rows, 1)
+    lines.append(f"\n[3] 评估集重构 MSE: {recon_mse:.5f}")
+
+    # 4. Fingerprint health
+    finite = bool(np.isfinite(all_vectors).all())
+    std_per_dim = all_vectors.std(axis=0)
+    collapsed = int((std_per_dim < 1e-6).sum())
+    lines.append("\n[4] 指纹健康检查")
+    lines.append(f"  向量形状: {all_vectors.shape}")
+    lines.append(f"  全部有限值: {finite}")
+    lines.append(f"  128 维中退化维数(std<1e-6): {collapsed}")
+
+    # 2. Intra vs inter part distance
+    by_part: dict[str, list[np.ndarray]] = defaultdict(list)
+    for vector, part in zip(all_vectors, parts_of):
+        by_part[part].append(vector)
+
+    centroids = {}
+    intra_dists: list[float] = []
+    for part, vecs in by_part.items():
+        arr = np.stack(vecs)
+        centroid = arr.mean(axis=0)
+        centroids[part] = centroid
+        if len(vecs) > 1:
+            dists = np.linalg.norm(arr - centroid, axis=1)
+            intra_dists.append(float(dists.mean()))
+
+    inter_dists: list[float] = []
+    part_keys = sorted(centroids)
+    for i in range(len(part_keys)):
+        for j in range(i + 1, len(part_keys)):
+            inter_dists.append(float(np.linalg.norm(centroids[part_keys[i]] - centroids[part_keys[j]])))
+
+    intra_mean = float(np.mean(intra_dists)) if intra_dists else float("nan")
+    inter_mean = float(np.mean(inter_dists)) if inter_dists else float("nan")
+    ratio = intra_mean / inter_mean if inter_mean else float("nan")
+
+    lines.append("\n[2] 指纹区分度")
+    lines.append(f"  平均同部位距离: {intra_mean:.6f}")
+    lines.append(f"  平均跨部位距离: {inter_mean:.6f}")
+    lines.append(f"  同部位/跨部位比值: {ratio:.4f} (应明显 < 1)")
+
+    ok = finite and collapsed == 0 and ratio < 1.0
+    lines.append("\n结论: " + ("通过" if ok else "未通过"))
+
+    BASELINE_DIR.mkdir(parents=True, exist_ok=True)
+    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
+    print("\n".join(lines))
+    print(f"\n报告已保存: {REPORT_PATH}")
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 195 - 0
backend/LabelingPreTraining/validate_samples.py

@@ -0,0 +1,195 @@
+"""Sample validation for the generated per-cycle pretraining samples.
+
+Cross-checks a sampled subset of the generated .npy files against the database:
+
+1. 周期数核对: npy 文件数与 manifest 中的 cycle_count 一致。
+2. 顺序核对: 每个 npy 内的信号列与 DB 中 [start, end) 区间的原始行完全一致,
+   且 sample_index 逐行递增(时间顺序未打乱)。
+3. 边界核对: 周期起点/终点处的 second_value >= 30(键相触发),
+   第 3、4 列全为 1,信号列为有限值。
+4. 命名核对: 文件名中的 sample_index 起止与 DB 实测起止一致。
+
+Usage: python3 validate_samples.py [--all] [--max-files N]
+"""
+
+import argparse
+import csv
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from tqdm import tqdm
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from app.algorithms.cycles import detect_cycles  # noqa: E402
+from app.db import get_connection  # noqa: E402
+
+HERE = Path(__file__).resolve().parent
+OUTPUT_DIR = HERE / "inputdatas"
+MANIFEST_PATH = HERE / "manifest.csv"
+
+NAME_RE = re.compile(r"^(\d+)_(\d+)_(\d+)_(\d+)\.npy$")
+
+
+def load_manifest() -> list[dict[str, str]]:
+    with MANIFEST_PATH.open(encoding="utf-8-sig") as handle:
+        return list(csv.DictReader(handle))
+
+
+def list_npy_files(folder: Path, file_id: int | None = None) -> list[Path]:
+    pattern = f"{file_id}_*.npy" if file_id is not None else "*.npy"
+    return sorted(folder.glob(pattern))
+
+
+def read_wave_samples(connection, file_id: int) -> np.ndarray:
+    with connection.cursor() as cursor:
+        cursor.execute(
+            """
+            SELECT sample_index, signal_value, second_value
+            FROM wave_sample
+            WHERE wave_file_id = %s
+            ORDER BY wave_file_id, sample_index
+            """,
+            (file_id,),
+        )
+        rows = cursor.fetchall()
+    return np.asarray(
+        [
+            (
+                float(row["sample_index"]),
+                float(row["signal_value"]),
+                float(row["second_value"]) if row["second_value"] is not None else np.nan,
+            )
+            for row in rows
+        ],
+        dtype=float,
+    )
+
+
+def validate_file(connection, manifest_row: dict[str, str]) -> list[str]:
+    problems: list[str] = []
+    file_id = int(manifest_row["wave_file_id"])
+    folder = OUTPUT_DIR / manifest_row["group"]
+    files = sorted(folder.glob(f"{file_id}_*.npy"))
+    expected_count = int(manifest_row["cycle_count"] or 0)
+    if len(files) != expected_count:
+        problems.append(
+            f"周期数不符: manifest={expected_count}, 实际 npy 文件={len(files)}"
+        )
+        return problems
+
+    samples = read_wave_samples(connection, file_id)
+    cycles, _ = detect_cycles(samples)
+    if len(cycles) != expected_count:
+        problems.append(f"重算周期数不符: detect_cycles={len(cycles)}, manifest={expected_count}")
+
+    by_number: dict[int, Path] = {}
+    parsed: list[tuple[int, int, int]] = []
+    for path in files:
+        match = NAME_RE.match(path.name)
+        if not match:
+            problems.append(f"文件名不符合规范: {path.name}")
+            continue
+        file_id_name, cycle_number, start_name, end_name = (int(part) for part in match.groups())
+        if file_id_name != file_id:
+            problems.append(f"文件名 id 不符: {path.name}")
+        by_number[cycle_number] = path
+        parsed.append((cycle_number, start_name, end_name))
+
+    for cycle in cycles:
+        start_sample_index, end_exclusive = (
+            int(samples[cycle.start_offset, 0]),
+            int(samples[cycle.end_offset, 0]) if cycle.end_offset < len(samples) else int(samples[-1, 0]) + 1,
+        )
+        path = by_number.get(cycle.number)
+        if path is None:
+            problems.append(f"周期 {cycle.number} 缺少 npy 文件")
+            continue
+        expected_name = f"{file_id}_{cycle.number}_{start_sample_index}_{end_exclusive}.npy"
+        if path.name != expected_name:
+            problems.append(
+                f"周期 {cycle.number} 文件名不符: 应为 {expected_name}, 实际 {path.name}"
+            )
+
+        matrix = np.load(path)
+        if matrix.ndim != 2 or matrix.shape[1] != 4:
+            problems.append(f"周期 {cycle.number} 矩阵形状异常: {matrix.shape}")
+            continue
+        if not np.all(np.isfinite(matrix[:, :2])):
+            problems.append(f"周期 {cycle.number} 信号列存在非有限值")
+
+        signal = samples[cycle.start_offset : cycle.end_offset, 1]
+        second = samples[cycle.start_offset : cycle.end_offset, 2]
+        if matrix.shape[0] != len(signal):
+            problems.append(
+                f"周期 {cycle.number} 行数不符: npy={matrix.shape[0]}, DB={len(signal)}"
+            )
+        if not np.array_equal(matrix[:, 0], signal):
+            problems.append(f"周期 {cycle.number} signal_value 与 DB 不一致")
+        if not np.array_equal(matrix[:, 1], second):
+            problems.append(f"周期 {cycle.number} second_value 与 DB 不一致")
+        if not np.all(matrix[:, 2] == 1) or not np.all(matrix[:, 3] == 1):
+            problems.append(f"周期 {cycle.number} 扩展列不为 1")
+
+        indices = samples[cycle.start_offset : cycle.end_offset, 0]
+        if len(indices) > 1 and np.any(np.diff(indices) <= 0):
+            problems.append(f"周期 {cycle.number} sample_index 未严格递增")
+
+        boundary_start = samples[cycle.start_offset, 2]
+        boundary_end = samples[cycle.end_offset, 2]
+        if boundary_start < 30 or boundary_end < 30:
+            problems.append(
+                f"周期 {cycle.number} 零标记边界未达触发阈值 30 (start={boundary_start:.3f}, end={boundary_end:.3f})"
+            )
+
+    return problems
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="抽样验证预训练样本数据")
+    parser.add_argument("--all", action="store_true", help="验证全部文件而非抽样")
+    parser.add_argument("--max-files", type=int, default=20, help="抽样文件数上限(默认 20)")
+    args = parser.parse_args()
+
+    manifest = [row for row in load_manifest() if not row.get("error")]
+    if not manifest:
+        print("manifest 中没有成功记录,请先运行 generate_samples.py")
+        return 1
+
+    if args.all:
+        sampled = manifest
+    else:
+        step = max(1, len(manifest) // args.max_files)
+        sampled = manifest[::step][: args.max_files]
+        if len(sampled) < len(manifest):
+            sampled.append(manifest[-1])
+
+    print(f"抽样验证 {len(sampled)}/{len(manifest)} 个文件: " + ", ".join(item["wave_file_id"] for item in sampled))
+
+    problems_by_id: dict[str, list[str]] = {}
+    connection = get_connection()
+    try:
+        for row in tqdm(sampled, desc="验证样本", unit="文件"):
+            problems = validate_file(connection, row)
+            if problems:
+                problems_by_id[row["wave_file_id"]] = problems
+                print(f"  id={row['wave_file_id']} ({row['group']}): {len(problems)} 个问题")
+                for problem in problems[:8]:
+                    print(f"    - {problem}")
+    finally:
+        connection.close()
+
+    checked = sum(1 for item in sampled if item["wave_file_id"] not in problems_by_id)
+    print(
+        f"\n结果: {checked}/{len(sampled)} 个文件完全通过"
+        if not problems_by_id
+        else f"\n结果: {checked}/{len(sampled)} 通过, {len(problems_by_id)} 个文件存在问题"
+    )
+    return 1 if problems_by_id else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 40 - 2
backend/app/algorithms/cycles.py

@@ -30,6 +30,44 @@ def _trigger_runs(trigger: np.ndarray) -> list[tuple[int, int, int]]:
     ]
 
 
+def _zero_marker_threshold(lengths: np.ndarray) -> float:
+    """Adaptively separate ordinary keyphasor pulses from the wider 0-degree marker.
+
+    The wide marker's extra sensing time varies by machine (measured ratios from
+    ~1.4x to ~1.7x of the ordinary pulse), so a fixed margin misses machines
+    whose zero marker is only slightly wider. Otsu-style thresholding splits the
+    run lengths into two clusters regardless of the exact ratio; a near-single
+    cluster (ratio < 1.15) means no reliable zero marker exists and the returned
+    threshold rejects every run.
+    """
+    lengths = np.asarray(lengths, dtype=float)
+    if len(lengths) < 3:
+        return float("inf")
+    uniq = np.unique(lengths)
+    if len(uniq) < 2:
+        return float("inf")
+    best_variance = -1.0
+    best_threshold = float("inf")
+    best_means = (float(lengths.min()), float(lengths.max()))
+    for t in uniq[:-1]:
+        low = lengths[lengths <= t]
+        high = lengths[lengths > t]
+        if len(low) == 0 or len(high) == 0:
+            continue
+        weight_low = len(low) / len(lengths)
+        weight_high = len(high) / len(lengths)
+        mean_low = float(low.mean())
+        mean_high = float(high.mean())
+        between = weight_low * weight_high * (mean_low - mean_high) ** 2
+        if between > best_variance:
+            best_variance = between
+            best_threshold = float(t)
+            best_means = (mean_low, mean_high)
+    if best_means[1] < best_means[0] * 1.15:
+        return float("inf")
+    return best_threshold
+
+
 def detect_cycles(samples: np.ndarray) -> tuple[list[DetectedCycle], dict[str, float | int]]:
     """Detect complete crankshaft cycles from a wave_sample array.
 
@@ -57,8 +95,8 @@ def detect_cycles(samples: np.ndarray) -> tuple[list[DetectedCycle], dict[str, f
 
     lengths = np.asarray([run[2] for run in runs], dtype=float)
     ordinary_length = float(np.median(lengths))
-    long_limit = ordinary_length + max(5.0, ordinary_length * 0.45)
-    zero_runs = [run for run in runs if run[2] >= long_limit]
+    long_limit = _zero_marker_threshold(lengths)
+    zero_runs = [run for run in runs if run[2] > long_limit]
     diagnostics.update(
         ordinaryTriggerLength=ordinary_length,
         zeroMarkerThreshold=long_limit,

+ 48 - 0
backend/app/main.py

@@ -8,6 +8,7 @@ from .auth import create_token, revoke_token, validate_token, verify_credentials
 from .config import settings
 from .db import ensure_annotation_table
 from .services.data_service import data_service
+from .services.pretrain_service import pretrain_service
 
 
 class TimePointInput(BaseModel):
@@ -37,6 +38,15 @@ class AnnotationQueryInput(BaseModel):
     wave_file_ids: list[int] = Field(default=[])
 
 
+class PretrainRecordInput(BaseModel):
+    id: int = Field(gt=0)
+    point_name: str = Field(min_length=1)
+    measurement_type: str = Field(min_length=1)
+    rpm: float | None = None
+    sample_time: str = Field(min_length=1)
+    file_name: str = Field(default="")
+
+
 class LoginInput(BaseModel):
     username: str = Field(min_length=1)
     password: str = Field(min_length=1)
@@ -193,3 +203,41 @@ def delete_annotation(annotation_id: int, _auth: str = Depends(require_auth)) ->
         raise HTTPException(status_code=404, detail=str(error)) from error
     except Exception as error:
         raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.get("/api/pretrain-records")
+def pretrain_records_status(
+    ids: list[int] = Query(default=[]),
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return {"recorded": pretrain_service.recorded_ids(ids)}
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/pretrain-records")
+def create_pretrain_record(
+    payload: PretrainRecordInput,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        recorded, already = pretrain_service.record(payload.model_dump())
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+    return {"recorded": recorded, "already": already}
+
+
+@app.delete("/api/pretrain-records/{file_id}")
+def delete_pretrain_record(file_id: int, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        deleted = pretrain_service.remove(file_id)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+    if not deleted:
+        raise HTTPException(status_code=404, detail="CSV 中不存在该记录")
+    return {"deleted": deleted}

+ 6 - 1
backend/app/services/data_service.py

@@ -723,6 +723,9 @@ class DataService:
                         "measurementType": measurement_type,
                         "sampleCount": int(metadata.get("sample_count") or sample_count),
                         "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
+                        "pointName": str(metadata.get("point_name") or point_name),
+                        "rpm": float(metadata.get("rpm") or 0),
+                        "fileName": str(metadata.get("file_name") or ""),
                     },
                 )
             # Keep at most the current time point's raw arrays resident. The
@@ -918,7 +921,7 @@ class DataService:
                 cursor.execute(
                     """
                     SELECT id, point_name, measurement_type, sample_frequency_hz,
-                           sample_count, sample_time
+                           sample_count, sample_time, rpm, file_name
                     FROM wave_file
                     WHERE id = %s
                     """,
@@ -998,6 +1001,8 @@ class DataService:
             "sample_frequency_hz": 25600,
             "sample_count": len(samples),
             "sample_time": sample_time,
+            "rpm": 998.0,
+            "file_name": f"demo-{file_id}.dat",
         }
         return metadata, samples
 

+ 84 - 0
backend/app/services/pretrain_service.py

@@ -0,0 +1,84 @@
+import csv
+import threading
+from pathlib import Path
+
+_PRETRAIN_CSV_NAME = "预训练之文件头.csv"
+_CSV_COLUMNS = ["id", "point_name", "measurement_type", "rpm", "sample_time", "file_name"]
+
+
+class PretrainService:
+    """Append selected wave_file rows to the pre-training CSV (deduplicated by id)."""
+
+    def __init__(self) -> None:
+        self._csv_path = Path(__file__).resolve().parents[2] / _PRETRAIN_CSV_NAME
+        self._lock = threading.Lock()
+
+    @property
+    def csv_path(self) -> Path:
+        return self._csv_path
+
+    def _read_records(self) -> list[dict[str, str]]:
+        if not self._csv_path.exists():
+            return []
+        with self._csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
+            reader = csv.DictReader(handle)
+            return [dict(row) for row in reader]
+
+    def _write_records(self, records: list[dict[str, str]]) -> None:
+        with self._csv_path.open("w", encoding="utf-8-sig", newline="") as handle:
+            writer = csv.DictWriter(handle, fieldnames=_CSV_COLUMNS)
+            writer.writeheader()
+            writer.writerows(records)
+
+    def recorded_ids(self, ids: list[int]) -> list[int]:
+        wanted = {int(value) for value in ids if value}
+        if not wanted:
+            return []
+        with self._lock:
+            records = self._read_records()
+        existing: set[int] = set()
+        for row in records:
+            raw = str(row.get("id") or "").strip()
+            if raw.isdigit():
+                existing.add(int(raw))
+        return sorted(existing & wanted)
+
+    def record(self, record: dict[str, object]) -> tuple[bool, bool]:
+        row = {column: str(record.get(column) or "") for column in _CSV_COLUMNS}
+        try:
+            file_id = int(row["id"])
+        except (TypeError, ValueError) as error:
+            raise ValueError("id 必须为正整数") from error
+        if file_id <= 0:
+            raise ValueError("id 必须为正整数")
+        with self._lock:
+            records = self._read_records()
+            existing: set[int] = set()
+            for item in records:
+                raw = str(item.get("id") or "").strip()
+                if raw.isdigit():
+                    existing.add(int(raw))
+            if file_id in existing:
+                return False, True
+            records.append(row)
+            self._write_records(records)
+        return True, False
+
+    def remove(self, file_id: int) -> bool:
+        if file_id <= 0:
+            raise ValueError("id 必须为正整数")
+        with self._lock:
+            records = self._read_records()
+            remaining = [
+                row
+                for row in records
+                if not str(row.get("id") or "").strip().isdigit()
+                or int(str(row.get("id") or "").strip()) != file_id
+            ]
+            if len(remaining) == len(records):
+                return False
+            self._write_records(remaining)
+        return True
+
+
+pretrain_service = PretrainService()

+ 107 - 2
frontend/src/App.vue

@@ -1,11 +1,11 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessageBox } from 'element-plus'
-import { createAnnotation, deleteAnnotation, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchQueryOptions, fetchTimePoints, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout } from './api'
+import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchTimePoints, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
 import PeriodModal from './components/PeriodModal.vue'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
-import { MEASUREMENT_TYPES, type Annotation, type AnnotationLabel, type Cycle, type MeasurementType, type PeriodDetail, type QueryOption, type TimePoint, type QueryOptionsResponse, type WaveWindowResponse } from './types'
+import { MEASUREMENT_TYPES, type Annotation, type AnnotationLabel, type Cycle, type MeasurementType, type PeriodDetail, type QueryOption, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
 
 const queryMeta = ref<QueryOptionsResponse | null>(null)
 const selectedPointName = ref('')
@@ -34,6 +34,9 @@ const loggedIn = ref(false)
 const loginLoading = ref(false)
 const loginError = ref('')
 const loginForm = reactive({ username: '', password: '' })
+const waveChartRef = ref<InstanceType<typeof WaveChart> | null>(null)
+const recordedFileIds = ref<number[]>([])
+const recordingFileId = ref<number | null>(null)
 let waveDebounce: ReturnType<typeof setTimeout> | undefined
 let queryDebounce: ReturnType<typeof setTimeout> | undefined
 let waveRequestId = 0
@@ -400,6 +403,60 @@ watch(windowSize, () => {
 watch(noSampling, () => {
   if (initialized.value) loadWaveWindowNow()
 })
+watch(waveData, () => void refreshPretrainStatus(), { deep: false })
+
+async function refreshPretrainStatus() {
+  const ids = waveData.value?.files.map((file) => file.id) ?? []
+  if (!ids.length) {
+    recordedFileIds.value = []
+    return
+  }
+  try {
+    const result = await fetchPretrainStatus(ids)
+    recordedFileIds.value = result.recorded
+  } catch {
+    recordedFileIds.value = []
+  }
+}
+
+function locateFile(file: WaveWindowFile) {
+  waveChartRef.value?.locateFile(file.pointIndex)
+}
+
+async function recordFile(file: WaveWindowFile) {
+  if (recordedFileIds.value.includes(file.id) || recordingFileId.value != null) return
+  recordingFileId.value = file.id
+  try {
+    const result = await recordPretrain({
+      id: file.id,
+      point_name: file.pointName,
+      measurement_type: file.measurementType,
+      rpm: file.rpm,
+      sample_time: file.sampleTime,
+      file_name: file.fileName,
+    })
+    if (result.recorded || result.already) {
+      recordedFileIds.value = [...new Set([...recordedFileIds.value, file.id])]
+    }
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '记录失败'
+  } finally {
+    recordingFileId.value = null
+  }
+}
+
+async function deleteRecordedFile(file: WaveWindowFile) {
+  if (!recordedFileIds.value.includes(file.id) || recordingFileId.value != null) return
+  recordingFileId.value = file.id
+  try {
+    await deletePretrain(file.id)
+    recordedFileIds.value = recordedFileIds.value.filter((id) => id !== file.id)
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '删除失败'
+  } finally {
+    recordingFileId.value = null
+  }
+}
 
 async function handleLogin() {
   if (!loginForm.username || !loginForm.password) {
@@ -595,6 +652,53 @@ onBeforeUnmount(() => {
             <el-button class="ghost-button" plain :disabled="startIndex >= timePoints.length - windowSize" @click="pageWindow(1)">下一页</el-button>
           </div>
         </div>
+        <div v-if="waveData?.files.length" class="window-files">
+          <div class="window-files-title">当前窗口 wave_file 记录</div>
+          <table class="window-files-table">
+            <thead>
+              <tr>
+                <th>id</th>
+                <th>point_name</th>
+                <th>measurement_type</th>
+                <th>rpm</th>
+                <th>sample_time</th>
+                <th>file_name</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="file in waveData.files" :key="file.id">
+                <td class="mono">{{ file.id }}</td>
+                <td>{{ file.pointName }}</td>
+                <td>{{ file.measurementType }}</td>
+                <td class="mono">{{ file.rpm }}</td>
+                <td class="mono">{{ file.sampleTime.replace('T', ' ') }}</td>
+                <td class="file-name" :title="file.fileName">{{ file.fileName || '—' }}</td>
+                <td class="actions">
+                  <el-button class="file-action" size="small" plain :disabled="!waveData" @click="locateFile(file)">定位</el-button>
+                  <el-button
+                    v-if="!recordedFileIds.includes(file.id)"
+                    class="file-action"
+                    size="small"
+                    type="primary"
+                    plain
+                    :loading="recordingFileId === file.id"
+                    @click="recordFile(file)"
+                  >记录</el-button>
+                  <el-button
+                    v-else
+                    class="file-action"
+                    size="small"
+                    type="danger"
+                    plain
+                    :loading="recordingFileId === file.id"
+                    @click="deleteRecordedFile(file)"
+                  >删除</el-button>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
       </section>
 
       <section class="chart-panel panel">
@@ -620,6 +724,7 @@ onBeforeUnmount(() => {
           </div>
         </div>
         <WaveChart
+          ref="waveChartRef"
           :data="waveData"
           :points="selectedWindowPoints"
           :mode="chartMode"

+ 29 - 0
frontend/src/api.ts

@@ -133,3 +133,32 @@ export function deleteAnnotation(id: number) {
     method: 'DELETE',
   })
 }
+
+export type PretrainRecordInput = {
+  id: number
+  point_name: string
+  measurement_type: string
+  rpm: number
+  sample_time: string
+  file_name: string
+}
+
+export function fetchPretrainStatus(ids: number[]) {
+  if (!ids.length) return Promise.resolve({ recorded: [] as number[] })
+  const search = new URLSearchParams()
+  ids.forEach((id) => search.append('ids', String(id)))
+  return request<{ recorded: number[] }>(`/api/pretrain-records?${search.toString()}`)
+}
+
+export function recordPretrain(row: PretrainRecordInput) {
+  return request<{ recorded: boolean; already: boolean }>('/api/pretrain-records', {
+    method: 'POST',
+    body: JSON.stringify(row),
+  })
+}
+
+export function deletePretrain(id: number) {
+  return request<{ deleted: boolean }>(`/api/pretrain-records/${id}`, {
+    method: 'DELETE',
+  })
+}

+ 10 - 0
frontend/src/components/WaveChart.vue

@@ -197,6 +197,16 @@ function focusAnnotation(index: number) {
   chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
 }
 
+function locateFile(pointIndex: number) {
+  if (!chart || !props.data) return
+  const start = Math.max(0, (pointIndex / xMax.value) * 100)
+  const end = Math.min(100, ((pointIndex + 1) / xMax.value) * 100)
+  chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
+  chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
+}
+
+defineExpose({ locateFile })
+
 watch(annotationSegments, () => {
   if (activeAnnotationIndex.value >= annotationSegments.value.length) {
     activeAnnotationIndex.value = Math.max(0, annotationSegments.value.length - 1)

+ 12 - 0
frontend/src/styles.css

@@ -148,6 +148,18 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .selection-accent { width: 7px; height: 7px; background: #409eff; border-radius: 50%; }
 .ghost-button.el-button { height: 32px; padding: 0 14px; font-size: 13px; }
 
+.window-files { margin-top: 12px; border: 1px solid #ebeef5; border-radius: 4px; overflow: hidden; background: #fff; }
+.window-files-title { padding: 8px 12px; color: #606266; font-size: 12px; font-weight: 600; background: #f5f7fa; border-bottom: 1px solid #ebeef5; }
+.window-files-table { width: 100%; border-collapse: collapse; font-size: 12px; }
+.window-files-table th, .window-files-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid #f0f2f5; white-space: nowrap; }
+.window-files-table th { color: #909399; font-weight: 500; background: #fafbfc; }
+.window-files-table td { color: #303133; }
+.window-files-table td.mono { color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
+.window-files-table td.file-name { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
+.window-files-table tr:last-child td { border-bottom: 0; }
+.window-files-table td.actions { white-space: nowrap; }
+.window-files-table .file-action.el-button { height: 24px; margin: 0 2px; padding: 0 10px; font-size: 12px; }
+
 .chart-panel { min-height: 500px; padding: 22px 24px 15px; }
 .chart-heading { margin-bottom: 10px; }
 .chart-actions { gap: 15px; }

+ 13 - 8
frontend/src/types.ts

@@ -70,6 +70,18 @@ export type Cycle = {
   sourceType: MeasurementType
 }
 
+export type WaveWindowFile = {
+  id: number
+  pointIndex: number
+  sampleTime: string
+  measurementType: MeasurementType
+  sampleCount: number
+  sampleFrequencyHz: number
+  pointName: string
+  rpm: number
+  fileName: string
+}
+
 export type WaveWindowResponse = {
   source: 'database' | 'demo'
   notice: string | null
@@ -127,14 +139,7 @@ export type WaveWindowResponse = {
   }
   cycles: Cycle[]
   triggerXs: number[]
-  files: Array<{
-    id: number
-    pointIndex: number
-    sampleTime: string
-    measurementType: MeasurementType
-    sampleCount: number
-    sampleFrequencyHz: number
-  }>
+  files: WaveWindowFile[]
   diagnostics: Array<Record<string, string | number>>
 }