build_datasets.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. #!/usr/bin/env python3
  2. """Create reviewable configurable-window PKS samples from scanner hourly data.
  3. This module deliberately imports the scanner's read and aggregation helpers.
  4. It must not create an independent interpretation of PKS timestamps, coverage,
  5. thresholds, or batch-to-unit mapping.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import csv
  10. import json
  11. import math
  12. import shutil
  13. import sys
  14. from collections import Counter, defaultdict
  15. from datetime import datetime, timedelta
  16. from pathlib import Path
  17. from statistics import median
  18. ROOT = Path(__file__).resolve().parents[2]
  19. BACKEND = ROOT / "backend"
  20. if str(BACKEND) not in sys.path:
  21. sys.path.insert(0, str(BACKEND))
  22. import scan_pks_fault_evolution as scanner # noqa: E402
  23. WINDOW_HOURS = 6
  24. ISOLATION_HOURS = 12
  25. RESTART_GAP_HOURS = 12
  26. DEFAULT_START = "2025-01-01 00:00:00"
  27. DEFAULT_END = "2026-09-01 00:00:00"
  28. def parse_dt(value: str) -> datetime:
  29. return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
  30. def finite(value) -> float | None:
  31. if value is None or value == "":
  32. return None
  33. try:
  34. number = float(value)
  35. except (TypeError, ValueError):
  36. return None
  37. return number if math.isfinite(number) else None
  38. def mean(values: list[float | None]) -> float | None:
  39. valid = [value for value in values if value is not None]
  40. return sum(valid) / len(valid) if valid else None
  41. def value_range(values: list[float | None]) -> float | None:
  42. valid = [value for value in values if value is not None]
  43. return max(valid) - min(valid) if valid else None
  44. def slope(values: list[float | None]) -> float | None:
  45. return scanner.linear_slope(values)
  46. def ratio(count, running) -> float:
  47. return float(count or 0) / max(float(running or 0), 1.0)
  48. def csv_rows(path: Path) -> list[dict]:
  49. with path.open(encoding="utf-8-sig", newline="") as handle:
  50. return list(csv.DictReader(handle))
  51. def stage_ranges(path: Path, stage_field: str) -> list[dict]:
  52. ranges = []
  53. for index, row in enumerate(csv_rows(path), 1):
  54. ranges.append({
  55. "stage_id": f"{stage_field}_{row['机组']}_{row['批次']}_{index:04d}",
  56. "unit": row["机组"],
  57. "batch": int(row["批次"]),
  58. "level": row[stage_field],
  59. "start": parse_dt(row["开始小时"]),
  60. "end": parse_dt(row["结束小时"]),
  61. "confirmed": row.get("是否形成确认等级", ""),
  62. })
  63. return ranges
  64. def fetch_hourly(start: datetime, end: datetime, chunk_days: int) -> tuple[list[dict], dict[int, dict]]:
  65. batch_to_unit = scanner.load_pks_batches()
  66. configs = scanner.load_alarm_config(batch_to_unit)
  67. ranges = scanner.load_batch_ranges(sorted(batch_to_unit), start, end)
  68. grouped = defaultdict(list)
  69. for batch in sorted(ranges):
  70. grouped[batch_to_unit[batch]].append(batch)
  71. rows = []
  72. for unit, batches in sorted(grouped.items(), key=lambda item: scanner._unit_sort_key(item[0])):
  73. unit_start = min(ranges[batch][0] for batch in batches)
  74. unit_end = max(ranges[batch][1] for batch in batches)
  75. rows.extend(scanner.fetch_hourly(unit, batches, unit_start, unit_end, chunk_days, configs[batches[0]]))
  76. rows.sort(key=lambda row: (scanner._unit_sort_key(row["机组"]), row["批次"], row["_dt"]))
  77. return rows, configs
  78. def load_hourly_csv(path: Path) -> list[dict]:
  79. """Rebuild the hourly row structure from a saved hourly.csv without querying the DB."""
  80. result = []
  81. for row in csv_rows(path):
  82. item = {
  83. "机组": row["机组"],
  84. "批次": int(row["批次"]),
  85. "小时": row["小时"],
  86. "_dt": parse_dt(row["小时"]),
  87. "样本数": int(row["样本数"]),
  88. "运行样本数": int(row["运行样本数"]),
  89. "运行覆盖率": float(row["运行覆盖率"]),
  90. }
  91. for field in scanner.NUMERIC_HOURLY_FIELDS:
  92. item[field] = finite(row.get(field))
  93. result.append(item)
  94. result.sort(key=lambda item: (scanner._unit_sort_key(item["机组"]), item["批次"], item["_dt"]))
  95. return result
  96. def assign_run_cycles(rows: list[dict], model_type: str) -> list[dict]:
  97. grouped = defaultdict(list)
  98. for row in rows:
  99. if row["运行覆盖率"] < scanner.MIN_HOUR_COVERAGE:
  100. continue
  101. if model_type == "pressure" and row["oil_avg"] is None:
  102. continue
  103. if model_type == "vibration" and (row["coupling_avg"] is None or row["chain_avg"] is None):
  104. continue
  105. grouped[(row["机组"], row["批次"])].append(row)
  106. result = []
  107. for (unit, batch), series in grouped.items():
  108. series.sort(key=lambda row: row["_dt"])
  109. cycle = 0
  110. previous = None
  111. for row in series:
  112. restart = previous is None or row["_dt"] - previous > timedelta(hours=RESTART_GAP_HOURS)
  113. if restart:
  114. cycle += 1
  115. item = dict(row)
  116. item["run_cycle_id"] = f"{unit}_B{batch}_R{cycle:04d}"
  117. item["restart_hour"] = restart
  118. result.append(item)
  119. previous = row["_dt"]
  120. return sorted(result, key=lambda row: (scanner._unit_sort_key(row["机组"]), row["批次"], row["_dt"]))
  121. def previous_baseline(series: list[dict], index: int, field: str, hours: int) -> tuple[float | None, float | None]:
  122. now = series[index]["_dt"]
  123. prior = [row[field] for row in series[:index] if row["_dt"] >= now - timedelta(hours=hours) and row[field] is not None]
  124. return scanner.robust_baseline(prior)
  125. def source_hour(row: dict) -> dict:
  126. running = row["运行样本数"]
  127. return {
  128. "hour": row["小时"],
  129. "running_samples": running,
  130. "running_coverage": row["运行覆盖率"],
  131. "oil_avg": row["oil_avg"], "oil_min": row["oil_min"], "oil_max": row["oil_max"],
  132. "oil_low_ratio": ratio(row["oil_low_count"], running),
  133. "oil_low_low_ratio": ratio(row["oil_low_low_count"], running),
  134. "coupling_avg": row["coupling_avg"], "coupling_max": row["coupling_max"],
  135. "chain_avg": row["chain_avg"], "chain_max": row["chain_max"],
  136. "coupling_high_ratio": ratio(row["coupling_high_count"], running),
  137. "coupling_high_high_ratio": ratio(row["coupling_high_high_count"], running),
  138. "chain_high_ratio": ratio(row["chain_high_count"], running),
  139. "chain_high_high_ratio": ratio(row["chain_high_high_count"], running),
  140. }
  141. def cycle_baseline(series: list[dict], days: int = scanner.BASELINE_DAYS) -> float | None:
  142. """Median of daily oil medians over the first `days` calendar days of a cycle.
  143. Mirrors the scanner's cycle baseline: the baseline is taken from the start of
  144. the run cycle, so it never uses information from the window being scored.
  145. """
  146. by_day: dict = {}
  147. for row in series:
  148. if row["oil_avg"] is None:
  149. continue
  150. day = row["_dt"].date()
  151. if day not in by_day and len(by_day) >= days:
  152. break
  153. by_day.setdefault(day, []).append(row["oil_avg"])
  154. daily = [scanner.median_value(values) for _, values in sorted(by_day.items())[:days]]
  155. daily = [value for value in daily if value is not None]
  156. return scanner.median_value(daily)
  157. def decline_hours(series: list[dict], end_index: int) -> int:
  158. """Consecutive valid hours of oil-pressure decline ending at end_index."""
  159. count = 0
  160. for index in range(end_index, 0, -1):
  161. current, previous = series[index]["oil_avg"], series[index - 1]["oil_avg"]
  162. if current is None or previous is None or current >= previous:
  163. break
  164. count += 1
  165. return count
  166. def pressure_features(series: list[dict], end_index: int, window: list[dict]) -> dict:
  167. oil = [row["oil_avg"] for row in window]
  168. coverages = [row["运行覆盖率"] for row in window]
  169. baseline = scanner.median_value([row["oil_avg"] for row in window[:-1]])
  170. current = oil[-1]
  171. oil_mean, oil_min, oil_max = mean(oil), min(oil), max(oil)
  172. oil_range = value_range(oil)
  173. oil_slope = slope(oil)
  174. cycle_base = cycle_baseline(series)
  175. recent_center, _ = previous_baseline(series, end_index, "oil_avg", 24)
  176. return {
  177. "oil_mean": oil_mean, "oil_min": oil_min, "oil_max": oil_max, "oil_range": oil_range,
  178. "oil_slope": oil_slope,
  179. "oil_last_relative_to_window": (current / baseline - 1) if baseline else None,
  180. "oil_low_ratio_mean": mean([ratio(row["oil_low_count"], row["运行样本数"]) for row in window]),
  181. "oil_low_low_ratio_mean": mean([ratio(row["oil_low_low_count"], row["运行样本数"]) for row in window]),
  182. "running_coverage_mean": mean(coverages), "running_coverage_min": min(coverages),
  183. "oil_rel_cycle_mean": (oil_mean / cycle_base - 1) if cycle_base else None,
  184. "oil_rel_cycle_min": (oil_min / cycle_base - 1) if cycle_base else None,
  185. "oil_rel_cycle_last": (current / cycle_base - 1) if cycle_base else None,
  186. "oil_rel_recent_mean": (oil_mean / recent_center - 1) if recent_center else None,
  187. "oil_rel_recent_last": (current / recent_center - 1) if recent_center else None,
  188. "oil_slope_rel_cycle": (oil_slope / cycle_base) if cycle_base else None,
  189. "oil_decline_hours": decline_hours(series, end_index),
  190. "oil_drawdown_cycle": ((cycle_base - oil_min) / cycle_base) if cycle_base else None,
  191. "oil_range_rel_cycle": (oil_range / cycle_base) if cycle_base else None,
  192. }
  193. def vibration_features(series: list[dict], end_index: int, window: list[dict]) -> dict:
  194. features = {
  195. "running_coverage_mean": mean([row["运行覆盖率"] for row in window]),
  196. "running_coverage_min": min(row["运行覆盖率"] for row in window),
  197. }
  198. for side in ("coupling", "chain"):
  199. averages = [row[f"{side}_avg"] for row in window]
  200. peaks = [row[f"{side}_max"] for row in window]
  201. avg_center, avg_scale = previous_baseline(series, end_index, f"{side}_avg", scanner.VIBRATION_BASELINE_DAYS * 24)
  202. peak_center, peak_scale = previous_baseline(series, end_index, f"{side}_max", scanner.VIBRATION_BASELINE_DAYS * 24)
  203. current_avg, current_peak = averages[-1], peaks[-1]
  204. features.update({
  205. f"{side}_avg_mean": mean(averages), f"{side}_avg_max": max(averages), f"{side}_avg_range": value_range(averages),
  206. f"{side}_avg_slope": slope(averages), f"{side}_peak_mean": mean(peaks), f"{side}_peak_max": max(peaks),
  207. f"{side}_peak_range": value_range(peaks), f"{side}_peak_slope": slope(peaks),
  208. f"{side}_avg_baseline_deviation": ((current_avg - avg_center) / avg_scale) if avg_center is not None and avg_scale else None,
  209. f"{side}_peak_baseline_deviation": ((current_peak - peak_center) / peak_scale) if peak_center is not None and peak_scale else None,
  210. f"{side}_avg_rel_last": (current_avg / avg_center - 1) if avg_center else None,
  211. f"{side}_peak_rel_last": (current_peak / peak_center - 1) if peak_center else None,
  212. f"{side}_avg_rel_mean": (mean(averages) / avg_center - 1) if avg_center else None,
  213. f"{side}_peak_rel_max": (max(peaks) / peak_center - 1) if peak_center else None,
  214. f"{side}_high_ratio_mean": mean([ratio(row[f"{side}_high_count"], row["运行样本数"]) for row in window]),
  215. f"{side}_high_high_ratio_mean": mean([ratio(row[f"{side}_high_high_count"], row["运行样本数"]) for row in window]),
  216. })
  217. features["both_sides_rising"] = int(features["coupling_avg_slope"] > 0 and features["chain_avg_slope"] > 0)
  218. return features
  219. def window_status(window: list[dict], stages: list[dict]) -> tuple[int | None, str, str | None, str | None]:
  220. start, end = window[0]["_dt"], window[-1]["_dt"]
  221. overlapping = [stage for stage in stages if stage["start"] <= end and stage["end"] >= start]
  222. if overlapping:
  223. stage = min(overlapping, key=lambda item: (item["start"], item["stage_id"]))
  224. return 1, "rule_stage", stage["stage_id"], None
  225. nearby = [stage for stage in stages if stage["start"] - timedelta(hours=ISOLATION_HOURS) <= end and stage["end"] + timedelta(hours=ISOLATION_HOURS) >= start]
  226. if nearby:
  227. return None, "excluded", None, "anomaly_isolation_period"
  228. return 0, "stable_normal", None, None
  229. def build_samples(rows: list[dict], model_type: str, stages: list[dict], window_hours: int) -> list[dict]:
  230. cycle_rows = assign_run_cycles(rows, model_type)
  231. by_cycle = defaultdict(list)
  232. for row in cycle_rows:
  233. by_cycle[(row["机组"], row["批次"], row["run_cycle_id"])].append(row)
  234. # The scanner's vibration baseline is the robust baseline over the device's
  235. # full valid history in the prior 7*24 hours, across restarts, excluding
  236. # restart transition hours. Reproduce that here so features match the rule.
  237. full_by_batch: dict[tuple, list[dict]] = {}
  238. index_by_batch: dict[tuple, dict] = {}
  239. if model_type == "vibration":
  240. grouped = defaultdict(list)
  241. for row in cycle_rows:
  242. if not row["restart_hour"]:
  243. grouped[(row["机组"], row["批次"])].append(row)
  244. for key, series in grouped.items():
  245. series.sort(key=lambda row: row["_dt"])
  246. full_by_batch[key] = series
  247. index_by_batch[key] = {row["_dt"]: index for index, row in enumerate(series)}
  248. samples = []
  249. sequence = 0
  250. for (unit, batch, cycle_id), series in sorted(by_cycle.items()):
  251. series.sort(key=lambda row: row["_dt"])
  252. for end_index in range(window_hours - 1, len(series)):
  253. window = series[end_index - window_hours + 1:end_index + 1]
  254. if any(window[index]["_dt"] - window[index - 1]["_dt"] > timedelta(hours=RESTART_GAP_HOURS) for index in range(1, len(window))):
  255. continue
  256. if any(row["restart_hour"] for row in window):
  257. exclude_reason = "contains_restart_transition_hour"
  258. label, source, stage_id = None, "excluded", None
  259. else:
  260. scoped_stages = [stage for stage in stages if stage["unit"] == unit and stage["batch"] == batch]
  261. label, source, stage_id, exclude_reason = window_status(window, scoped_stages)
  262. sequence += 1
  263. sample_id = f"{model_type}_{unit}_B{batch}_{window[0]['_dt']:%Y%m%d%H}_{window[-1]['_dt']:%Y%m%d%H}_{sequence:06d}"
  264. event_group_id = stage_id or f"{cycle_id}_normal"
  265. if model_type == "pressure":
  266. features = pressure_features(series, end_index, window)
  267. else:
  268. key = (unit, batch)
  269. full_series = full_by_batch[key]
  270. full_index = index_by_batch[key][window[-1]["_dt"]]
  271. features = vibration_features(full_series, full_index, window)
  272. samples.append({
  273. "sample_id": sample_id, "model_type": model_type, "unit": unit, "batch": batch,
  274. "run_cycle_id": cycle_id, "event_group_id": event_group_id,
  275. "start_hour": window[0]["小时"], "end_hour": window[-1]["小时"],
  276. "valid_hour_count": window_hours,
  277. "natural_duration_hours": int((window[-1]["_dt"] - window[0]["_dt"]).total_seconds() / 3600) + 1,
  278. "label": label, "label_name": "anomaly" if label == 1 else "normal" if label == 0 else "excluded",
  279. "label_source": source, "label_confidence": "initial_rule" if label is not None else "not_usable",
  280. "rule_stage_id": stage_id, "exclude_reason": exclude_reason,
  281. "features": features, "source_hours": [source_hour(row) for row in window],
  282. })
  283. return samples
  284. def write_hourly(path: Path, rows: list[dict]) -> None:
  285. fields = ["机组", "批次", "小时", "样本数", "运行样本数", "运行覆盖率", *scanner.NUMERIC_HOURLY_FIELDS]
  286. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  287. writer = csv.DictWriter(handle, fieldnames=fields)
  288. writer.writeheader()
  289. for row in rows:
  290. writer.writerow({field: row.get(field) for field in fields})
  291. def write_samples(base: Path, samples: list[dict]) -> Counter:
  292. for folder in ("candidates", "excluded"):
  293. (base / folder).mkdir(parents=True, exist_ok=True)
  294. manifest_fields = ["sample_id", "model_type", "unit", "batch", "run_cycle_id", "event_group_id", "start_hour", "end_hour", "label", "label_name", "label_source", "label_confidence", "rule_stage_id", "exclude_reason", "split"]
  295. counts = Counter()
  296. with (base / "manifest.csv").open("w", encoding="utf-8-sig", newline="") as handle:
  297. writer = csv.DictWriter(handle, fieldnames=manifest_fields)
  298. writer.writeheader()
  299. for sample in samples:
  300. folder = "excluded" if sample["label"] is None else "candidates"
  301. with (base / folder / f"{sample['sample_id']}.json").open("w", encoding="utf-8") as output:
  302. json.dump(sample, output, ensure_ascii=False, indent=2, allow_nan=False)
  303. writer.writerow({field: sample.get(field, "") for field in manifest_fields})
  304. counts[(folder, sample["label_name"], sample.get("exclude_reason") or "")] += 1
  305. return counts
  306. def main() -> None:
  307. parser = argparse.ArgumentParser(description="从现有PKS扫描器构建可配置小时窗口的模型辅助样本")
  308. parser.add_argument("--start", default=DEFAULT_START)
  309. parser.add_argument("--end", default=DEFAULT_END)
  310. parser.add_argument("--chunk-days", type=int, default=14)
  311. parser.add_argument("--window-hours", type=int, default=WINDOW_HOURS)
  312. parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parent / "datasets"))
  313. parser.add_argument("--evolution-dir", default=str(ROOT / "cache" / "pks_scanner" / "evolution"))
  314. parser.add_argument("--hourly-csv", default=None, help="复用已保存的 hourly.csv,不查询数据库")
  315. parser.add_argument("--model-types", nargs="+", choices=("pressure", "vibration"), default=["pressure", "vibration"])
  316. parser.add_argument("--units", nargs="+", default=None, help="只构建指定机组,例如 8号机")
  317. args = parser.parse_args()
  318. start, end = parse_dt(args.start), parse_dt(args.end)
  319. if end <= start or args.chunk_days < 1 or args.window_hours < 1:
  320. parser.error("时间范围或 chunk-days 无效")
  321. output = Path(args.output_dir)
  322. evolution = Path(args.evolution_dir)
  323. for name in ("润滑油压力小时阶段段.csv", "振动异常段.csv"):
  324. if not (evolution / name).is_file():
  325. parser.error(f"缺少扫描器结果:{evolution / name}")
  326. if output.exists():
  327. shutil.rmtree(output)
  328. output.mkdir(parents=True)
  329. if args.hourly_csv:
  330. hourly_path = Path(args.hourly_csv)
  331. if not hourly_path.is_file():
  332. parser.error(f"缺少 hourly.csv:{hourly_path}")
  333. print(f"复用小时中间表:{hourly_path}")
  334. rows = load_hourly_csv(hourly_path)
  335. else:
  336. print("只读模式:复用 scan_pks_fault_evolution.py 的小时聚合和数据口径")
  337. rows, _ = fetch_hourly(start, end, args.chunk_days)
  338. pressure_stages = stage_ranges(evolution / "润滑油压力小时阶段段.csv", "压力阶段")
  339. vibration_stages = stage_ranges(evolution / "振动异常段.csv", "振动阶段")
  340. selected_units = set(args.units or [])
  341. for model_type in args.model_types:
  342. stages = pressure_stages if model_type == "pressure" else vibration_stages
  343. scoped_rows = [row for row in rows if not selected_units or row["机组"] in selected_units]
  344. scoped_stages = [stage for stage in stages if not selected_units or stage["unit"] in selected_units]
  345. base = output / model_type
  346. base.mkdir()
  347. write_hourly(base / "hourly.csv", scoped_rows)
  348. counts = write_samples(base, build_samples(scoped_rows, model_type, scoped_stages, args.window_hours))
  349. print(f"{model_type} samples: {dict(counts)}")
  350. print(f"小时中间数据:{len(rows):,} 行")
  351. print(f"样本输出目录:{output}")
  352. if __name__ == "__main__":
  353. main()