data.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """Hourly temperature aggregation + robust run/stop inference for 4#~9#."""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from datetime import datetime, timedelta
  5. from models.common.db import connect
  6. from .config import EXPECTED_PER_HOUR, TEMP_MIN_HOUR_COVERAGE, UnitConfig
  7. def chunks(start: datetime, end: datetime, days: int):
  8. cursor = start
  9. while cursor < end:
  10. step = min(cursor + timedelta(days=days), end)
  11. yield cursor, step
  12. cursor = step
  13. def _run_condition(run: dict) -> str:
  14. if run["type"] == "rpm":
  15. return f"`{run['column']}` > 0"
  16. return run["sql"]
  17. def _hourly_sql(config: UnitConfig) -> tuple[str, list]:
  18. run = _run_condition(config.run)
  19. select = [
  20. "import_batch_id",
  21. "FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start",
  22. "COUNT(*) AS samples",
  23. f"SUM(CASE WHEN {run} THEN 1 ELSE 0 END) AS run_samples",
  24. ]
  25. if config.run["type"] == "cumulative":
  26. select.append(f"AVG(`{config.run['column']}`) AS cum_run")
  27. params: list = []
  28. for column in config.columns:
  29. limits = config.limits.get(column, {})
  30. high = limits.get("high")
  31. high_high = limits.get("high_high")
  32. high = 1e9 if high is None else float(high)
  33. high_high = 1e9 if high_high is None else float(high_high)
  34. select.append(f"AVG(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__avg`")
  35. select.append(f"MIN(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__min`")
  36. select.append(f"MAX(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__max`")
  37. select.append(f"STDDEV(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__std`")
  38. select.append(f"MIN(CASE WHEN {run} THEN `{column}` END) AS `{column}__minraw`")
  39. select.append(f"SUM(CASE WHEN {run} AND `{column}` = 0 THEN 1 ELSE 0 END) AS `{column}__zeros`")
  40. select.append(f"SUM(CASE WHEN ({run}) AND `{column}` >= %s THEN 1 ELSE 0 END) AS `{column}__high`")
  41. select.append(f"SUM(CASE WHEN ({run}) AND `{column}` >= %s THEN 1 ELSE 0 END) AS `{column}__high_high`")
  42. params.extend([high, high_high])
  43. sql = (
  44. "SELECT " + ",\n ".join(select) +
  45. "\nFROM pks_long_sample\n"
  46. "WHERE import_batch_id = %s AND sample_time >= %s AND sample_time < %s\n"
  47. "GROUP BY import_batch_id, FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)\n"
  48. "ORDER BY hour_start"
  49. )
  50. return sql, params
  51. def _apply_run_state(rows: list[dict], config: UnitConfig) -> None:
  52. """Infer run/stop with hysteresis and drop start/stop transition hours."""
  53. grouped: dict[int, list[dict]] = defaultdict(list)
  54. for row in rows:
  55. grouped[row["import_batch_id"]].append(row)
  56. for batch_rows in grouped.values():
  57. batch_rows.sort(key=lambda row: row["hour_start"])
  58. raw: list[bool] = []
  59. if config.run["type"] == "cumulative":
  60. previous_value = previous_dt = None
  61. for row in batch_rows:
  62. value = row.get("cum_run")
  63. state = None
  64. if value is not None and previous_value is not None:
  65. gap = (row["hour_start"] - previous_dt).total_seconds() / 3600
  66. delta = value - previous_value
  67. if gap <= 1.5 and -1.0 < delta < 2.0:
  68. if delta >= 0.5:
  69. state = True
  70. elif abs(delta) < 0.05:
  71. state = False
  72. if state is None:
  73. state = (row.get("coverage") or 0.0) >= TEMP_MIN_HOUR_COVERAGE
  74. raw.append(state)
  75. previous_value, previous_dt = value, row["hour_start"]
  76. elif config.run["type"] == "multi":
  77. raw = [(row.get("coverage") or 0.0) >= 0.5 for row in batch_rows]
  78. else:
  79. raw = [(row.get("coverage") or 0.0) >= TEMP_MIN_HOUR_COVERAGE for row in batch_rows]
  80. # 2-hour hysteresis: switch state only after two consecutive opposite hours.
  81. states: list[bool] = []
  82. current = raw[0] if raw else False
  83. pending = 0
  84. for value in raw:
  85. if value == current:
  86. pending = 0
  87. else:
  88. pending += 1
  89. if pending >= 2:
  90. current = value
  91. pending = 0
  92. states.append(current)
  93. transition = [False] * len(states)
  94. for index, state in enumerate(states):
  95. if not state:
  96. continue
  97. if index == 0 or not states[index - 1]:
  98. transition[index] = True
  99. if index + 1 < len(states):
  100. transition[index + 1] = True
  101. if index == len(states) - 1 or not states[index + 1]:
  102. transition[index] = True
  103. for row, state, trans in zip(batch_rows, states, transition):
  104. row["run_state"] = bool(state)
  105. row["transition"] = bool(trans)
  106. row["valid"] = bool(state and not trans)
  107. def fetch_unit_hourly(config: UnitConfig, start: datetime, end: datetime, chunk_days: int = 30) -> list[dict]:
  108. sql, params = _hourly_sql(config)
  109. rows: list[dict] = []
  110. connection = connect()
  111. try:
  112. with connection.cursor() as cursor:
  113. for batch in config.batches:
  114. for chunk_start, chunk_end in chunks(start, end, chunk_days):
  115. cursor.execute(sql, [*params, batch, chunk_start, chunk_end])
  116. for row in cursor.fetchall():
  117. row["机组"] = config.name
  118. row["coverage"] = (row.get("run_samples") or 0) / EXPECTED_PER_HOUR
  119. rows.append(row)
  120. finally:
  121. connection.close()
  122. rows.sort(key=lambda row: (row["import_batch_id"], row["hour_start"]))
  123. _apply_run_state(rows, config)
  124. return rows
  125. __all__ = ["fetch_unit_hourly"]