stats.py 2.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. """Robust statistics and trend helpers shared by both algorithms."""
  2. from __future__ import annotations
  3. import math
  4. from statistics import median
  5. from .constants import MIN_HISTORY_HOURS
  6. def number(value):
  7. if value is None:
  8. return None
  9. try:
  10. value = float(value)
  11. except (TypeError, ValueError):
  12. return None
  13. return value if math.isfinite(value) else None
  14. def finite(value):
  15. return number(value)
  16. def median_value(values):
  17. values = [value for value in values if value is not None]
  18. return median(values) if values else None
  19. def max_consecutive(values):
  20. longest = 0
  21. current = 0
  22. for value in values:
  23. if value:
  24. current += 1
  25. longest = max(longest, current)
  26. else:
  27. current = 0
  28. return longest
  29. def robust_baseline(values, minimum: int = MIN_HISTORY_HOURS):
  30. values = [value for value in values if value is not None]
  31. if len(values) < minimum:
  32. return None, None
  33. center = median(values)
  34. mad = median(abs(value - center) for value in values)
  35. # Keep a meaningful relative scale when a sensor is very stable.
  36. scale = max(1.4826 * mad, abs(center) * 0.05, 1e-6)
  37. return center, scale
  38. def linear_slope(values):
  39. pairs = [(index, value) for index, value in enumerate(values) if value is not None]
  40. if len(pairs) < 3:
  41. return None
  42. xbar = sum(x for x, _ in pairs) / len(pairs)
  43. ybar = sum(y for _, y in pairs) / len(pairs)
  44. denominator = sum((x - xbar) ** 2 for x, _ in pairs)
  45. if denominator == 0:
  46. return None
  47. return sum((x - xbar) * (y - ybar) for x, y in pairs) / denominator
  48. def consecutive_decline_hours(history: list[dict], current: dict) -> int:
  49. """Count contiguous valid hours that have not risen from the prior hour."""
  50. from datetime import timedelta
  51. count = 1
  52. newer = current
  53. for older in reversed(history):
  54. if newer["_dt"] - older["_dt"] != timedelta(hours=1):
  55. break
  56. if newer["oil_avg"] is None or older["oil_avg"] is None or newer["oil_avg"] > older["oil_avg"]:
  57. break
  58. count += 1
  59. newer = older
  60. return count
  61. def consecutive_flag_hours(history: list[dict], current: dict, field: str) -> int:
  62. from datetime import timedelta
  63. count = 1 if current.get(field) else 0
  64. if not count:
  65. return 0
  66. newer = current
  67. for older in reversed(history):
  68. if newer["_dt"] - older["_dt"] != timedelta(hours=1) or not older.get(field):
  69. break
  70. count += 1
  71. newer = older
  72. return count
  73. __all__ = [
  74. "number", "finite", "median_value", "max_consecutive", "robust_baseline",
  75. "linear_slope", "consecutive_decline_hours", "consecutive_flag_hours",
  76. ]