| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144 |
- """Hourly temperature aggregation + robust run/stop inference for 4#~9#."""
- from __future__ import annotations
- from collections import defaultdict
- from datetime import datetime, timedelta
- from models.common.db import connect
- from .config import EXPECTED_PER_HOUR, TEMP_MIN_HOUR_COVERAGE, UnitConfig
- def chunks(start: datetime, end: datetime, days: int):
- cursor = start
- while cursor < end:
- step = min(cursor + timedelta(days=days), end)
- yield cursor, step
- cursor = step
- def _run_condition(run: dict) -> str:
- if run["type"] == "rpm":
- return f"`{run['column']}` > 0"
- return run["sql"]
- def _hourly_sql(config: UnitConfig) -> tuple[str, list]:
- run = _run_condition(config.run)
- select = [
- "import_batch_id",
- "FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start",
- "COUNT(*) AS samples",
- f"SUM(CASE WHEN {run} THEN 1 ELSE 0 END) AS run_samples",
- ]
- if config.run["type"] == "cumulative":
- select.append(f"AVG(`{config.run['column']}`) AS cum_run")
- params: list = []
- for column in config.columns:
- limits = config.limits.get(column, {})
- high = limits.get("high")
- high_high = limits.get("high_high")
- high = 1e9 if high is None else float(high)
- high_high = 1e9 if high_high is None else float(high_high)
- select.append(f"AVG(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__avg`")
- select.append(f"MIN(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__min`")
- select.append(f"MAX(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__max`")
- select.append(f"STDDEV(CASE WHEN {run} AND `{column}` > 0 THEN `{column}` END) AS `{column}__std`")
- select.append(f"MIN(CASE WHEN {run} THEN `{column}` END) AS `{column}__minraw`")
- select.append(f"SUM(CASE WHEN {run} AND `{column}` = 0 THEN 1 ELSE 0 END) AS `{column}__zeros`")
- select.append(f"SUM(CASE WHEN ({run}) AND `{column}` >= %s THEN 1 ELSE 0 END) AS `{column}__high`")
- select.append(f"SUM(CASE WHEN ({run}) AND `{column}` >= %s THEN 1 ELSE 0 END) AS `{column}__high_high`")
- params.extend([high, high_high])
- sql = (
- "SELECT " + ",\n ".join(select) +
- "\nFROM pks_long_sample\n"
- "WHERE import_batch_id = %s AND sample_time >= %s AND sample_time < %s\n"
- "GROUP BY import_batch_id, FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)\n"
- "ORDER BY hour_start"
- )
- return sql, params
- def _apply_run_state(rows: list[dict], config: UnitConfig) -> None:
- """Infer run/stop with hysteresis and drop start/stop transition hours."""
- grouped: dict[int, list[dict]] = defaultdict(list)
- for row in rows:
- grouped[row["import_batch_id"]].append(row)
- for batch_rows in grouped.values():
- batch_rows.sort(key=lambda row: row["hour_start"])
- raw: list[bool] = []
- if config.run["type"] == "cumulative":
- previous_value = previous_dt = None
- for row in batch_rows:
- value = row.get("cum_run")
- state = None
- if value is not None and previous_value is not None:
- gap = (row["hour_start"] - previous_dt).total_seconds() / 3600
- delta = value - previous_value
- if gap <= 1.5 and -1.0 < delta < 2.0:
- if delta >= 0.5:
- state = True
- elif abs(delta) < 0.05:
- state = False
- if state is None:
- state = (row.get("coverage") or 0.0) >= TEMP_MIN_HOUR_COVERAGE
- raw.append(state)
- previous_value, previous_dt = value, row["hour_start"]
- elif config.run["type"] == "multi":
- raw = [(row.get("coverage") or 0.0) >= 0.5 for row in batch_rows]
- else:
- raw = [(row.get("coverage") or 0.0) >= TEMP_MIN_HOUR_COVERAGE for row in batch_rows]
- # 2-hour hysteresis: switch state only after two consecutive opposite hours.
- states: list[bool] = []
- current = raw[0] if raw else False
- pending = 0
- for value in raw:
- if value == current:
- pending = 0
- else:
- pending += 1
- if pending >= 2:
- current = value
- pending = 0
- states.append(current)
- transition = [False] * len(states)
- for index, state in enumerate(states):
- if not state:
- continue
- if index == 0 or not states[index - 1]:
- transition[index] = True
- if index + 1 < len(states):
- transition[index + 1] = True
- if index == len(states) - 1 or not states[index + 1]:
- transition[index] = True
- for row, state, trans in zip(batch_rows, states, transition):
- row["run_state"] = bool(state)
- row["transition"] = bool(trans)
- row["valid"] = bool(state and not trans)
- def fetch_unit_hourly(config: UnitConfig, start: datetime, end: datetime, chunk_days: int = 30) -> list[dict]:
- sql, params = _hourly_sql(config)
- rows: list[dict] = []
- connection = connect()
- try:
- with connection.cursor() as cursor:
- for batch in config.batches:
- for chunk_start, chunk_end in chunks(start, end, chunk_days):
- cursor.execute(sql, [*params, batch, chunk_start, chunk_end])
- for row in cursor.fetchall():
- row["机组"] = config.name
- row["coverage"] = (row.get("run_samples") or 0) / EXPECTED_PER_HOUR
- rows.append(row)
- finally:
- connection.close()
- rows.sort(key=lambda row: (row["import_batch_id"], row["hour_start"]))
- _apply_run_state(rows, config)
- return rows
- __all__ = ["fetch_unit_hourly"]
|