| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- #!/usr/bin/env python3
- """Automated verification of the reconstructed algorithms and 3h models.
- Checks
- ------
- 1. Algorithm scan CSVs are byte-identical to the golden reference.
- 2. DB full rerun of the hourly intermediate matches the cached table.
- 3. Pressure / vibration 3h model metrics match the documented results.
- 4. Dataset candidate and split counts match the documented results.
- Writes ``models/results/verification/verification_report.json`` and exits
- non-zero if any check fails.
- """
- from __future__ import annotations
- import argparse
- import csv
- import json
- import sys
- from collections import Counter
- from pathlib import Path
- sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
- from models.common import alarm_store, devices, paths
- ALGORITHM_FILES = {
- "pressure": ["润滑油压力小时等级.csv", "润滑油压力小时阶段段.csv", "润滑油压力演化周期.csv"],
- "vibration": ["振动异常段.csv"],
- "common": ["报警阈值配置.csv"],
- }
- EXPECTED = {
- "pressure": {
- "candidates_normal": 12789, "candidates_anomaly": 1016,
- "splits": {"train": (9095, 8439, 656), "validation": (2457, 2256, 201), "test": (2253, 2094, 159)},
- "threshold": 0.8625534176826477,
- "anomaly": {"precision": 0.9512, "recall": 0.9811, "f1": 0.9659},
- "normal_f1": 0.9974,
- "pr_auc": 0.9961,
- "event_recall": (10, 10),
- "by_unit_normal_fp_rate": {"7号机": 0.0032, "8号机": 0.0113, "9号机": 0.0},
- },
- "vibration": {
- "candidates_normal": 13908, "candidates_anomaly": 231,
- "splits": {"train": (9302, 9112, 190), "validation": (2657, 2637, 20), "test": (2180, 2159, 21)},
- "threshold": 0.9166295528411865,
- "anomaly": {"precision": 0.7143, "recall": 0.7143, "f1": 0.7143},
- "normal_f1": 0.9972,
- "pr_auc": 0.8270,
- "event_recall": (4, 4),
- "by_unit_normal_fp_rate": {"7号机": 0.0, "8号机": 0.0071, "9号机": 0.0013},
- },
- }
- METRIC_TOLERANCE = 1e-3
- THRESHOLD_TOLERANCE = 1e-6
- def compare_files(reference: Path, candidate: Path) -> dict:
- return {
- "reference": str(reference),
- "candidate": str(candidate),
- "identical": reference.read_bytes() == candidate.read_bytes(),
- }
- def check_algorithm():
- checks = []
- for group, files in ALGORITHM_FILES.items():
- for name in files:
- reference = paths.REFERENCE / name
- candidate = (paths.COMMON_RESULTS if group == "common" else paths.RESULTS / group) / name
- checks.append({"group": group, "file": name, **compare_files(reference, candidate)})
- return {"passed": all(item["identical"] for item in checks), "checks": checks}
- def manifest_candidate_counts(dataset_dir: Path) -> dict:
- counts = {"normal": 0, "anomaly": 0}
- with (dataset_dir / "manifest.csv").open(encoding="utf-8-sig", newline="") as handle:
- for row in csv.DictReader(handle):
- if row["label"] == "0":
- counts["normal"] += 1
- elif row["label"] == "1":
- counts["anomaly"] += 1
- return counts
- def check_model(model_type: str):
- expected = EXPECTED[model_type]
- dataset_dir = paths.DATAS / model_type
- models_dir = paths.MODEL_ARTIFACTS / model_type
- results_dir = paths.RESULTS / model_type
- candidates = manifest_candidate_counts(dataset_dir)
- split_report = json.loads((dataset_dir / "split_report.json").read_text(encoding="utf-8"))[model_type]
- metadata = json.loads((models_dir / f"{model_type}_metadata.json").read_text(encoding="utf-8"))
- evaluation = json.loads((results_dir / f"{model_type}_test_evaluation.json").read_text(encoding="utf-8"))
- summary = evaluation["window_metrics"]
- details = []
- def check(name, actual, want, tolerance):
- passed = abs(actual - want) <= tolerance if isinstance(want, float) else actual == want
- details.append({"name": name, "actual": actual, "expected": want, "tolerance": tolerance, "passed": bool(passed)})
- check("candidates_normal", candidates["normal"], expected["candidates_normal"], 0)
- check("candidates_anomaly", candidates["anomaly"], expected["candidates_anomaly"], 0)
- for split, (total, normal, anomaly) in expected["splits"].items():
- got = split_report["sample_counts"][split]
- check(f"{split}_total", got["samples_total"], total, 0)
- check(f"{split}_normal", got["samples_normal"], normal, 0)
- check(f"{split}_anomaly", got["samples_anomaly"], anomaly, 0)
- check("threshold", metadata["threshold"], expected["threshold"], THRESHOLD_TOLERANCE)
- check("anomaly_precision", summary["class_metrics"]["anomaly"]["precision"], expected["anomaly"]["precision"], METRIC_TOLERANCE)
- check("anomaly_recall", summary["class_metrics"]["anomaly"]["recall"], expected["anomaly"]["recall"], METRIC_TOLERANCE)
- check("anomaly_f1", summary["class_metrics"]["anomaly"]["f1"], expected["anomaly"]["f1"], METRIC_TOLERANCE)
- check("normal_f1", summary["class_metrics"]["normal"]["f1"], expected["normal_f1"], METRIC_TOLERANCE)
- check("pr_auc", summary["pr_auc"], expected["pr_auc"], METRIC_TOLERANCE)
- detected, total = expected["event_recall"]
- check("event_recall", evaluation["event_metrics"]["detected_anomaly_events"], detected, 0)
- check("event_total", evaluation["event_metrics"]["anomaly_events"], total, 0)
- for unit, want in expected["by_unit_normal_fp_rate"].items():
- got = 1 - evaluation["by_unit"][unit]["specificity"]
- check(f"{unit}_normal_fp_rate", got, want, METRIC_TOLERANCE)
- return {"passed": all(item["passed"] for item in details), "checks": details}
- def check_db_parity():
- report_path = paths.RESULTS / "verification" / "db_parity.json"
- if not report_path.is_file():
- return {"passed": False, "reason": "db_parity.json 缺失"}
- report = json.loads(report_path.read_text(encoding="utf-8"))
- return {"passed": bool(report.get("identical")), "report": report}
- def _reference_counts(path: Path, batch_to_device: dict[int, str]) -> Counter:
- counts: Counter = Counter()
- if not path.is_file():
- return counts
- with path.open(encoding="utf-8-sig", newline="") as handle:
- for row in csv.DictReader(handle):
- counts[batch_to_device[int(row["批次"])]] += 1
- return counts
- def check_alarm_store():
- """Compare compressor_alarm rows to the reference stage CSVs (1 row per segment)."""
- device_list = list(devices.DEVICE_CODES)
- device_batches = devices.load_device_batches(device_list)
- batch_device = devices.batch_to_device(device_batches)
- pressure_ref = _reference_counts(paths.REFERENCE / "润滑油压力小时阶段段.csv", batch_device)
- vibration_ref = _reference_counts(paths.REFERENCE / "振动异常段.csv", batch_device)
- expected: Counter = Counter()
- for device in device_list:
- expected[(device, "pressure")] = pressure_ref.get(device, 0)
- expected[(device, "vibration")] = vibration_ref.get(device, 0)
- rows = alarm_store.fetch_alarms(device_list)
- actual: Counter = Counter()
- for row in rows:
- kind = "pressure" if row["alarm_type"] == alarm_store.OIL_ALARM_TYPE else "vibration"
- actual[(row["device_code"], kind)] += 1
- checks = []
- for key in sorted(set(expected) | set(actual), key=str):
- checks.append({"name": f"{key[0]}_{key[1]}", "actual": actual.get(key, 0), "expected": expected.get(key, 0),
- "passed": actual.get(key, 0) == expected.get(key, 0)})
- status_ok = all(int(row["status"]) == 1 for row in rows)
- json_ok = True
- des_ok = True
- for row in rows:
- try:
- json.loads(row["alarm_info_json"])
- except (TypeError, ValueError):
- json_ok = False
- if len(row["alarm_des"] or "") > 4000:
- des_ok = False
- checks.append({"name": "status_all_1", "actual": status_ok, "expected": True, "passed": status_ok})
- checks.append({"name": "json_valid", "actual": json_ok, "expected": True, "passed": json_ok})
- checks.append({"name": "des_within_4000", "actual": des_ok, "expected": True, "passed": des_ok})
- return {"passed": all(item["passed"] for item in checks), "checks": checks, "rows": len(rows)}
- def main() -> None:
- parser = argparse.ArgumentParser(description="PKS 重构算法/模型自动校验")
- parser.add_argument("--with-db", action="store_true", help="额外校验 compressor_alarm 与参考 CSV 的对应关系")
- args = parser.parse_args()
- report = {
- "algorithm": check_algorithm(),
- "db_parity": check_db_parity(),
- "models": {mt: check_model(mt) for mt in ("pressure", "vibration")},
- }
- if args.with_db:
- report["alarm_store"] = check_alarm_store()
- report["passed"] = (
- report["algorithm"]["passed"]
- and report["db_parity"]["passed"]
- and all(item["passed"] for item in report["models"].values())
- and report.get("alarm_store", {"passed": True})["passed"]
- )
- output = paths.RESULTS / "verification"
- output.mkdir(parents=True, exist_ok=True)
- (output / "verification_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
- print(f"算法扫描结果一致性:{'通过' if report['algorithm']['passed'] else '失败'}")
- print(f"数据库全量重跑一致性:{'通过' if report['db_parity']['passed'] else '失败'}")
- for model_type, result in report["models"].items():
- failed = [item["name"] for item in result["checks"] if not item["passed"]]
- print(f"{model_type} 模型复现:{'通过' if result['passed'] else '失败'}" + (f" 差异={failed}" if failed else ""))
- if "alarm_store" in report:
- failed = [item["name"] for item in report["alarm_store"]["checks"] if not item["passed"]]
- print(f"compressor_alarm 对应:{'通过' if report['alarm_store']['passed'] else '失败'}"
- + (f" 差异={failed}" if failed else "") + f"({report['alarm_store']['rows']} 行)")
- print(f"总体:{'全部通过' if report['passed'] else '存在失败项'}")
- print(f"报告:{output / 'verification_report.json'}")
- if not report["passed"]:
- sys.exit(1)
- if __name__ == "__main__":
- main()
|