Skip to content
2 changes: 2 additions & 0 deletions config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ security_duration = 7200
ph_pwm_period = 120
# PWM period in seconds for Cl
cl_pwm_period = 120
# Number of filtration cycles (~2 days) used to detect an empty pH- or Cl canister
canister_cycles = 3

[heating]
# Delay in seconds that the flow pump will run after the heat pump stopped when going from heating
Expand Down
182 changes: 181 additions & 1 deletion controller/disinfection.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@

import logging
import time
from collections import deque
from datetime import datetime, timedelta
from typing import Final
from typing import Final, NamedTuple

from .actor import PoupoolActor, PoupoolModel
from .config import config
Expand Down Expand Up @@ -102,12 +103,170 @@ def compute(self):
return constrain((self.pterm * self.__scale) * error, 0, 1)


class CycleStats(NamedTuple):
peak: float
mean: float


class CanisterMonitor:
MIN_CYCLE_SAMPLES: Final = 15
MAX_CYCLE_SAMPLES: Final = 120

def __init__(
self,
name: str,
cycles: int = 3,
min_samples: int = MIN_CYCLE_SAMPLES,
max_samples: int = MAX_CYCLE_SAMPLES,
) -> None:
self.__name: str = name
self.__cycles_count: int = cycles
self.__min_samples: int = min_samples
self.__max_samples: int = max_samples
self.__history: deque[CycleStats] = deque(maxlen=cycles)
self.__current_samples: list[float] = []
self.__is_empty: bool = False
self.__alert_stats: CycleStats | None = None

@property
def is_empty(self) -> bool:
return self.__is_empty

@property
def history(self) -> list[CycleStats]:
return list(self.__history)

@property
def current_samples(self) -> list[float]:
return list(self.__current_samples)

@property
def alert_stats(self) -> CycleStats | None:
return self.__alert_stats

def restore(self, is_empty: bool) -> None:
self.__is_empty = is_empty
if not is_empty:
self.__alert_stats = None
self.__history.clear()
self.__current_samples.clear()

def add_sample(self, value: float) -> bool:
"""Record a sample. Returns True if is_empty state changed."""
self.__current_samples.append(value)
if self.__is_empty and len(self.__current_samples) >= self.__min_samples:
current = self.__compute_current_stats()
if self.__is_low_demand(current):
logger.info(f"{self.__name} canister refilled: low duty cycle restored ({current.peak * 100:.1f}%)")
self.__clear_empty()
return True
if len(self.__current_samples) >= self.__max_samples:
return self.finish_cycle()
return False

def finish_cycle(self) -> bool:
"""Finalize the current cycle. Returns True if is_empty state changed."""
if len(self.__current_samples) < self.__min_samples:
self.__current_samples.clear()
return False
stats = self.__compute_current_stats()
self.__current_samples.clear()
if self.__is_empty:
if self.__should_clear(stats):
logger.info(
f"{self.__name} canister refilled: duty cycle dropped "
f"(peak: {stats.peak * 100:.1f}%, mean: {stats.mean * 100:.1f}%)"
)
self.__clear_empty()
return True
if self.__alert_stats is None or stats.peak > self.__alert_stats.peak:
self.__alert_stats = stats
return False
self.__history.append(stats)
if self.__should_trigger():
self.__is_empty = True
self.__alert_stats = stats
logger.warning(
f"{self.__name} canister empty detected! Peak: {stats.peak * 100:.1f}%, mean: {stats.mean * 100:.1f}%"
)
return True
return False

def __compute_current_stats(self) -> CycleStats:
return CycleStats(
peak=max(self.__current_samples),
mean=sum(self.__current_samples) / len(self.__current_samples),
)

def __should_trigger(self) -> bool:
if len(self.__history) < self.__cycles_count:
return False
peaks = [c.peak for c in self.__history]
means = [c.mean for c in self.__history]

# All cycles in the window must show persistent demand (never recovering to low duty cycle)
if any(p < 0.08 or m < 0.06 for p, m in zip(peaks, means, strict=True)):
return False

