windowing.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. """Shared window construction for the pressure and vibration models.
  2. These helpers reproduce ``backend/model_assistant_generalized/build_datasets.py``
  3. and only expose the parts that are identical for both model types. The model
  4. 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. ]