diff --git a/micro_ondes/esp_lora/lib/microwaveScreen.py b/micro_ondes/esp_lora/lib/microwaveScreen.py index 2e897c1..dde160d 100644 --- a/micro_ondes/esp_lora/lib/microwaveScreen.py +++ b/micro_ondes/esp_lora/lib/microwaveScreen.py @@ -94,7 +94,7 @@ class MicrowaveScreen: if cooking_state.paused: self.h_centered_text("Paused", 40, 1) elif cooking_state.state != CookingStates.DONE: - remaining_time = cooking_state.estimated_remaining_time + remaining_time = int(cooking_state.estimated_remaining_time) minutes = remaining_time // 60 seconds = remaining_time % 60 self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1) diff --git a/shared/cookingState.py b/shared/cookingState.py index c841394..2b76ed9 100644 --- a/shared/cookingState.py +++ b/shared/cookingState.py @@ -3,6 +3,7 @@ import time class CookingState: TEMPERATURE_TOLERANCE = 1.0 + MIN_SIGNIFICANT_HEATING_RATE = 0.05 # °C/s threshold to consider valid heating def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None): self.cook_time = cook_time @@ -27,6 +28,9 @@ class CookingState: self._last_refresh_signature = None self._stirred = False + # Moving average filter for heating rate (°C / sec) + self._smoothed_heating_rate = 0.0 + def set_temperature_provider(self, temperature_provider): self.temperature_provider = temperature_provider @@ -35,7 +39,7 @@ class CookingState: def set_refresh_callback(self, callback): self.on_refresh = callback - + def set_pause_callback(self, callback): self.on_pause = callback @@ -45,9 +49,9 @@ class CookingState: self.paused = True self._pause_started_at = time.time() - # self._notify_refresh(force=True) if self.on_pause: self.on_pause(self) + def unpause(self): if not self.paused: return @@ -56,16 +60,15 @@ class CookingState: if self._pause_started_at is not None: self._paused_duration += now - self._pause_started_at - # self._pause_started_at = None self.paused = False - # self._notify_refresh(force=True) def toggle_pause(self): if self.paused: self.unpause() else: self.pause() - self.on_pause(self) + if self.on_pause: + self.on_pause(self) def set_state(self, state): if self.state == state: @@ -90,20 +93,56 @@ class CookingState: def get_remaining_time_estimation(self) -> float: elapsed_time = self.get_elapsed_time() + + # 1. Base timer remaining based on standard cook time timer_remaining = max(0.0, float(self.cook_time) - elapsed_time) - if self.current_dish_temp is None: + # If temperature is unavailable or already met target, rely on standard timer + if self.current_dish_temp is None or self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): return timer_remaining - if self.current_dish_temp >= self.target_temp: - return timer_remaining + # 2. Prevent hitting 00:00 before stirring trigger: + # If we passed half cook_time and temp is far from target, extend expected base time to 1.25x cook_time + temp_progress = max(0.0, self.current_dish_temp) / max(1.0, self.target_temp) + if elapsed_time > (self.cook_time * 0.5) and temp_progress < 0.8: + adjusted_cook_time = self.cook_time * 1.25 + timer_remaining = max(0.0, adjusted_cook_time - elapsed_time) + # 3. Estimate using heating rate heating_rate = self._estimate_heating_rate() - if heating_rate <= 0: - return timer_remaining + temp_needed = self.target_temp - self.current_dish_temp - target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate - return max(timer_remaining, max(0.0, target_remaining)) + if heating_rate > 0.01: + rate_based_remaining = temp_needed / heating_rate + else: + # If flat/slow, project remaining time based on remaining missing temperature fraction + temp_ratio = max(0.1, temp_needed / self.target_temp) + rate_based_remaining = max(timer_remaining, self.cook_time * temp_ratio * 1.25) + + # 4. Strict dynamic cap: Never exceed maximum possible execution window (2.0x cook_time total) + max_possible_remaining = max(0.0, (self.cook_time * 2.0) - elapsed_time) + bounded_remaining = min(rate_based_remaining, max_possible_remaining) + + # Return the larger of the adjusted timer or the bounded prediction + return max(timer_remaining, bounded_remaining) + + def _estimate_heating_rate(self) -> float: + """Calculates heating rate in °C/sec over time interval.""" + if self._last_temperature_sample is None or self.current_dish_temp is None: + return 0.0 + + last_time, last_temp = self._last_temperature_sample + now = time.time() + delta_time = now - last_time + + if delta_time < 0.8: # Skip micro-ticks + return 0.0 + + delta_temp = self.current_dish_temp - last_temp + if delta_temp <= 0: + return 0.0 + + return delta_temp / delta_time def _read_temperatures(self): if self.temperature_provider is None: @@ -118,27 +157,29 @@ class CookingState: raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)") - def _estimate_heating_rate(self): + def _update_heating_rate(self): + """Calculates instantaneous rate and updates the Exponential Moving Average.""" + now = time.time() if self._last_temperature_sample is None: - return 0.0 + self._last_temperature_sample = (now, self.current_dish_temp) + return last_time, last_temp = self._last_temperature_sample - now = time.time() - current_temp = self.current_dish_temp - - if current_temp is None: - return 0.0 - delta_time = now - last_time - if delta_time <= 0: - return 0.0 - return (current_temp - last_temp) / delta_time + if delta_time <= 0.5 or self.current_dish_temp is None: + return + + instant_rate = (self.current_dish_temp - last_temp) / delta_time + self._last_temperature_sample = (now, self.current_dish_temp) + + # Exponential Moving Average (EMA) - alpha smooths out sensor noise + alpha = 0.2 + self._smoothed_heating_rate = (alpha * instant_rate) + ((1.0 - alpha) * self._smoothed_heating_rate) def _notify_state_change(self): if self.on_state_change is None: return - self.on_state_change(self) def _notify_refresh(self, force=False): @@ -177,21 +218,22 @@ class CookingState: now = time.time() elapsed_time = self.get_elapsed_time() - self.estimated_remaining_time = self.get_remaining_time_estimation() - + if self.current_dish_temp is not None: - if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring + self._update_heating_rate() + + if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration is None or self._paused_duration < 5): self.state = CookingStates.STIRRING_REQUIRED self.pause() elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): self.state = CookingStates.DONE - elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up + elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration is None or self._paused_duration < 5): self.state = CookingStates.STIRRING_REQUIRED self.pause() - elif self._pause_started_at != None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): # If the dish had to be pause and it's been a long time, we stop the cooking + elif self._pause_started_at is not None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): self.state = CookingStates.DONE - self._last_temperature_sample = (now, self.current_dish_temp) + self.estimated_remaining_time = self.get_remaining_time_estimation() if self.state != previous_state: self._notify_state_change()