# 1. High-duty saturation fallback (all cycles sustained high)
if all(p >= 0.35 and m >= 0.25 for p, m in zip(peaks, means, strict=True)):
return True

# 2. Consistent multi-cycle upward trend in peak or mean
if peaks[-1] < 0.20 and means[-1] < 0.15:
return False
# Trend deltas scale adaptively based on the cycle window size:
# - Shorter windows (<= 3 cycles) detect rapid empty canister climb earlier (~12-18h)
# with smaller total delta (min_delta_p = 0.07, min_delta_m = 0.05).
# - Longer windows (4-6 cycles) require a larger cumulative delta (0.10 - 0.12)
# to avoid drift over multiple days.
min_delta_p = 0.07 if self.__cycles_count <= 3 else (0.10 if self.__cycles_count <= 4 else 0.12)
min_delta_m = 0.05 if self.__cycles_count <= 3 else (0.08 if self.__cycles_count <= 4 else 0.10)
return self.__has_upward_trend(peaks, min_delta=min_delta_p) or self.__has_upward_trend(
means, min_delta=min_delta_m
)

def __has_upward_trend(self, values: list[float], min_delta: float, tolerance: float = 0.01) -> bool:
"""Check if values follow a consistent upward trend.

For short windows (n <= 4), every step must be strictly increasing (n - 1)
to prevent false positives from brief fluctuations. For longer windows (n > 4),
at most one step is allowed to plateau (n - 2).
"""
n = len(values)
if values[-1] - values[0] < min_delta:
return False
min_incr = n - 1 if n <= 4 else n - 2
increasing = sum(1 for i in range(1, n) if values[i] > values[i - 1])
no_drops = all(values[i] >= values[i - 1] - tolerance for i in range(1, n))
return increasing >= min_incr and no_drops

@staticmethod
def __is_low_demand(stats: CycleStats) -> bool:
return stats.peak < 0.10 and stats.mean < 0.08

def __should_clear(self, stats: CycleStats) -> bool:
if self.__is_low_demand(stats):
return True
return (
self.__alert_stats is not None
and stats.peak <= self.__alert_stats.peak - 0.15
and stats.mean <= self.__alert_stats.mean * 0.5
)

def __clear_empty(self) -> None:
self.__is_empty = False
self.__alert_stats = None
self.__history.clear()


class Disinfection(PoupoolActor):
STATE_REFRESH_DELAY = 10
START_DELAY = int(config["disinfection", "start_delay"])
WAITING_DELAY = int(config["disinfection", "waiting_delay"])
PH_PWM_PERIOD = int(config["disinfection", "ph_pwm_period"])
CL_PWM_PERIOD = int(config["disinfection", "cl_pwm_period"])
CANISTER_CYCLES = int(config["disinfection", "canister_cycles"])

