hourly.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. """把 15 分钟自适应标签汇总成小时标签,并合并相邻小时。"""
  2. from __future__ import annotations
  3. from datetime import timedelta
  4. import pandas as pd
  5. from . import config
  6. def build_hourly(intervals: pd.DataFrame, frame: pd.DataFrame | None = None) -> pd.DataFrame:
  7. if intervals.empty:
  8. return pd.DataFrame()
  9. rows = intervals.copy()
  10. rows["start_time"] = pd.to_datetime(rows["start_time"])
  11. rows["end_time"] = pd.to_datetime(rows["end_time"])
  12. frame_by_cylinder = {}
  13. if frame is not None:
  14. frame = frame.copy()
  15. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  16. frame_by_cylinder = {name: group.sort_values("sample_time") for name, group in frame.groupby("cylinder")}
  17. buckets: dict[tuple[str, pd.Timestamp], dict] = {}
  18. for row in rows.itertuples(index=False):
  19. hour = pd.Timestamp(row.start_time).floor("h")
  20. end = pd.Timestamp(row.end_time)
  21. hours = []
  22. if row.cylinder in frame_by_cylinder:
  23. part = frame_by_cylinder[row.cylinder]
  24. part = part[(part["sample_time"] >= row.start_time) & (part["sample_time"] <= end)]
  25. counts = part.groupby(part["sample_time"].dt.floor("h")).size()
  26. hours = [(pd.Timestamp(hour_value), int(count)) for hour_value, count in counts.items() if count >= 2]
  27. if not hours and frame is None:
  28. # 没有明细时退回“任意重叠”,兼容旧调用。
  29. cursor = hour
  30. while cursor < end:
  31. hours.append((cursor, 0))
  32. cursor += timedelta(hours=1)
  33. for hour, point_count in hours:
  34. key = (row.cylinder, hour)
  35. current = buckets.get(key)
  36. candidate = {
  37. "cylinder": row.cylinder,
  38. "stage": row.stage,
  39. "hour": hour,
  40. "max_score": float(row.max_score),
  41. "direction": row.direction,
  42. "level": row.level,
  43. "confirmed": bool(row.multi_feature_confirmed),
  44. "start_time": pd.Timestamp(row.start_time),
  45. "end_time": pd.Timestamp(row.end_time),
  46. }
  47. candidate["point_count"] = point_count
  48. if current is None or candidate["max_score"] > current["max_score"]:
  49. buckets[key] = candidate
  50. else:
  51. current["confirmed"] = current["confirmed"] or candidate["confirmed"]
  52. current["point_count"] = max(current.get("point_count", 0), point_count)
  53. ordered = sorted(buckets.values(), key=lambda item: (item["cylinder"], item["hour"]))
  54. merged: list[dict] = []
  55. for item in ordered:
  56. if (
  57. merged
  58. and merged[-1]["cylinder"] == item["cylinder"]
  59. and merged[-1]["end_hour"] == item["hour"]
  60. ):
  61. merged[-1]["end_hour"] = item["hour"] + timedelta(hours=1)
  62. merged[-1]["max_score"] = max(merged[-1]["max_score"], item["max_score"])
  63. merged[-1]["confirmed"] = merged[-1]["confirmed"] or item["confirmed"]
  64. if item["max_score"] > merged[-1]["peak_score"]:
  65. merged[-1]["peak_score"] = item["max_score"]
  66. merged[-1]["direction"] = item["direction"]
  67. merged[-1]["level"] = item["level"]
  68. else:
  69. merged.append(
  70. {
  71. "cylinder": item["cylinder"],
  72. "stage": item["stage"],
  73. "start_hour": item["hour"],
  74. "end_hour": item["hour"] + timedelta(hours=1),
  75. "max_score": item["max_score"],
  76. "peak_score": item["max_score"],
  77. "direction": item["direction"],
  78. "level": item["level"],
  79. "confirmed": item["confirmed"],
  80. }
  81. )
  82. for item in merged:
  83. item["hour_count"] = int((item["end_hour"] - item["start_hour"]).total_seconds() / 3600)
  84. group = frame_by_cylinder.get(item["cylinder"])
  85. if group is not None:
  86. part = group[(group["sample_time"] >= item["start_hour"]) & (group["sample_time"] < item["end_hour"])]
  87. previous = group[group["sample_time"] < item["start_hour"]].tail(1)
  88. item["gap_before_hours"] = (
  89. round((item["start_hour"] - pd.Timestamp(previous["sample_time"].iloc[0])).total_seconds() / 3600, 2)
  90. if not previous.empty else None
  91. )
  92. item["transition_ratio"] = round(float(part["transition"].mean()), 2) if "transition" in part else 0.0
  93. else:
  94. item["gap_before_hours"] = None
  95. item["transition_ratio"] = 0.0
  96. result = pd.DataFrame(merged)
  97. return result.sort_values(["start_hour", "cylinder"]).reset_index(drop=True)
  98. def clean_observations(labels: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
  99. """只清理弱观察标签;确认标签全部保留。"""
  100. if labels.empty:
  101. return labels, labels
  102. kept, removed = [], []
  103. for row in labels.itertuples(index=False):
  104. reasons = []
  105. if not bool(row.confirmed):
  106. if row.gap_before_hours is not None and row.gap_before_hours >= 2:
  107. reasons.append("前面断档")
  108. if row.direction == "未确认":
  109. reasons.append("未确认")
  110. if row.direction == "高幅":
  111. reasons.append("仅高幅")
  112. if float(row.transition_ratio or 0) >= 0.5:
  113. reasons.append("启动/过渡")
  114. if reasons:
  115. item = row._asdict()
  116. item["remove_reason"] = " / ".join(dict.fromkeys(reasons))
  117. removed.append(item)
  118. else:
  119. kept.append(row._asdict())
  120. kept_frame = pd.DataFrame(kept).sort_values(["start_hour", "cylinder"]).reset_index(drop=True) if kept else pd.DataFrame(columns=labels.columns)
  121. removed_frame = pd.DataFrame(removed).sort_values(["start_hour", "cylinder"]).reset_index(drop=True) if removed else pd.DataFrame(columns=list(labels.columns) + ["remove_reason"])
  122. return kept_frame, removed_frame