queries.py 9.0 KB

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