states: Final = [
"halt",
Expand Down Expand Up @@ -135,6 +294,9 @@ def __init__(self, encoder, devices, sensors_reader, sensors_writer, disable=Fal
# Chlorine
self.__cl = PWM.start("cl", self.__devices.get_pump("cl")).proxy()
self.__cl.period = Disinfection.CL_PWM_PERIOD
# Canister empty monitors
self.__ph_canister = CanisterMonitor("pH", cycles=Disinfection.CANISTER_CYCLES)
self.__cl_canister = CanisterMonitor("Cl", cycles=Disinfection.CANISTER_CYCLES)
# Initialize the state machine
self.__machine = PoupoolModel(model=self, states=Disinfection.states, initial="halt")

Expand Down Expand Up @@ -169,6 +331,16 @@ def orp_pterm(self, value):
self.__orp_controller.pterm = value
logger.info(f"ORP pterm set to: {self.__orp_controller.pterm:.2f}")

def ph_canister_empty(self, value: bool) -> None:
if self.__ph_canister.is_empty != value:
self.__ph_canister.restore(value)
logger.info(f"pH canister empty flag set to {value}")

def cl_canister_empty(self, value: bool) -> None:
if self.__cl_canister.is_empty != value:
self.__cl_canister.restore(value)
logger.info(f"Cl canister empty flag set to {value}")

def is_disabled(self):
return self.__is_disabled

Expand All @@ -182,6 +354,10 @@ def on_enter_halt(self):
self.__encoder.disinfection_cl_feedback(0)
self.__encoder.disinfection_ph_feedback(0)
self.__sensors_writer.do_cancel.defer()
if self.__ph_enable and self.__ph_canister.finish_cycle():
self.__encoder.disinfection_ph_canister__empty(1 if self.__ph_canister.is_empty else 0, retain=True)
if self.__orp_enable and self.__cl_canister.finish_cycle():
self.__encoder.disinfection_cl_canister__empty(1 if self.__cl_canister.is_empty else 0, retain=True)

def on_enter_waiting(self):
logger.info("Entering waiting state")
Expand All @@ -205,13 +381,17 @@ def on_enter_running_adjusting(self):
self.__encoder.disinfection_ph_feedback(round(ph_feedback * 100))
logger.debug(f"pH: {ph:.2f} feedback: {ph_feedback:.2f}")
self.__ph.value = ph_feedback
if self.__ph_enable and self.__ph_canister.add_sample(ph_feedback):
self.__encoder.disinfection_ph_canister__empty(1 if self.__ph_canister.is_empty else 0, retain=True)
# ORP/Chlorine
orp = self.__sensors_reader.get_orp().get()
self.__orp_controller.current = orp
cl_feedback = self.__orp_controller.compute() if self.__orp_enable else 0
self.__encoder.disinfection_cl_feedback(round(cl_feedback * 100))
logger.debug(f"ORP: {orp} feedback: {cl_feedback:.2f}")
self.__cl.value = cl_feedback
if self.__orp_enable and self.__cl_canister.add_sample(cl_feedback):
self.__encoder.disinfection_cl_canister__empty(1 if self.__cl_canister.is_empty else 0, retain=True)
self._proxy.treat.defer()

def on_enter_running_treating(self):
Expand Down
14 changes: 14 additions & 0 deletions controller/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,20 @@ def register(self, filtration, tank, swim, light, heater, heating, disinfection,
to_float,
False,
),
"/status/disinfection/ph/canister_empty": (
disinfection,
lambda x: x.lower() in ("0", "1", "off", "on"),
lambda _: "ph_canister_empty",
to_bool,
False,
),
"/status/disinfection/cl/canister_empty": (
disinfection,
lambda x: x.lower() in ("0", "1", "off", "on"),
lambda _: "cl_canister_empty",
to_bool,
False,
),
"/status/water/counter": (
arduino,
greater_equal(0),
Expand Down
2 changes: 2 additions & 0 deletions default_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ def publish(topic, value):
publish("/settings/disinfection/orp/enable", "1")
publish("/settings/disinfection/orp/setpoint", "600")
publish("/settings/disinfection/orp/pterm", "1.0")
publish("/status/disinfection/ph/canister_empty", "0")
publish("/status/disinfection/cl/canister_empty", "0")

print("\n**** Missing default parameters ***")
[print(t) for t in remaining]
Expand Down
24 changes: 24 additions & 0 deletions home-assistant/mqtt.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,28 @@
mqtt:
binary_sensor:
# DESINFECTION
- name: "Desinfection pH Canister Empty"
unique_id: "desinfection_ph_canister_empty"
icon: mdi:bottle-tonic-alert
state_topic: "/status/disinfection/ph/canister_empty"
payload_on: "1"
payload_off: "0"
device_class: "problem"
device:
identifiers: "Poupool01"
name: "Poupool"

- name: "Desinfection Cl Canister Empty"
unique_id: "desinfection_cl_canister_empty"
icon: mdi:bottle-tonic-alert
state_topic: "/status/disinfection/cl/canister_empty"
payload_on: "1"
payload_off: "0"
device_class: "problem"
device:
identifiers: "Poupool01"
name: "Poupool"

climate:
- name: "Heating"
unique_id: "heating"
Expand Down
Loading
Loading