queries.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """Hourly aggregation queries and the shared hourly intermediate table."""
  2. from __future__ import annotations
  3. import csv
  4. from collections import defaultdict
  5. from datetime import datetime, timedelta
  6. from pathlib import Path
  7. from . import stats
  8. from .constants import EXPECTED_PER_HOUR, MIN_HOUR_COVERAGE
  9. from .db import connect
  10. from .settings import BACKEND # noqa: F401 (ensures backend path is importable)
  11. HOURLY_SQL = """
  12. SELECT
  13. import_batch_id,
  14. FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  15. COUNT(*) AS samples,
  16. SUM(CASE WHEN YSJ_41 > 0 THEN 1 ELSE 0 END) AS running_samples,
  17. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
  18. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
  19. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
  20. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
  21. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
  22. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
  23. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
  24. SUM(CASE WHEN YSJ_41 > 0 AND YSJ_5 <= %s THEN 1 ELSE 0 END) AS oil_low_count,
  25. SUM(CASE WHEN YSJ_41 > 0 AND YSJ_5 <= %s THEN 1 ELSE 0 END) AS oil_low_low_count,
  26. SUM(CASE WHEN YSJ_41 > 0 AND YSJ_10 >= %s THEN 1 ELSE 0 END) AS coupling_high_count,
  27. SUM(CASE WHEN YSJ_41 > 0 AND YSJ_10 >= %s THEN 1 ELSE 0 END) AS coupling_high_high_count,
  28. SUM(CASE WHEN YSJ_41 > 0 AND YSJ_11 >= %s THEN 1 ELSE 0 END) AS chain_high_count,
  29. SUM(CASE WHEN YSJ_41 > 0 AND YSJ_11 >= %s THEN 1 ELSE 0 END) AS chain_high_high_count
  30. FROM pks_long_sample
  31. WHERE import_batch_id = %s
  32. AND sample_time >= %s
  33. AND sample_time < %s
  34. GROUP BY import_batch_id, FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  35. ORDER BY hour_start, import_batch_id
  36. """
  37. NUMERIC_HOURLY_FIELDS = (
  38. "oil_avg", "oil_min", "oil_max", "coupling_avg", "coupling_max",
  39. "chain_avg", "chain_max", "oil_low_count", "oil_low_low_count",
  40. "coupling_high_count", "coupling_high_high_count", "chain_high_count",
  41. "chain_high_high_count",
  42. )
  43. def parse_dt(value: str) -> datetime:
  44. return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
  45. def chunks(start: datetime, end: datetime, days: int):
  46. cursor = start
  47. while cursor < end:
  48. nxt = min(cursor + timedelta(days=days), end)
  49. yield cursor, nxt
  50. cursor = nxt
  51. def unit_sort_key(value):
  52. import re
  53. match = re.search(r"[789]", str(value or ""))
  54. return int(match.group()) if match else 999
  55. def load_batch_ranges(batches: list[int], start: datetime, end: datetime) -> dict[int, tuple[datetime, datetime]]:
  56. placeholders = ",".join(["%s"] * len(batches))
  57. connection = connect()
  58. try:
  59. with connection.cursor() as cursor:
  60. cursor.execute(
  61. f"""
  62. SELECT import_batch_id, MIN(sample_time) AS data_start, MAX(sample_time) AS data_end
  63. FROM pks_long_sample
  64. WHERE import_batch_id IN ({placeholders})
  65. AND sample_time >= %s AND sample_time < %s
  66. GROUP BY import_batch_id
  67. """,
  68. (*batches, start, end),
  69. )
  70. return {
  71. int(row["import_batch_id"]): (
  72. max(row["data_start"], start),
  73. min(row["data_end"] + timedelta(seconds=5), end),
  74. )
  75. for row in cursor.fetchall()
  76. }
  77. finally:
  78. connection.close()
  79. def query_params(batches: list[int], start: datetime, end: datetime, config: dict) -> tuple:
  80. return (
  81. config["oil"]["low"], config["oil"]["low_low"],
  82. config["coupling"]["high"], config["coupling"]["high_high"],
  83. config["chain"]["high"], config["chain"]["high_high"],
  84. *batches, start, end,
  85. )
  86. def fetch_hourly(unit: str, batches: list[int], start: datetime, end: datetime, chunk_days: int, config: dict):
  87. rows = []
  88. placeholders = ",".join(["%s"] * len(batches))
  89. sql = HOURLY_SQL.replace("import_batch_id = %s", f"import_batch_id IN ({placeholders})")
  90. connection = connect()
  91. try:
  92. with connection.cursor() as cursor:
  93. for index, (chunk_start, chunk_end) in enumerate(chunks(start, end, chunk_days), 1):
  94. params = query_params(batches, chunk_start, chunk_end, config)
  95. cursor.execute("EXPLAIN " + sql, params)
  96. plan = cursor.fetchone()
  97. if not plan or plan.get("key") not in ("PRIMARY",):
  98. raise RuntimeError(
  99. f"安全检查失败:unit={unit} batches={batches} chunk={chunk_start} "
  100. f"EXPLAIN 未使用 PRIMARY: {plan}"
  101. )
  102. cursor.execute(sql, params)
  103. chunk_count = 0
  104. for raw in cursor.fetchall():
  105. batch = int(raw["import_batch_id"])
  106. row = {
  107. "批次": batch,
  108. "机组": unit,
  109. "小时": raw["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
  110. "样本数": int(raw["samples"] or 0),
  111. "运行样本数": int(raw["running_samples"] or 0),
  112. }
  113. row["运行覆盖率"] = row["运行样本数"] / EXPECTED_PER_HOUR
  114. for field in NUMERIC_HOURLY_FIELDS:
  115. row[field] = stats.number(raw[field])
  116. row["_dt"] = raw["hour_start"]
  117. rows.append(row)
  118. chunk_count += 1
  119. print(
  120. f"unit={unit} batches={batches} chunk={index} {chunk_start}..{chunk_end}: "
  121. f"{chunk_count:,} hourly rows"
  122. )
  123. finally:
  124. connection.close()
  125. return rows
  126. def fetch_all_hourly(start: datetime, end: datetime, chunk_days: int, batch_to_unit: dict[int, str], configs: dict[int, dict], selected_batches=None):
  127. """Fetch hourly rows for every selected batch, grouped by unit like the scanner."""
  128. selected = selected_batches or sorted(batch_to_unit)
  129. batch_ranges = load_batch_ranges(selected, start, end)
  130. grouped = defaultdict(list)
  131. for batch in selected:
  132. if batch in batch_ranges:
  133. grouped[batch_to_unit[batch]].append(batch)
  134. rows = []
  135. for unit, batches in sorted(grouped.items(), key=lambda item: unit_sort_key(item[0])):
  136. unit_start = min(batch_ranges[batch][0] for batch in batches)
  137. unit_end = max(batch_ranges[batch][1] for batch in batches)
  138. rows.extend(fetch_hourly(unit, sorted(batches), unit_start, unit_end, chunk_days, configs[batches[0]]))
  139. rows.sort(key=lambda row: (unit_sort_key(row["机组"]), row["_dt"], row["批次"]))
  140. return rows
  141. HOURLY_CSV_FIELDS = ["机组", "批次", "小时", "样本数", "运行样本数", "运行覆盖率", *NUMERIC_HOURLY_FIELDS]
  142. def write_hourly_csv(path: Path, rows: list[dict]) -> None:
  143. path.parent.mkdir(parents=True, exist_ok=True)
  144. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  145. writer = csv.DictWriter(handle, fieldnames=HOURLY_CSV_FIELDS)
  146. writer.writeheader()
  147. for row in rows:
  148. writer.writerow({field: row.get(field) for field in HOURLY_CSV_FIELDS})
  149. def load_hourly_csv(path: Path) -> list[dict]:
  150. """Rebuild the hourly row structure from a saved hourly.csv without querying the DB."""
  151. with Path(path).open(encoding="utf-8-sig", newline="") as handle:
  152. raw_rows = list(csv.DictReader(handle))
  153. result = []
  154. for row in raw_rows:
  155. item = {
  156. "机组": row["机组"],
  157. "批次": int(row["批次"]),
  158. "小时": row["小时"],
  159. "_dt": parse_dt(row["小时"]),
  160. "样本数": int(row["样本数"]),
  161. "运行样本数": int(row["运行样本数"]),
  162. "运行覆盖率": float(row["运行覆盖率"]),
  163. }
  164. for field in NUMERIC_HOURLY_FIELDS:
  165. item[field] = stats.finite(row.get(field))
  166. result.append(item)
  167. result.sort(key=lambda item: (unit_sort_key(item["机组"]), item["批次"], item["_dt"]))
  168. return result
  169. __all__ = [
  170. "HOURLY_SQL", "NUMERIC_HOURLY_FIELDS", "HOURLY_CSV_FIELDS",
  171. "parse_dt", "chunks", "unit_sort_key", "load_batch_ranges", "query_params",
  172. "fetch_hourly", "fetch_all_hourly", "write_hourly_csv", "load_hourly_csv",
  173. "MIN_HOUR_COVERAGE",
  174. ]