windowing.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. """Shared window construction for the pressure and vibration models.
  2. These helpers reproduce the original generalized model pipeline's dataset
  3. building and only expose the parts that are identical for both model types.
  4. The model specific feature extraction is injected via the ``feature_fn`` argument.
  5. """
  6. from __future__ import annotations
  7. import csv
  8. import json
  9. from collections import Counter, defaultdict
  10. from datetime import datetime, timedelta
  11. from pathlib import Path
  12. from .constants import (
  13. BASELINE_DAYS,
  14. MIN_HOUR_COVERAGE,
  15. RESET_GAP_HOURS,
  16. VIBRATION_BASELINE_DAYS,
  17. VIBRATION_RESTART_GAP_HOURS,
  18. )
  19. from .queries import NUMERIC_HOURLY_FIELDS, parse_dt, unit_sort_key
  20. from .stats import median_value, robust_baseline
  21. WINDOW_HOURS = 6
  22. ISOLATION_HOURS = 12
  23. RESTART_GAP_HOURS = RESET_GAP_HOURS
  24. def finite(value):
  25. from .stats import finite as _finite
  26. return _finite(value)
  27. def mean(values):
  28. valid = [value for value in values if value is not None]
  29. return sum(valid) / len(valid) if valid else None
  30. def value_range(values):
  31. valid = [value for value in values if value is not None]
  32. return max(valid) - min(valid) if valid else None
  33. def slope(values):
  34. from .stats import linear_slope
  35. return linear_slope(values)
  36. def ratio(count, running):
  37. return float(count or 0) / max(float(running or 0), 1.0)
  38. def csv_rows(path: Path) -> list[dict]:
  39. with Path(path).open(encoding="utf-8-sig", newline="") as handle:
  40. return list(csv.DictReader(handle))
  41. def stage_ranges(path: Path, stage_field: str) -> list[dict]:
  42. ranges = []
  43. for index, row in enumerate(csv_rows(path), 1):
  44. ranges.append({
  45. "stage_id": f"{stage_field}_{row['机组']}_{row['批次']}_{index:04d}",
  46. "unit": row["机组"],
  47. "batch": int(row["批次"]),
  48. "level": row[stage_field],
  49. "start": parse_dt(row["开始小时"]),
  50. "end": parse_dt(row["结束小时"]),
  51. "confirmed": row.get("是否形成确认等级", ""),
  52. })
  53. return ranges
  54. def assign_run_cycles(rows: list[dict], model_type: str) -> list[dict]:
  55. grouped = defaultdict(list)
  56. for row in rows:
  57. if row["运行覆盖率"] < MIN_HOUR_COVERAGE:
  58. continue
  59. if model_type == "pressure" and row["oil_avg"] is None:
  60. continue
  61. if model_type == "vibration" and (row["coupling_avg"] is None or row["chain_avg"] is None):
  62. continue
  63. grouped[(row["机组"], row["批次"])].append(row)
  64. result = []
  65. for (unit, batch), series in grouped.items():
  66. series.sort(key=lambda row: row["_dt"])
  67. cycle = 0
  68. previous = None
  69. for row in series:
  70. restart = previous is None or row["_dt"] - previous > timedelta(hours=RESTART_GAP_HOURS)
  71. if restart:
  72. cycle += 1
  73. item = dict(row)
  74. item["run_cycle_id"] = f"{unit}_B{batch}_R{cycle:04d}"
  75. item["restart_hour"] = restart
  76. result.append(item)
  77. previous = row["_dt"]
  78. return sorted(result, key=lambda row: (unit_sort_key(row["机组"]), row["批次"], row["_dt"]))
  79. def previous_baseline(series: list[dict], index: int, field: str, hours: int):
  80. now = series[index]["_dt"]
  81. prior = [row[field] for row in series[:index] if row["_dt"] >= now - timedelta(hours=hours) and row[field] is not None]
  82. return robust_baseline(prior)
  83. def source_hour(row: dict) -> dict:
  84. running = row["运行样本数"]
  85. return {
  86. "hour": row["小时"],
  87. "running_samples": running,
  88. "running_coverage": row["运行覆盖率"],
  89. "oil_avg": row["oil_avg"], "oil_min": row["oil_min"], "oil_max": row["oil_max"],
  90. "oil_low_ratio": ratio(row["oil_low_count"], running),
  91. "oil_low_low_ratio": ratio(row["oil_low_low_count"], running),
  92. "coupling_avg": row["coupling_avg"], "coupling_max": row["coupling_max"],
  93. "chain_avg": row["chain_avg"], "chain_max": row["chain_max"],
  94. "coupling_high_ratio": ratio(row["coupling_high_count"], running),
  95. "coupling_high_high_ratio": ratio(row["coupling_high_high_count"], running),
  96. "chain_high_ratio": ratio(row["chain_high_count"], running),
  97. "chain_high_high_ratio": ratio(row["chain_high_high_count"], running),
  98. }
  99. def cycle_baseline(series: list[dict], days: int = BASELINE_DAYS):
  100. """Median of daily oil medians over the first ``days`` calendar days of a cycle."""
  101. by_day: dict = {}
  102. for row in series:
  103. if row["oil_avg"] is None:
  104. continue
  105. day = row["_dt"].date()
  106. if day not in by_day and len(by_day) >= days:
  107. break
  108. by_day.setdefault(day, []).append(row["oil_avg"])
  109. daily = [median_value(values) for _, values in sorted(by_day.items())[:days]]
  110. daily = [value for value in daily if value is not None]
  111. return median_value(daily)
  112. def decline_hours(series: list[dict], end_index: int) -> int:
  113. """Consecutive valid hours of oil-pressure decline ending at end_index."""
  114. count = 0
  115. for index in range(end_index, 0, -1):
  116. current, previous = series[index]["oil_avg"], series[index - 1]["oil_avg"]
  117. if current is None or previous is None or current >= previous:
  118. break
  119. count += 1
  120. return count
  121. def window_status(window: list[dict], stages: list[dict]):
  122. start, end = window[0]["_dt"], window[-1]["_dt"]
  123. overlapping = [stage for stage in stages if stage["start"] <= end and stage["end"] >= start]
  124. if overlapping:
  125. stage = min(overlapping, key=lambda item: (item["start"], item["stage_id"]))
  126. return 1, "rule_stage", stage["stage_id"], None
  127. nearby = [stage for stage in stages if stage["start"] - timedelta(hours=ISOLATION_HOURS) <= end and stage["end"] + timedelta(hours=ISOLATION_HOURS) >= start]
  128. if nearby:
  129. return None, "excluded", None, "anomaly_isolation_period"
  130. return 0, "stable_normal", None, None
  131. def build_samples(rows: list[dict], model_type: str, stages: list[dict], window_hours: int, feature_fn):
  132. """Build window samples; ``feature_fn(series, end_index, window) -> dict``."""
  133. cycle_rows = assign_run_cycles(rows, model_type)
  134. by_cycle = defaultdict(list)
  135. for row in cycle_rows:
  136. by_cycle[(row["机组"], row["批次"], row["run_cycle_id"])].append(row)
  137. # The scanner's vibration baseline is the robust baseline over the device's
  138. # full valid history in the prior 7*24 hours, across restarts, excluding
  139. # restart transition hours. Reproduce that here so features match the rule.
  140. full_by_batch: dict[tuple, list[dict]] = {}
  141. index_by_batch: dict[tuple, dict] = {}
  142. if model_type == "vibration":
  143. grouped = defaultdict(list)
  144. for row in cycle_rows:
  145. if not row["restart_hour"]:
  146. grouped[(row["机组"], row["批次"])].append(row)
  147. for key, series in grouped.items():
  148. series.sort(key=lambda row: row["_dt"])
  149. full_by_batch[key] = series
  150. index_by_batch[key] = {row["_dt"]: index for index, row in enumerate(series)}
  151. samples = []
  152. sequence = 0
  153. for (unit, batch, cycle_id), series in sorted(by_cycle.items()):
  154. series.sort(key=lambda row: row["_dt"])
  155. for end_index in range(window_hours - 1, len(series)):
  156. window = series[end_index - window_hours + 1:end_index + 1]
  157. if any(window[index]["_dt"] - window[index - 1]["_dt"] > timedelta(hours=RESTART_GAP_HOURS) for index in range(1, len(window))):
  158. continue
  159. if any(row["restart_hour"] for row in window):
  160. label, source, stage_id, exclude_reason = None, "excluded", None, "contains_restart_transition_hour"
  161. else:
  162. scoped_stages = [stage for stage in stages if stage["unit"] == unit and stage["batch"] == batch]
  163. label, source, stage_id, exclude_reason = window_status(window, scoped_stages)
  164. sequence += 1
  165. sample_id = f"{model_type}_{unit}_B{batch}_{window[0]['_dt']:%Y%m%d%H}_{window[-1]['_dt']:%Y%m%d%H}_{sequence:06d}"
  166. event_group_id = stage_id or f"{cycle_id}_normal"
  167. if model_type == "pressure":
  168. features = feature_fn(series, end_index, window)
  169. else:
  170. key = (unit, batch)
  171. full_series = full_by_batch[key]
  172. full_index = index_by_batch[key][window[-1]["_dt"]]
  173. features = feature_fn(full_series, full_index, window)
  174. samples.append({
  175. "sample_id": sample_id, "model_type": model_type, "unit": unit, "batch": batch,
  176. "run_cycle_id": cycle_id, "event_group_id": event_group_id,
  177. "start_hour": window[0]["小时"], "end_hour": window[-1]["小时"],
  178. "valid_hour_count": window_hours,
  179. "natural_duration_hours": int((window[-1]["_dt"] - window[0]["_dt"]).total_seconds() / 3600) + 1,
  180. "label": label, "label_name": "anomaly" if label == 1 else "normal" if label == 0 else "excluded",
  181. "label_source": source, "label_confidence": "initial_rule" if label is not None else "not_usable",
  182. "rule_stage_id": stage_id, "exclude_reason": exclude_reason,
  183. "features": features, "source_hours": [source_hour(row) for row in window],
  184. })
  185. return samples
  186. def write_samples(base: Path, samples: list[dict]) -> Counter:
  187. base = Path(base)
  188. for folder in ("candidates", "excluded"):
  189. (base / folder).mkdir(parents=True, exist_ok=True)
  190. 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"]
  191. counts = Counter()
  192. with (base / "manifest.csv").open("w", encoding="utf-8-sig", newline="") as handle:
  193. writer = csv.DictWriter(handle, fieldnames=manifest_fields)
  194. writer.writeheader()
  195. for sample in samples:
  196. folder = "excluded" if sample["label"] is None else "candidates"
  197. with (base / folder / f"{sample['sample_id']}.json").open("w", encoding="utf-8") as output:
  198. json.dump(sample, output, ensure_ascii=False, indent=2, allow_nan=False)
  199. writer.writerow({field: sample.get(field, "") for field in manifest_fields})
  200. counts[(folder, sample["label_name"], sample.get("exclude_reason") or "")] += 1
  201. return counts
  202. def write_hourly(path: Path, rows: list[dict]) -> None:
  203. fields = ["机组", "批次", "小时", "样本数", "运行样本数", "运行覆盖率", *NUMERIC_HOURLY_FIELDS]
  204. path = Path(path)
  205. path.parent.mkdir(parents=True, exist_ok=True)
  206. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  207. writer = csv.DictWriter(handle, fieldnames=fields)
  208. writer.writeheader()
  209. for row in rows:
  210. writer.writerow({field: row.get(field) for field in fields})
  211. __all__ = [
  212. "WINDOW_HOURS", "ISOLATION_HOURS", "RESTART_GAP_HOURS",
  213. "stage_ranges", "assign_run_cycles", "previous_baseline", "source_hour",
  214. "cycle_baseline", "decline_hours", "window_status", "build_samples",
  215. "write_samples", "write_hourly", "mean", "value_range", "slope", "ratio", "finite",
  216. ]